diff --git a/README.md b/README.md index add2f1b..101726c 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ dotnet run --project Tiku.Worker - 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 - Scalar: - OpenAPI: -- Liveness: -- Readiness: +- Liveness: +- Readiness: ## 运行时边界 diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs index 7d3e216..f540393 100644 --- a/Tiku.Api/Configuration/ObservabilityExtensions.cs +++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs @@ -22,7 +22,7 @@ internal static class ObservabilityExtensions serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString())) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(options => - options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health")) + options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health")) .AddHttpClientInstrumentation() .AddSource("Npgsql") .ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!))) diff --git a/Tiku.Api/Controllers/AssetsController.cs b/Tiku.Api/Controllers/AssetsController.cs index b13f93e..df845cb 100644 --- a/Tiku.Api/Controllers/AssetsController.cs +++ b/Tiku.Api/Controllers/AssetsController.cs @@ -1,11 +1,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Tiku.Api.Contracts; using Tiku.Application.Assets; using Tiku.Application.Security; +using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Controllers; @@ -13,12 +12,12 @@ namespace Tiku.Api.Controllers; [Tags("学生端-资源访问")] [AllowAnonymous] [Produces("application/json")] -[Route("api/assets")] +[Route("api/student/assets")] public sealed class AssetsController( IAssetAccessService assetAccessService, ITenantContext currentTenant, ICurrentUser currentUser, - TikuDbContext dbContext) : ControllerBase + ITenantDirectory tenantDirectory) : ControllerBase { [HttpGet("{assetId:guid}/download")] [EndpointSummary("获取资源下载地址")] @@ -86,13 +85,7 @@ public sealed class AssetsController( throw new TenantNotFoundException(); } - var tenantId = await dbContext.Tenants - .Where(tenant => - tenant.Slug == resolvedTenantCode.Trim() && - tenant.Status == TenantStatus.Active) - .Select(tenant => (Guid?)tenant.Id) - .SingleOrDefaultAsync(cancellationToken); - - return tenantId ?? throw new TenantNotFoundException(); + var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode, cancellationToken); + return tenant?.TenantId ?? throw new TenantNotFoundException(); } } diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index d87f86f..9e14437 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -3,18 +3,20 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Tiku.Application.Auth; +using Tiku.Application.Content; using Tiku.Api.Contracts; using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Infrastructure.Content; using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-认证")] -[Route("api/auth")] +[Route("api/platform/auth")] +[Route("api/tenant/auth")] +[Route("api/student/auth")] [Produces("application/json")] public sealed class AuthController( IAuthService authService, diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs index 46320f5..4574887 100644 --- a/Tiku.Api/Controllers/BackgroundJobsController.cs +++ b/Tiku.Api/Controllers/BackgroundJobsController.cs @@ -8,10 +8,11 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-后台任务")] -[Route("api/backoffice/tenant/jobs")] +[Route("api/tenant/access/jobs")] [Authorize(Policy = BackendPermissions.TenantJobManage)] public sealed class BackgroundJobsController( - IBackgroundJobService backgroundJobService, + IBackgroundJobQueue backgroundJobQueue, + IBackgroundJobOperations backgroundJobOperations, ITenantContext tenantContext, ICurrentUser currentUser) : ControllerBase { @@ -24,7 +25,7 @@ public sealed class BackgroundJobsController( CancellationToken cancellationToken) { var tenantId = ResolveTenantId(); - return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken)); + return Ok(await backgroundJobOperations.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken)); } [HttpPost] @@ -35,14 +36,14 @@ public sealed class BackgroundJobsController( CancellationToken cancellationToken) { var tenantId = ResolveTenantId(); - return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken)); + return Ok(await backgroundJobQueue.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); + var item = await backgroundJobOperations.GetAsync(jobId, ResolveTenantId(), cancellationToken); return item is null ? NotFound() : Ok(item); } @@ -53,7 +54,7 @@ public sealed class BackgroundJobsController( CancelBackgroundJobDto request, CancellationToken cancellationToken) { - return Ok(await backgroundJobService.RequestCancellationAsync( + return Ok(await backgroundJobOperations.RequestCancellationAsync( jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken)); } @@ -61,7 +62,7 @@ public sealed class BackgroundJobsController( [EndpointSummary("重试失败或已取消的租户后台任务")] public async Task> Retry(Guid jobId, CancellationToken cancellationToken) { - return Ok(await backgroundJobService.RetryAsync( + return Ok(await backgroundJobOperations.RetryAsync( jobId, ResolveTenantId(), ResolveUserId(), cancellationToken)); } diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs index 320bba3..caa14e4 100644 --- a/Tiku.Api/Controllers/BrowserAuthController.cs +++ b/Tiku.Api/Controllers/BrowserAuthController.cs @@ -6,16 +6,16 @@ using Microsoft.Extensions.Options; using Tiku.Api.Contracts; using Tiku.Api.Options; using Tiku.Application.Auth; +using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Content; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-浏览器认证")] -[Route("api/browser-auth")] +[Route("api/tenant/auth/browser")] [Produces("application/json")] public sealed class BrowserAuthController( IAuthService authService, @@ -303,7 +303,7 @@ public sealed class BrowserAuthController( Secure = true, HttpOnly = true, SameSite = SameSiteMode.Strict, - Path = "/api/browser-auth", + Path = "/api/tenant/auth/browser", MaxAge = TimeSpan.FromDays(30) }); Response.Cookies.Append(BrowserAuthOptions.CsrfCookie, @@ -319,7 +319,7 @@ public sealed class BrowserAuthController( private void ClearCookies() { Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" }); - Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" }); + Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" }); Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" }); } diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index 3f593d4..5b89d39 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Api.Contracts; using Tiku.Application.Catalog; @@ -9,8 +8,8 @@ using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Application.StudyContent; +using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Controllers; @@ -18,7 +17,7 @@ namespace Tiku.Api.Controllers; [Tags("学生端-公开目录")] [AllowAnonymous] [Produces("application/json")] -[Route("api/catalog")] +[Route("api/public/catalog")] [OutputCache(PolicyName = "TenantPublic")] public sealed class CatalogController( ICatalogQueryService catalogQueryService, @@ -27,7 +26,7 @@ public sealed class CatalogController( IStudyContentQueryService studyContentQueryService, IAssetQueryService assetQueryService, ITenantContext currentTenant, - TikuDbContext dbContext) : ControllerBase + ITenantDirectory tenantDirectory) : ControllerBase { [HttpGet("regions")] [EndpointSummary("查询可用地区")] @@ -486,14 +485,8 @@ public sealed class CatalogController( throw new TenantNotFoundException(); } - var tenantId = await dbContext.Tenants - .Where(tenant => - tenant.Slug == tenantCode.Trim() && - tenant.Status == TenantStatus.Active) - .Select(tenant => (Guid?)tenant.Id) - .SingleOrDefaultAsync(cancellationToken); - - return tenantId ?? throw new TenantNotFoundException(); + var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken); + return tenant?.TenantId ?? throw new TenantNotFoundException(); } private Task ResolveTenantIdAsync( diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index ca83e6a..eb555e5 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] -[Route("api/commerce")] +[Route("api/student/commerce")] public sealed class CommerceController( ICommerceService commerceService, ICommerceAdminService commerceAdminService, diff --git a/Tiku.Api/Controllers/CommissionController.cs b/Tiku.Api/Controllers/CommissionController.cs index 8689d3c..f2b200c 100644 --- a/Tiku.Api/Controllers/CommissionController.cs +++ b/Tiku.Api/Controllers/CommissionController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = BackendPermissions.TenantCommissionManage)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] [Produces("application/json")] -[Route("api/commission")] +[Route("api/tenant/commission")] public sealed class CommissionController( ICommissionService commissionService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/CrmController.cs b/Tiku.Api/Controllers/CrmController.cs index b9c7065..944898a 100644 --- a/Tiku.Api/Controllers/CrmController.cs +++ b/Tiku.Api/Controllers/CrmController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = BackendPermissions.TenantCrmManage)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Crm)] [Produces("application/json")] -[Route("api/crm")] +[Route("api/tenant/crm")] public sealed class CrmController( ICrmService crmService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs index 42c0465..2b3f7db 100644 --- a/Tiku.Api/Controllers/HealthController.cs +++ b/Tiku.Api/Controllers/HealthController.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Security; -using Tiku.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; namespace Tiku.Api.Controllers; @@ -11,10 +9,8 @@ namespace Tiku.Api.Controllers; [Tags("平台端-系统健康")] [AllowAnonymous] [Produces("application/json")] -[Route("api/health")] -public sealed class HealthController( - TikuDbContext dbContext, - IRedisSecurityStore redisSecurityStore) : ControllerBase +[Route("api/system/health")] +public sealed class HealthController(IDependencyReadinessProbe readinessProbe) : ControllerBase { [HttpGet] [EndpointSummary("健康检查")] @@ -32,10 +28,8 @@ public sealed class HealthController( [EndpointSummary("依赖就绪检查")] public async Task> Ready(CancellationToken cancellationToken) { - 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", checkedAt = DateTimeOffset.UtcNow }; - return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response); + var readiness = await readinessProbe.CheckAsync(cancellationToken); + var response = new { status = readiness.Ready ? "ready" : "not_ready", readiness.CheckedAt }; + return readiness.Ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response); } } diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs index 5eaea24..37232a8 100644 --- a/Tiku.Api/Controllers/LearningController.cs +++ b/Tiku.Api/Controllers/LearningController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] [Produces("application/json")] -[Route("api/learning")] +[Route("api/student/learning")] public sealed class LearningController( ILearningActivityService learningActivityService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs index 2604d44..4d3b683 100644 --- a/Tiku.Api/Controllers/MeController.cs +++ b/Tiku.Api/Controllers/MeController.cs @@ -1,21 +1,19 @@ 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; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-当前用户")] [Authorize(Policy = TikuPolicies.AuthenticatedUser)] -[Route("api/me")] +[Route("api/tenant/me")] public sealed class MeController( ICurrentUser currentUser, IAuthSessionStore sessionStore, - TikuDbContext dbContext) : ControllerBase + ICurrentIdentityQueryService identityQueries) : ControllerBase { [HttpGet] [EndpointSummary("获取当前登录用户")] @@ -27,34 +25,23 @@ public sealed class MeController( return Unauthorized(); } - var user = await dbContext.Users.FindAsync([currentUser.UserId.Value], cancellationToken); + var user = await identityQueries.GetUserAsync(currentUser.UserId.Value, cancellationToken); if (user is null) { return Unauthorized(); } - var memberships = await dbContext.TenantMemberships - .Where(membership => - membership.UserId == user.Id && - membership.Status == MembershipStatus.Active) - .Join( - dbContext.Tenants, - membership => membership.TenantId, - tenant => tenant.Id, - (membership, tenant) => new TenantMembershipResponse( - tenant.Id, - tenant.Name, - tenant.Slug, - membership.Role, - membership.Status)) - .ToArrayAsync(cancellationToken); - return Ok(new MeResponse( - user.Id, + user.UserId, user.Phone, user.Email, user.Name, - memberships)); + user.Tenants.Select(item => new TenantMembershipResponse( + item.TenantId, + item.TenantName, + item.TenantSlug, + item.Role, + item.Status)).ToArray())); } [HttpGet("sessions")] diff --git a/Tiku.Api/Controllers/PlatformAdminController.cs b/Tiku.Api/Controllers/PlatformAdminController.cs index 0a16104..f1cc17d 100644 --- a/Tiku.Api/Controllers/PlatformAdminController.cs +++ b/Tiku.Api/Controllers/PlatformAdminController.cs @@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers; [Tags("平台端-平台管理")] [Authorize(Policy = BackendPermissions.PlatformDashboardView)] [Produces("application/json")] -[Route("api/platform-admin")] +[Route("api/platform")] public sealed class PlatformAdminController( IPlatformAdminService platformAdminService, IPlatformApprovalService approvalService, diff --git a/Tiku.Api/Controllers/PlatformApprovalsController.cs b/Tiku.Api/Controllers/PlatformApprovalsController.cs index b86d1eb..c0c7da5 100644 --- a/Tiku.Api/Controllers/PlatformApprovalsController.cs +++ b/Tiku.Api/Controllers/PlatformApprovalsController.cs @@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-审批中心")] -[Route("api/platform-admin/approvals")] +[Route("api/platform/approvals")] [Authorize(Policy = BackendPermissions.PlatformApprovalView)] public sealed class PlatformApprovalsController( IPlatformApprovalService approvalService, diff --git a/Tiku.Api/Controllers/PlatformBackofficeController.cs b/Tiku.Api/Controllers/PlatformBackofficeController.cs index 52d70a8..41903a1 100644 --- a/Tiku.Api/Controllers/PlatformBackofficeController.cs +++ b/Tiku.Api/Controllers/PlatformBackofficeController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-后台权限")] -[Route("api/backoffice/platform")] +[Route("api/platform/access")] public sealed class PlatformBackofficeController( IBackofficeService backofficeService, IPlatformApprovalService approvalService, diff --git a/Tiku.Api/Controllers/PlatformBillingCallbackController.cs b/Tiku.Api/Controllers/PlatformBillingCallbackController.cs index 796adbf..c12890b 100644 --- a/Tiku.Api/Controllers/PlatformBillingCallbackController.cs +++ b/Tiku.Api/Controllers/PlatformBillingCallbackController.cs @@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-账务回调")] -[Route("api/platform-billing/callbacks")] +[Route("api/integrations/platform-billing/callbacks")] public sealed class PlatformBillingCallbackController( IPlatformBillingNotificationService notificationService) : ControllerBase { diff --git a/Tiku.Api/Controllers/PlatformGovernanceController.cs b/Tiku.Api/Controllers/PlatformGovernanceController.cs index 7580fdc..c5b6679 100644 --- a/Tiku.Api/Controllers/PlatformGovernanceController.cs +++ b/Tiku.Api/Controllers/PlatformGovernanceController.cs @@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-治理配置")] -[Route("api/platform-admin/governance")] +[Route("api/platform/governance")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] public sealed class PlatformGovernanceController( IPlatformGovernanceService governanceService, diff --git a/Tiku.Api/Controllers/PlatformOperationsController.cs b/Tiku.Api/Controllers/PlatformOperationsController.cs index 0106d9f..7190f0e 100644 --- a/Tiku.Api/Controllers/PlatformOperationsController.cs +++ b/Tiku.Api/Controllers/PlatformOperationsController.cs @@ -3,56 +3,34 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Jobs; using Tiku.Application.Security; +using Tiku.Application.PlatformAdmin.Operations; using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -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")] +[Route("api/platform/operations")] [Authorize(Policy = BackendPermissions.PlatformOperationsView)] public sealed class PlatformOperationsController( - IBackgroundJobService backgroundJobService, + IBackgroundJobOperations backgroundJobService, ICurrentUser currentUser, - TikuDbContext dbContext, - IRedisSecurityStore redisSecurityStore, - IAssetSecurityScanner assetSecurityScanner, - IObjectStorageService objectStorageService, - IOptions aliyunOssOptions) : ControllerBase + IPlatformOperationsQueryService operationsQueries) : 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); + var health = await operationsQueries.GetHealthAsync(cancellationToken); 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 + status = health.Healthy ? "healthy" : "degraded", + database = health.Database, + redis = new { configured = health.RedisConfigured, ready = health.RedisReady }, + worker = new { ready = health.WorkerReady, lastHeartbeatAt = health.LastHeartbeatAt }, + clamAv = health.ClamAv, + storage = new { provider = health.StorageProvider, configured = health.StorageConfigured }, + checkedAt = health.CheckedAt }); } @@ -60,23 +38,7 @@ public sealed class PlatformOperationsController( [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); + var items = await operationsQueries.GetWorkersAsync(cancellationToken); return Ok(new { staleAfterSeconds = 120, @@ -91,7 +53,7 @@ public sealed class PlatformOperationsController( item.LastSucceededAt, item.LastError, item.IsRunning, - stale = item.LastHeartbeatAt < now.AddMinutes(-2) + stale = item.Stale }) }); } @@ -100,23 +62,14 @@ public sealed class PlatformOperationsController( [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); + var metrics = await operationsQueries.GetJobMetricsAsync(cancellationToken); return Ok(new { - counts, - oldestPendingAt = oldestPending, - queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0, - expiredLeases, - checkedAt = now + counts = metrics.Counts.Select(item => new { status = item.Status, count = item.Count }), + oldestPendingAt = metrics.OldestPendingAt, + queueAgeSeconds = metrics.QueueAgeSeconds, + expiredLeases = metrics.ExpiredLeases, + checkedAt = metrics.CheckedAt }); } @@ -124,20 +77,15 @@ public sealed class PlatformOperationsController( [EndpointSummary("查询审批、配置与通知治理指标")] public async Task> GovernanceMetrics(CancellationToken cancellationToken) { - var now = DateTimeOffset.UtcNow; - var approvalCounts = await dbContext.PlatformApprovalRequests.AsNoTracking() - .GroupBy(item => item.Status) - .Select(group => new { status = group.Key, count = group.Count() }) - .ToArrayAsync(cancellationToken); - var expiredPending = await dbContext.PlatformApprovalRequests.AsNoTracking() - .CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken); - var configurationDrafts = await dbContext.PlatformConfigurationVersions.AsNoTracking() - .CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken); - var notificationCounts = await dbContext.PlatformNotificationDeliveries.AsNoTracking() - .GroupBy(item => item.Status) - .Select(group => new { status = group.Key, count = group.Count() }) - .ToArrayAsync(cancellationToken); - return Ok(new { approvals = approvalCounts, expiredPending, configurationDrafts, notifications = notificationCounts, checkedAt = now }); + var metrics = await operationsQueries.GetGovernanceMetricsAsync(cancellationToken); + return Ok(new + { + approvals = metrics.Approvals.Select(item => new { status = item.Status, count = item.Count }), + expiredPending = metrics.ExpiredPending, + configurationDrafts = metrics.ConfigurationDrafts, + notifications = metrics.Notifications.Select(item => new { status = item.Status, count = item.Count }), + checkedAt = metrics.CheckedAt + }); } [HttpGet("jobs")] diff --git a/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs b/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs index 84cdf3b..fbb22f1 100644 --- a/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs +++ b/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs @@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers; [Tags("平台端-支付设置")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] [Produces("application/json")] -[Route("api/platform-admin/payment-settings")] +[Route("api/platform/payment-settings")] public sealed class PlatformPaymentSettingsController( IPlatformPaymentSettingsService paymentSettingsService, IPlatformApprovalService approvalService, diff --git a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs index 74186ca..83a15e2 100644 --- a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs +++ b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [Tags("平台端-公共题库")] [Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)] [Produces("application/json")] -[Route("api/platform-admin/question-banks")] +[Route("api/platform/question-banks")] public sealed class PlatformQuestionBanksController( IPlatformQuestionBankService service, ICurrentUser currentUser) : ControllerBase diff --git a/Tiku.Api/Controllers/PlatformSaasController.cs b/Tiku.Api/Controllers/PlatformSaasController.cs index 1675004..4a99a2d 100644 --- a/Tiku.Api/Controllers/PlatformSaasController.cs +++ b/Tiku.Api/Controllers/PlatformSaasController.cs @@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-SaaS 套餐")] -[Route("api/platform-admin/saas")] +[Route("api/platform/saas")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] public sealed class PlatformSaasController( ISaasCatalogAdminService catalogService, diff --git a/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs b/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs index 309e8d3..0b244d9 100644 --- a/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs +++ b/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs @@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers; [Tags("平台端-租户 CRM 能力")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] [Produces("application/json")] -[Route("api/platform-admin/tenant-capabilities/crm")] +[Route("api/platform/tenant-capabilities/crm")] public sealed class PlatformAdminCrmController( IPlatformCrmAdminService crmService, ICurrentUser currentUser) : ControllerBase @@ -57,7 +57,7 @@ public sealed class PlatformAdminCrmController( [Tags("平台端-租户短信能力")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] [Produces("application/json")] -[Route("api/platform-admin/tenant-capabilities/sms")] +[Route("api/platform/tenant-capabilities/sms")] public sealed class PlatformAdminSmsController( IPlatformSmsAdminService smsService, ICurrentUser currentUser) : ControllerBase @@ -119,7 +119,7 @@ public sealed class PlatformAdminSmsController( [Tags("平台端-租户支付能力")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] [Produces("application/json")] -[Route("api/platform-admin/tenant-capabilities/payments")] +[Route("api/platform/tenant-capabilities/payments")] public sealed class PlatformAdminTenantPaymentSettingsController( IPlatformTenantPaymentAdminService paymentService, ICurrentUser currentUser) : ControllerBase diff --git a/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs index e7cb414..7ba7b59 100644 --- a/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs +++ b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs @@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("平台端-租户生命周期")] -[Route("api/platform-admin/tenants/{tenantId:guid}")] +[Route("api/platform/tenants/{tenantId:guid}")] [Authorize(Policy = BackendPermissions.PlatformTenantManage)] public sealed class PlatformTenantLifecycleController( ITenantLifecycleService lifecycleService, diff --git a/Tiku.Api/Controllers/PointsController.cs b/Tiku.Api/Controllers/PointsController.cs index f49bfb9..fb70a12 100644 --- a/Tiku.Api/Controllers/PointsController.cs +++ b/Tiku.Api/Controllers/PointsController.cs @@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] -[Route("api/points")] +[Route("api/student/points")] public sealed class PointsController( IPointService pointService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/ProfileController.cs b/Tiku.Api/Controllers/ProfileController.cs index a2022d3..83e0a98 100644 --- a/Tiku.Api/Controllers/ProfileController.cs +++ b/Tiku.Api/Controllers/ProfileController.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Profile; using Tiku.Application.Security; -using Tiku.Infrastructure.Profile; namespace Tiku.Api.Controllers; @@ -11,7 +10,7 @@ namespace Tiku.Api.Controllers; [Tags("学生端-个人中心")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Produces("application/json")] -[Route("api/profile")] +[Route("api/student/profile")] public sealed class ProfileController( IProfileService profileService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/QuestionVideosController.cs b/Tiku.Api/Controllers/QuestionVideosController.cs index 0268fed..8192ef2 100644 --- a/Tiku.Api/Controllers/QuestionVideosController.cs +++ b/Tiku.Api/Controllers/QuestionVideosController.cs @@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] [Produces("application/json")] -[Route("api/questions/videos")] +[Route("api/student/questions/videos")] public sealed class QuestionVideosController( IVideoPlaybackService videoPlaybackService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs index 3193a96..38aa1a2 100644 --- a/Tiku.Api/Controllers/ReferralController.cs +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -1,12 +1,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Tiku.Api.Contracts; using Tiku.Application.Growth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Controllers; @@ -14,7 +12,7 @@ namespace Tiku.Api.Controllers; [Tags("学生端-推荐增长")] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] [Produces("application/json")] -[Route("api/referral")] +[Route("api/student/referral")] public sealed class ReferralController( IReferralService referralService, ICurrentUser currentUser, @@ -96,7 +94,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpGet("stats")] + [HttpGet("/api/tenant/referral/stats")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐人个人统计")] @@ -111,7 +109,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpGet("sales-stats")] + [HttpGet("/api/tenant/referral/sales-stats")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询销售推荐统计排行")] @@ -126,7 +124,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpGet("conversion-report")] + [HttpGet("/api/tenant/referral/conversion-report")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐转化报告")] @@ -141,7 +139,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpGet("sales-clients")] + [HttpGet("/api/tenant/referral/sales-clients")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐人名下客户")] @@ -156,7 +154,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpPost("manual-bind")] + [HttpPost("/api/tenant/referral/manual-bind")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("人工调整学生推荐归属")] @@ -171,7 +169,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpGet("team")] + [HttpGet("/api/tenant/referral/team")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("查询推荐团队成员")] @@ -186,7 +184,7 @@ public sealed class ReferralController( cancellationToken)); } - [HttpPut("team")] + [HttpPut("/api/tenant/referral/team")] [Tags("租户端-推荐增长")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] [EndpointSummary("新增或更新推荐团队关系")] diff --git a/Tiku.Api/Controllers/RuntimeController.cs b/Tiku.Api/Controllers/RuntimeController.cs index 7004a67..943a407 100644 --- a/Tiku.Api/Controllers/RuntimeController.cs +++ b/Tiku.Api/Controllers/RuntimeController.cs @@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers; [Tags("租户端-运行时配置")] [AllowAnonymous] [Produces("application/json")] -[Route("api/runtime")] +[Route("api/public/runtime")] public sealed class RuntimeController( ITenantContext tenantContext, ITenantFrontendConfigService frontendConfigService) : ControllerBase diff --git a/Tiku.Api/Controllers/ScorelineController.cs b/Tiku.Api/Controllers/ScorelineController.cs index 3c001eb..6e5541d 100644 --- a/Tiku.Api/Controllers/ScorelineController.cs +++ b/Tiku.Api/Controllers/ScorelineController.cs @@ -1,13 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; -using Microsoft.EntityFrameworkCore; using Tiku.Api.Contracts; using Tiku.Application.Catalog; using Tiku.Application.Scoreline; using Tiku.Application.Security; +using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Controllers; @@ -16,11 +15,11 @@ namespace Tiku.Api.Controllers; [AllowAnonymous] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [Produces("application/json")] -[Route("api/scoreline")] +[Route("api/public/scoreline")] public sealed class ScorelineController( IScorelineQueryService scorelineQueryService, ITenantContext currentTenant, - TikuDbContext dbContext) : ControllerBase + ITenantDirectory tenantDirectory) : ControllerBase { private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."]; @@ -133,13 +132,7 @@ public sealed class ScorelineController( throw new TenantNotFoundException(); } - var tenantId = await dbContext.Tenants - .Where(tenant => - tenant.Slug == tenantCode.Trim() && - tenant.Status == TenantStatus.Active) - .Select(tenant => (Guid?)tenant.Id) - .SingleOrDefaultAsync(cancellationToken); - - return tenantId ?? throw new TenantNotFoundException(); + var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken); + return tenant?.TenantId ?? throw new TenantNotFoundException(); } } diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs index 191920d..80dbb77 100644 --- a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs +++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs @@ -6,7 +6,7 @@ namespace Tiku.Api.Controllers; [ApiController] [ApiExplorerSettings(IgnoreApi = true)] -[Route("api/_security")] +[Route("api/system/security")] public sealed class SecurityDiagnosticsController( ICurrentUser currentUser, ITenantContext currentTenant) : ControllerBase diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs index d6de84a..a8e1bd5 100644 --- a/Tiku.Api/Controllers/TaxonomyController.cs +++ b/Tiku.Api/Controllers/TaxonomyController.cs @@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers; [Tags("租户端-分类管理")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Produces("application/json")] -[Route("api/taxonomy/nodes")] +[Route("api/tenant/taxonomy/nodes")] public sealed class TaxonomyController( ITenantContext tenantContext, ITaxonomyService taxonomyService) : ControllerBase diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index 51169c1..cf98510 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -7,14 +7,13 @@ using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Content; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-运营管理")] [Produces("application/json")] -[Route("api/tenant-admin")] +[Route("api/tenant")] public sealed class TenantAdminDirectController( ITenantAdminDirectService tenantAdminService, IAuthAdministrationService authAdministrationService, diff --git a/Tiku.Api/Controllers/TenantBackofficeController.cs b/Tiku.Api/Controllers/TenantBackofficeController.cs index 681a3bf..e61a20f 100644 --- a/Tiku.Api/Controllers/TenantBackofficeController.cs +++ b/Tiku.Api/Controllers/TenantBackofficeController.cs @@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-后台权限")] -[Route("api/backoffice/tenant")] +[Route("api/tenant/access")] public sealed class TenantBackofficeController( IBackofficeService backofficeService, ICurrentAccessContext currentAccessContext) : ControllerBase diff --git a/Tiku.Api/Controllers/TenantBillingController.cs b/Tiku.Api/Controllers/TenantBillingController.cs index dc4dd6c..410e748 100644 --- a/Tiku.Api/Controllers/TenantBillingController.cs +++ b/Tiku.Api/Controllers/TenantBillingController.cs @@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-账务")] -[Route("api/tenant-billing")] +[Route("api/tenant/billing")] [Authorize(Policy = BackendPermissions.TenantBillingManage)] public sealed class TenantBillingController( ITenantBillingService billingService, diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index c58b1d1..11f2815 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = BackendPermissions.TenantCommerceOperate)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] -[Route("api/tenant-commerce")] +[Route("api/tenant/commerce")] public sealed class TenantCommerceController( ICommerceAdminService commerceAdminService, ICurrentUser currentUser, diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index be97f09..fae3bda 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers; [Tags("租户端-内容管理")] [Authorize(Policy = BackendPermissions.TenantContentManage)] [Produces("application/json")] -[Route("api/tenant-content")] +[Route("api/tenant/content")] public sealed class TenantContentController( IAssetManagementService assetManagementService, IContentManagementService contentManagementService, diff --git a/Tiku.Api/Controllers/TenantContentDirectController.cs b/Tiku.Api/Controllers/TenantContentDirectController.cs index 4bed5b7..e89e674 100644 --- a/Tiku.Api/Controllers/TenantContentDirectController.cs +++ b/Tiku.Api/Controllers/TenantContentDirectController.cs @@ -14,10 +14,10 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-内容直接管理")] [Produces("application/json")] -[Route("api/tenant-content")] +[Route("api/tenant/content")] public sealed class TenantContentDirectController( IDirectContentService directContentService, - IBackgroundJobService backgroundJobService, + IBackgroundJobQueue backgroundJobService, ICurrentUser currentUser, ITenantContext currentTenant) : ControllerBase { diff --git a/Tiku.Api/Controllers/TenantFrontendConfigController.cs b/Tiku.Api/Controllers/TenantFrontendConfigController.cs index 93c0a31..4213340 100644 --- a/Tiku.Api/Controllers/TenantFrontendConfigController.cs +++ b/Tiku.Api/Controllers/TenantFrontendConfigController.cs @@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers; [Tags("租户端-前端配置")] [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [Produces("application/json")] -[Route("api/tenant-admin/frontend-config")] +[Route("api/tenant/frontend-config")] public sealed class TenantFrontendConfigController( ITenantContext tenantContext, ITenantFrontendConfigService frontendConfigService) : ControllerBase diff --git a/Tiku.Api/Controllers/TenantOnboardingController.cs b/Tiku.Api/Controllers/TenantOnboardingController.cs index 3d8cfdf..9aab9f4 100644 --- a/Tiku.Api/Controllers/TenantOnboardingController.cs +++ b/Tiku.Api/Controllers/TenantOnboardingController.cs @@ -7,7 +7,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-开通引导")] -[Route("api/tenant-onboarding")] +[Route("api/tenant/onboarding")] [Authorize(Policy = BackendPermissions.TenantSettingsManage)] public sealed class TenantOnboardingController( ITenantOnboardingService onboardingService, diff --git a/Tiku.Api/Controllers/TenantPublicController.cs b/Tiku.Api/Controllers/TenantPublicController.cs index 3696b56..74d185f 100644 --- a/Tiku.Api/Controllers/TenantPublicController.cs +++ b/Tiku.Api/Controllers/TenantPublicController.cs @@ -1,12 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Text.Json; using Tiku.Api.Contracts; -using Tiku.Domain.Common; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; using Tiku.Application.Security; using Tiku.Application.Tenancy; @@ -16,12 +11,12 @@ namespace Tiku.Api.Controllers; [Tags("租户端-公开配置")] [AllowAnonymous] [Produces("application/json")] -[Route("api/tenant")] +[Route("api/public/tenant")] public sealed class TenantPublicController( - TikuDbContext dbContext, ITenantContext tenantContext, ITenantContextInitializer tenantContextInitializer, - ITenantDirectory tenantDirectory) : ControllerBase + ITenantDirectory tenantDirectory, + IPublicTenantConfigurationQuery publicConfiguration) : ControllerBase { [HttpGet("resolve")] [EndpointSummary("解析当前租户")] @@ -83,63 +78,12 @@ public sealed class TenantPublicController( return Resolve(query, cancellationToken); } - private async Task FindActiveTenantByCodeAsync( - string tenantCode, - CancellationToken cancellationToken) - { - return await dbContext.Tenants - .Where(tenant => - tenant.Slug == tenantCode && - tenant.Status == TenantStatus.Active) - .Select(tenant => new TenantLookupResult( - tenant.Id, - tenant.Slug, - tenant.Name, - tenant.Status, - tenant.Mode, - null)) - .SingleOrDefaultAsync(cancellationToken); - } - - private async Task FindActiveTenantByHostAsync( - string? host, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(host)) - { - return null; - } - - return await dbContext.TenantDomains - .Where(domain => - domain.Host == host && - domain.Status == TenantDomainStatus.Active) - .Join( - dbContext.Tenants.Where(tenant => tenant.Status == TenantStatus.Active), - domain => domain.TenantId, - tenant => tenant.Id, - (domain, tenant) => new TenantLookupResult( - tenant.Id, - tenant.Slug, - tenant.Name, - tenant.Status, - tenant.Mode, - domain.Host)) - .SingleOrDefaultAsync(cancellationToken); - } - private async Task BuildResponseAsync( TenantLookupResult tenant, string? host, CancellationToken cancellationToken) { - var branding = await dbContext.TenantBrandings.FindAsync([tenant.Id], cancellationToken); - var settings = await dbContext.TenantSettings.FindAsync([tenant.Id], cancellationToken); - var themeConfig = await dbContext.TenantThemeConfigs - .SingleOrDefaultAsync( - entity => entity.TenantId == tenant.Id && - entity.Status == TenantThemeConfigStatus.Published, - cancellationToken); + var configuration = await publicConfiguration.GetAsync(tenant.Id, cancellationToken); return new TenantResolveResponseDto( new PublicTenantDto( @@ -149,31 +93,19 @@ public sealed class TenantPublicController( tenant.Status, tenant.Mode, host), - BuildBranding(branding, themeConfig), - CloneOrDefault(settings?.FeatureFlags), - CloneOrDefault(settings?.AdminFeatureFlags), - CloneOrDefault(settings?.PublicConfig)); - } - - private static PublicTenantBrandingDto BuildBranding( - TenantBranding? branding, - TenantThemeConfig? themeConfig) - { - if (branding is null && themeConfig is null) - { - return PublicTenantBrandingDto.Empty; - } - - return new PublicTenantBrandingDto( - branding?.BrandName, - branding?.ShortName, - branding?.Slogan, - branding?.LogoUrl, - branding?.FaviconUrl, - branding?.ServiceWechat, - branding?.ServiceAccountName, - IsNonEmptyObject(themeConfig?.ActiveTheme) ? themeConfig!.ActiveTheme.Clone() : CloneOrDefault(branding?.Theme), - IsNonEmptyObject(themeConfig?.ActivePublicAssets) ? themeConfig!.ActivePublicAssets.Clone() : CloneOrDefault(branding?.PublicAssets)); + new PublicTenantBrandingDto( + configuration.BrandName, + configuration.ShortName, + configuration.Slogan, + configuration.LogoUrl, + configuration.FaviconUrl, + configuration.ServiceWechat, + configuration.ServiceAccountName, + configuration.Theme, + configuration.PublicAssets), + configuration.FeatureFlags, + configuration.AdminFeatureFlags, + configuration.PublicConfig); } private static string? NormalizeTenantCode(string? value) @@ -192,17 +124,6 @@ public sealed class TenantPublicController( return host.Split(':', 2)[0]; } - private static bool IsNonEmptyObject(JsonElement? value) - { - return value is { ValueKind: JsonValueKind.Object } json && - json.EnumerateObject().Any(); - } - - private static JsonElement CloneOrDefault(JsonElement? value) - { - return value.HasValue ? value.Value.Clone() : JsonDefaults.Object(); - } - private sealed record TenantLookupResult( Guid Id, string Slug, diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs index b324c77..c302f1f 100644 --- a/Tiku.Api/Controllers/TenantsController.cs +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -1,21 +1,19 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-当前租户")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Route("api/tenants")] +[Route("api/tenant/context")] public sealed class TenantsController( ICurrentUser currentUser, ITenantContext currentTenant, - TikuDbContext dbContext) : ControllerBase + ICurrentIdentityQueryService identityQueries) : ControllerBase { [HttpGet("current")] [EndpointSummary("查询当前租户")] @@ -27,29 +25,22 @@ public sealed class TenantsController( throw new TenantAccessDeniedException(); } - var result = await dbContext.TenantMemberships - .Where(membership => - membership.UserId == currentUser.UserId.Value && - membership.TenantId == currentTenant.TenantId.Value && - membership.Status == MembershipStatus.Active) - .Join( - dbContext.Tenants, - membership => membership.TenantId, - tenant => tenant.Id, - (membership, tenant) => new CurrentTenantResponse( - tenant.Id, - tenant.Name, - tenant.Slug, - tenant.Status, - membership.Role)) - .SingleOrDefaultAsync(cancellationToken); + var membership = await identityQueries.GetTenantMembershipAsync( + currentUser.UserId.Value, + currentTenant.TenantId.Value, + cancellationToken); - if (result is null) + if (membership is null) { throw new TenantAccessDeniedException(); } - return Ok(result); + return Ok(new CurrentTenantResponse( + membership.TenantId, + membership.TenantName, + membership.TenantSlug, + membership.Status, + membership.Role)); } } diff --git a/Tiku.Api/Controllers/VideosController.cs b/Tiku.Api/Controllers/VideosController.cs index 4cd9861..a9143ab 100644 --- a/Tiku.Api/Controllers/VideosController.cs +++ b/Tiku.Api/Controllers/VideosController.cs @@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers; [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] [Produces("application/json")] -[Route("api/videos")] +[Route("api/student/videos")] public sealed class VideosController( IVideoPlaybackService videoPlaybackService, ICurrentUser currentUser, diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 7d52b4d..9ba0dd1 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -2,24 +2,22 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Controllers; using Tiku.Application.Assets; using Tiku.Application.Auth; +using Tiku.Application.Backoffice; 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.Profile; using Tiku.Application.PlatformAdmin; using Tiku.Application.PlatformBilling; using Tiku.Application.QuestionBanks; +using Tiku.Application.Scoreline; using Tiku.Application.Storage; -using Tiku.Infrastructure.Content; -using Tiku.Infrastructure.Learning; -using Tiku.Infrastructure.Profile; -using Tiku.Infrastructure.QuestionBanks; -using Tiku.Infrastructure.Scoreline; using Tiku.Application.TenantAdmin; using Tiku.Application.Tenancy; -using Tiku.Infrastructure.Backoffice; +using Tiku.Application.Learning; namespace Tiku.Api.Middleware; diff --git a/Tiku.Api/Options/TenantResolutionOptions.cs b/Tiku.Api/Options/TenantResolutionOptions.cs index b9bccba..d31a744 100644 --- a/Tiku.Api/Options/TenantResolutionOptions.cs +++ b/Tiku.Api/Options/TenantResolutionOptions.cs @@ -7,6 +7,6 @@ public sealed class TenantResolutionOptions public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"]; public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"]; public string[] TenantCodePathPrefixes { get; set; } = - ["/api/auth", "/api/tenant", "/api/catalog", "/api/assets", "/api/scoreline", "/api/referral", "/api/commerce/payments/notify"]; + ["/api/tenant/auth", "/api/tenant", "/api/public/catalog", "/api/student/assets", "/api/public/scoreline", "/api/student/referral", "/api/student/commerce/payments/notify"]; public string[] TrustedProxyAddresses { get; set; } = []; } diff --git a/Tiku.Application/Auth/CurrentIdentityQueries.cs b/Tiku.Application/Auth/CurrentIdentityQueries.cs new file mode 100644 index 0000000..b85cd75 --- /dev/null +++ b/Tiku.Application/Auth/CurrentIdentityQueries.cs @@ -0,0 +1,34 @@ +using Tiku.Domain.Tenancy; + +namespace Tiku.Application.Auth; + +public sealed record CurrentUserTenant( + Guid TenantId, + string TenantName, + string TenantSlug, + TenantRole Role, + MembershipStatus Status); + +public sealed record CurrentUserProfile( + Guid UserId, + string? Phone, + string? Email, + string? Name, + IReadOnlyCollection Tenants); + +public sealed record CurrentTenantMembership( + Guid TenantId, + string TenantName, + string TenantSlug, + TenantStatus Status, + TenantRole Role); + +public interface ICurrentIdentityQueryService +{ + Task GetUserAsync(Guid userId, CancellationToken cancellationToken = default); + + Task GetTenantMembershipAsync( + Guid userId, + Guid tenantId, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Backoffice/BackofficeException.cs b/Tiku.Application/Backoffice/BackofficeException.cs new file mode 100644 index 0000000..0e9a996 --- /dev/null +++ b/Tiku.Application/Backoffice/BackofficeException.cs @@ -0,0 +1,6 @@ +namespace Tiku.Application.Backoffice; + +public sealed class BackofficeException(string message, string code) : InvalidOperationException(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/Content/ContentExceptions.cs b/Tiku.Application/Content/ContentExceptions.cs new file mode 100644 index 0000000..e3bad81 --- /dev/null +++ b/Tiku.Application/Content/ContentExceptions.cs @@ -0,0 +1,5 @@ +namespace Tiku.Application.Content; + +public sealed class RequiredFieldException(string message) : Exception(message); + +public sealed class ContentNavigationNotFoundException(string message) : Exception(message); diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs index 4e3c187..f4f3bb2 100644 --- a/Tiku.Application/Jobs/BackgroundJobModels.cs +++ b/Tiku.Application/Jobs/BackgroundJobModels.cs @@ -35,12 +35,16 @@ public sealed record BackgroundJobItem( Guid? OutputAssetId, JsonElement Result); -public interface IBackgroundJobService +public interface IBackgroundJobQueue { Task EnqueueAsync( CreateBackgroundJobCommand command, CancellationToken cancellationToken = default); +} + +public interface IBackgroundJobProcessor +{ Task ProcessPendingAsync( string workerId, int batchSize, @@ -54,6 +58,10 @@ public interface IBackgroundJobService string workerId, CancellationToken cancellationToken = default); +} + +public interface IBackgroundJobOperations +{ Task> ListAsync( Guid tenantId, string? jobType = null, @@ -85,3 +93,23 @@ public interface IBackgroundJobService Guid actorUserId, CancellationToken cancellationToken = default); } + +public interface IBackgroundJobService : + IBackgroundJobQueue, + IBackgroundJobProcessor, + IBackgroundJobOperations; + +public sealed record BackgroundJobExecutionContext( + Guid JobId, + Guid TenantId, + string JobType, + JsonElement Payload); + +public interface IBackgroundJobHandler +{ + string JobType { get; } + + Task HandleAsync( + BackgroundJobExecutionContext context, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Learning/LearningExceptions.cs b/Tiku.Application/Learning/LearningExceptions.cs new file mode 100644 index 0000000..37b720f --- /dev/null +++ b/Tiku.Application/Learning/LearningExceptions.cs @@ -0,0 +1,12 @@ +namespace Tiku.Application.Learning; + +public class LearningException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +public sealed class LearningResourceNotFoundException(string code, string message) + : LearningException(code, message); + +public sealed class LearningValidationException(string code, string message) + : LearningException(code, message); diff --git a/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs b/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs new file mode 100644 index 0000000..9e8b70b --- /dev/null +++ b/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs @@ -0,0 +1,49 @@ +namespace Tiku.Application.PlatformAdmin.Operations; + +public sealed record PlatformDependencyHealth( + bool Healthy, + bool Database, + bool RedisConfigured, + bool RedisReady, + bool WorkerReady, + DateTimeOffset? LastHeartbeatAt, + bool ClamAv, + string StorageProvider, + bool StorageConfigured, + DateTimeOffset CheckedAt); + +public sealed record PlatformWorkerState( + string WorkerId, + string Processor, + DateTimeOffset StartedAt, + DateTimeOffset LastHeartbeatAt, + DateTimeOffset? LastIterationStartedAt, + DateTimeOffset? LastIterationCompletedAt, + DateTimeOffset? LastSucceededAt, + string? LastError, + bool IsRunning, + bool Stale); + +public sealed record PlatformMetricCount(string Status, int Count); + +public sealed record PlatformJobMetrics( + IReadOnlyCollection Counts, + DateTimeOffset? OldestPendingAt, + double QueueAgeSeconds, + int ExpiredLeases, + DateTimeOffset CheckedAt); + +public sealed record PlatformGovernanceMetrics( + IReadOnlyCollection Approvals, + int ExpiredPending, + int ConfigurationDrafts, + IReadOnlyCollection Notifications, + DateTimeOffset CheckedAt); + +public interface IPlatformOperationsQueryService +{ + Task GetHealthAsync(CancellationToken cancellationToken = default); + Task> GetWorkersAsync(CancellationToken cancellationToken = default); + Task GetJobMetricsAsync(CancellationToken cancellationToken = default); + Task GetGovernanceMetricsAsync(CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Profile/ProfileException.cs b/Tiku.Application/Profile/ProfileException.cs new file mode 100644 index 0000000..4791ada --- /dev/null +++ b/Tiku.Application/Profile/ProfileException.cs @@ -0,0 +1,6 @@ +namespace Tiku.Application.Profile; + +public sealed class ProfileException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs b/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs new file mode 100644 index 0000000..dc1314c --- /dev/null +++ b/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs @@ -0,0 +1,5 @@ +namespace Tiku.Application.QuestionBanks; + +public sealed class QuestionBankRequiredFieldException(string message) : Exception(message); + +public sealed class QuestionBankNotFoundException(string message) : Exception(message); diff --git a/Tiku.Application/Scoreline/ScorelineQueryException.cs b/Tiku.Application/Scoreline/ScorelineQueryException.cs new file mode 100644 index 0000000..9a4396b --- /dev/null +++ b/Tiku.Application/Scoreline/ScorelineQueryException.cs @@ -0,0 +1,6 @@ +namespace Tiku.Application.Scoreline; + +public sealed class ScorelineQueryException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/Security/DependencyReadiness.cs b/Tiku.Application/Security/DependencyReadiness.cs new file mode 100644 index 0000000..4b3e078 --- /dev/null +++ b/Tiku.Application/Security/DependencyReadiness.cs @@ -0,0 +1,8 @@ +namespace Tiku.Application.Security; + +public sealed record DependencyReadiness(bool Ready, DateTimeOffset CheckedAt); + +public interface IDependencyReadinessProbe +{ + Task CheckAsync(CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Tenancy/PublicTenantConfiguration.cs b/Tiku.Application/Tenancy/PublicTenantConfiguration.cs new file mode 100644 index 0000000..5d84bce --- /dev/null +++ b/Tiku.Application/Tenancy/PublicTenantConfiguration.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace Tiku.Application.Tenancy; + +public sealed record PublicTenantConfiguration( + string? BrandName, + string? ShortName, + string? Slogan, + string? LogoUrl, + string? FaviconUrl, + string? ServiceWechat, + string? ServiceAccountName, + JsonElement Theme, + JsonElement PublicAssets, + JsonElement FeatureFlags, + JsonElement AdminFeatureFlags, + JsonElement PublicConfig); + +public interface IPublicTenantConfigurationQuery +{ + Task GetAsync( + Guid tenantId, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs index 1e0c761..b2e5cbc 100644 --- a/Tiku.Infrastructure/Assets/AssetManagementService.cs +++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs @@ -14,958 +14,17 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; -public sealed class AssetManagementService( +public sealed partial class AssetManagementService( TikuDbContext dbContext, IObjectStorageService objectStorageService, ITenantExternalProviderConfigService providerConfigService, IFeatureAccessService featureAccessService, - IBackgroundJobService backgroundJobService) : IAssetManagementService + IBackgroundJobQueue backgroundJobService) : IAssetManagementService { private const int DefaultLimit = 100; private const int MaxLimit = 500; private static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15); private static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1); - public async Task> GetAssetsAsync( - AssetManagementActor actor, - AssetManagementFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.ContentAssets - .AsNoTracking() - .Where(asset => asset.TenantId == actor.TenantId); - if (filter.RegionId.HasValue) - { - query = query.Where(asset => asset.RegionId == filter.RegionId.Value); - } - - if (filter.SubjectId.HasValue) - { - query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value); - } - - if (filter.CategoryId.HasValue) - { - query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.AssetType) && - Enum.TryParse(filter.AssetType, ignoreCase: true, out var assetType)) - { - query = query.Where(asset => asset.AssetType == assetType); - } - - if (!string.IsNullOrWhiteSpace(filter.UploadStatus) && - Enum.TryParse(filter.UploadStatus, ignoreCase: true, out var uploadStatus)) - { - query = query.Where(asset => asset.UploadStatus == uploadStatus); - } - - if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) && - Enum.TryParse(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus)) - { - query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus); - } - - if (!string.IsNullOrWhiteSpace(filter.Category)) - { - var category = filter.Category.Trim(); - query = query.Where(asset => asset.Category == category); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(asset => - (asset.Title != null && asset.Title.Contains(keyword)) || - (asset.FileName != null && asset.FileName.Contains(keyword)) || - (asset.Description != null && asset.Description.Contains(keyword)) || - (asset.AssetKey != null && asset.AssetKey.Contains(keyword))); - } - - var items = await query - .OrderBy(asset => asset.SortOrder) - .ThenByDescending(asset => asset.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(asset => ToItem(asset)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertAssetAsync( - AssetManagementActor actor, - UpsertAssetCommand command, - CancellationToken cancellationToken = default) - { - var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken); - var accountedBytesBefore = AccountedStorageBytes(asset); - asset.RegionId = command.RegionId; - asset.SubjectId = command.SubjectId; - asset.CategoryId = command.CategoryId; - asset.ContentNodeId = command.ContentNodeId; - asset.LegacyId = NormalizeOptional(command.LegacyId); - asset.AssetKey = NormalizeOptional(command.AssetKey); - asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源"; - asset.Category = NormalizeOptional(command.Category); - asset.Description = NormalizeOptional(command.Description); - asset.FileName = NormalizeOptional(command.FileName); - asset.CdnUrl = NormalizeOptional(command.CdnUrl); - asset.IsPublic = command.IsPublic ?? asset.IsPublic; - asset.AssetType = ParseEnum(command.AssetType, asset.AssetType); - asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); - asset.Status = ParseEnum(command.Status, asset.Status); - asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider))); - asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim(); - asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey) - ? asset.ObjectKey - : objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim()); - asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim()); - asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes); - asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; - asset.PreviewUrl = NormalizeOptional(command.PreviewUrl); - asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey; - asset.SortOrder = command.Order ?? asset.SortOrder; - asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules; - asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata; - asset.UpdatedBy = actor.UserId; - - var accountedBytesAfter = AccountedStorageBytes(asset); - await SaveWithStorageQuotaAdjustmentAsync( - actor.TenantId, - accountedBytesAfter - accountedBytesBefore, - cancellationToken); - return new ContentManagementResult(ToItem(asset)); - } - - public async Task SignUploadAsync( - AssetManagementActor actor, - AssetUploadSignCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName); - ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType); - - var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken); - var provider = storageConfig.Provider; - var bucket = storageConfig.Bucket; - var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); - var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); - var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); - var objectKey = objectStorageService.ValidateObjectKey( - actor.TenantId, - string.IsNullOrWhiteSpace(command.ObjectKey) - ? CreateObjectKey(actor.TenantId, asset.Id, command.FileName) - : command.ObjectKey.Trim()); - - objectStorageService.AssertUploadProvider(provider); - objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey)); - - asset.StorageProvider = ToAssetStorageProvider(provider); - asset.Bucket = bucket; - asset.ObjectKey = objectKey; - asset.FileName = command.FileName.Trim(); - asset.MimeType = mimeType; - asset.FileSizeBytes = fileSizeBytes; - asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256); - asset.UploadStatus = AssetUploadStatus.Pending; - asset.VerifiedAt = null; - asset.VerifiedBy = null; - asset.VerificationDetails = JsonDefaults.Object(); - asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; - asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType); - asset.UpdatedBy = actor.UserId; - - var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds); - var upload = await objectStorageService.SignUploadAsync( - new ObjectStorageUploadSignRequest( - actor.TenantId, - provider, - bucket, - objectKey, - asset.FileName, - mimeType, - fileSizeBytes, - expiresIn, - Upsert: true), - cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); - return new AssetUploadSignResult(ToItem(asset), upload); - } - - public async Task ConfirmUploadAsync( - AssetManagementActor actor, - AssetUploadConfirmCommand command, - CancellationToken cancellationToken = default) - { - var asset = await dbContext.ContentAssets - .SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.Id == command.AssetId && - item.Status == ContentStatus.Active, - cancellationToken); - - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } - - if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket)) - { - throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing"); - } - - var accountedBytesBefore = AccountedStorageBytes(asset); - var provider = ToObjectStorageProvider(asset.StorageProvider); - var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim(); - var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes; - var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; - var metadata = await objectStorageService.HeadObjectAsync( - new ObjectStorageHeadRequest( - actor.TenantId, - provider, - asset.Bucket, - asset.ObjectKey, - declaredMimeType, - declaredSize, - declaredChecksum), - cancellationToken); - - asset.VerificationDetails = JsonSerializer.SerializeToElement(new - { - metadata.Exists, - metadata.SizeBytes, - metadata.MimeType, - metadata.ChecksumSha256, - metadata.ETag, - metadata.LastModified, - metadata.VerificationSource, - declared = new - { - MimeType = declaredMimeType, - FileSizeBytes = declaredSize, - ChecksumSha256 = declaredChecksum - } - }); - - if (!metadata.Exists) - { - asset.UploadStatus = AssetUploadStatus.Failed; - asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); - throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing"); - } - if (metadata.SizeBytes is not { } verifiedSizeBytes) - { - asset.UploadStatus = AssetUploadStatus.Failed; - asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); - throw new AssetManagementException( - "Object storage did not return a verified asset size.", - "asset_upload_size_unverified"); - } - - asset.UploadStatus = AssetUploadStatus.Verified; - asset.VerifiedAt = DateTimeOffset.UtcNow; - asset.VerifiedBy = actor.UserId; - asset.VerifiedSizeBytes = verifiedSizeBytes; - asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum; - asset.MimeType = metadata.MimeType ?? declaredMimeType; - asset.FileSizeBytes = verifiedSizeBytes; - asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; - asset.UpdatedBy = actor.UserId; - var accountedBytesAfter = AccountedStorageBytes(asset); - await SaveWithStorageQuotaAdjustmentAsync( - actor.TenantId, - 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); - } - - public async Task> ArchiveAssetAsync( - AssetManagementActor actor, - Guid assetId, - CancellationToken cancellationToken = default) - { - var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == assetId, - cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } - - if (asset.Status == ContentStatus.Archived) - { - return new ContentManagementResult(ToItem(asset)); - } - - var accountedBytes = AccountedStorageBytes(asset); - asset.Status = ContentStatus.Archived; - asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); - if (accountedBytes > 0) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StorageBytes, - accountedBytes, - CancellationToken.None); - } - - return new ContentManagementResult(ToItem(asset)); - } - - public Task SignDownloadAsync( - AssetManagementActor actor, - AssetAccessSignCommand command, - CancellationToken cancellationToken = default) - { - return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken); - } - - public Task SignPreviewAsync( - AssetManagementActor actor, - AssetAccessSignCommand command, - CancellationToken cancellationToken = default) - { - return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken); - } - - public async Task> GetAccessEventsAsync( - AssetManagementActor actor, - AssetEventFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.ContentAssetAccessEvents.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (filter.AssetId.HasValue) - { - query = query.Where(item => item.AssetId == filter.AssetId.Value); - } - - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } - - var items = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new ContentAssetAccessEventItem( - item.Id, - item.AssetId, - item.UserId, - item.ActorRole, - item.AccessType, - item.Visibility, - item.AssetType, - item.StorageProvider, - item.Disposition, - item.ExpiresInSeconds, - item.SignatureMode, - item.Result, - item.DenyCode, - item.IpAddress, - item.UserAgent, - item.Metadata, - item.CreatedAt)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> GetSecurityScanEventsAsync( - AssetManagementActor actor, - AssetEventFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (filter.AssetId.HasValue) - { - query = query.Where(item => item.AssetId == filter.AssetId.Value); - } - - var items = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new ContentAssetSecurityScanEventItem( - item.Id, - item.AssetId, - item.Provider, - item.ScanStatus, - item.RiskLevel, - item.IssueCodes, - item.Details, - item.CreatedAt)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> GetImportJobsAsync( - AssetManagementActor actor, - ImportJobFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.ContentImportJobs - .AsNoTracking() - .Where(job => job.TenantId == actor.TenantId); - - if (!string.IsNullOrWhiteSpace(filter.Status) && - Enum.TryParse(filter.Status, ignoreCase: true, out var status)) - { - query = query.Where(job => job.Status == status); - } - - if (!string.IsNullOrWhiteSpace(filter.ImportType) && - Enum.TryParse(filter.ImportType, ignoreCase: true, out var importType)) - { - query = query.Where(job => job.ImportType == importType); - } - - if (!string.IsNullOrWhiteSpace(filter.SourceFormat) && - Enum.TryParse(filter.SourceFormat, ignoreCase: true, out var sourceFormat)) - { - query = query.Where(job => job.SourceFormat == sourceFormat); - } - - var items = await query - .OrderByDescending(job => job.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(job => ToJobItem(job)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task GetImportJobAsync( - AssetManagementActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) - .Select(item => ToJobItem(item)) - .SingleOrDefaultAsync(cancellationToken); - - if (job is null) - { - throw new AssetManagementException("Import job was not found.", "import_job_not_found"); - } - - var items = await dbContext.ContentImportItems - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) - .OrderBy(item => item.RowNo) - .Take(MaxLimit) - .Select(item => new ContentImportItemModel( - item.Id, - item.JobId, - item.RowNo, - item.ExternalId, - item.Status, - item.TargetType, - item.TargetId, - item.SourcePayload, - item.NormalizedPayload, - item.ContentHash, - item.IssuesCount)) - .ToArrayAsync(cancellationToken); - - var issues = await dbContext.ContentImportIssues - .AsNoTracking() - .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) - .OrderBy(issue => issue.RowNo) - .ThenBy(issue => issue.CreatedAt) - .Take(MaxLimit) - .Select(issue => new ContentImportIssueModel( - issue.Id, - issue.JobId, - issue.ItemId, - issue.RowNo, - issue.Severity, - issue.Code, - issue.FieldPath, - issue.Message, - issue.Details)) - .ToArrayAsync(cancellationToken); - - return new ContentImportJobDetail(job, items, issues); - } - - private async Task ResolveUploadAssetAsync( - AssetManagementActor actor, - AssetUploadSignCommand command, - string provider, - string bucket, - string mimeType, - long? fileSizeBytes, - CancellationToken cancellationToken) - { - ContentAsset? asset = null; - if (command.AssetId.HasValue) - { - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, - cancellationToken); - - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } - } - - if (asset is null) - { - asset = new ContentAsset - { - Id = command.AssetId ?? Guid.NewGuid(), - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - Source = "manual", - Status = ContentStatus.Active - }; - dbContext.ContentAssets.Add(asset); - } - - asset.RegionId = command.RegionId; - asset.SubjectId = command.SubjectId; - asset.CategoryId = command.CategoryId; - asset.ContentNodeId = command.ContentNodeId; - asset.AssetKey = NormalizeOptional(command.AssetKey); - asset.Title = NormalizeOptional(command.Title) ?? command.FileName.Trim(); - asset.Category = NormalizeOptional(command.Category); - asset.Description = NormalizeOptional(command.Description); - asset.IsPublic = command.IsPublic ?? false; - asset.AssetType = ResolveAssetType(command.AssetType, mimeType); - asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); - asset.StorageProvider = ToAssetStorageProvider(provider); - asset.Bucket = bucket; - asset.FileSizeBytes = fileSizeBytes; - asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata; - return asset; - } - - private async Task ResolveManagementAssetAsync( - AssetManagementActor actor, - UpsertAssetCommand command, - CancellationToken cancellationToken) - { - ContentAsset? asset = null; - if (command.AssetId.HasValue) - { - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, - cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } - } - else if (!string.IsNullOrWhiteSpace(command.LegacyId)) - { - var legacyId = command.LegacyId.Trim(); - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.LegacyId == legacyId, - cancellationToken); - } - - if (asset is not null) - { - return asset; - } - - asset = new ContentAsset - { - Id = command.AssetId ?? Guid.NewGuid(), - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - UpdatedBy = actor.UserId, - Source = "manual", - Status = ContentStatus.Active - }; - dbContext.ContentAssets.Add(asset); - return asset; - } - - private async Task SignAssetAccessAsync( - AssetManagementActor actor, - AssetAccessSignCommand command, - AssetAccessType accessType, - string disposition, - CancellationToken cancellationToken) - { - var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, - cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } - - var provider = ToObjectStorageProvider(asset.StorageProvider); - var objectKey = accessType == AssetAccessType.AdminPreview - ? asset.PreviewObjectKey ?? asset.ObjectKey - : asset.ObjectKey; - var cdnUrl = accessType == AssetAccessType.AdminPreview - ? asset.PreviewUrl ?? asset.CdnUrl - : asset.CdnUrl; - var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600)); - var url = await objectStorageService.SignDownloadAsync( - new ObjectStorageDownloadSignRequest( - actor.TenantId, - provider, - asset.Bucket, - objectKey, - expiresIn, - cdnUrl, - asset.FileName, - disposition), - cancellationToken); - dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent - { - TenantId = actor.TenantId, - AssetId = asset.Id, - UserId = actor.UserId, - ActorRole = AssetAccessActorRole.TenantAdmin, - AccessType = accessType, - Visibility = asset.Visibility.ToString(), - AssetType = asset.AssetType.ToString(), - StorageProvider = asset.StorageProvider.ToString(), - Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment, - ExpiresInSeconds = (int)expiresIn.TotalSeconds, - SignatureMode = url.SignatureMode, - Result = AssetAccessResult.Granted, - Metadata = JsonSerializer.SerializeToElement(new - { - url.Provider, - url.Bucket, - url.ObjectKey - }) - }); - await dbContext.SaveChangesAsync(cancellationToken); - return new AssetManagementSignedAccessResult(ToItem(asset), url); - } - - private static ContentAssetManagementItem ToItem(ContentAsset asset) - { - return new ContentAssetManagementItem( - asset.Id, - asset.LegacyId, - asset.RegionId, - asset.SubjectId, - asset.CategoryId, - asset.ContentNodeId, - asset.AssetKey, - asset.Title, - asset.Category, - asset.Description, - asset.FileName, - asset.CdnUrl, - asset.IsPublic, - asset.AssetType, - asset.StorageProvider, - asset.Bucket, - asset.ObjectKey, - asset.MimeType, - asset.FileSizeBytes, - asset.ChecksumSha256, - asset.Visibility, - asset.Status, - asset.SortOrder, - asset.UploadStatus, - asset.VerifiedAt, - asset.VerifiedSizeBytes, - asset.VerifiedChecksumSha256, - asset.PreviewStatus, - asset.SecurityScanStatus, - asset.Metadata, - asset.CreatedAt, - asset.UpdatedAt); - } - - private async Task SaveWithStorageQuotaAdjustmentAsync( - Guid tenantId, - long byteDelta, - CancellationToken cancellationToken) - { - if (byteDelta > 0) - { - var reserved = await featureAccessService.TryConsumeQuotaAsync( - tenantId, - SaasQuotaMetricCatalog.StorageBytes, - byteDelta, - cancellationToken); - if (!reserved) - { - throw new FeatureAccessException( - "Tenant storage quota is exhausted.", - "feature_quota_exhausted"); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch - { - await featureAccessService.ReleaseQuotaAsync( - tenantId, - SaasQuotaMetricCatalog.StorageBytes, - byteDelta, - CancellationToken.None); - throw; - } - return; - } - - await dbContext.SaveChangesAsync(cancellationToken); - if (byteDelta < 0) - { - await featureAccessService.ReleaseQuotaAsync( - tenantId, - SaasQuotaMetricCatalog.StorageBytes, - -byteDelta, - CancellationToken.None); - } - } - - private static long AccountedStorageBytes(ContentAsset asset) - { - return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0 - ? asset.VerifiedSizeBytes.Value - : 0; - } - - private static ContentImportJobItem ToJobItem(ContentImportJob job) - { - return new ContentImportJobItem( - job.Id, - job.TargetRegionId, - job.TargetSubjectId, - job.TargetCategoryId, - job.TargetContentNodeId, - job.TargetQuestionBankId, - job.ImportType, - job.SourceFormat, - job.Status, - job.SourceName, - job.SourceHash, - job.DryRun, - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount, - job.Summary, - job.ErrorMessage, - job.StartedAt, - job.FinishedAt, - job.CreatedAt, - job.UpdatedAt); - } - - private static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName) - { - var now = DateTimeOffset.UtcNow; - return $"{tenantId:N}/assets/{now:yyyy}/{now:MM}/{assetId:N}/{SanitizeFileName(fileName)}"; - } - - private static string SanitizeFileName(string fileName) - { - var trimmed = Path.GetFileName(fileName.Trim()); - return string.Join( - "-", - trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - } - - private static string? NormalizeOptional(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static string? NormalizeChecksum(string? checksum) - { - return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant(); - } - - private static TimeSpan ResolveUploadTtl(int? expiresInSeconds) - { - if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) - { - return DefaultUploadTtl; - } - - var requested = TimeSpan.FromSeconds(expiresInSeconds.Value); - return requested <= MaxUploadTtl ? requested : MaxUploadTtl; - } - - private static int ResolveLimit(int? limit) - { - if (!limit.HasValue || limit <= 0) - { - return DefaultLimit; - } - - return Math.Min(limit.Value, MaxLimit); - } - - private static ContentAssetType ResolveAssetType(string? value, string mimeType) - { - if (!string.IsNullOrWhiteSpace(value) && - Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } - - var normalizedMimeType = mimeType.ToLowerInvariant(); - if (normalizedMimeType == "application/pdf") - { - return ContentAssetType.Pdf; - } - - if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) - { - return ContentAssetType.Image; - } - - if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) - { - return ContentAssetType.Video; - } - - if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) - { - return ContentAssetType.Audio; - } - - return ContentAssetType.Document; - } - - private static ContentVisibility ResolveVisibility(string? value, bool isPublic) - { - if (!string.IsNullOrWhiteSpace(value) && - Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } - - return isPublic ? ContentVisibility.Public : ContentVisibility.Members; - } - - private static TEnum ParseEnum(string? value, TEnum fallback) - where TEnum : struct - { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } - - return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) - ? parsed - : fallback; - } - - private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType) - { - return assetType is ContentAssetType.Pdf or ContentAssetType.Image || - mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) || - mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) - ? AssetPreviewStatus.Pending - : AssetPreviewStatus.None; - } - - private static AssetStorageProvider ToAssetStorageProvider(string provider) - { - return provider switch - { - ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl, - ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, - ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos, - ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo, - ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev, - _ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported") - }; - } - - private static string ToObjectStorageProvider(AssetStorageProvider provider) - { - return provider switch - { - AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, - AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, - AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, - AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, - AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, - _ => ObjectStorageProviders.ExternalUrl - }; - } - - private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync( - Guid tenantId, - CancellationToken cancellationToken) - { - try - { - var account = await providerConfigService.GetActiveProviderAsync( - tenantId, - TenantExternalProviderCapability.ObjectStorage, - cancellationToken: cancellationToken); - var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket"); - if (string.IsNullOrWhiteSpace(bucket)) - { - throw new ObjectStorageException( - "Object storage provider bucket is not configured.", - "STORAGE_BUCKET_NOT_CONFIGURED"); - } - - return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim()); - } - catch (TenantExternalProviderException) - { - return ( - objectStorageService.ConfiguredDefaultProvider(), - objectStorageService.ConfiguredDefaultBucket()); - } - } - - private static string? GetJsonString(JsonElement element, params string[] keys) - { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } - - foreach (var key in keys) - { - if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) - { - return value.GetString(); - } - } - - return null; - } } diff --git a/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs b/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs new file mode 100644 index 0000000..43891a2 --- /dev/null +++ b/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + public async Task> GetAccessEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssetAccessEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (filter.AssetId.HasValue) + { + query = query.Where(item => item.AssetId == filter.AssetId.Value); + } + + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new ContentAssetAccessEventItem( + item.Id, + item.AssetId, + item.UserId, + item.ActorRole, + item.AccessType, + item.Visibility, + item.AssetType, + item.StorageProvider, + item.Disposition, + item.ExpiresInSeconds, + item.SignatureMode, + item.Result, + item.DenyCode, + item.IpAddress, + item.UserAgent, + item.Metadata, + item.CreatedAt)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> GetSecurityScanEventsAsync( + AssetManagementActor actor, + AssetEventFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (filter.AssetId.HasValue) + { + query = query.Where(item => item.AssetId == filter.AssetId.Value); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new ContentAssetSecurityScanEventItem( + item.Id, + item.AssetId, + item.Provider, + item.ScanStatus, + item.RiskLevel, + item.IssueCodes, + item.Details, + item.CreatedAt)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + +} diff --git a/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs b/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs new file mode 100644 index 0000000..ebb4321 --- /dev/null +++ b/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + public async Task> GetAssetsAsync( + AssetManagementActor actor, + AssetManagementFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentAssets + .AsNoTracking() + .Where(asset => asset.TenantId == actor.TenantId); + + if (filter.RegionId.HasValue) + { + query = query.Where(asset => asset.RegionId == filter.RegionId.Value); + } + + if (filter.SubjectId.HasValue) + { + query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value); + } + + if (filter.CategoryId.HasValue) + { + query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.AssetType) && + Enum.TryParse(filter.AssetType, ignoreCase: true, out var assetType)) + { + query = query.Where(asset => asset.AssetType == assetType); + } + + if (!string.IsNullOrWhiteSpace(filter.UploadStatus) && + Enum.TryParse(filter.UploadStatus, ignoreCase: true, out var uploadStatus)) + { + query = query.Where(asset => asset.UploadStatus == uploadStatus); + } + + if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) && + Enum.TryParse(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus)) + { + query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus); + } + + if (!string.IsNullOrWhiteSpace(filter.Category)) + { + var category = filter.Category.Trim(); + query = query.Where(asset => asset.Category == category); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(asset => + (asset.Title != null && asset.Title.Contains(keyword)) || + (asset.FileName != null && asset.FileName.Contains(keyword)) || + (asset.Description != null && asset.Description.Contains(keyword)) || + (asset.AssetKey != null && asset.AssetKey.Contains(keyword))); + } + + var items = await query + .OrderBy(asset => asset.SortOrder) + .ThenByDescending(asset => asset.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(asset => ToItem(asset)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertAssetAsync( + AssetManagementActor actor, + UpsertAssetCommand command, + CancellationToken cancellationToken = default) + { + var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken); + var accountedBytesBefore = AccountedStorageBytes(asset); + asset.RegionId = command.RegionId; + asset.SubjectId = command.SubjectId; + asset.CategoryId = command.CategoryId; + asset.ContentNodeId = command.ContentNodeId; + asset.LegacyId = NormalizeOptional(command.LegacyId); + asset.AssetKey = NormalizeOptional(command.AssetKey); + asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源"; + asset.Category = NormalizeOptional(command.Category); + asset.Description = NormalizeOptional(command.Description); + asset.FileName = NormalizeOptional(command.FileName); + asset.CdnUrl = NormalizeOptional(command.CdnUrl); + asset.IsPublic = command.IsPublic ?? asset.IsPublic; + asset.AssetType = ParseEnum(command.AssetType, asset.AssetType); + asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); + asset.Status = ParseEnum(command.Status, asset.Status); + asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider))); + asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim(); + asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey) + ? asset.ObjectKey + : objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim()); + asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim()); + asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes); + asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; + asset.PreviewUrl = NormalizeOptional(command.PreviewUrl); + asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey; + asset.SortOrder = command.Order ?? asset.SortOrder; + asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules; + asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata; + asset.UpdatedBy = actor.UserId; + + var accountedBytesAfter = AccountedStorageBytes(asset); + await SaveWithStorageQuotaAdjustmentAsync( + actor.TenantId, + accountedBytesAfter - accountedBytesBefore, + cancellationToken); + return new ContentManagementResult(ToItem(asset)); + } + + +} diff --git a/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs b/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs new file mode 100644 index 0000000..c807e69 --- /dev/null +++ b/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + private async Task ResolveUploadAssetAsync( + AssetManagementActor actor, + AssetUploadSignCommand command, + string provider, + string bucket, + string mimeType, + long? fileSizeBytes, + CancellationToken cancellationToken) + { + ContentAsset? asset = null; + if (command.AssetId.HasValue) + { + asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, + cancellationToken); + + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + } + + if (asset is null) + { + asset = new ContentAsset + { + Id = command.AssetId ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + Source = "manual", + Status = ContentStatus.Active + }; + dbContext.ContentAssets.Add(asset); + } + + asset.RegionId = command.RegionId; + asset.SubjectId = command.SubjectId; + asset.CategoryId = command.CategoryId; + asset.ContentNodeId = command.ContentNodeId; + asset.AssetKey = NormalizeOptional(command.AssetKey); + asset.Title = NormalizeOptional(command.Title) ?? command.FileName.Trim(); + asset.Category = NormalizeOptional(command.Category); + asset.Description = NormalizeOptional(command.Description); + asset.IsPublic = command.IsPublic ?? false; + asset.AssetType = ResolveAssetType(command.AssetType, mimeType); + asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); + asset.StorageProvider = ToAssetStorageProvider(provider); + asset.Bucket = bucket; + asset.FileSizeBytes = fileSizeBytes; + asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata; + return asset; + } + + private async Task ResolveManagementAssetAsync( + AssetManagementActor actor, + UpsertAssetCommand command, + CancellationToken cancellationToken) + { + ContentAsset? asset = null; + if (command.AssetId.HasValue) + { + asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, + cancellationToken); + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + } + else if (!string.IsNullOrWhiteSpace(command.LegacyId)) + { + var legacyId = command.LegacyId.Trim(); + asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.LegacyId == legacyId, + cancellationToken); + } + + if (asset is not null) + { + return asset; + } + + asset = new ContentAsset + { + Id = command.AssetId ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId, + Source = "manual", + Status = ContentStatus.Active + }; + dbContext.ContentAssets.Add(asset); + return asset; + } + + private async Task SignAssetAccessAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + AssetAccessType accessType, + string disposition, + CancellationToken cancellationToken) + { + var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, + cancellationToken); + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + + var provider = ToObjectStorageProvider(asset.StorageProvider); + var objectKey = accessType == AssetAccessType.AdminPreview + ? asset.PreviewObjectKey ?? asset.ObjectKey + : asset.ObjectKey; + var cdnUrl = accessType == AssetAccessType.AdminPreview + ? asset.PreviewUrl ?? asset.CdnUrl + : asset.CdnUrl; + var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600)); + var url = await objectStorageService.SignDownloadAsync( + new ObjectStorageDownloadSignRequest( + actor.TenantId, + provider, + asset.Bucket, + objectKey, + expiresIn, + cdnUrl, + asset.FileName, + disposition), + cancellationToken); + dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent + { + TenantId = actor.TenantId, + AssetId = asset.Id, + UserId = actor.UserId, + ActorRole = AssetAccessActorRole.TenantAdmin, + AccessType = accessType, + Visibility = asset.Visibility.ToString(), + AssetType = asset.AssetType.ToString(), + StorageProvider = asset.StorageProvider.ToString(), + Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment, + ExpiresInSeconds = (int)expiresIn.TotalSeconds, + SignatureMode = url.SignatureMode, + Result = AssetAccessResult.Granted, + Metadata = JsonSerializer.SerializeToElement(new + { + url.Provider, + url.Bucket, + url.ObjectKey + }) + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new AssetManagementSignedAccessResult(ToItem(asset), url); + } + + private static ContentAssetManagementItem ToItem(ContentAsset asset) + { + return new ContentAssetManagementItem( + asset.Id, + asset.LegacyId, + asset.RegionId, + asset.SubjectId, + asset.CategoryId, + asset.ContentNodeId, + asset.AssetKey, + asset.Title, + asset.Category, + asset.Description, + asset.FileName, + asset.CdnUrl, + asset.IsPublic, + asset.AssetType, + asset.StorageProvider, + asset.Bucket, + asset.ObjectKey, + asset.MimeType, + asset.FileSizeBytes, + asset.ChecksumSha256, + asset.Visibility, + asset.Status, + asset.SortOrder, + asset.UploadStatus, + asset.VerifiedAt, + asset.VerifiedSizeBytes, + asset.VerifiedChecksumSha256, + asset.PreviewStatus, + asset.SecurityScanStatus, + asset.Metadata, + asset.CreatedAt, + asset.UpdatedAt); + } + + private async Task SaveWithStorageQuotaAdjustmentAsync( + Guid tenantId, + long byteDelta, + CancellationToken cancellationToken) + { + if (byteDelta > 0) + { + var reserved = await featureAccessService.TryConsumeQuotaAsync( + tenantId, + SaasQuotaMetricCatalog.StorageBytes, + byteDelta, + cancellationToken); + if (!reserved) + { + throw new FeatureAccessException( + "Tenant storage quota is exhausted.", + "feature_quota_exhausted"); + } + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + await featureAccessService.ReleaseQuotaAsync( + tenantId, + SaasQuotaMetricCatalog.StorageBytes, + byteDelta, + CancellationToken.None); + throw; + } + return; + } + + await dbContext.SaveChangesAsync(cancellationToken); + if (byteDelta < 0) + { + await featureAccessService.ReleaseQuotaAsync( + tenantId, + SaasQuotaMetricCatalog.StorageBytes, + -byteDelta, + CancellationToken.None); + } + } + + private static long AccountedStorageBytes(ContentAsset asset) + { + return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0 + ? asset.VerifiedSizeBytes.Value + : 0; + } + + private static ContentImportJobItem ToJobItem(ContentImportJob job) + { + return new ContentImportJobItem( + job.Id, + job.TargetRegionId, + job.TargetSubjectId, + job.TargetCategoryId, + job.TargetContentNodeId, + job.TargetQuestionBankId, + job.ImportType, + job.SourceFormat, + job.Status, + job.SourceName, + job.SourceHash, + job.DryRun, + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount, + job.Summary, + job.ErrorMessage, + job.StartedAt, + job.FinishedAt, + job.CreatedAt, + job.UpdatedAt); + } + + private static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName) + { + var now = DateTimeOffset.UtcNow; + return $"{tenantId:N}/assets/{now:yyyy}/{now:MM}/{assetId:N}/{SanitizeFileName(fileName)}"; + } + + private static string SanitizeFileName(string fileName) + { + var trimmed = Path.GetFileName(fileName.Trim()); + return string.Join( + "-", + trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string? NormalizeChecksum(string? checksum) + { + return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant(); + } + + private static TimeSpan ResolveUploadTtl(int? expiresInSeconds) + { + if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) + { + return DefaultUploadTtl; + } + + var requested = TimeSpan.FromSeconds(expiresInSeconds.Value); + return requested <= MaxUploadTtl ? requested : MaxUploadTtl; + } + + private static int ResolveLimit(int? limit) + { + if (!limit.HasValue || limit <= 0) + { + return DefaultLimit; + } + + return Math.Min(limit.Value, MaxLimit); + } + + private static ContentAssetType ResolveAssetType(string? value, string mimeType) + { + if (!string.IsNullOrWhiteSpace(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + var normalizedMimeType = mimeType.ToLowerInvariant(); + if (normalizedMimeType == "application/pdf") + { + return ContentAssetType.Pdf; + } + + if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) + { + return ContentAssetType.Image; + } + + if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) + { + return ContentAssetType.Video; + } + + if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) + { + return ContentAssetType.Audio; + } + + return ContentAssetType.Document; + } + + private static ContentVisibility ResolveVisibility(string? value, bool isPublic) + { + if (!string.IsNullOrWhiteSpace(value) && + Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return isPublic ? ContentVisibility.Public : ContentVisibility.Members; + } + + private static TEnum ParseEnum(string? value, TEnum fallback) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) + ? parsed + : fallback; + } + + private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType) + { + return assetType is ContentAssetType.Pdf or ContentAssetType.Image || + mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) || + mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + ? AssetPreviewStatus.Pending + : AssetPreviewStatus.None; + } + + private static AssetStorageProvider ToAssetStorageProvider(string provider) + { + return provider switch + { + ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl, + ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, + ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos, + ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo, + ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev, + _ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported") + }; + } + + private static string ToObjectStorageProvider(AssetStorageProvider provider) + { + return provider switch + { + AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl, + AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, + AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos, + AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo, + AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, + _ => ObjectStorageProviders.ExternalUrl + }; + } + + private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync( + Guid tenantId, + CancellationToken cancellationToken) + { + try + { + var account = await providerConfigService.GetActiveProviderAsync( + tenantId, + TenantExternalProviderCapability.ObjectStorage, + cancellationToken: cancellationToken); + var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket"); + if (string.IsNullOrWhiteSpace(bucket)) + { + throw new ObjectStorageException( + "Object storage provider bucket is not configured.", + "STORAGE_BUCKET_NOT_CONFIGURED"); + } + + return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim()); + } + catch (TenantExternalProviderException) + { + return ( + objectStorageService.ConfiguredDefaultProvider(), + objectStorageService.ConfiguredDefaultBucket()); + } + } + + private static string? GetJsonString(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } +} diff --git a/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs b/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs new file mode 100644 index 0000000..6e87bcc --- /dev/null +++ b/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + public async Task> GetImportJobsAsync( + AssetManagementActor actor, + ImportJobFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentImportJobs + .AsNoTracking() + .Where(job => job.TenantId == actor.TenantId); + + if (!string.IsNullOrWhiteSpace(filter.Status) && + Enum.TryParse(filter.Status, ignoreCase: true, out var status)) + { + query = query.Where(job => job.Status == status); + } + + if (!string.IsNullOrWhiteSpace(filter.ImportType) && + Enum.TryParse(filter.ImportType, ignoreCase: true, out var importType)) + { + query = query.Where(job => job.ImportType == importType); + } + + if (!string.IsNullOrWhiteSpace(filter.SourceFormat) && + Enum.TryParse(filter.SourceFormat, ignoreCase: true, out var sourceFormat)) + { + query = query.Where(job => job.SourceFormat == sourceFormat); + } + + var items = await query + .OrderByDescending(job => job.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(job => ToJobItem(job)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task GetImportJobAsync( + AssetManagementActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) + .Select(item => ToJobItem(item)) + .SingleOrDefaultAsync(cancellationToken); + + if (job is null) + { + throw new AssetManagementException("Import job was not found.", "import_job_not_found"); + } + + var items = await dbContext.ContentImportItems + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .Take(MaxLimit) + .Select(item => new ContentImportItemModel( + item.Id, + item.JobId, + item.RowNo, + item.ExternalId, + item.Status, + item.TargetType, + item.TargetId, + item.SourcePayload, + item.NormalizedPayload, + item.ContentHash, + item.IssuesCount)) + .ToArrayAsync(cancellationToken); + + var issues = await dbContext.ContentImportIssues + .AsNoTracking() + .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) + .OrderBy(issue => issue.RowNo) + .ThenBy(issue => issue.CreatedAt) + .Take(MaxLimit) + .Select(issue => new ContentImportIssueModel( + issue.Id, + issue.JobId, + issue.ItemId, + issue.RowNo, + issue.Severity, + issue.Code, + issue.FieldPath, + issue.Message, + issue.Details)) + .ToArrayAsync(cancellationToken); + + return new ContentImportJobDetail(job, items, issues); + } + + +} diff --git a/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs b/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs new file mode 100644 index 0000000..52dda06 --- /dev/null +++ b/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + public async Task> ArchiveAssetAsync( + AssetManagementActor actor, + Guid assetId, + CancellationToken cancellationToken = default) + { + var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == assetId, + cancellationToken); + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + + if (asset.Status == ContentStatus.Archived) + { + return new ContentManagementResult(ToItem(asset)); + } + + var accountedBytes = AccountedStorageBytes(asset); + asset.Status = ContentStatus.Archived; + asset.UpdatedBy = actor.UserId; + await dbContext.SaveChangesAsync(cancellationToken); + if (accountedBytes > 0) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StorageBytes, + accountedBytes, + CancellationToken.None); + } + + return new ContentManagementResult(ToItem(asset)); + } + + public Task SignDownloadAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default) + { + return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken); + } + + public Task SignPreviewAsync( + AssetManagementActor actor, + AssetAccessSignCommand command, + CancellationToken cancellationToken = default) + { + return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs b/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs new file mode 100644 index 0000000..8c0c148 --- /dev/null +++ b/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +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; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +public sealed partial class AssetManagementService +{ + public async Task SignUploadAsync( + AssetManagementActor actor, + AssetUploadSignCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName); + ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType); + + var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken); + var provider = storageConfig.Provider; + var bucket = storageConfig.Bucket; + var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); + var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); + var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); + var objectKey = objectStorageService.ValidateObjectKey( + actor.TenantId, + string.IsNullOrWhiteSpace(command.ObjectKey) + ? CreateObjectKey(actor.TenantId, asset.Id, command.FileName) + : command.ObjectKey.Trim()); + + objectStorageService.AssertUploadProvider(provider); + objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey)); + + asset.StorageProvider = ToAssetStorageProvider(provider); + asset.Bucket = bucket; + asset.ObjectKey = objectKey; + asset.FileName = command.FileName.Trim(); + asset.MimeType = mimeType; + asset.FileSizeBytes = fileSizeBytes; + asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256); + asset.UploadStatus = AssetUploadStatus.Pending; + asset.VerifiedAt = null; + asset.VerifiedBy = null; + asset.VerificationDetails = JsonDefaults.Object(); + asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; + asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType); + asset.UpdatedBy = actor.UserId; + + var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds); + var upload = await objectStorageService.SignUploadAsync( + new ObjectStorageUploadSignRequest( + actor.TenantId, + provider, + bucket, + objectKey, + asset.FileName, + mimeType, + fileSizeBytes, + expiresIn, + Upsert: true), + cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return new AssetUploadSignResult(ToItem(asset), upload); + } + + public async Task ConfirmUploadAsync( + AssetManagementActor actor, + AssetUploadConfirmCommand command, + CancellationToken cancellationToken = default) + { + var asset = await dbContext.ContentAssets + .SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.Id == command.AssetId && + item.Status == ContentStatus.Active, + cancellationToken); + + if (asset is null) + { + throw new AssetManagementException("Asset was not found.", "asset_not_found"); + } + + if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket)) + { + throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing"); + } + + var accountedBytesBefore = AccountedStorageBytes(asset); + var provider = ToObjectStorageProvider(asset.StorageProvider); + var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim(); + var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes; + var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; + var metadata = await objectStorageService.HeadObjectAsync( + new ObjectStorageHeadRequest( + actor.TenantId, + provider, + asset.Bucket, + asset.ObjectKey, + declaredMimeType, + declaredSize, + declaredChecksum), + cancellationToken); + + asset.VerificationDetails = JsonSerializer.SerializeToElement(new + { + metadata.Exists, + metadata.SizeBytes, + metadata.MimeType, + metadata.ChecksumSha256, + metadata.ETag, + metadata.LastModified, + metadata.VerificationSource, + declared = new + { + MimeType = declaredMimeType, + FileSizeBytes = declaredSize, + ChecksumSha256 = declaredChecksum + } + }); + + if (!metadata.Exists) + { + asset.UploadStatus = AssetUploadStatus.Failed; + asset.UpdatedBy = actor.UserId; + await dbContext.SaveChangesAsync(cancellationToken); + throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing"); + } + if (metadata.SizeBytes is not { } verifiedSizeBytes) + { + asset.UploadStatus = AssetUploadStatus.Failed; + asset.UpdatedBy = actor.UserId; + await dbContext.SaveChangesAsync(cancellationToken); + throw new AssetManagementException( + "Object storage did not return a verified asset size.", + "asset_upload_size_unverified"); + } + + asset.UploadStatus = AssetUploadStatus.Verified; + asset.VerifiedAt = DateTimeOffset.UtcNow; + asset.VerifiedBy = actor.UserId; + asset.VerifiedSizeBytes = verifiedSizeBytes; + asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum; + asset.MimeType = metadata.MimeType ?? declaredMimeType; + asset.FileSizeBytes = verifiedSizeBytes; + asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; + asset.UpdatedBy = actor.UserId; + var accountedBytesAfter = AccountedStorageBytes(asset); + await SaveWithStorageQuotaAdjustmentAsync( + actor.TenantId, + 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/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index 15c0125..bd572a0 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; -public sealed class AuthService( +public sealed partial class AuthService( TikuDbContext dbContext, SignInManager signInManager, UserManager userManager, @@ -31,855 +31,5 @@ public sealed class AuthService( private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; - public async Task LoginWithPasswordAsync( - PasswordLoginRequest request, - CancellationToken cancellationToken = default) - { - var identifier = request.Phone.Trim(); - var normalizedEmail = userManager.NormalizeEmail(identifier); - var normalizedUserName = userManager.NormalizeName(identifier); - var user = await dbContext.Users - .SingleOrDefaultAsync(entity => - entity.Phone == identifier || - entity.NormalizedEmail == normalizedEmail || - entity.NormalizedUserName == normalizedUserName, - cancellationToken); - var passwordResult = user is null || user.Status != UserStatus.Active - ? SignInResult.Failed - : await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); - - if (!passwordResult.Succeeded) - { - var loginResult = passwordResult.IsLockedOut - ? AuthLoginResult.Blocked - : AuthLoginResult.Failed; - var failureCode = passwordResult.IsLockedOut - ? "account_locked" - : "invalid_credentials"; - await AddLoginEventAsync( - request.TenantId, - user?.Id, - PasswordProvider, - identifier, - loginResult, - failureCode, - request.IpAddress, - request.UserAgent, - cancellationToken); - throw new InvalidCredentialsException(); - } - - return await CompleteSuccessfulLoginAsync( - request.Realm, - request.TenantId, - user!, - PasswordProvider, - identifier, - request.IpAddress, - request.UserAgent, - cancellationToken); - } - - public async Task LoginWithSmsAsync( - SmsLoginRequest request, - CancellationToken cancellationToken = default) - { - var phone = SmsCodeHashing.NormalizePhone(request.Phone); - var user = await dbContext.Users - .SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken); - - try - { - if (!request.TenantId.HasValue) - { - throw new InvalidCredentialsException("tenant_required_for_sms"); - } - await smsVerificationService.VerifyCodeAsync( - request.TenantId.Value, - phone, - SmsPurpose.Login, - request.Code, - cancellationToken); - } - catch (InvalidCredentialsException exception) - { - await AddLoginEventAsync( - request.TenantId, - user?.Id, - SmsProvider, - phone, - AuthLoginResult.Failed, - exception.Code, - request.IpAddress, - request.UserAgent, - cancellationToken); - throw; - } - - if (user is null) - { - await AddLoginEventAsync( - request.TenantId, - null, - SmsProvider, - phone, - AuthLoginResult.Failed, - "user_not_found", - request.IpAddress, - request.UserAgent, - cancellationToken); - throw new InvalidCredentialsException(); - } - - return await CompleteSuccessfulLoginAsync( - request.Realm, - request.TenantId, - user, - SmsProvider, - phone, - request.IpAddress, - request.UserAgent, - cancellationToken); - } - - public Task LoginWithWechatWebAsync( - WechatLoginRequest request, - CancellationToken cancellationToken = default) - { - return LoginWithWechatAsync( - request, - WechatWebProvider, - WechatWebProviderAliases, - (options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token), - cancellationToken); - } - - public Task LoginWithWechatMiniAppAsync( - WechatLoginRequest request, - CancellationToken cancellationToken = default) - { - return LoginWithWechatAsync( - request, - WechatMiniAppProvider, - WechatMiniAppProviderAliases, - (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), - cancellationToken); - } - - public async Task RefreshAsync( - RefreshSessionRequest request, - CancellationToken cancellationToken = default) - { - return await sessionStore.RotateAsync( - request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken); - } - - public async Task LogoutAsync( - LogoutSessionRequest request, - CancellationToken cancellationToken = default) - { - await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken); - } - - public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default) - { - var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new InvalidCredentialsException(); - var stampResult = await userManager.UpdateSecurityStampAsync(user); - if (!stampResult.Succeeded) - { - throw new InvalidOperationException("Unable to update the user's security stamp."); - } - - await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); - } - - public async Task ChangeRequiredPasswordAsync( - PasswordChangeChallengeRequest request, - CancellationToken cancellationToken = default) - { - var challenge = await FindChallengeAsync( - request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken); - var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) - ?? throw new InvalidAuthChallengeException(); - var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); - var reset = await userManager.ResetPasswordAsync(user, resetToken, 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 clear the password-change requirement."); - } - - await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); - await ConsumeChallengeAsync(challenge, cancellationToken); - await AddSecurityAuditAsync( - user.Id, challenge.TenantId, "auth.password.changed", null, - request.IpAddress, request.UserAgent, cancellationToken); - return await CompleteSuccessfulLoginAsync( - challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(), - 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, - CancellationToken cancellationToken) - { - var tokenHash = HashChallengeToken(token); - var now = DateTimeOffset.UtcNow; - return await dbContext.AuthChallenges.SingleOrDefaultAsync( - item => item.TokenHash == tokenHash && item.Purpose == purpose && - item.ConsumedAt == null && item.ExpiresAt > now && - dbContext.Users.Any(user => - user.Id == item.UserId && user.Status == UserStatus.Active && - user.SecurityStamp == item.SecurityStamp), - cancellationToken) - ?? throw new InvalidAuthChallengeException(); - } - - private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken) - { - var now = DateTimeOffset.UtcNow; - var consumed = await dbContext.AuthChallenges - .Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now) - .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken); - if (consumed != 1) - { - throw new InvalidAuthChallengeException(); - } - } - - private async Task CompleteSuccessfulLoginAsync( - AuthRealm realm, - Guid? tenantId, - User user, - string provider, - string identifier, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken) - { - if (user.Status != UserStatus.Active) - { - await AddLoginEventAsync( - tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, - "user_disabled", ipAddress, userAgent, cancellationToken); - throw new InvalidCredentialsException(); - } - - TenantMembership? membership = null; - Tenant? tenant = null; - if (realm == AuthRealm.Tenant && tenantId.HasValue) - { - membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken); - tenant = await dbContext.Tenants.SingleOrDefaultAsync( - item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken); - if (membership is null || tenant is null) - { - await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, - "tenant_access_denied", ipAddress, userAgent, cancellationToken); - throw new TenantAccessDeniedException(); - } - } - else if (realm == AuthRealm.Platform) - { - if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken)) - { - await AddLoginEventAsync( - null, user.Id, provider, identifier, AuthLoginResult.Failed, - "platform_access_denied", ipAddress, userAgent, cancellationToken); - throw new TenantAccessDeniedException(); - } - } - else - { - throw new TenantAccessDeniedException(); - } - - if (user.ForcePasswordChange) - { - return await CreateChallengeResultAsync( - user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider, - AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken); - } - - return await IssueAuthenticatedResultAsync( - user, realm, tenant, membership, provider, - identifier, ipAddress, userAgent, cancellationToken); - } - - private async Task IssueAuthenticatedResultAsync( - User user, - AuthRealm realm, - Tenant? tenant, - TenantMembership? membership, - string provider, - string? identifier, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken) - { - var tokens = await sessionStore.IssueAsync( - new AuthSessionIssueRequest( - user.Id, - user.Phone, - user.Email, - user.SecurityStamp ?? string.Empty, - realm, - tenant?.Id, - provider, - ipAddress, - userAgent), - cancellationToken); - - await AddLoginEventAsync( - tenant?.Id, - user.Id, - provider, - identifier, - AuthLoginResult.Success, - null, - ipAddress, - userAgent, - cancellationToken); - - var tenantSummary = tenant is not null && membership is not null - ? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status) - : null; - return new AuthenticationResult( - AuthenticationStatus.Authenticated, - new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens)); - } - - private async Task LoginWithWechatAsync( - WechatLoginRequest request, - string provider, - IReadOnlyList providerAliases, - Func> exchangeCodeAsync, - CancellationToken cancellationToken) - { - if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue) - { - throw new InvalidCredentialsException("tenant_realm_required_for_wechat"); - } - - var config = await LoadWechatProviderOptionsAsync( - request.TenantId.Value, - provider, - providerAliases, - cancellationToken); - - WechatIdentity identity; - try - { - identity = await exchangeCodeAsync(config, request.Code, cancellationToken); - } - catch (AuthException exception) - { - await AddLoginEventAsync( - request.TenantId, - null, - provider, - null, - AuthLoginResult.Failed, - exception.Code, - request.IpAddress, - request.UserAgent, - cancellationToken); - throw; - } - - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var providerSubject = $"{config.AppId}:{identity.OpenId}"; - var user = await UpsertWechatUserAsync( - provider, - providerSubject, - config.AppId, - identity, - cancellationToken); - await EnsureTenantMembershipAsync( - request.TenantId.Value, - user.Id, - cancellationToken); - // Persist the external identity and membership together only after the - // tenant policy and existing membership state have accepted the login. - // A denied first login must not leave a user or provider identity behind. - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - - return await CompleteSuccessfulLoginAsync( - request.Realm, - request.TenantId, - user, - provider, - identity.OpenId, - request.IpAddress, - request.UserAgent, - cancellationToken); - } - - private async Task LoadWechatProviderOptionsAsync( - Guid tenantId, - string provider, - IReadOnlyList aliases, - CancellationToken cancellationToken) - { - TenantExternalProviderAccount? account = null; - foreach (var alias in aliases) - { - try - { - account = await providerConfigService.GetActiveProviderAsync( - tenantId, - TenantExternalProviderCapability.Identity, - alias, - cancellationToken); - break; - } - catch (TenantExternalProviderException) - { - } - } - - if (account is null) - { - throw new AuthProviderNotConfiguredException(provider); - } - - var appId = GetJsonString(account.ConfigPublic, "appId", "clientId"); - var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret"); - if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) - { - throw new AuthProviderNotConfiguredException(provider); - } - - return new WechatProviderOptions(appId, appSecret); - } - - private async Task UpsertWechatUserAsync( - string provider, - string providerSubject, - string appId, - WechatIdentity wechatIdentity, - CancellationToken cancellationToken) - { - var existingIdentity = await dbContext.UserIdentities - .SingleOrDefaultAsync( - identity => - identity.Provider == provider && - identity.ProviderSubject == providerSubject, - cancellationToken); - var user = existingIdentity is null - ? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken) - : await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken); - - if (user is null) - { - user = new User - { - Name = wechatIdentity.Nickname, - AvatarUrl = wechatIdentity.AvatarUrl, - PrimaryRole = "student", - RawProfile = CreateWechatRawProfile(wechatIdentity) - }; - dbContext.Users.Add(user); - } - else - { - user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name; - user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl; - } - - if (existingIdentity is null) - { - existingIdentity = new UserIdentity - { - UserId = user.Id, - Provider = provider, - ProviderSubject = providerSubject - }; - dbContext.UserIdentities.Add(existingIdentity); - } - - existingIdentity.UserId = user.Id; - existingIdentity.OpenId = wechatIdentity.OpenId; - existingIdentity.UnionId = wechatIdentity.UnionId; - - return user; - } - - private async Task FindUserByWechatUnionIdAsync( - string? unionId, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(unionId)) - { - return null; - } - - var identity = await dbContext.UserIdentities - .Where(entity => - entity.UnionId == unionId && - WechatIdentityProviders.Contains(entity.Provider)) - .OrderBy(entity => entity.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - - return identity is null - ? null - : await dbContext.Users.FindAsync([identity.UserId], cancellationToken); - } - - private async Task EnsureTenantMembershipAsync( - Guid tenantId, - Guid userId, - CancellationToken cancellationToken) - { - var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync( - membership => - membership.TenantId == tenantId && - membership.UserId == userId && - membership.Status == MembershipStatus.Active, - cancellationToken); - - if (activeMembershipExists) - { - return; - } - - var studentMembership = await dbContext.TenantMemberships - .FirstOrDefaultAsync( - membership => - membership.TenantId == tenantId && - membership.UserId == userId && - membership.Role == TenantRole.Student, - cancellationToken); - if (studentMembership is not null) - { - // Invited and Disabled memberships require an explicit administrator action. - throw new TenantAccessDeniedException(); - } - - var policy = await dbContext.TenantAuthPolicies.AsNoTracking() - .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - if (policy is not null && !policy.AllowExternalStudentSelfRegistration) - { - throw new TenantAccessDeniedException(); - } - - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - tenantId, - SaasQuotaMetricCatalog.StudentCount, - cancellationToken: cancellationToken); - - dbContext.TenantMemberships.Add(new TenantMembership - { - TenantId = tenantId, - UserId = userId, - Role = TenantRole.Student, - Status = MembershipStatus.Active - }); - } - - private async Task FindActiveMembershipAsync( - Guid tenantId, - Guid userId, - CancellationToken cancellationToken) - { - return await dbContext.TenantMemberships - .Where(entity => - entity.TenantId == tenantId && - entity.UserId == userId && - entity.Status == MembershipStatus.Active) - .OrderBy(entity => entity.Role) - .FirstOrDefaultAsync(cancellationToken); - } - - private async Task CreateChallengeResultAsync( - User user, - AuthRealm realm, - Guid? tenantId, - AuthChallengePurpose purpose, - string provider, - AuthenticationStatus status, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken) - { - var realmCode = realm == AuthRealm.Tenant ? "t" : "p"; - var tenantCode = tenantId?.ToString("N") ?? "-"; - var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}"; - var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); - dbContext.AuthChallenges.Add(new AuthChallenge - { - UserId = user.Id, - Realm = realm, - TenantId = tenantId, - Purpose = purpose, - TokenHash = HashChallengeToken(rawToken), - SecurityStamp = user.SecurityStamp ?? string.Empty, - Provider = provider, - ExpiresAt = expiresAt, - IpAddress = ipAddress, - UserAgent = userAgent - }); - await dbContext.SaveChangesAsync(cancellationToken); - await AddSecurityAuditAsync( - user.Id, tenantId, "auth.challenge.issued", status.ToString(), - ipAddress, userAgent, cancellationToken); - return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt); - } - - private async Task HasBackendPermissionsAsync( - AuthRealm realm, - Guid? tenantId, - Guid userId, - CancellationToken cancellationToken) - { - if (realm == AuthRealm.Platform) - { - return await ( - from userRole in dbContext.PlatformBackendUserRoles - join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id - join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId - join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code - where userRole.UserId == userId && - role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && - (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform || - permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) - select permission.Id).AnyAsync(cancellationToken); - } - - if (!tenantId.HasValue) - { - return false; - } - - return await ( - from userRole in dbContext.TenantBackendUserRoles - join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id - join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId - join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code - where userRole.TenantId == tenantId.Value && userRole.UserId == userId && - binding.TenantId == tenantId.Value && - role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && - (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant || - permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) - select permission.Id).AnyAsync(cancellationToken); - } - - private static string HashChallengeToken(string token) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); - - private async Task AddSecurityAuditAsync( - Guid userId, - Guid? tenantId, - string action, - string? reason, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken) - { - dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog - { - TenantId = tenantId, - ActorUserId = userId, - Action = action, - TargetType = "user", - TargetId = userId.ToString(), - Details = JsonSerializer.SerializeToElement(new { reason }), - IpAddress = ipAddress, - UserAgent = userAgent - }); - await dbContext.SaveChangesAsync(cancellationToken); - } - - private async Task AddLoginEventAsync( - Guid? tenantId, - Guid? userId, - string provider, - string? identifier, - AuthLoginResult result, - string? failureCode, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken) - { - dbContext.AuthLoginEvents.Add(new AuthLoginEvent - { - TenantId = tenantId, - UserId = userId, - Provider = provider, - Identifier = identifier, - Result = result, - FailureCode = failureCode, - IpAddress = ipAddress, - UserAgent = userAgent - }); - - await dbContext.SaveChangesAsync(cancellationToken); - } - - private static string? GetJsonString(JsonElement element, params string[] names) - { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } - - foreach (var name in names) - { - if (element.TryGetProperty(name, out var property) && - property.ValueKind == JsonValueKind.String && - !string.IsNullOrWhiteSpace(property.GetString())) - { - return property.GetString()!.Trim(); - } - } - - return null; - } - - private static JsonElement CreateWechatRawProfile(WechatIdentity identity) - { - return JsonSerializer.SerializeToElement(new - { - openId = identity.OpenId, - unionId = identity.UnionId, - nickname = identity.Nickname, - avatarUrl = identity.AvatarUrl - }); - } } diff --git a/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs new file mode 100644 index 0000000..6c4cfe0 --- /dev/null +++ b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Auth; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICurrentIdentityQueryService +{ + public async Task GetUserAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + var user = await dbContext.Users.AsNoTracking() + .Where(item => item.Id == userId) + .Select(item => new { item.Id, item.Phone, item.Email, item.Name }) + .SingleOrDefaultAsync(cancellationToken); + if (user is null) + { + return null; + } + + var memberships = await dbContext.TenantMemberships.AsNoTracking() + .Where(item => item.UserId == userId && item.Status == MembershipStatus.Active) + .Join( + dbContext.Tenants.AsNoTracking(), + membership => membership.TenantId, + tenant => tenant.Id, + (membership, tenant) => new CurrentUserTenant( + tenant.Id, + tenant.Name, + tenant.Slug, + membership.Role, + membership.Status)) + .ToArrayAsync(cancellationToken); + + return new CurrentUserProfile(user.Id, user.Phone, user.Email, user.Name, memberships); + } + + public Task GetTenantMembershipAsync( + Guid userId, + Guid tenantId, + CancellationToken cancellationToken = default) + { + return dbContext.TenantMemberships.AsNoTracking() + .Where(item => + item.UserId == userId && + item.TenantId == tenantId && + item.Status == MembershipStatus.Active) + .Join( + dbContext.Tenants.AsNoTracking(), + membership => membership.TenantId, + tenant => tenant.Id, + (membership, tenant) => new CurrentTenantMembership( + tenant.Id, + tenant.Name, + tenant.Slug, + tenant.Status, + membership.Role)) + .SingleOrDefaultAsync(cancellationToken); + } +} diff --git a/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs b/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs new file mode 100644 index 0000000..86f27e9 --- /dev/null +++ b/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs @@ -0,0 +1,553 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + private async Task FindChallengeAsync( + string token, + AuthChallengePurpose purpose, + CancellationToken cancellationToken) + { + var tokenHash = HashChallengeToken(token); + var now = DateTimeOffset.UtcNow; + return await dbContext.AuthChallenges.SingleOrDefaultAsync( + item => item.TokenHash == tokenHash && item.Purpose == purpose && + item.ConsumedAt == null && item.ExpiresAt > now && + dbContext.Users.Any(user => + user.Id == item.UserId && user.Status == UserStatus.Active && + user.SecurityStamp == item.SecurityStamp), + cancellationToken) + ?? throw new InvalidAuthChallengeException(); + } + + private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var consumed = await dbContext.AuthChallenges + .Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken); + if (consumed != 1) + { + throw new InvalidAuthChallengeException(); + } + } + + private async Task CompleteSuccessfulLoginAsync( + AuthRealm realm, + Guid? tenantId, + User user, + string provider, + string identifier, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + if (user.Status != UserStatus.Active) + { + await AddLoginEventAsync( + tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, + "user_disabled", ipAddress, userAgent, cancellationToken); + throw new InvalidCredentialsException(); + } + + TenantMembership? membership = null; + Tenant? tenant = null; + if (realm == AuthRealm.Tenant && tenantId.HasValue) + { + membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken); + tenant = await dbContext.Tenants.SingleOrDefaultAsync( + item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken); + if (membership is null || tenant is null) + { + await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, + "tenant_access_denied", ipAddress, userAgent, cancellationToken); + throw new TenantAccessDeniedException(); + } + } + else if (realm == AuthRealm.Platform) + { + if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken)) + { + await AddLoginEventAsync( + null, user.Id, provider, identifier, AuthLoginResult.Failed, + "platform_access_denied", ipAddress, userAgent, cancellationToken); + throw new TenantAccessDeniedException(); + } + } + else + { + throw new TenantAccessDeniedException(); + } + + if (user.ForcePasswordChange) + { + return await CreateChallengeResultAsync( + user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider, + AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken); + } + + return await IssueAuthenticatedResultAsync( + user, realm, tenant, membership, provider, + identifier, ipAddress, userAgent, cancellationToken); + } + + private async Task IssueAuthenticatedResultAsync( + User user, + AuthRealm realm, + Tenant? tenant, + TenantMembership? membership, + string provider, + string? identifier, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + var tokens = await sessionStore.IssueAsync( + new AuthSessionIssueRequest( + user.Id, + user.Phone, + user.Email, + user.SecurityStamp ?? string.Empty, + realm, + tenant?.Id, + provider, + ipAddress, + userAgent), + cancellationToken); + + await AddLoginEventAsync( + tenant?.Id, + user.Id, + provider, + identifier, + AuthLoginResult.Success, + null, + ipAddress, + userAgent, + cancellationToken); + + var tenantSummary = tenant is not null && membership is not null + ? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status) + : null; + return new AuthenticationResult( + AuthenticationStatus.Authenticated, + new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens)); + } + + private async Task LoginWithWechatAsync( + WechatLoginRequest request, + string provider, + IReadOnlyList providerAliases, + Func> exchangeCodeAsync, + CancellationToken cancellationToken) + { + if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue) + { + throw new InvalidCredentialsException("tenant_realm_required_for_wechat"); + } + + var config = await LoadWechatProviderOptionsAsync( + request.TenantId.Value, + provider, + providerAliases, + cancellationToken); + + WechatIdentity identity; + try + { + identity = await exchangeCodeAsync(config, request.Code, cancellationToken); + } + catch (AuthException exception) + { + await AddLoginEventAsync( + request.TenantId, + null, + provider, + null, + AuthLoginResult.Failed, + exception.Code, + request.IpAddress, + request.UserAgent, + cancellationToken); + throw; + } + + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var providerSubject = $"{config.AppId}:{identity.OpenId}"; + var user = await UpsertWechatUserAsync( + provider, + providerSubject, + config.AppId, + identity, + cancellationToken); + await EnsureTenantMembershipAsync( + request.TenantId.Value, + user.Id, + cancellationToken); + // Persist the external identity and membership together only after the + // tenant policy and existing membership state have accepted the login. + // A denied first login must not leave a user or provider identity behind. + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return await CompleteSuccessfulLoginAsync( + request.Realm, + request.TenantId, + user, + provider, + identity.OpenId, + request.IpAddress, + request.UserAgent, + cancellationToken); + } + + private async Task LoadWechatProviderOptionsAsync( + Guid tenantId, + string provider, + IReadOnlyList aliases, + CancellationToken cancellationToken) + { + TenantExternalProviderAccount? account = null; + foreach (var alias in aliases) + { + try + { + account = await providerConfigService.GetActiveProviderAsync( + tenantId, + TenantExternalProviderCapability.Identity, + alias, + cancellationToken); + break; + } + catch (TenantExternalProviderException) + { + } + } + + if (account is null) + { + throw new AuthProviderNotConfiguredException(provider); + } + + var appId = GetJsonString(account.ConfigPublic, "appId", "clientId"); + var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret"); + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) + { + throw new AuthProviderNotConfiguredException(provider); + } + + return new WechatProviderOptions(appId, appSecret); + } + + private async Task UpsertWechatUserAsync( + string provider, + string providerSubject, + string appId, + WechatIdentity wechatIdentity, + CancellationToken cancellationToken) + { + var existingIdentity = await dbContext.UserIdentities + .SingleOrDefaultAsync( + identity => + identity.Provider == provider && + identity.ProviderSubject == providerSubject, + cancellationToken); + var user = existingIdentity is null + ? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken) + : await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken); + + if (user is null) + { + user = new User + { + Name = wechatIdentity.Nickname, + AvatarUrl = wechatIdentity.AvatarUrl, + PrimaryRole = "student", + RawProfile = CreateWechatRawProfile(wechatIdentity) + }; + dbContext.Users.Add(user); + } + else + { + user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name; + user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl; + } + + if (existingIdentity is null) + { + existingIdentity = new UserIdentity + { + UserId = user.Id, + Provider = provider, + ProviderSubject = providerSubject + }; + dbContext.UserIdentities.Add(existingIdentity); + } + + existingIdentity.UserId = user.Id; + existingIdentity.OpenId = wechatIdentity.OpenId; + existingIdentity.UnionId = wechatIdentity.UnionId; + + return user; + } + + private async Task FindUserByWechatUnionIdAsync( + string? unionId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(unionId)) + { + return null; + } + + var identity = await dbContext.UserIdentities + .Where(entity => + entity.UnionId == unionId && + WechatIdentityProviders.Contains(entity.Provider)) + .OrderBy(entity => entity.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + return identity is null + ? null + : await dbContext.Users.FindAsync([identity.UserId], cancellationToken); + } + + private async Task EnsureTenantMembershipAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) + { + var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync( + membership => + membership.TenantId == tenantId && + membership.UserId == userId && + membership.Status == MembershipStatus.Active, + cancellationToken); + + if (activeMembershipExists) + { + return; + } + + var studentMembership = await dbContext.TenantMemberships + .FirstOrDefaultAsync( + membership => + membership.TenantId == tenantId && + membership.UserId == userId && + membership.Role == TenantRole.Student, + cancellationToken); + if (studentMembership is not null) + { + // Invited and Disabled memberships require an explicit administrator action. + throw new TenantAccessDeniedException(); + } + + var policy = await dbContext.TenantAuthPolicies.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (policy is not null && !policy.AllowExternalStudentSelfRegistration) + { + throw new TenantAccessDeniedException(); + } + + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + tenantId, + SaasQuotaMetricCatalog.StudentCount, + cancellationToken: cancellationToken); + + dbContext.TenantMemberships.Add(new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + } + + private async Task FindActiveMembershipAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) + { + return await dbContext.TenantMemberships + .Where(entity => + entity.TenantId == tenantId && + entity.UserId == userId && + entity.Status == MembershipStatus.Active) + .OrderBy(entity => entity.Role) + .FirstOrDefaultAsync(cancellationToken); + } + + private async Task CreateChallengeResultAsync( + User user, + AuthRealm realm, + Guid? tenantId, + AuthChallengePurpose purpose, + string provider, + AuthenticationStatus status, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + var realmCode = realm == AuthRealm.Tenant ? "t" : "p"; + var tenantCode = tenantId?.ToString("N") ?? "-"; + var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}"; + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); + dbContext.AuthChallenges.Add(new AuthChallenge + { + UserId = user.Id, + Realm = realm, + TenantId = tenantId, + Purpose = purpose, + TokenHash = HashChallengeToken(rawToken), + SecurityStamp = user.SecurityStamp ?? string.Empty, + Provider = provider, + ExpiresAt = expiresAt, + IpAddress = ipAddress, + UserAgent = userAgent + }); + await dbContext.SaveChangesAsync(cancellationToken); + await AddSecurityAuditAsync( + user.Id, tenantId, "auth.challenge.issued", status.ToString(), + ipAddress, userAgent, cancellationToken); + return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt); + } + + private async Task HasBackendPermissionsAsync( + AuthRealm realm, + Guid? tenantId, + Guid userId, + CancellationToken cancellationToken) + { + if (realm == AuthRealm.Platform) + { + return await ( + from userRole in dbContext.PlatformBackendUserRoles + join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.UserId == userId && + role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && + (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform || + permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + } + + if (!tenantId.HasValue) + { + return false; + } + + return await ( + from userRole in dbContext.TenantBackendUserRoles + join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id + join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId + join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code + where userRole.TenantId == tenantId.Value && userRole.UserId == userId && + binding.TenantId == tenantId.Value && + role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && + (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant || + permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + select permission.Id).AnyAsync(cancellationToken); + } + + private static string HashChallengeToken(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); + + private async Task AddSecurityAuditAsync( + Guid userId, + Guid? tenantId, + string action, + string? reason, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + { + TenantId = tenantId, + ActorUserId = userId, + Action = action, + TargetType = "user", + TargetId = userId.ToString(), + Details = JsonSerializer.SerializeToElement(new { reason }), + IpAddress = ipAddress, + UserAgent = userAgent + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task AddLoginEventAsync( + Guid? tenantId, + Guid? userId, + string provider, + string? identifier, + AuthLoginResult result, + string? failureCode, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken) + { + dbContext.AuthLoginEvents.Add(new AuthLoginEvent + { + TenantId = tenantId, + UserId = userId, + Provider = provider, + Identifier = identifier, + Result = result, + FailureCode = failureCode, + IpAddress = ipAddress, + UserAgent = userAgent + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string? GetJsonString(JsonElement element, params string[] names) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var name in names) + { + if (element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString()!.Trim(); + } + } + + return null; + } + + private static JsonElement CreateWechatRawProfile(WechatIdentity identity) + { + return JsonSerializer.SerializeToElement(new + { + openId = identity.OpenId, + unionId = identity.UnionId, + nickname = identity.Nickname, + avatarUrl = identity.AvatarUrl + }); + } + +} diff --git a/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs b/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs new file mode 100644 index 0000000..418750b --- /dev/null +++ b/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + public async Task ChangeRequiredPasswordAsync( + PasswordChangeChallengeRequest request, + CancellationToken cancellationToken = default) + { + var challenge = await FindChallengeAsync( + request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken); + var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) + ?? throw new InvalidAuthChallengeException(); + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + var reset = await userManager.ResetPasswordAsync(user, resetToken, 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 clear the password-change requirement."); + } + + await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); + await ConsumeChallengeAsync(challenge, cancellationToken); + await AddSecurityAuditAsync( + user.Id, challenge.TenantId, "auth.password.changed", null, + request.IpAddress, request.UserAgent, cancellationToken); + return await CompleteSuccessfulLoginAsync( + challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(), + 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); + } + + +} diff --git a/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs b/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs new file mode 100644 index 0000000..8eae8fc --- /dev/null +++ b/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + public async Task LoginWithPasswordAsync( + PasswordLoginRequest request, + CancellationToken cancellationToken = default) + { + var identifier = request.Phone.Trim(); + var normalizedEmail = userManager.NormalizeEmail(identifier); + var normalizedUserName = userManager.NormalizeName(identifier); + var user = await dbContext.Users + .SingleOrDefaultAsync(entity => + entity.Phone == identifier || + entity.NormalizedEmail == normalizedEmail || + entity.NormalizedUserName == normalizedUserName, + cancellationToken); + var passwordResult = user is null || user.Status != UserStatus.Active + ? SignInResult.Failed + : await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); + + if (!passwordResult.Succeeded) + { + var loginResult = passwordResult.IsLockedOut + ? AuthLoginResult.Blocked + : AuthLoginResult.Failed; + var failureCode = passwordResult.IsLockedOut + ? "account_locked" + : "invalid_credentials"; + await AddLoginEventAsync( + request.TenantId, + user?.Id, + PasswordProvider, + identifier, + loginResult, + failureCode, + request.IpAddress, + request.UserAgent, + cancellationToken); + throw new InvalidCredentialsException(); + } + + return await CompleteSuccessfulLoginAsync( + request.Realm, + request.TenantId, + user!, + PasswordProvider, + identifier, + request.IpAddress, + request.UserAgent, + cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs b/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs new file mode 100644 index 0000000..ee88a34 --- /dev/null +++ b/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + public async Task RefreshAsync( + RefreshSessionRequest request, + CancellationToken cancellationToken = default) + { + return await sessionStore.RotateAsync( + request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken); + } + + public async Task LogoutAsync( + LogoutSessionRequest request, + CancellationToken cancellationToken = default) + { + await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken); + } + + public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidCredentialsException(); + var stampResult = await userManager.UpdateSecurityStampAsync(user); + if (!stampResult.Succeeded) + { + throw new InvalidOperationException("Unable to update the user's security stamp."); + } + + await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs b/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs new file mode 100644 index 0000000..68c8e54 --- /dev/null +++ b/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + public async Task LoginWithSmsAsync( + SmsLoginRequest request, + CancellationToken cancellationToken = default) + { + var phone = SmsCodeHashing.NormalizePhone(request.Phone); + var user = await dbContext.Users + .SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken); + + try + { + if (!request.TenantId.HasValue) + { + throw new InvalidCredentialsException("tenant_required_for_sms"); + } + await smsVerificationService.VerifyCodeAsync( + request.TenantId.Value, + phone, + SmsPurpose.Login, + request.Code, + cancellationToken); + } + catch (InvalidCredentialsException exception) + { + await AddLoginEventAsync( + request.TenantId, + user?.Id, + SmsProvider, + phone, + AuthLoginResult.Failed, + exception.Code, + request.IpAddress, + request.UserAgent, + cancellationToken); + throw; + } + + if (user is null) + { + await AddLoginEventAsync( + request.TenantId, + null, + SmsProvider, + phone, + AuthLoginResult.Failed, + "user_not_found", + request.IpAddress, + request.UserAgent, + cancellationToken); + throw new InvalidCredentialsException(); + } + + return await CompleteSuccessfulLoginAsync( + request.Realm, + request.TenantId, + user, + SmsProvider, + phone, + request.IpAddress, + request.UserAgent, + cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs b/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs new file mode 100644 index 0000000..19a0f63 --- /dev/null +++ b/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Auth; + +public sealed partial class AuthService +{ + public Task LoginWithWechatWebAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default) + { + return LoginWithWechatAsync( + request, + WechatWebProvider, + WechatWebProviderAliases, + (options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token), + cancellationToken); + } + + public Task LoginWithWechatMiniAppAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default) + { + return LoginWithWechatAsync( + request, + WechatMiniAppProvider, + WechatMiniAppProviderAliases, + (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), + cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index 81d7948..43f315c 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -488,8 +488,3 @@ internal sealed class BackofficeService( } } - -public sealed class BackofficeException(string message, string code) : InvalidOperationException(message) -{ - public string Code { get; } = code; -} diff --git a/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs b/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs new file mode 100644 index 0000000..a042fed --- /dev/null +++ b/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task CreateCodeBatchAsync( + CommerceAdminActor actor, + CreateCodeBatchCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.TotalCount is < 1 or > 1000) + { + throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count"); + } + + if (command.Days <= 0) + { + throw new CommerceException("Activation code days must be positive.", "invalid_activation_days"); + } + + if (command.RegionId.HasValue) + { + var regionExists = await dbContext.Regions + .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); + if (!regionExists) + { + throw new CommerceException("Region was not found.", "region_not_found"); + } + } + + var batch = new CodeBatch + { + TenantId = actor.TenantId, + RegionId = command.RegionId, + CreatedBy = actor.UserId, + Name = command.Name.Trim(), + SaleType = command.SaleType?.Trim(), + Channel = command.Channel?.Trim(), + DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0, + CostPriceCents = command.CostPriceCents ?? 0, + TotalCount = command.TotalCount, + Days = command.Days, + IssuedAt = DateTimeOffset.UtcNow, + Remark = command.Remark + }; + dbContext.CodeBatches.Add(batch); + for (var index = 0; index < command.TotalCount; index++) + { + dbContext.ActivationCodes.Add(new ActivationCode + { + TenantId = actor.TenantId, + BatchId = batch.Id, + Code = GenerateActivationCode(), + Days = command.Days, + SaleType = batch.SaleType, + UnitPriceCents = batch.DefaultUnitPriceCents, + Remark = batch.Remark + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return ToCodeBatchItem(batch); + } + + public async Task GetActivationCodesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var codes = dbContext.ActivationCodes.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase); + codes = codes.Where(item => item.IsUsed == used); + } + + var items = await codes + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray()); + } + + public async Task RedeemActivationCodeAsync( + CommerceAdminActor actor, + RedeemActivationCodeCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var code = await dbContext.ActivationCodes + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Code == command.Code.Trim(), + cancellationToken) + ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); + if (code.IsUsed) + { + throw new CommerceException("Activation code has already been used.", "activation_code_used"); + } + + var userIsMember = await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == actor.TenantId && + item.UserId == command.UserId && + item.Status == MembershipStatus.Active, + cancellationToken); + if (!userIsMember) + { + throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found"); + } + + code.IsUsed = true; + code.UsedBy = command.UserId; + code.UsedRegionId = command.RegionId; + code.UsedAt = DateTimeOffset.UtcNow; + dbContext.Entitlements.Add(new Entitlement + { + TenantId = actor.TenantId, + UserId = command.UserId, + EntitlementType = "svip", + SourceType = "activation_code", + SourceId = code.Id, + StartsAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days), + Status = EntitlementStatus.Active, + Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId }) + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToActivationCodeItem(code); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs new file mode 100644 index 0000000..1fc76c0 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs @@ -0,0 +1,440 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetAdjustmentVouchersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); + } + + var items = await vouchers + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantAdjustmentVoucherList(items); + } + + public async Task GetAdjustmentVoucherAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return await dbContext.CommerceAdjustmentVouchers.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) + ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + } + + public async Task CreateAdjustmentVoucherAsync( + CommerceAdminActor actor, + CreateAdjustmentVoucherCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); + var voucher = new CommerceAdjustmentVoucher + { + TenantId = actor.TenantId, + IssueId = command.IssueId, + BatchId = command.BatchId, + ItemId = command.ItemId, + OrderId = command.OrderId, + PaymentId = command.PaymentId, + RefundRequestId = command.RefundRequestId, + CreatedBy = actor.UserId, + VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}", + Status = CommerceAdjustmentVoucherStatus.Draft, + Direction = command.Direction, + AmountCents = command.AmountCents, + Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(), + Reason = command.Reason.Trim(), + ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(), + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceAdjustmentVouchers.Add(voucher); + dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + { + TenantId = actor.TenantId, + VoucherId = voucher.Id, + ToStatus = voucher.Status, + ActorUserId = actor.UserId, + Note = voucher.Reason, + Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents }) + }); + await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return voucher; + } + + public async Task UpdateAdjustmentVoucherStatusAsync( + CommerceAdminActor actor, + UpdateAdjustmentVoucherStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, + cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + var fromStatus = voucher.Status; + if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status)) + { + throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition"); + } + + voucher.Status = command.Status; + if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected) + { + voucher.ReviewedBy = actor.UserId; + voucher.ReviewedAt ??= DateTimeOffset.UtcNow; + } + else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void) + { + voucher.ClosedAt ??= DateTimeOffset.UtcNow; + } + + dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + { + TenantId = actor.TenantId, + VoucherId = voucher.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + ActorUserId = actor.UserId, + Note = command.Note, + Details = JsonSerializer.SerializeToElement(new { }) + }); + await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return voucher; + } + + public async Task GetAdjustmentVoucherEventsAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == voucherId, + cancellationToken); + if (!exists) + { + throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + } + + var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantAdjustmentVoucherEventList(events); + } + + public async Task GetAdjustmentReportAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return new TenantAdjustmentReport( + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), + await dbContext.CommerceAdjustmentVouchers + .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) + .SumAsync(item => item.AmountCents, cancellationToken), + await dbContext.CommerceAdjustmentVouchers + .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) + .SumAsync(item => item.AmountCents, cancellationToken)); + } + + public async Task GetReconciliationItemsAsync( + CommerceAdminActor actor, + Guid batchId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == batchId, + cancellationToken); + if (!batchExists) + { + throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found"); + } + + var items = await dbContext.CommerceReconciliationItems.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) + .OrderBy(item => item.RowNo) + .Take(500) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationItemList(items); + } + + public async Task GetReconciliationIssueEventsAsync( + CommerceAdminActor actor, + Guid issueId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == issueId, + cancellationToken); + if (!issueExists) + { + throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); + } + + var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationIssueEventList(events); + } + + public async Task GetAnomalySummaryAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var openRefunds = await dbContext.CommerceRefundRequests.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested, + cancellationToken); + var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, + cancellationToken); + var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync( + item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, + cancellationToken); + var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, + cancellationToken); + var pendingPayments = await dbContext.Payments.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending, + cancellationToken); + var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync( + item => item.TenantId == actor.TenantId && + (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch || + item.MatchStatus == ReconciliationMatchStatus.StatusMismatch), + cancellationToken); + + return new TenantCommerceAnomalySummary( + openRefunds, + processingRefunds, + openIssues, + failedBatches, + pendingPayments, + mismatchCount); + } + + public async Task PreviewReconciliationImportAsync( + CommerceAdminActor actor, + PreviewReconciliationImportCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return BuildImportPreview(command.Provider, command.Rows); + } + + public async Task ImportReconciliationAsync( + CommerceAdminActor actor, + ImportReconciliationCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var preview = BuildImportPreview(command.Provider, command.Rows); + var batch = new CommerceReconciliationBatch + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + Provider = NormalizeProvider(command.Provider), + BillDate = command.BillDate, + BillType = command.BillType, + Source = ReconciliationSource.ManualUpload, + SourceName = command.SourceName.Trim(), + SourceHash = preview.SourceHash, + Status = preview.InvalidCount == 0 + ? ReconciliationBatchStatus.Completed + : ReconciliationBatchStatus.CompletedWithIssues, + TotalCount = preview.TotalCount, + MatchedCount = preview.TotalCount - preview.InvalidCount, + MismatchCount = preview.InvalidCount, + AmountCents = preview.AmountCents, + RefundAmountCents = preview.RefundAmountCents, + CompletedAt = DateTimeOffset.UtcNow, + Metadata = JsonSerializer.SerializeToElement(new + { + preview.PaymentCount, + preview.RefundCount, + preview.InvalidCount + }) + }; + dbContext.CommerceReconciliationBatches.Add(batch); + var rowNo = 0; + foreach (var row in EnumerateImportRows(command.Rows)) + { + rowNo++; + var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row); + dbContext.CommerceReconciliationItems.Add(item); + if (item.MatchStatus != ReconciliationMatchStatus.Matched) + { + dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue + { + TenantId = actor.TenantId, + BatchId = batch.Id, + Provider = item.Provider, + TransactionType = item.TransactionType, + IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}", + MatchStatus = item.MatchStatus switch + { + ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal, + ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider, + ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate, + ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch, + _ => ReconciliationIssueMatchStatus.AmountMismatch + }, + Severity = item.Severity, + Status = ReconciliationIssueStatus.Open, + OrderNo = item.OrderNo, + RefundNo = item.RefundNo, + ProviderTradeNo = item.ProviderTradeNo, + ProviderRefundNo = item.ProviderRefundNo, + AmountCents = item.AmountCents, + RefundAmountCents = item.RefundAmountCents, + Summary = item.IssueCode, + CreatedBy = actor.UserId, + Metadata = item.Details + }); + } + } + + await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return batch; + } + + public async Task RequestProviderBillJobAsync( + CommerceAdminActor actor, + RequestProviderBillJobCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var job = await backgroundJobQueue.EnqueueAsync( + new CreateBackgroundJobCommand( + actor.TenantId, + "commerce_reconciliation", + JsonSerializer.SerializeToElement(new + { + provider = NormalizeProvider(command.Provider), + command.BillDate, + billType = command.BillType.ToString() + }), + command.RunAfter, + 5), + cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return job; + } + + public async Task> GetProviderBillJobsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); + } + + public async Task ProcessRefundNotificationAsync( + Guid tenantId, + RefundNotificationCommand command, + CancellationToken cancellationToken = default) + { + var provider = NormalizeProvider(command.Provider); + var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.RefundNo == command.RefundNo, + cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + var eventId = string.IsNullOrWhiteSpace(command.EventId) + ? $"{provider}:{command.RefundNo}:{command.Status}" + : command.EventId.Trim(); + var duplicate = await dbContext.PaymentEvents.AnyAsync( + item => item.TenantId == tenantId && + item.Provider == provider && + item.EventType == "refund" && + item.EventId == eventId, + cancellationToken); + if (duplicate) + { + return refund; + } + + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = tenantId, + Provider = provider, + EventType = "refund", + EventId = eventId, + SignatureValid = true, + Payload = JsonObjectOrDefault(command.Payload), + ProcessedAt = DateTimeOffset.UtcNow + }); + var fromStatus = refund.Status; + if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status)) + { + refund.Status = command.Status; + refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) + ? refund.ProviderRefundNo + : command.ProviderRefundNo.Trim(); + if (command.Status == CommerceRefundStatus.Succeeded) + { + refund.SucceededAt = DateTimeOffset.UtcNow; + await ApplyRefundToOrderAsync(refund, cancellationToken); + } + else if (command.Status == CommerceRefundStatus.Failed) + { + refund.FailedAt = DateTimeOffset.UtcNow; + } + + dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + { + TenantId = tenantId, + RefundRequestId = refund.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + EventType = "provider_notify", + Details = JsonObjectOrDefault(command.Payload) + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + + +} diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index cd7a122..0f201fb 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -14,1742 +14,13 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; -internal sealed class CommerceAdminService( +internal sealed partial class CommerceAdminService( TikuDbContext dbContext, ITenantSecretProtector tenantSecretProtector, ITenantExternalProviderConfigService providerConfigService, ICurrentAccessContext currentAccessContext, - IBackgroundJobService backgroundJobService) : ICommerceAdminService + IBackgroundJobQueue backgroundJobQueue, + IBackgroundJobOperations backgroundJobOperations) : ICommerceAdminService { - public async Task> GetPaymentAccountsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var accounts = await providerConfigService.GetProvidersAsync( - actor.TenantId, - TenantExternalProviderCapability.Payment, - string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider), - query.Limit, - cancellationToken); - return accounts.Select(ToPaymentAccountItem).ToArray(); - } - - public async Task UpsertPaymentAccountAsync( - CommerceAdminActor actor, - UpsertPaymentAccountCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var account = await providerConfigService.UpsertProviderAsync( - actor.TenantId, - new UpsertTenantExternalProviderCommand( - TenantExternalProviderCapability.Payment, - command.Provider, - command.Status, - command.DisplayName, - command.SecretRef, - command.Priority, - WithPaymentMode(command.ConfigPublic, command.Mode), - JsonObjectOrDefault(default)), - cancellationToken); - - return ToPaymentAccountItem(account); - } - - public async Task UpsertTenantSecretAsync( - CommerceAdminActor actor, - UpsertTenantSecretCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var secretRef = string.IsNullOrWhiteSpace(command.SecretRef) - ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}" - : command.SecretRef.Trim(); - var provider = NormalizeProvider(command.Provider); - var secret = await dbContext.TenantSecrets - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); - if (secret is null) - { - secret = new TenantSecret - { - TenantId = actor.TenantId, - SecretRef = secretRef - }; - dbContext.TenantSecrets.Add(secret); - } - else - { - secret.RotatedAt = DateTimeOffset.UtcNow; - } - - var protectedPayload = tenantSecretProtector.Protect( - actor.TenantId, - secretRef, - JsonObjectOrDefault(command.SecretPayload)); - - secret.Purpose = command.Purpose.Trim(); - secret.Provider = provider; - secret.SecretKey = command.SecretKey.Trim(); - secret.Status = command.Status; - secret.EncryptionKeyId = protectedPayload.KeyId; - secret.EncryptedPayload = protectedPayload.Ciphertext; - secret.EncryptionNonce = protectedPayload.Nonce; - secret.EncryptionTag = protectedPayload.Tag; - secret.ExpiresAt = command.ExpiresAt; - - await dbContext.SaveChangesAsync(cancellationToken); - return ToSecretItem(secret); - } - - public async Task GetOrdersAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var orders = dbContext.Orders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); - } - - var items = await orders - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new AdminOrderList(items.Select(ToOrderItem).ToArray()); - } - - public async Task GetPaymentsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var scopedOrders = dbContext.Orders.AsNoTracking() - .Where(order => order.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - order => order.UserId == actor.UserId, - order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value)); - var payments = from payment in dbContext.Payments.AsNoTracking() - join order in scopedOrders - on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } - where payment.TenantId == actor.TenantId - select new { payment, order.OrderNo }; - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - var provider = NormalizeProvider(query.Provider); - payments = payments.Where(item => item.payment.Provider == provider); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status)); - } - - var rows = await payments - .OrderByDescending(item => item.payment.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray()); - } - - public async Task CreateCodeBatchAsync( - CommerceAdminActor actor, - CreateCodeBatchCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - if (command.TotalCount is < 1 or > 1000) - { - throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count"); - } - - if (command.Days <= 0) - { - throw new CommerceException("Activation code days must be positive.", "invalid_activation_days"); - } - - if (command.RegionId.HasValue) - { - var regionExists = await dbContext.Regions - .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); - if (!regionExists) - { - throw new CommerceException("Region was not found.", "region_not_found"); - } - } - - var batch = new CodeBatch - { - TenantId = actor.TenantId, - RegionId = command.RegionId, - CreatedBy = actor.UserId, - Name = command.Name.Trim(), - SaleType = command.SaleType?.Trim(), - Channel = command.Channel?.Trim(), - DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0, - CostPriceCents = command.CostPriceCents ?? 0, - TotalCount = command.TotalCount, - Days = command.Days, - IssuedAt = DateTimeOffset.UtcNow, - Remark = command.Remark - }; - dbContext.CodeBatches.Add(batch); - for (var index = 0; index < command.TotalCount; index++) - { - dbContext.ActivationCodes.Add(new ActivationCode - { - TenantId = actor.TenantId, - BatchId = batch.Id, - Code = GenerateActivationCode(), - Days = command.Days, - SaleType = batch.SaleType, - UnitPriceCents = batch.DefaultUnitPriceCents, - Remark = batch.Remark - }); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return ToCodeBatchItem(batch); - } - - public async Task GetActivationCodesAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var codes = dbContext.ActivationCodes.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase); - codes = codes.Where(item => item.IsUsed == used); - } - - var items = await codes - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray()); - } - - public async Task RedeemActivationCodeAsync( - CommerceAdminActor actor, - RedeemActivationCodeCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var code = await dbContext.ActivationCodes - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Code == command.Code.Trim(), - cancellationToken) - ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); - if (code.IsUsed) - { - throw new CommerceException("Activation code has already been used.", "activation_code_used"); - } - - var userIsMember = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == command.UserId && - item.Status == MembershipStatus.Active, - cancellationToken); - if (!userIsMember) - { - throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found"); - } - - code.IsUsed = true; - code.UsedBy = command.UserId; - code.UsedRegionId = command.RegionId; - code.UsedAt = DateTimeOffset.UtcNow; - dbContext.Entitlements.Add(new Entitlement - { - TenantId = actor.TenantId, - UserId = command.UserId, - EntitlementType = "svip", - SourceType = "activation_code", - SourceId = code.Id, - StartsAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days), - Status = EntitlementStatus.Active, - Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId }) - }); - - await dbContext.SaveChangesAsync(cancellationToken); - return ToActivationCodeItem(code); - } - - public async Task GetPointTasksAsync( - CommerceAdminActor actor, - TenantPointQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var tasks = dbContext.PointActivityTasks.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status)); - } - - var items = await tasks - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantPointTaskList(items); - } - - public async Task UpsertPointTaskAsync( - CommerceAdminActor actor, - UpsertPointTaskCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - if (command.Points <= 0 || command.MaxClaimsPerUser <= 0) - { - throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task"); - } - - var task = command.Id.HasValue - ? await dbContext.PointActivityTasks.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, - cancellationToken) - : await dbContext.PointActivityTasks.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(), - cancellationToken); - if (task is null) - { - task = new PointActivityTask { TenantId = actor.TenantId }; - dbContext.PointActivityTasks.Add(task); - } - - task.TaskKey = command.TaskKey.Trim(); - task.Title = command.Title.Trim(); - task.Description = command.Description?.Trim(); - task.TaskType = command.TaskType; - task.Status = command.Status; - task.Points = command.Points; - task.MaxClaimsPerUser = command.MaxClaimsPerUser; - task.StartsAt = command.StartsAt; - task.EndsAt = command.EndsAt; - task.SortOrder = command.SortOrder; - task.Rules = JsonObjectOrDefault(command.Rules); - task.Metadata = JsonObjectOrDefault(command.Metadata); - - await dbContext.SaveChangesAsync(cancellationToken); - return task; - } - - public async Task GetPointClaimsAsync( - CommerceAdminActor actor, - TenantPointQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var claims = dbContext.PointActivityClaims.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (query.UserId.HasValue) - { - claims = claims.Where(item => item.UserId == query.UserId.Value); - } - - var items = await claims - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantPointClaimList(items); - } - - public async Task GetPointExchangeItemsAsync( - CommerceAdminActor actor, - TenantPointQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var items = dbContext.PointExchangeItems.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status)); - } - - if (query.RegionId.HasValue) - { - items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); - } - - var result = await items - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantPointExchangeItemList(result); - } - - public async Task UpsertPointExchangeItemAsync( - CommerceAdminActor actor, - UpsertPointExchangeItemCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - if (command.PointsCost <= 0) - { - throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item"); - } - - var item = command.Id.HasValue - ? await dbContext.PointExchangeItems.SingleOrDefaultAsync( - entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value, - cancellationToken) - : await dbContext.PointExchangeItems.SingleOrDefaultAsync( - entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(), - cancellationToken); - if (item is null) - { - item = new PointExchangeItem { TenantId = actor.TenantId }; - dbContext.PointExchangeItems.Add(item); - } - - item.RegionId = command.RegionId; - item.ItemKey = command.ItemKey.Trim(); - item.Name = command.Name.Trim(); - item.Description = command.Description?.Trim(); - item.ItemType = command.ItemType; - item.Status = command.Status; - item.PointsCost = command.PointsCost; - item.Stock = command.Stock; - item.Days = command.Days; - item.SortOrder = command.SortOrder; - item.StartsAt = command.StartsAt; - item.EndsAt = command.EndsAt; - item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload); - item.Metadata = JsonObjectOrDefault(command.Metadata); - - await dbContext.SaveChangesAsync(cancellationToken); - return item; - } - - public async Task GetPointExchangeOrdersAsync( - CommerceAdminActor actor, - TenantPointQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var orders = dbContext.PointExchangeOrders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (query.UserId.HasValue) - { - orders = orders.Where(item => item.UserId == query.UserId.Value); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status)); - } - - var result = await orders - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantPointExchangeOrderList(result); - } - - public async Task UpdatePointExchangeOrderStatusAsync( - CommerceAdminActor actor, - UpdatePointExchangeOrderStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var order = await dbContext.PointExchangeOrders - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken) - ?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found"); - order.Status = command.Status; - if (command.Status == PointExchangeOrderStatus.Completed) - { - order.CompletedAt ??= DateTimeOffset.UtcNow; - order.CancelledAt = null; - } - else if (command.Status == PointExchangeOrderStatus.Cancelled) - { - order.CancelledAt ??= DateTimeOffset.UtcNow; - } - - await dbContext.SaveChangesAsync(cancellationToken); - return order; - } - - public async Task GetCouponsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var coupons = dbContext.Coupons.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - var items = await coupons - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantCouponList(items); - } - - public async Task UpsertCouponAsync( - CommerceAdminActor actor, - UpsertCouponCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var coupon = command.Id.HasValue - ? await dbContext.Coupons.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, - cancellationToken) - : await dbContext.Coupons.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), - cancellationToken); - if (coupon is null) - { - coupon = new Coupon { TenantId = actor.TenantId }; - dbContext.Coupons.Add(coupon); - } - - coupon.Code = command.Code.Trim(); - coupon.PlanId = command.PlanId; - coupon.DiscountType = command.DiscountType; - coupon.DiscountValue = command.DiscountValue; - coupon.ValidFrom = command.ValidFrom; - coupon.ValidTo = command.ValidTo; - coupon.MaxUses = command.MaxUses; - coupon.Source = command.Source?.Trim(); - coupon.Remark = command.Remark; - - await dbContext.SaveChangesAsync(cancellationToken); - return coupon; - } - - public async Task GetCouponRedemptionsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var redemptions = dbContext.CouponRedemptions.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); - } - - var items = await redemptions - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantCouponRedemptionList(items); - } - - public async Task GetCouponReportAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); - var redemptions = dbContext.CouponRedemptions.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - var claimedCount = await redemptions.CountAsync(cancellationToken); - var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); - var discountApplied = await redemptions - .Where(item => item.Status == CouponRedemptionStatus.Used) - .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0; - return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); - } - - public async Task GetRefundsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var refunds = dbContext.CommerceRefundRequests.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && - order.Id == item.OrderId && - order.RegionId.HasValue && - regionIds.Contains(order.RegionId.Value))); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); - } - - var items = await refunds - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantRefundList(items); - } - - public async Task CreateRefundRequestAsync( - CommerceAdminActor actor, - CreateRefundRequestCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var order = await dbContext.Orders - .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) - .SingleOrDefaultAsync(cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); - if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) - { - throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); - } - - if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents) - { - throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount"); - } - - if (command.PaymentId.HasValue) - { - var paymentExists = await dbContext.Payments.AnyAsync( - item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, - cancellationToken); - if (!paymentExists) - { - throw new CommerceException("Payment was not found.", "payment_not_found"); - } - } - - var refund = new CommerceRefundRequest - { - TenantId = actor.TenantId, - OrderId = order.Id, - PaymentId = command.PaymentId, - RequestedBy = actor.UserId, - RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}", - Provider = order.PayProvider, - Status = CommerceRefundStatus.Requested, - AmountCents = command.AmountCents, - Reason = command.Reason?.Trim(), - EntitlementAction = command.EntitlementAction, - Metadata = JsonObjectOrDefault(command.Metadata) - }; - dbContext.CommerceRefundRequests.Add(refund); - AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason }); - await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return refund; - } - - public async Task UpdateRefundStatusAsync( - CommerceAdminActor actor, - UpdateRefundStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var refund = await dbContext.CommerceRefundRequests - .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) - .ApplyDataScope( - scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && - order.Id == item.OrderId && - order.RegionId.HasValue && - regionIds.Contains(order.RegionId.Value))) - .SingleOrDefaultAsync(cancellationToken) - ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); - var fromStatus = refund.Status; - if (!IsAllowedRefundTransition(fromStatus, command.Status)) - { - throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition"); - } - - refund.Status = command.Status; - refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) - ? refund.ProviderRefundNo - : command.ProviderRefundNo.Trim(); - switch (command.Status) - { - case CommerceRefundStatus.Approved: - refund.ReviewedBy = actor.UserId; - refund.ReviewedAt = DateTimeOffset.UtcNow; - break; - case CommerceRefundStatus.Processing: - refund.ProcessedBy = actor.UserId; - refund.ProcessedAt = DateTimeOffset.UtcNow; - break; - case CommerceRefundStatus.Succeeded: - refund.SucceededAt = DateTimeOffset.UtcNow; - await ApplyRefundToOrderAsync(refund, cancellationToken); - break; - case CommerceRefundStatus.Failed: - refund.FailedAt = DateTimeOffset.UtcNow; - refund.FailureReason = command.Reason; - break; - case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected: - refund.CancelledAt = DateTimeOffset.UtcNow; - break; - } - - AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo }); - await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return refund; - } - - public async Task GetRefundEventsAsync( - CommerceAdminActor actor, - Guid refundRequestId, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var refundExists = await dbContext.CommerceRefundRequests - .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) - .ApplyDataScope( - scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && - order.Id == item.OrderId && - order.RegionId.HasValue && - regionIds.Contains(order.RegionId.Value))) - .AnyAsync(cancellationToken); - if (!refundExists) - { - throw new CommerceException("Refund request was not found.", "refund_not_found"); - } - - var items = await dbContext.CommerceRefundEvents.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) - .OrderBy(item => item.CreatedAt) - .ToArrayAsync(cancellationToken); - return new TenantRefundEventList(items); - } - - public async Task GetReconciliationBatchesAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var batches = dbContext.CommerceReconciliationBatches.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - var provider = NormalizeProvider(query.Provider); - batches = batches.Where(item => item.Provider == provider); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status)); - } - - var items = await batches.OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantReconciliationBatchList(items); - } - - public async Task CreateReconciliationBatchAsync( - CommerceAdminActor actor, - CreateReconciliationBatchCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var batch = new CommerceReconciliationBatch - { - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - Provider = NormalizeProvider(command.Provider), - BillDate = command.BillDate, - BillType = command.BillType, - Source = command.Source, - SourceName = command.SourceName?.Trim(), - SourceHash = command.SourceHash.Trim(), - Status = ReconciliationBatchStatus.Pending, - Metadata = JsonObjectOrDefault(command.Metadata) - }; - dbContext.CommerceReconciliationBatches.Add(batch); - await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return batch; - } - - public async Task GetReconciliationIssuesAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var issues = dbContext.CommerceReconciliationIssues.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - var provider = NormalizeProvider(query.Provider); - issues = issues.Where(item => item.Provider == provider); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status)); - } - - var items = await issues.OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantReconciliationIssueList(items); - } - - public async Task UpdateReconciliationIssueAsync( - CommerceAdminActor actor, - UpdateReconciliationIssueCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.IssueId, - cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); - var fromStatus = issue.Status; - issue.Status = command.Status; - issue.ResolutionType = command.ResolutionType; - issue.ResolutionNote = command.Note?.Trim(); - issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo; - if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored) - { - issue.ResolvedBy = actor.UserId; - issue.ResolvedAt = DateTimeOffset.UtcNow; - } - - dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent - { - TenantId = actor.TenantId, - IssueId = issue.Id, - FromStatus = fromStatus, - ToStatus = command.Status, - EventType = "status_changed", - ActorUserId = actor.UserId, - Note = command.Note, - Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo }) - }); - await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return issue; - } - - public async Task GetAdjustmentVouchersAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); - } - - var items = await vouchers - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) - .ToArrayAsync(cancellationToken); - return new TenantAdjustmentVoucherList(items); - } - - public async Task GetAdjustmentVoucherAsync( - CommerceAdminActor actor, - Guid voucherId, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - return await dbContext.CommerceAdjustmentVouchers.AsNoTracking() - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) - ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); - } - - public async Task CreateAdjustmentVoucherAsync( - CommerceAdminActor actor, - CreateAdjustmentVoucherCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); - var voucher = new CommerceAdjustmentVoucher - { - TenantId = actor.TenantId, - IssueId = command.IssueId, - BatchId = command.BatchId, - ItemId = command.ItemId, - OrderId = command.OrderId, - PaymentId = command.PaymentId, - RefundRequestId = command.RefundRequestId, - CreatedBy = actor.UserId, - VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}", - Status = CommerceAdjustmentVoucherStatus.Draft, - Direction = command.Direction, - AmountCents = command.AmountCents, - Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(), - Reason = command.Reason.Trim(), - ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(), - Metadata = JsonObjectOrDefault(command.Metadata) - }; - dbContext.CommerceAdjustmentVouchers.Add(voucher); - dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent - { - TenantId = actor.TenantId, - VoucherId = voucher.Id, - ToStatus = voucher.Status, - ActorUserId = actor.UserId, - Note = voucher.Reason, - Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents }) - }); - await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return voucher; - } - - public async Task UpdateAdjustmentVoucherStatusAsync( - CommerceAdminActor actor, - UpdateAdjustmentVoucherStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, - cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); - var fromStatus = voucher.Status; - if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status)) - { - throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition"); - } - - voucher.Status = command.Status; - if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected) - { - voucher.ReviewedBy = actor.UserId; - voucher.ReviewedAt ??= DateTimeOffset.UtcNow; - } - else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void) - { - voucher.ClosedAt ??= DateTimeOffset.UtcNow; - } - - dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent - { - TenantId = actor.TenantId, - VoucherId = voucher.Id, - FromStatus = fromStatus, - ToStatus = command.Status, - ActorUserId = actor.UserId, - Note = command.Note, - Details = JsonSerializer.SerializeToElement(new { }) - }); - await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return voucher; - } - - public async Task GetAdjustmentVoucherEventsAsync( - CommerceAdminActor actor, - Guid voucherId, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync( - item => item.TenantId == actor.TenantId && item.Id == voucherId, - cancellationToken); - if (!exists) - { - throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); - } - - var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) - .OrderBy(item => item.CreatedAt) - .ToArrayAsync(cancellationToken); - return new TenantAdjustmentVoucherEventList(events); - } - - public async Task GetAdjustmentReportAsync( - CommerceAdminActor actor, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - return new TenantAdjustmentReport( - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), - await dbContext.CommerceAdjustmentVouchers - .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) - .SumAsync(item => item.AmountCents, cancellationToken), - await dbContext.CommerceAdjustmentVouchers - .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) - .SumAsync(item => item.AmountCents, cancellationToken)); - } - - public async Task GetReconciliationItemsAsync( - CommerceAdminActor actor, - Guid batchId, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync( - item => item.TenantId == actor.TenantId && item.Id == batchId, - cancellationToken); - if (!batchExists) - { - throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found"); - } - - var items = await dbContext.CommerceReconciliationItems.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) - .OrderBy(item => item.RowNo) - .Take(500) - .ToArrayAsync(cancellationToken); - return new TenantReconciliationItemList(items); - } - - public async Task GetReconciliationIssueEventsAsync( - CommerceAdminActor actor, - Guid issueId, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync( - item => item.TenantId == actor.TenantId && item.Id == issueId, - cancellationToken); - if (!issueExists) - { - throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); - } - - var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) - .OrderBy(item => item.CreatedAt) - .ToArrayAsync(cancellationToken); - return new TenantReconciliationIssueEventList(events); - } - - public async Task GetAnomalySummaryAsync( - CommerceAdminActor actor, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var openRefunds = await dbContext.CommerceRefundRequests.CountAsync( - item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested, - cancellationToken); - var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync( - item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, - cancellationToken); - var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync( - item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, - cancellationToken); - var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync( - item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, - cancellationToken); - var pendingPayments = await dbContext.Payments.CountAsync( - item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending, - cancellationToken); - var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync( - item => item.TenantId == actor.TenantId && - (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch || - item.MatchStatus == ReconciliationMatchStatus.StatusMismatch), - cancellationToken); - - return new TenantCommerceAnomalySummary( - openRefunds, - processingRefunds, - openIssues, - failedBatches, - pendingPayments, - mismatchCount); - } - - public async Task PreviewReconciliationImportAsync( - CommerceAdminActor actor, - PreviewReconciliationImportCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - return BuildImportPreview(command.Provider, command.Rows); - } - - public async Task ImportReconciliationAsync( - CommerceAdminActor actor, - ImportReconciliationCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var preview = BuildImportPreview(command.Provider, command.Rows); - var batch = new CommerceReconciliationBatch - { - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - Provider = NormalizeProvider(command.Provider), - BillDate = command.BillDate, - BillType = command.BillType, - Source = ReconciliationSource.ManualUpload, - SourceName = command.SourceName.Trim(), - SourceHash = preview.SourceHash, - Status = preview.InvalidCount == 0 - ? ReconciliationBatchStatus.Completed - : ReconciliationBatchStatus.CompletedWithIssues, - TotalCount = preview.TotalCount, - MatchedCount = preview.TotalCount - preview.InvalidCount, - MismatchCount = preview.InvalidCount, - AmountCents = preview.AmountCents, - RefundAmountCents = preview.RefundAmountCents, - CompletedAt = DateTimeOffset.UtcNow, - Metadata = JsonSerializer.SerializeToElement(new - { - preview.PaymentCount, - preview.RefundCount, - preview.InvalidCount - }) - }; - dbContext.CommerceReconciliationBatches.Add(batch); - var rowNo = 0; - foreach (var row in EnumerateImportRows(command.Rows)) - { - rowNo++; - var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row); - dbContext.CommerceReconciliationItems.Add(item); - if (item.MatchStatus != ReconciliationMatchStatus.Matched) - { - dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue - { - TenantId = actor.TenantId, - BatchId = batch.Id, - Provider = item.Provider, - TransactionType = item.TransactionType, - IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}", - MatchStatus = item.MatchStatus switch - { - ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal, - ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider, - ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate, - ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch, - _ => ReconciliationIssueMatchStatus.AmountMismatch - }, - Severity = item.Severity, - Status = ReconciliationIssueStatus.Open, - OrderNo = item.OrderNo, - RefundNo = item.RefundNo, - ProviderTradeNo = item.ProviderTradeNo, - ProviderRefundNo = item.ProviderRefundNo, - AmountCents = item.AmountCents, - RefundAmountCents = item.RefundAmountCents, - Summary = item.IssueCode, - CreatedBy = actor.UserId, - Metadata = item.Details - }); - } - } - - await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return batch; - } - - public async Task RequestProviderBillJobAsync( - CommerceAdminActor actor, - RequestProviderBillJobCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var job = await backgroundJobService.EnqueueAsync( - new CreateBackgroundJobCommand( - actor.TenantId, - "commerce_reconciliation", - JsonSerializer.SerializeToElement(new - { - provider = NormalizeProvider(command.Provider), - command.BillDate, - billType = command.BillType.ToString() - }), - command.RunAfter, - 5), - cancellationToken); - await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return job; - } - - public async Task> GetProviderBillJobsAsync( - CommerceAdminActor actor, - CommerceAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - return await backgroundJobService.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); - } - - public async Task ProcessRefundNotificationAsync( - Guid tenantId, - RefundNotificationCommand command, - CancellationToken cancellationToken = default) - { - var provider = NormalizeProvider(command.Provider); - var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.RefundNo == command.RefundNo, - cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); - var eventId = string.IsNullOrWhiteSpace(command.EventId) - ? $"{provider}:{command.RefundNo}:{command.Status}" - : command.EventId.Trim(); - var duplicate = await dbContext.PaymentEvents.AnyAsync( - item => item.TenantId == tenantId && - item.Provider == provider && - item.EventType == "refund" && - item.EventId == eventId, - cancellationToken); - if (duplicate) - { - return refund; - } - - dbContext.PaymentEvents.Add(new PaymentEvent - { - TenantId = tenantId, - Provider = provider, - EventType = "refund", - EventId = eventId, - SignatureValid = true, - Payload = JsonObjectOrDefault(command.Payload), - ProcessedAt = DateTimeOffset.UtcNow - }); - var fromStatus = refund.Status; - if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status)) - { - refund.Status = command.Status; - refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) - ? refund.ProviderRefundNo - : command.ProviderRefundNo.Trim(); - if (command.Status == CommerceRefundStatus.Succeeded) - { - refund.SucceededAt = DateTimeOffset.UtcNow; - await ApplyRefundToOrderAsync(refund, cancellationToken); - } - else if (command.Status == CommerceRefundStatus.Failed) - { - refund.FailedAt = DateTimeOffset.UtcNow; - } - - dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent - { - TenantId = tenantId, - RefundRequestId = refund.Id, - FromStatus = fromStatus, - ToStatus = command.Status, - EventType = "provider_notify", - Details = JsonObjectOrDefault(command.Payload) - }); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return refund; - } - - private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (!access.IsCurrentTenantMember || - access.UserId != actor.UserId || - access.TenantId != actor.TenantId || - !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate)) - { - throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); - } - } - - private async Task RequireDataScopeAsync( - CommerceAdminActor actor, - CancellationToken cancellationToken) - { - await AssertAdminAsync(actor, cancellationToken); - return (await currentAccessContext.GetAsync(cancellationToken)).DataScope; - } - - private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => - new( - item.Id, - item.Provider, - GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect", - item.DisplayName, - item.Status, - item.SecretRef, - item.Priority, - item.ConfigPublic, - item.CreatedAt, - item.UpdatedAt); - - private static TenantSecretItem ToSecretItem(TenantSecret item) => - new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt); - - private static CodeBatchItem ToCodeBatchItem(CodeBatch item) => - new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt); - - private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) => - new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt); - - private static CommerceOrderItem ToOrderItem(Order order) => - new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); - - private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) => - new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload); - - private static OrderStatus ParseOrderStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Order status is invalid.", "invalid_order_status"); - - private static PaymentStatus ParsePaymentStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Payment status is invalid.", "invalid_payment_status"); - - private static PointActivityTaskStatus ParsePointTaskStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Point task status is invalid.", "invalid_point_task_status"); - - private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status"); - - private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); - - private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status"); - - private static CommerceRefundStatus ParseRefundStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Refund status is invalid.", "invalid_refund_status"); - - private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status"); - - private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status"); - - private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status"); - - private async Task AssertOptionalReferenceAsync( - DbSet set, - Guid tenantId, - Guid? id, - string code, - CancellationToken cancellationToken) - where TEntity : class - { - if (!id.HasValue) - { - return; - } - - var exists = await set.AnyAsync( - item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, - cancellationToken); - if (!exists) - { - throw new CommerceException("Referenced commerce entity was not found.", code); - } - } - - private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) - { - var order = await dbContext.Orders.SingleAsync( - item => item.TenantId == refund.TenantId && item.Id == refund.OrderId, - cancellationToken); - if (order.RefundedAmountCents < order.AmountCents) - { - order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents); - order.Status = order.RefundedAmountCents >= order.AmountCents - ? OrderStatus.Refunded - : OrderStatus.PartiallyRefunded; - } - - if (refund.PaymentId.HasValue) - { - var payment = await dbContext.Payments.SingleOrDefaultAsync( - item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value, - cancellationToken); - if (payment is not null) - { - payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents); - payment.Status = payment.RefundedAmountCents >= payment.AmountCents - ? PaymentStatus.Refunded - : PaymentStatus.PartiallyRefunded; - } - } - } - - private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to) - { - return from switch - { - CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled, - CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, - CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed, - CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, - _ => false - }; - } - - private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to) - { - return from switch - { - CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void, - CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void, - CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed, - _ => false - }; - } - - private void AddRefundEvent( - CommerceRefundRequest refund, - CommerceRefundStatus? fromStatus, - CommerceRefundStatus toStatus, - string eventType, - Guid actorUserId, - object details) - { - dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent - { - TenantId = refund.TenantId, - RefundRequestId = refund.Id, - FromStatus = fromStatus, - ToStatus = toStatus, - EventType = eventType, - ActorUserId = actorUserId, - Details = JsonSerializer.SerializeToElement(details) - }); - } - - private Task AddAuditAsync( - CommerceAdminActor actor, - string action, - string targetType, - Guid targetId, - object details, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog - { - TenantId = actor.TenantId, - ActorUserId = actor.UserId, - Action = action, - TargetType = targetType, - TargetId = targetId.ToString(), - Details = JsonSerializer.SerializeToElement(details) - }); - return Task.CompletedTask; - } - - private static string NormalizeEnum(string? value) => - string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); - - private static string NormalizeProvider(string? provider) - { - var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); - return normalized switch - { - "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, - "ali_pay" => PaymentProviders.Alipay, - "" => throw new CommerceException("Provider is required.", "provider_required"), - _ => normalized - }; - } - - private static JsonElement WithPaymentMode(JsonElement element, string? mode) - { - var values = new Dictionary(StringComparer.Ordinal); - if (element.ValueKind == JsonValueKind.Object) - { - foreach (var property in element.EnumerateObject()) - { - values[property.Name] = property.Value.Clone(); - } - } - - values["mode"] = JsonSerializer.SerializeToElement( - string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim()); - return JsonSerializer.SerializeToElement(values); - } - - private static string? GetJsonString(JsonElement element, params string[] keys) - { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } - - foreach (var key in keys) - { - if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) - { - return value.GetString(); - } - } - - return null; - } - - private static JsonElement JsonObjectOrDefault(JsonElement element) => - element.ValueKind == JsonValueKind.Object - ? element.Clone() - : JsonSerializer.SerializeToElement(new { }); - - private static void AssertNoSecrets(JsonElement element, string path) - { - if (element.ValueKind != JsonValueKind.Object) - { - return; - } - - foreach (var property in element.EnumerateObject()) - { - var key = property.Name.ToLowerInvariant(); - if (key is "secretref" or "secret_ref") - { - continue; - } - - if (key.Contains("secret", StringComparison.Ordinal) || - key.Contains("privatekey", StringComparison.Ordinal) || - key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret") - { - throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret"); - } - - AssertNoSecrets(property.Value, $"{path}.{property.Name}"); - } - } - - private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows) - { - var normalizedProvider = NormalizeProvider(provider); - var parsedRows = EnumerateImportRows(rows).ToArray(); - var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment); - var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund); - var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched); - var amountCents = parsedRows.Sum(row => row.AmountCents); - var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents); - var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant(); - return new ReconciliationImportPreview( - parsedRows.Length, - paymentCount, - refundCount, - invalidCount, - amountCents, - refundAmountCents, - sourceHash); - } - - private sealed record ReconciliationImportRow( - ReconciliationTransactionType TransactionType, - string? ProviderTradeNo, - string? ProviderRefundNo, - string? OrderNo, - string? RefundNo, - int AmountCents, - int RefundAmountCents, - string? ProviderStatus, - string? LocalStatus, - ReconciliationMatchStatus MatchStatus, - string? IssueCode, - JsonElement Details); - - private static IEnumerable EnumerateImportRows(JsonElement rows) - { - if (rows.ValueKind != JsonValueKind.Array) - { - throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows"); - } - - foreach (var row in rows.EnumerateArray()) - { - if (row.ValueKind != JsonValueKind.Object) - { - yield return InvalidImportRow("row_not_object", row); - continue; - } - - var transactionType = Enum.TryParse( - NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"), - true, - out var parsedTransactionType) - ? parsedTransactionType - : ReconciliationTransactionType.Payment; - var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount"); - var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount"); - var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo"); - var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no"); - var orderNo = GetJsonString(row, "orderNo", "order_no"); - var refundNo = GetJsonString(row, "refundNo", "refund_no"); - var issueCode = GetJsonString(row, "issueCode", "issue_code"); - var matchStatus = Enum.TryParse( - NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"), - true, - out var parsedMatchStatus) - ? parsedMatchStatus - : ReconciliationMatchStatus.AmountMismatch; - if (string.IsNullOrWhiteSpace(providerTradeNo) && - string.IsNullOrWhiteSpace(providerRefundNo) && - string.IsNullOrWhiteSpace(orderNo) && - string.IsNullOrWhiteSpace(refundNo)) - { - matchStatus = ReconciliationMatchStatus.MissingLocal; - issueCode ??= "missing_business_identifier"; - } - - yield return new ReconciliationImportRow( - transactionType, - providerTradeNo, - providerRefundNo, - orderNo, - refundNo, - Math.Max(0, amountCents), - Math.Max(0, refundAmountCents), - GetJsonString(row, "providerStatus", "provider_status"), - GetJsonString(row, "localStatus", "local_status"), - matchStatus, - issueCode, - row.Clone()); - } - } - - private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) => - new( - ReconciliationTransactionType.Payment, - null, - null, - null, - null, - 0, - 0, - null, - null, - ReconciliationMatchStatus.AmountMismatch, - issueCode, - row.Clone()); - - private static CommerceReconciliationItem CreateReconciliationItem( - Guid tenantId, - Guid batchId, - int rowNo, - string provider, - ReconciliationImportRow row) => - new() - { - TenantId = tenantId, - BatchId = batchId, - RowNo = rowNo, - Provider = provider, - TransactionType = row.TransactionType, - ProviderTradeNo = row.ProviderTradeNo, - ProviderRefundNo = row.ProviderRefundNo, - OrderNo = row.OrderNo, - RefundNo = row.RefundNo, - AmountCents = row.AmountCents, - RefundAmountCents = row.RefundAmountCents, - ProviderStatus = row.ProviderStatus, - LocalStatus = row.LocalStatus, - MatchStatus = row.MatchStatus, - Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning, - IssueCode = row.IssueCode, - Details = row.Details - }; - - private static int GetJsonInt(JsonElement element, params string[] keys) - { - foreach (var key in keys) - { - if (!element.TryGetProperty(key, out var value)) - { - continue; - } - - if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) - { - return number; - } - - if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) - { - return parsed; - } - } - - return 0; - } - - private static string GenerateActivationCode() - { - Span bytes = stackalloc byte[8]; - RandomNumberGenerator.Fill(bytes); - return $"TKU{Convert.ToHexString(bytes)}"; - } - - private static string FormatCny(int cents) => - (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); } diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs index e2d23ea..5cd9008 100644 --- a/Tiku.Infrastructure/Commerce/CommerceService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceService.cs @@ -10,992 +10,11 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; -public sealed class CommerceService( +public sealed partial class CommerceService( TikuDbContext dbContext, IPaymentProviderGateway paymentGateway) : ICommerceService { private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents); - public async Task CreateOrderAsync( - CommerceActor actor, - CreateCommerceOrderCommand command, - CancellationToken cancellationToken = default) - { - if (command.Quantity is < 1 or > 99) - { - throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); - } - await AssertActiveMemberAsync(actor, cancellationToken); - var plan = await dbContext.SvipPlans - .AsNoTracking() - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Id == command.PlanId && - item.IsActive, - cancellationToken) - ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); - - if (plan.CouponOnly && - string.IsNullOrWhiteSpace(command.CouponCode) && - !command.CouponRedemptionId.HasValue) - { - throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); - } - - if (command.RegionId.HasValue) - { - var regionExists = await dbContext.Regions - .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); - if (!regionExists) - { - throw new CommerceException("Region was not found.", "region_not_found"); - } - } - - await using var transaction = dbContext.Database.IsRelational() - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var originalAmountCents = checked(plan.PriceCents * command.Quantity); - var coupon = await ApplyCouponForOrderAsync( - actor, - command, - plan, - originalAmountCents, - cancellationToken); - if (plan.CouponOnly && coupon is null) - { - throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); - } - - var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0)); - var order = new Order - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PlanId = plan.Id, - RegionId = command.RegionId ?? plan.RegionId, - OrderNo = GenerateOrderNo(), - Status = OrderStatus.Pending, - ProductType = "svip", - ProductName = plan.Name, - AmountCents = amountCents, - PayMethod = NormalizeMethod(command.PayMethod), - PayProvider = NormalizeProvider(command.PayProvider), - Days = checked(plan.Days * command.Quantity), - RawPayload = JsonSerializer.SerializeToElement(new - { - source = "student_checkout", - command.Quantity, - requestedCouponCode = command.CouponCode, - requestedCouponRedemptionId = command.CouponRedemptionId, - plan.PriceCents, - plan.OriginalPriceCents, - originalAmountCents, - discountCents = coupon?.DiscountCents ?? 0, - couponId = coupon?.Coupon.Id, - couponCode = coupon?.Coupon.Code, - couponRedemptionId = coupon?.Redemption.Id - }) - }; - dbContext.Orders.Add(order); - dbContext.OrderItems.Add(new OrderItem - { - TenantId = actor.TenantId, - OrderId = order.Id, - ItemType = "svip_plan", - ItemId = plan.Id, - Name = plan.Name, - Quantity = command.Quantity, - UnitAmountCents = plan.PriceCents, - TotalAmountCents = originalAmountCents, - Metadata = JsonSerializer.SerializeToElement(new - { - plan.Days, - plan.RegionId, - plan.VpProductId - }) - }); - - if (coupon is not null) - { - coupon.Redemption.Status = CouponRedemptionStatus.Used; - coupon.Redemption.OrderId = order.Id; - coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents; - coupon.Redemption.UsedAt = DateTimeOffset.UtcNow; - } - - if (amountCents == 0) - { - var payment = new Payment - { - TenantId = actor.TenantId, - OrderId = order.Id, - Provider = "manual", - Method = "zero_amount", - Status = PaymentStatus.Pending, - AmountCents = 0 - }; - dbContext.Payments.Add(payment); - await MarkPaidAsync( - actor, - order, - payment, - $"zero-{order.OrderNo}", - order.RawPayload, - "zero_amount_paid", - $"zero-{order.OrderNo}", - true, - DateTimeOffset.UtcNow, - cancellationToken); - } - - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - - return ToOrderItem(order); - } - - public async Task GetOrdersAsync( - CommerceActor actor, - CommerceOrderQuery query, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var orders = dbContext.Orders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); - } - - var items = await orders - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) - .ToArrayAsync(cancellationToken); - - return new CommerceOrderList(items.Select(ToOrderItem).ToArray()); - } - - public async Task GetOrderAsync( - CommerceActor actor, - string orderNo, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var order = await FindActorOrderAsync(actor, orderNo, cancellationToken); - return ToOrderItem(order); - } - - public async Task CreatePaymentAsync( - CommerceActor actor, - CreateCommercePaymentCommand command, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken); - if (order.Status != OrderStatus.Pending) - { - throw new CommerceException("Only pending orders can create payments.", "order_status_invalid"); - } - - var provider = NormalizeProvider(command.Provider); - var method = NormalizeMethod(command.Method); - var payment = await dbContext.Payments - .Where(item => - item.TenantId == actor.TenantId && - item.OrderId == order.Id && - item.Provider == provider && - item.Status == PaymentStatus.Pending) - .OrderByDescending(item => item.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - - if (payment is null) - { - payment = new Payment - { - TenantId = actor.TenantId, - OrderId = order.Id, - Provider = provider, - Method = method, - Status = PaymentStatus.Pending, - AmountCents = order.AmountCents - }; - dbContext.Payments.Add(payment); - } - - var result = await paymentGateway.CreatePaymentAsync( - provider, - new CreatePaymentProviderRequest( - actor.TenantId, - order.OrderNo, - order.ProductName ?? order.OrderNo, - order.AmountCents, - method, - command.OpenId, - command.ReturnUrl, - command.QuitUrl, - $"/api/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}", - JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })), - cancellationToken); - - payment.Method = result.Method; - payment.RawPayload = result.RawPayload; - if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) - { - payment.ProviderTradeNo = result.ProviderTradeNo; - } - - if (IsPaid(result.Status)) - { - await MarkPaidAsync( - actor, - order, - payment, - result.ProviderTradeNo, - result.RawPayload, - "payment_paid", - result.ProviderTradeNo, - true, - null, - cancellationToken); - } - else - { - dbContext.PaymentEvents.Add(new PaymentEvent - { - TenantId = actor.TenantId, - PaymentId = payment.Id, - Provider = provider, - EventType = "payment_created", - Payload = result.RawPayload - }); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return ToPaymentItem(payment, order.OrderNo, result.ClientPayload); - } - - public async Task GetCurrentEntitlementAsync( - CommerceActor actor, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var now = DateTimeOffset.UtcNow; - var entitlement = await dbContext.Entitlements - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.EntitlementType == "svip" && - item.Status == EntitlementStatus.Active && - (item.ExpiresAt == null || item.ExpiresAt > now)) - .OrderByDescending(item => item.ExpiresAt) - .FirstOrDefaultAsync(cancellationToken); - - if (entitlement is null) - { - return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null); - } - - return new CurrentEntitlementItem( - true, - entitlement.EntitlementType, - entitlement.StartsAt, - entitlement.ExpiresAt, - entitlement.Status.ToString(), - entitlement.ExpiresAt.HasValue - ? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays)) - : null); - } - - public async Task ClaimCouponAsync( - CommerceActor actor, - ClaimCommerceCouponCommand command, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken); - ValidateCouponClaimable(coupon, null); - - var existing = await dbContext.CouponRedemptions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.CouponId == coupon.Id) - .OrderByDescending(item => item.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (existing is not null) - { - return ToCouponItem(coupon, existing, null); - } - - if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { - throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } - - var redemption = new CouponRedemption - { - TenantId = actor.TenantId, - CouponId = coupon.Id, - UserId = actor.UserId, - PlanId = coupon.PlanId, - CouponCode = coupon.Code, - Status = CouponRedemptionStatus.Claimed, - Source = "student_claim", - ClaimedAt = DateTimeOffset.UtcNow - }; - coupon.UsedCount += 1; - dbContext.CouponRedemptions.Add(redemption); - await dbContext.SaveChangesAsync(cancellationToken); - return ToCouponItem(coupon, redemption, null); - } - - public async Task GetCouponsAsync( - CommerceActor actor, - CommerceCouponQuery query, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - var redemptions = dbContext.CouponRedemptions - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); - } - - var items = await redemptions - .OrderByDescending(item => item.CreatedAt) - .Take(Math.Clamp(query.Limit ?? 50, 1, 100)) - .ToArrayAsync(cancellationToken); - var couponIds = items - .Where(item => item.CouponId.HasValue) - .Select(item => item.CouponId!.Value) - .Distinct() - .ToArray(); - var coupons = await dbContext.Coupons - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id)) - .ToDictionaryAsync(item => item.Id, cancellationToken); - - return new CommerceCouponList( - items.Select(item => - { - coupons.TryGetValue(item.CouponId ?? Guid.Empty, out var coupon); - return ToCouponItem(coupon, item, item.DiscountAppliedCents); - }).ToArray()); - } - - public async Task CheckCouponAsync( - CommerceActor actor, - CheckCommerceCouponCommand command, - CancellationToken cancellationToken = default) - { - await AssertActiveMemberAsync(actor, cancellationToken); - if (command.Quantity is < 1 or > 99) - { - throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); - } - - var plan = await dbContext.SvipPlans - .AsNoTracking() - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Id == command.PlanId && - item.IsActive, - cancellationToken) - ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); - var originalAmountCents = checked(plan.PriceCents * command.Quantity); - try - { - var coupon = await ResolveCouponForCheckAsync(actor, command, plan, originalAmountCents, cancellationToken); - return new CommerceCouponCheckResult( - true, - null, - coupon.Coupon.Id, - coupon.Redemption.Id, - coupon.Coupon.Code, - originalAmountCents, - coupon.DiscountCents, - Math.Max(0, originalAmountCents - coupon.DiscountCents), - FormatCny(Math.Max(0, originalAmountCents - coupon.DiscountCents))); - } - catch (CommerceException exception) when (exception.Code.StartsWith("coupon_", StringComparison.Ordinal)) - { - return new CommerceCouponCheckResult( - false, - exception.Code, - null, - command.CouponRedemptionId, - command.CouponCode, - originalAmountCents, - 0, - originalAmountCents, - FormatCny(originalAmountCents)); - } - } - - public async Task ProcessPaymentNotificationAsync( - Guid tenantId, - string provider, - IReadOnlyDictionary headers, - string rawBody, - JsonElement body, - CancellationToken cancellationToken = default) - { - var normalizedProvider = NormalizeProvider(provider); - var notification = await paymentGateway.ParsePaymentNotificationAsync( - normalizedProvider, - new PaymentNotificationRequest( - tenantId, - normalizedProvider, - headers, - rawBody, - body), - cancellationToken); - - if (!notification.SignatureValid) - { - throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid"); - } - - var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync( - item => - item.Provider == normalizedProvider && - item.EventId == notification.EventId && - item.ProcessedAt != null, - cancellationToken); - if (alreadyProcessed) - { - return new PaymentNotificationProcessResult( - normalizedProvider, - notification.EventId, - notification.OrderNo, - "processed", - true); - } - - var order = await dbContext.Orders - .SingleOrDefaultAsync(item => - item.TenantId == tenantId && - item.OrderNo == notification.OrderNo, - cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); - - if (order.UserId is null) - { - throw new CommerceException("Order does not belong to a user.", "order_user_missing"); - } - - if (order.AmountCents != notification.AmountCents) - { - dbContext.PaymentEvents.Add(new PaymentEvent - { - TenantId = tenantId, - Provider = normalizedProvider, - EventType = notification.EventType, - EventId = notification.EventId, - SignatureValid = true, - Payload = notification.RawPayload, - Error = "payment_amount_mismatch" - }); - await dbContext.SaveChangesAsync(cancellationToken); - throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch"); - } - - var payment = await dbContext.Payments - .Where(item => - item.TenantId == tenantId && - item.OrderId == order.Id && - item.Provider == normalizedProvider) - .OrderByDescending(item => item.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (payment is null) - { - payment = new Payment - { - TenantId = tenantId, - OrderId = order.Id, - Provider = normalizedProvider, - Method = order.PayMethod, - Status = PaymentStatus.Pending, - AmountCents = order.AmountCents - }; - dbContext.Payments.Add(payment); - } - - if (notification.Paid && order.Status == OrderStatus.Pending) - { - await MarkPaidAsync( - new CommerceActor(tenantId, order.UserId.Value), - order, - payment, - notification.ProviderTradeNo, - notification.RawPayload, - notification.EventType, - notification.EventId, - notification.SignatureValid, - notification.PaidAt, - cancellationToken); - } - else - { - dbContext.PaymentEvents.Add(new PaymentEvent - { - TenantId = tenantId, - PaymentId = payment.Id, - Provider = normalizedProvider, - EventType = notification.EventType, - EventId = notification.EventId, - SignatureValid = notification.SignatureValid, - Payload = notification.RawPayload, - ProcessedAt = DateTimeOffset.UtcNow - }); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new PaymentNotificationProcessResult( - normalizedProvider, - notification.EventId, - notification.OrderNo, - "processed", - false); - } - - private async Task MarkPaidAsync( - CommerceActor actor, - Order order, - Payment payment, - string? providerTradeNo, - JsonElement rawPayload, - string eventType, - string? eventId, - bool signatureValid, - DateTimeOffset? paidAtOverride, - CancellationToken cancellationToken) - { - var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow; - payment.Status = PaymentStatus.Paid; - payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo; - payment.PaidAt = paidAt; - order.Status = OrderStatus.Paid; - order.TradeNo = payment.ProviderTradeNo; - order.PaidAt = paidAt; - - var days = Math.Max(order.Days ?? 0, 0); - var current = await dbContext.Entitlements - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.EntitlementType == "svip" && - item.Status == EntitlementStatus.Active) - .OrderByDescending(item => item.ExpiresAt) - .FirstOrDefaultAsync(cancellationToken); - if (current is null) - { - dbContext.Entitlements.Add(new Entitlement - { - TenantId = actor.TenantId, - UserId = actor.UserId, - EntitlementType = "svip", - ScopeType = EntitlementScopeType.Tenant, - SourceType = "order", - SourceId = order.Id, - StartsAt = paidAt, - ExpiresAt = days > 0 ? paidAt.AddDays(days) : null, - Status = EntitlementStatus.Active, - Metadata = rawPayload - }); - } - else if (days > 0) - { - var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt - ? current.ExpiresAt.Value - : paidAt; - current.ExpiresAt = baseAt.AddDays(days); - current.Metadata = rawPayload; - } - - dbContext.PaymentEvents.Add(new PaymentEvent - { - TenantId = actor.TenantId, - PaymentId = payment.Id, - Provider = payment.Provider, - EventType = eventType, - EventId = eventId, - SignatureValid = signatureValid, - Payload = rawPayload, - ProcessedAt = paidAt - }); - } - - private async Task ApplyCouponForOrderAsync( - CommerceActor actor, - CreateCommerceOrderCommand command, - SvipPlan plan, - int originalAmountCents, - CancellationToken cancellationToken) - { - if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) - { - return null; - } - - var coupon = command.CouponRedemptionId.HasValue - ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) - : await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken); - ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId); - return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; - } - - private async Task ResolveCouponForCheckAsync( - CommerceActor actor, - CheckCommerceCouponCommand command, - SvipPlan plan, - int originalAmountCents, - CancellationToken cancellationToken) - { - if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) - { - throw new CommerceException("Coupon code or redemption id is required.", "coupon_required"); - } - - var coupon = command.CouponRedemptionId.HasValue - ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) - : await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken); - ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId); - return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; - } - - private async Task ResolveOrClaimCouponByCodeAsync( - CommerceActor actor, - string? couponCode, - CancellationToken cancellationToken) - { - var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); - ValidateCouponClaimable(coupon, null); - var existing = await dbContext.CouponRedemptions - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.CouponId == coupon.Id) - .OrderByDescending(item => item.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (existing is not null) - { - return new CouponApplication(coupon, existing, 0); - } - - if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { - throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } - - var redemption = new CouponRedemption - { - TenantId = actor.TenantId, - CouponId = coupon.Id, - UserId = actor.UserId, - PlanId = coupon.PlanId, - CouponCode = coupon.Code, - Status = CouponRedemptionStatus.Claimed, - Source = "checkout_claim", - ClaimedAt = DateTimeOffset.UtcNow - }; - coupon.UsedCount += 1; - dbContext.CouponRedemptions.Add(redemption); - return new CouponApplication(coupon, redemption, 0); - } - - private async Task ResolveCouponByCodeForCheckAsync( - CommerceActor actor, - string? couponCode, - CancellationToken cancellationToken) - { - var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); - var redemption = await dbContext.CouponRedemptions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.CouponId == coupon.Id) - .OrderByDescending(item => item.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (redemption is not null) - { - return new CouponApplication(coupon, redemption, 0); - } - - ValidateCouponClaimable(coupon, null); - return new CouponApplication( - coupon, - new CouponRedemption - { - Id = Guid.Empty, - TenantId = actor.TenantId, - CouponId = coupon.Id, - UserId = actor.UserId, - PlanId = coupon.PlanId, - CouponCode = coupon.Code, - Status = CouponRedemptionStatus.Claimed - }, - 0); - } - - private async Task ResolveCouponByRedemptionAsync( - CommerceActor actor, - Guid couponRedemptionId, - CancellationToken cancellationToken) - { - var redemption = await dbContext.CouponRedemptions - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Id == couponRedemptionId, - cancellationToken) - ?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found"); - if (redemption.CouponId is null) - { - throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid"); - } - - var coupon = await dbContext.Coupons - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken) - ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); - return new CouponApplication(coupon, redemption, 0); - } - - private async Task FindCouponByCodeAsync( - Guid tenantId, - string? couponCode, - CancellationToken cancellationToken) - { - var code = NormalizeRequired(couponCode, "coupon_code_required"); - return await dbContext.Coupons - .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken) - ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); - } - - private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption) - { - var now = DateTimeOffset.UtcNow; - if (coupon.ValidFrom is not null && coupon.ValidFrom > now || - coupon.ValidTo is not null && coupon.ValidTo <= now) - { - throw new CommerceException("Coupon is expired or not started.", "coupon_inactive"); - } - - if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { - throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } - } - - private static void ValidateCouponUsable( - Coupon coupon, - CouponRedemption redemption, - SvipPlan plan, - Guid? regionId) - { - ValidateCouponClaimable(coupon, redemption); - if (redemption.Status != CouponRedemptionStatus.Claimed) - { - throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid"); - } - - if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id || - redemption.PlanId.HasValue && redemption.PlanId != plan.Id) - { - throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable"); - } - - if (redemption.RegionId.HasValue && - regionId.HasValue && - redemption.RegionId != regionId) - { - throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable"); - } - } - - private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents) - { - var discount = coupon.DiscountType switch - { - DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero), - DiscountType.Percent => (int)Math.Round( - originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0), - MidpointRounding.AwayFromZero), - _ => 0 - }; - return Math.Clamp(discount, 0, originalAmountCents); - } - - private static decimal PercentFactor(decimal value) - { - if (value <= 0) - { - return 0; - } - - return value <= 1 ? value : value / 100; - } - - private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) - { - var exists = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active, - cancellationToken); - if (!exists) - { - throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); - } - } - - private async Task FindActorOrderAsync( - CommerceActor actor, - string orderNo, - CancellationToken cancellationToken) - { - var trimmed = orderNo.Trim(); - return await dbContext.Orders - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.OrderNo == trimmed, - cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); - } - - private static CommerceOrderItem ToOrderItem(Order order) - { - return new CommerceOrderItem( - order.Id, - order.OrderNo, - order.Status.ToString(), - order.PlanId, - order.RegionId, - order.ProductType, - order.ProductName, - order.AmountCents, - FormatCny(order.AmountCents), - order.PayMethod, - order.PayProvider, - order.TradeNo, - order.Days, - order.PaidAt, - order.CreatedAt, - order.RawPayload); - } - - private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload) - { - return new CommercePaymentItem( - payment.Id, - payment.OrderId, - orderNo, - payment.Provider, - payment.Method, - payment.Status.ToString(), - payment.AmountCents, - FormatCny(payment.AmountCents), - payment.ProviderTradeNo, - payment.PaidAt, - clientPayload, - payment.RawPayload); - } - - private static CommerceCouponItem ToCouponItem( - Coupon? coupon, - CouponRedemption redemption, - int? discountPreviewCents) - { - return new CommerceCouponItem( - redemption.Id, - redemption.CouponId, - redemption.CouponCode ?? coupon?.Code ?? string.Empty, - redemption.Status.ToString(), - redemption.PlanId ?? coupon?.PlanId, - redemption.RegionId, - coupon?.DiscountType?.ToString(), - coupon?.DiscountValue, - discountPreviewCents, - discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null, - coupon?.ValidFrom, - coupon?.ValidTo, - redemption.ClaimedAt, - redemption.UsedAt); - } - - private static OrderStatus ParseOrderStatus(string? status) - { - return Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Order status is invalid.", "invalid_order_status"); - } - - private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) - { - return Enum.TryParse(NormalizeEnum(status), true, out var parsed) - ? parsed - : throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status"); - } - - private static string NormalizeEnum(string? value) => - string.Concat((value ?? string.Empty).Split( - ['_', '-', ' '], - StringSplitOptions.RemoveEmptyEntries)); - - private static string NormalizeRequired(string? value, string code) - { - var trimmed = value?.Trim(); - return !string.IsNullOrWhiteSpace(trimmed) - ? trimmed - : throw new CommerceException("Required commerce value is missing.", code); - } - - private static string NormalizeProvider(string? provider) - { - var normalized = (provider ?? PaymentProviders.Manual) - .Trim() - .ToLowerInvariant() - .Replace("-", "_", StringComparison.Ordinal); - - return normalized switch - { - "" => PaymentProviders.Manual, - "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, - "ali_pay" => PaymentProviders.Alipay, - PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized, - _ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider") - }; - } - - private static string NormalizeMethod(string? method) - { - var normalized = (method ?? "manual").Trim().ToLowerInvariant(); - return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; - } - - private static bool IsPaid(string status) => - string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); - - private static string GenerateOrderNo() - { - Span bytes = stackalloc byte[4]; - RandomNumberGenerator.Fill(bytes); - return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"; - } - - private static string FormatCny(int cents) => - (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); } diff --git a/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs b/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs new file mode 100644 index 0000000..3b6c292 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetCouponsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var coupons = dbContext.Coupons.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + var items = await coupons + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantCouponList(items); + } + + public async Task UpsertCouponAsync( + CommerceAdminActor actor, + UpsertCouponCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var coupon = command.Id.HasValue + ? await dbContext.Coupons.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, + cancellationToken) + : await dbContext.Coupons.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), + cancellationToken); + if (coupon is null) + { + coupon = new Coupon { TenantId = actor.TenantId }; + dbContext.Coupons.Add(coupon); + } + + coupon.Code = command.Code.Trim(); + coupon.PlanId = command.PlanId; + coupon.DiscountType = command.DiscountType; + coupon.DiscountValue = command.DiscountValue; + coupon.ValidFrom = command.ValidFrom; + coupon.ValidTo = command.ValidTo; + coupon.MaxUses = command.MaxUses; + coupon.Source = command.Source?.Trim(); + coupon.Remark = command.Remark; + + await dbContext.SaveChangesAsync(cancellationToken); + return coupon; + } + + public async Task GetCouponRedemptionsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var redemptions = dbContext.CouponRedemptions.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); + } + + var items = await redemptions + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantCouponRedemptionList(items); + } + + public async Task GetCouponReportAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); + var redemptions = dbContext.CouponRedemptions.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + var claimedCount = await redemptions.CountAsync(cancellationToken); + var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); + var discountApplied = await redemptions + .Where(item => item.Status == CouponRedemptionStatus.Used) + .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0; + return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs b/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs new file mode 100644 index 0000000..a4e7f9c --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + public async Task ClaimCouponAsync( + CommerceActor actor, + ClaimCommerceCouponCommand command, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken); + ValidateCouponClaimable(coupon, null); + + var existing = await dbContext.CouponRedemptions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.CouponId == coupon.Id) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (existing is not null) + { + return ToCouponItem(coupon, existing, null); + } + + if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) + { + throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); + } + + var redemption = new CouponRedemption + { + TenantId = actor.TenantId, + CouponId = coupon.Id, + UserId = actor.UserId, + PlanId = coupon.PlanId, + CouponCode = coupon.Code, + Status = CouponRedemptionStatus.Claimed, + Source = "student_claim", + ClaimedAt = DateTimeOffset.UtcNow + }; + coupon.UsedCount += 1; + dbContext.CouponRedemptions.Add(redemption); + await dbContext.SaveChangesAsync(cancellationToken); + return ToCouponItem(coupon, redemption, null); + } + + public async Task GetCouponsAsync( + CommerceActor actor, + CommerceCouponQuery query, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var redemptions = dbContext.CouponRedemptions + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); + } + + var items = await redemptions + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 100)) + .ToArrayAsync(cancellationToken); + var couponIds = items + .Where(item => item.CouponId.HasValue) + .Select(item => item.CouponId!.Value) + .Distinct() + .ToArray(); + var coupons = await dbContext.Coupons + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id)) + .ToDictionaryAsync(item => item.Id, cancellationToken); + + return new CommerceCouponList( + items.Select(item => + { + coupons.TryGetValue(item.CouponId ?? Guid.Empty, out var coupon); + return ToCouponItem(coupon, item, item.DiscountAppliedCents); + }).ToArray()); + } + + public async Task CheckCouponAsync( + CommerceActor actor, + CheckCommerceCouponCommand command, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + if (command.Quantity is < 1 or > 99) + { + throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); + } + + var plan = await dbContext.SvipPlans + .AsNoTracking() + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Id == command.PlanId && + item.IsActive, + cancellationToken) + ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); + var originalAmountCents = checked(plan.PriceCents * command.Quantity); + try + { + var coupon = await ResolveCouponForCheckAsync(actor, command, plan, originalAmountCents, cancellationToken); + return new CommerceCouponCheckResult( + true, + null, + coupon.Coupon.Id, + coupon.Redemption.Id, + coupon.Coupon.Code, + originalAmountCents, + coupon.DiscountCents, + Math.Max(0, originalAmountCents - coupon.DiscountCents), + FormatCny(Math.Max(0, originalAmountCents - coupon.DiscountCents))); + } + catch (CommerceException exception) when (exception.Code.StartsWith("coupon_", StringComparison.Ordinal)) + { + return new CommerceCouponCheckResult( + false, + exception.Code, + null, + command.CouponRedemptionId, + command.CouponCode, + originalAmountCents, + 0, + originalAmountCents, + FormatCny(originalAmountCents)); + } + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs b/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs new file mode 100644 index 0000000..25512e8 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + public async Task GetCurrentEntitlementAsync( + CommerceActor actor, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var now = DateTimeOffset.UtcNow; + var entitlement = await dbContext.Entitlements + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.EntitlementType == "svip" && + item.Status == EntitlementStatus.Active && + (item.ExpiresAt == null || item.ExpiresAt > now)) + .OrderByDescending(item => item.ExpiresAt) + .FirstOrDefaultAsync(cancellationToken); + + if (entitlement is null) + { + return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null); + } + + return new CurrentEntitlementItem( + true, + entitlement.EntitlementType, + entitlement.StartsAt, + entitlement.ExpiresAt, + entitlement.Status.ToString(), + entitlement.ExpiresAt.HasValue + ? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays)) + : null); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs new file mode 100644 index 0000000..f3b6754 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs @@ -0,0 +1,478 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.UserId != actor.UserId || + access.TenantId != actor.TenantId || + !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate)) + { + throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); + } + } + + private async Task RequireDataScopeAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken) + { + await AssertAdminAsync(actor, cancellationToken); + return (await currentAccessContext.GetAsync(cancellationToken)).DataScope; + } + + private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => + new( + item.Id, + item.Provider, + GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect", + item.DisplayName, + item.Status, + item.SecretRef, + item.Priority, + item.ConfigPublic, + item.CreatedAt, + item.UpdatedAt); + + private static TenantSecretItem ToSecretItem(TenantSecret item) => + new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt); + + private static CodeBatchItem ToCodeBatchItem(CodeBatch item) => + new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt); + + private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) => + new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt); + + private static CommerceOrderItem ToOrderItem(Order order) => + new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); + + private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) => + new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload); + + private static OrderStatus ParseOrderStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Order status is invalid.", "invalid_order_status"); + + private static PaymentStatus ParsePaymentStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Payment status is invalid.", "invalid_payment_status"); + + private static PointActivityTaskStatus ParsePointTaskStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Point task status is invalid.", "invalid_point_task_status"); + + private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status"); + + private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); + + private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status"); + + private static CommerceRefundStatus ParseRefundStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Refund status is invalid.", "invalid_refund_status"); + + private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status"); + + private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status"); + + private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status"); + + private async Task AssertOptionalReferenceAsync( + DbSet set, + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await set.AnyAsync( + item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, + cancellationToken); + if (!exists) + { + throw new CommerceException("Referenced commerce entity was not found.", code); + } + } + + private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) + { + var order = await dbContext.Orders.SingleAsync( + item => item.TenantId == refund.TenantId && item.Id == refund.OrderId, + cancellationToken); + if (order.RefundedAmountCents < order.AmountCents) + { + order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents); + order.Status = order.RefundedAmountCents >= order.AmountCents + ? OrderStatus.Refunded + : OrderStatus.PartiallyRefunded; + } + + if (refund.PaymentId.HasValue) + { + var payment = await dbContext.Payments.SingleOrDefaultAsync( + item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value, + cancellationToken); + if (payment is not null) + { + payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents); + payment.Status = payment.RefundedAmountCents >= payment.AmountCents + ? PaymentStatus.Refunded + : PaymentStatus.PartiallyRefunded; + } + } + } + + private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to) + { + return from switch + { + CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled, + CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, + CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed, + CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, + _ => false + }; + } + + private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to) + { + return from switch + { + CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed, + _ => false + }; + } + + private void AddRefundEvent( + CommerceRefundRequest refund, + CommerceRefundStatus? fromStatus, + CommerceRefundStatus toStatus, + string eventType, + Guid actorUserId, + object details) + { + dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + { + TenantId = refund.TenantId, + RefundRequestId = refund.Id, + FromStatus = fromStatus, + ToStatus = toStatus, + EventType = eventType, + ActorUserId = actorUserId, + Details = JsonSerializer.SerializeToElement(details) + }); + } + + private Task AddAuditAsync( + CommerceAdminActor actor, + string action, + string targetType, + Guid targetId, + object details, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + { + TenantId = actor.TenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + return Task.CompletedTask; + } + + private static string NormalizeEnum(string? value) => + string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); + + private static string NormalizeProvider(string? provider) + { + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + return normalized switch + { + "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, + "ali_pay" => PaymentProviders.Alipay, + "" => throw new CommerceException("Provider is required.", "provider_required"), + _ => normalized + }; + } + + private static JsonElement WithPaymentMode(JsonElement element, string? mode) + { + var values = new Dictionary(StringComparer.Ordinal); + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + values[property.Name] = property.Value.Clone(); + } + } + + values["mode"] = JsonSerializer.SerializeToElement( + string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim()); + return JsonSerializer.SerializeToElement(values); + } + + private static string? GetJsonString(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var key in keys) + { + if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } + + private static JsonElement JsonObjectOrDefault(JsonElement element) => + element.ValueKind == JsonValueKind.Object + ? element.Clone() + : JsonSerializer.SerializeToElement(new { }); + + private static void AssertNoSecrets(JsonElement element, string path) + { + if (element.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in element.EnumerateObject()) + { + var key = property.Name.ToLowerInvariant(); + if (key is "secretref" or "secret_ref") + { + continue; + } + + if (key.Contains("secret", StringComparison.Ordinal) || + key.Contains("privatekey", StringComparison.Ordinal) || + key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret") + { + throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret"); + } + + AssertNoSecrets(property.Value, $"{path}.{property.Name}"); + } + } + + private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows) + { + var normalizedProvider = NormalizeProvider(provider); + var parsedRows = EnumerateImportRows(rows).ToArray(); + var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment); + var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund); + var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched); + var amountCents = parsedRows.Sum(row => row.AmountCents); + var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents); + var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant(); + return new ReconciliationImportPreview( + parsedRows.Length, + paymentCount, + refundCount, + invalidCount, + amountCents, + refundAmountCents, + sourceHash); + } + + private sealed record ReconciliationImportRow( + ReconciliationTransactionType TransactionType, + string? ProviderTradeNo, + string? ProviderRefundNo, + string? OrderNo, + string? RefundNo, + int AmountCents, + int RefundAmountCents, + string? ProviderStatus, + string? LocalStatus, + ReconciliationMatchStatus MatchStatus, + string? IssueCode, + JsonElement Details); + + private static IEnumerable EnumerateImportRows(JsonElement rows) + { + if (rows.ValueKind != JsonValueKind.Array) + { + throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows"); + } + + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + yield return InvalidImportRow("row_not_object", row); + continue; + } + + var transactionType = Enum.TryParse( + NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"), + true, + out var parsedTransactionType) + ? parsedTransactionType + : ReconciliationTransactionType.Payment; + var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount"); + var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount"); + var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo"); + var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no"); + var orderNo = GetJsonString(row, "orderNo", "order_no"); + var refundNo = GetJsonString(row, "refundNo", "refund_no"); + var issueCode = GetJsonString(row, "issueCode", "issue_code"); + var matchStatus = Enum.TryParse( + NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"), + true, + out var parsedMatchStatus) + ? parsedMatchStatus + : ReconciliationMatchStatus.AmountMismatch; + if (string.IsNullOrWhiteSpace(providerTradeNo) && + string.IsNullOrWhiteSpace(providerRefundNo) && + string.IsNullOrWhiteSpace(orderNo) && + string.IsNullOrWhiteSpace(refundNo)) + { + matchStatus = ReconciliationMatchStatus.MissingLocal; + issueCode ??= "missing_business_identifier"; + } + + yield return new ReconciliationImportRow( + transactionType, + providerTradeNo, + providerRefundNo, + orderNo, + refundNo, + Math.Max(0, amountCents), + Math.Max(0, refundAmountCents), + GetJsonString(row, "providerStatus", "provider_status"), + GetJsonString(row, "localStatus", "local_status"), + matchStatus, + issueCode, + row.Clone()); + } + } + + private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) => + new( + ReconciliationTransactionType.Payment, + null, + null, + null, + null, + 0, + 0, + null, + null, + ReconciliationMatchStatus.AmountMismatch, + issueCode, + row.Clone()); + + private static CommerceReconciliationItem CreateReconciliationItem( + Guid tenantId, + Guid batchId, + int rowNo, + string provider, + ReconciliationImportRow row) => + new() + { + TenantId = tenantId, + BatchId = batchId, + RowNo = rowNo, + Provider = provider, + TransactionType = row.TransactionType, + ProviderTradeNo = row.ProviderTradeNo, + ProviderRefundNo = row.ProviderRefundNo, + OrderNo = row.OrderNo, + RefundNo = row.RefundNo, + AmountCents = row.AmountCents, + RefundAmountCents = row.RefundAmountCents, + ProviderStatus = row.ProviderStatus, + LocalStatus = row.LocalStatus, + MatchStatus = row.MatchStatus, + Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning, + IssueCode = row.IssueCode, + Details = row.Details + }; + + private static int GetJsonInt(JsonElement element, params string[] keys) + { + foreach (var key in keys) + { + if (!element.TryGetProperty(key, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) + { + return number; + } + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + } + + return 0; + } + + private static string GenerateActivationCode() + { + Span bytes = stackalloc byte[8]; + RandomNumberGenerator.Fill(bytes); + return $"TKU{Convert.ToHexString(bytes)}"; + } + + private static string FormatCny(int cents) => + (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); +} diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs new file mode 100644 index 0000000..59e53ff --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs @@ -0,0 +1,445 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + private async Task MarkPaidAsync( + CommerceActor actor, + Order order, + Payment payment, + string? providerTradeNo, + JsonElement rawPayload, + string eventType, + string? eventId, + bool signatureValid, + DateTimeOffset? paidAtOverride, + CancellationToken cancellationToken) + { + var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow; + payment.Status = PaymentStatus.Paid; + payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo; + payment.PaidAt = paidAt; + order.Status = OrderStatus.Paid; + order.TradeNo = payment.ProviderTradeNo; + order.PaidAt = paidAt; + + var days = Math.Max(order.Days ?? 0, 0); + var current = await dbContext.Entitlements + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.EntitlementType == "svip" && + item.Status == EntitlementStatus.Active) + .OrderByDescending(item => item.ExpiresAt) + .FirstOrDefaultAsync(cancellationToken); + if (current is null) + { + dbContext.Entitlements.Add(new Entitlement + { + TenantId = actor.TenantId, + UserId = actor.UserId, + EntitlementType = "svip", + ScopeType = EntitlementScopeType.Tenant, + SourceType = "order", + SourceId = order.Id, + StartsAt = paidAt, + ExpiresAt = days > 0 ? paidAt.AddDays(days) : null, + Status = EntitlementStatus.Active, + Metadata = rawPayload + }); + } + else if (days > 0) + { + var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt + ? current.ExpiresAt.Value + : paidAt; + current.ExpiresAt = baseAt.AddDays(days); + current.Metadata = rawPayload; + } + + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = actor.TenantId, + PaymentId = payment.Id, + Provider = payment.Provider, + EventType = eventType, + EventId = eventId, + SignatureValid = signatureValid, + Payload = rawPayload, + ProcessedAt = paidAt + }); + } + + private async Task ApplyCouponForOrderAsync( + CommerceActor actor, + CreateCommerceOrderCommand command, + SvipPlan plan, + int originalAmountCents, + CancellationToken cancellationToken) + { + if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) + { + return null; + } + + var coupon = command.CouponRedemptionId.HasValue + ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) + : await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken); + ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId); + return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; + } + + private async Task ResolveCouponForCheckAsync( + CommerceActor actor, + CheckCommerceCouponCommand command, + SvipPlan plan, + int originalAmountCents, + CancellationToken cancellationToken) + { + if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) + { + throw new CommerceException("Coupon code or redemption id is required.", "coupon_required"); + } + + var coupon = command.CouponRedemptionId.HasValue + ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) + : await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken); + ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId); + return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; + } + + private async Task ResolveOrClaimCouponByCodeAsync( + CommerceActor actor, + string? couponCode, + CancellationToken cancellationToken) + { + var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); + ValidateCouponClaimable(coupon, null); + var existing = await dbContext.CouponRedemptions + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.CouponId == coupon.Id) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (existing is not null) + { + return new CouponApplication(coupon, existing, 0); + } + + if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) + { + throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); + } + + var redemption = new CouponRedemption + { + TenantId = actor.TenantId, + CouponId = coupon.Id, + UserId = actor.UserId, + PlanId = coupon.PlanId, + CouponCode = coupon.Code, + Status = CouponRedemptionStatus.Claimed, + Source = "checkout_claim", + ClaimedAt = DateTimeOffset.UtcNow + }; + coupon.UsedCount += 1; + dbContext.CouponRedemptions.Add(redemption); + return new CouponApplication(coupon, redemption, 0); + } + + private async Task ResolveCouponByCodeForCheckAsync( + CommerceActor actor, + string? couponCode, + CancellationToken cancellationToken) + { + var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); + var redemption = await dbContext.CouponRedemptions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.CouponId == coupon.Id) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (redemption is not null) + { + return new CouponApplication(coupon, redemption, 0); + } + + ValidateCouponClaimable(coupon, null); + return new CouponApplication( + coupon, + new CouponRedemption + { + Id = Guid.Empty, + TenantId = actor.TenantId, + CouponId = coupon.Id, + UserId = actor.UserId, + PlanId = coupon.PlanId, + CouponCode = coupon.Code, + Status = CouponRedemptionStatus.Claimed + }, + 0); + } + + private async Task ResolveCouponByRedemptionAsync( + CommerceActor actor, + Guid couponRedemptionId, + CancellationToken cancellationToken) + { + var redemption = await dbContext.CouponRedemptions + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Id == couponRedemptionId, + cancellationToken) + ?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found"); + if (redemption.CouponId is null) + { + throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid"); + } + + var coupon = await dbContext.Coupons + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken) + ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); + return new CouponApplication(coupon, redemption, 0); + } + + private async Task FindCouponByCodeAsync( + Guid tenantId, + string? couponCode, + CancellationToken cancellationToken) + { + var code = NormalizeRequired(couponCode, "coupon_code_required"); + return await dbContext.Coupons + .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken) + ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); + } + + private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption) + { + var now = DateTimeOffset.UtcNow; + if (coupon.ValidFrom is not null && coupon.ValidFrom > now || + coupon.ValidTo is not null && coupon.ValidTo <= now) + { + throw new CommerceException("Coupon is expired or not started.", "coupon_inactive"); + } + + if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) + { + throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); + } + } + + private static void ValidateCouponUsable( + Coupon coupon, + CouponRedemption redemption, + SvipPlan plan, + Guid? regionId) + { + ValidateCouponClaimable(coupon, redemption); + if (redemption.Status != CouponRedemptionStatus.Claimed) + { + throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid"); + } + + if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id || + redemption.PlanId.HasValue && redemption.PlanId != plan.Id) + { + throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable"); + } + + if (redemption.RegionId.HasValue && + regionId.HasValue && + redemption.RegionId != regionId) + { + throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable"); + } + } + + private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents) + { + var discount = coupon.DiscountType switch + { + DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero), + DiscountType.Percent => (int)Math.Round( + originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0), + MidpointRounding.AwayFromZero), + _ => 0 + }; + return Math.Clamp(discount, 0, originalAmountCents); + } + + private static decimal PercentFactor(decimal value) + { + if (value <= 0) + { + return 0; + } + + return value <= 1 ? value : value / 100; + } + + private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) + { + var exists = await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active, + cancellationToken); + if (!exists) + { + throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); + } + } + + private async Task FindActorOrderAsync( + CommerceActor actor, + string orderNo, + CancellationToken cancellationToken) + { + var trimmed = orderNo.Trim(); + return await dbContext.Orders + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.OrderNo == trimmed, + cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); + } + + private static CommerceOrderItem ToOrderItem(Order order) + { + return new CommerceOrderItem( + order.Id, + order.OrderNo, + order.Status.ToString(), + order.PlanId, + order.RegionId, + order.ProductType, + order.ProductName, + order.AmountCents, + FormatCny(order.AmountCents), + order.PayMethod, + order.PayProvider, + order.TradeNo, + order.Days, + order.PaidAt, + order.CreatedAt, + order.RawPayload); + } + + private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload) + { + return new CommercePaymentItem( + payment.Id, + payment.OrderId, + orderNo, + payment.Provider, + payment.Method, + payment.Status.ToString(), + payment.AmountCents, + FormatCny(payment.AmountCents), + payment.ProviderTradeNo, + payment.PaidAt, + clientPayload, + payment.RawPayload); + } + + private static CommerceCouponItem ToCouponItem( + Coupon? coupon, + CouponRedemption redemption, + int? discountPreviewCents) + { + return new CommerceCouponItem( + redemption.Id, + redemption.CouponId, + redemption.CouponCode ?? coupon?.Code ?? string.Empty, + redemption.Status.ToString(), + redemption.PlanId ?? coupon?.PlanId, + redemption.RegionId, + coupon?.DiscountType?.ToString(), + coupon?.DiscountValue, + discountPreviewCents, + discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null, + coupon?.ValidFrom, + coupon?.ValidTo, + redemption.ClaimedAt, + redemption.UsedAt); + } + + private static OrderStatus ParseOrderStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Order status is invalid.", "invalid_order_status"); + } + + private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status"); + } + + private static string NormalizeEnum(string? value) => + string.Concat((value ?? string.Empty).Split( + ['_', '-', ' '], + StringSplitOptions.RemoveEmptyEntries)); + + private static string NormalizeRequired(string? value, string code) + { + var trimmed = value?.Trim(); + return !string.IsNullOrWhiteSpace(trimmed) + ? trimmed + : throw new CommerceException("Required commerce value is missing.", code); + } + + private static string NormalizeProvider(string? provider) + { + var normalized = (provider ?? PaymentProviders.Manual) + .Trim() + .ToLowerInvariant() + .Replace("-", "_", StringComparison.Ordinal); + + return normalized switch + { + "" => PaymentProviders.Manual, + "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, + "ali_pay" => PaymentProviders.Alipay, + PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized, + _ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider") + }; + } + + private static string NormalizeMethod(string? method) + { + var normalized = (method ?? "manual").Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; + } + + private static bool IsPaid(string status) => + string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || + string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || + string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); + + private static string GenerateOrderNo() + { + Span bytes = stackalloc byte[4]; + RandomNumberGenerator.Fill(bytes); + return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"; + } + + private static string FormatCny(int cents) => + (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); +} diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs new file mode 100644 index 0000000..31969ba --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetOrdersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var orders = dbContext.Orders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); + } + + var items = await orders + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new AdminOrderList(items.Select(ToOrderItem).ToArray()); + } + + public async Task GetPaymentsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var scopedOrders = dbContext.Orders.AsNoTracking() + .Where(order => order.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + order => order.UserId == actor.UserId, + order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value)); + var payments = from payment in dbContext.Payments.AsNoTracking() + join order in scopedOrders + on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } + where payment.TenantId == actor.TenantId + select new { payment, order.OrderNo }; + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + payments = payments.Where(item => item.payment.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status)); + } + + var rows = await payments + .OrderByDescending(item => item.payment.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray()); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs new file mode 100644 index 0000000..0784b3d --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs @@ -0,0 +1,190 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + public async Task CreateOrderAsync( + CommerceActor actor, + CreateCommerceOrderCommand command, + CancellationToken cancellationToken = default) + { + if (command.Quantity is < 1 or > 99) + { + throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); + } + + await AssertActiveMemberAsync(actor, cancellationToken); + var plan = await dbContext.SvipPlans + .AsNoTracking() + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Id == command.PlanId && + item.IsActive, + cancellationToken) + ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); + + if (plan.CouponOnly && + string.IsNullOrWhiteSpace(command.CouponCode) && + !command.CouponRedemptionId.HasValue) + { + throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); + } + + if (command.RegionId.HasValue) + { + var regionExists = await dbContext.Regions + .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); + if (!regionExists) + { + throw new CommerceException("Region was not found.", "region_not_found"); + } + } + + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var originalAmountCents = checked(plan.PriceCents * command.Quantity); + var coupon = await ApplyCouponForOrderAsync( + actor, + command, + plan, + originalAmountCents, + cancellationToken); + if (plan.CouponOnly && coupon is null) + { + throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); + } + + var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0)); + var order = new Order + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PlanId = plan.Id, + RegionId = command.RegionId ?? plan.RegionId, + OrderNo = GenerateOrderNo(), + Status = OrderStatus.Pending, + ProductType = "svip", + ProductName = plan.Name, + AmountCents = amountCents, + PayMethod = NormalizeMethod(command.PayMethod), + PayProvider = NormalizeProvider(command.PayProvider), + Days = checked(plan.Days * command.Quantity), + RawPayload = JsonSerializer.SerializeToElement(new + { + source = "student_checkout", + command.Quantity, + requestedCouponCode = command.CouponCode, + requestedCouponRedemptionId = command.CouponRedemptionId, + plan.PriceCents, + plan.OriginalPriceCents, + originalAmountCents, + discountCents = coupon?.DiscountCents ?? 0, + couponId = coupon?.Coupon.Id, + couponCode = coupon?.Coupon.Code, + couponRedemptionId = coupon?.Redemption.Id + }) + }; + dbContext.Orders.Add(order); + dbContext.OrderItems.Add(new OrderItem + { + TenantId = actor.TenantId, + OrderId = order.Id, + ItemType = "svip_plan", + ItemId = plan.Id, + Name = plan.Name, + Quantity = command.Quantity, + UnitAmountCents = plan.PriceCents, + TotalAmountCents = originalAmountCents, + Metadata = JsonSerializer.SerializeToElement(new + { + plan.Days, + plan.RegionId, + plan.VpProductId + }) + }); + + if (coupon is not null) + { + coupon.Redemption.Status = CouponRedemptionStatus.Used; + coupon.Redemption.OrderId = order.Id; + coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents; + coupon.Redemption.UsedAt = DateTimeOffset.UtcNow; + } + + if (amountCents == 0) + { + var payment = new Payment + { + TenantId = actor.TenantId, + OrderId = order.Id, + Provider = "manual", + Method = "zero_amount", + Status = PaymentStatus.Pending, + AmountCents = 0 + }; + dbContext.Payments.Add(payment); + await MarkPaidAsync( + actor, + order, + payment, + $"zero-{order.OrderNo}", + order.RawPayload, + "zero_amount_paid", + $"zero-{order.OrderNo}", + true, + DateTimeOffset.UtcNow, + cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return ToOrderItem(order); + } + + public async Task GetOrdersAsync( + CommerceActor actor, + CommerceOrderQuery query, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var orders = dbContext.Orders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); + } + + var items = await orders + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) + .ToArrayAsync(cancellationToken); + + return new CommerceOrderList(items.Select(ToOrderItem).ToArray()); + } + + public async Task GetOrderAsync( + CommerceActor actor, + string orderNo, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var order = await FindActorOrderAsync(actor, orderNo, cancellationToken); + return ToOrderItem(order); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs new file mode 100644 index 0000000..5d8ee0f --- /dev/null +++ b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs @@ -0,0 +1,143 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + public async Task ProcessPaymentNotificationAsync( + Guid tenantId, + string provider, + IReadOnlyDictionary headers, + string rawBody, + JsonElement body, + CancellationToken cancellationToken = default) + { + var normalizedProvider = NormalizeProvider(provider); + var notification = await paymentGateway.ParsePaymentNotificationAsync( + normalizedProvider, + new PaymentNotificationRequest( + tenantId, + normalizedProvider, + headers, + rawBody, + body), + cancellationToken); + + if (!notification.SignatureValid) + { + throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid"); + } + + var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync( + item => + item.Provider == normalizedProvider && + item.EventId == notification.EventId && + item.ProcessedAt != null, + cancellationToken); + if (alreadyProcessed) + { + return new PaymentNotificationProcessResult( + normalizedProvider, + notification.EventId, + notification.OrderNo, + "processed", + true); + } + + var order = await dbContext.Orders + .SingleOrDefaultAsync(item => + item.TenantId == tenantId && + item.OrderNo == notification.OrderNo, + cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); + + if (order.UserId is null) + { + throw new CommerceException("Order does not belong to a user.", "order_user_missing"); + } + + if (order.AmountCents != notification.AmountCents) + { + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = tenantId, + Provider = normalizedProvider, + EventType = notification.EventType, + EventId = notification.EventId, + SignatureValid = true, + Payload = notification.RawPayload, + Error = "payment_amount_mismatch" + }); + await dbContext.SaveChangesAsync(cancellationToken); + throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch"); + } + + var payment = await dbContext.Payments + .Where(item => + item.TenantId == tenantId && + item.OrderId == order.Id && + item.Provider == normalizedProvider) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (payment is null) + { + payment = new Payment + { + TenantId = tenantId, + OrderId = order.Id, + Provider = normalizedProvider, + Method = order.PayMethod, + Status = PaymentStatus.Pending, + AmountCents = order.AmountCents + }; + dbContext.Payments.Add(payment); + } + + if (notification.Paid && order.Status == OrderStatus.Pending) + { + await MarkPaidAsync( + new CommerceActor(tenantId, order.UserId.Value), + order, + payment, + notification.ProviderTradeNo, + notification.RawPayload, + notification.EventType, + notification.EventId, + notification.SignatureValid, + notification.PaidAt, + cancellationToken); + } + else + { + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = tenantId, + PaymentId = payment.Id, + Provider = normalizedProvider, + EventType = notification.EventType, + EventId = notification.EventId, + SignatureValid = notification.SignatureValid, + Payload = notification.RawPayload, + ProcessedAt = DateTimeOffset.UtcNow + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new PaymentNotificationProcessResult( + normalizedProvider, + notification.EventId, + notification.OrderNo, + "processed", + false); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs b/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs new file mode 100644 index 0000000..8648eb6 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task> GetPaymentAccountsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var accounts = await providerConfigService.GetProvidersAsync( + actor.TenantId, + TenantExternalProviderCapability.Payment, + string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider), + query.Limit, + cancellationToken); + + return accounts.Select(ToPaymentAccountItem).ToArray(); + } + + public async Task UpsertPaymentAccountAsync( + CommerceAdminActor actor, + UpsertPaymentAccountCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var account = await providerConfigService.UpsertProviderAsync( + actor.TenantId, + new UpsertTenantExternalProviderCommand( + TenantExternalProviderCapability.Payment, + command.Provider, + command.Status, + command.DisplayName, + command.SecretRef, + command.Priority, + WithPaymentMode(command.ConfigPublic, command.Mode), + JsonObjectOrDefault(default)), + cancellationToken); + + return ToPaymentAccountItem(account); + } + + public async Task UpsertTenantSecretAsync( + CommerceAdminActor actor, + UpsertTenantSecretCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var secretRef = string.IsNullOrWhiteSpace(command.SecretRef) + ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}" + : command.SecretRef.Trim(); + var provider = NormalizeProvider(command.Provider); + var secret = await dbContext.TenantSecrets + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); + if (secret is null) + { + secret = new TenantSecret + { + TenantId = actor.TenantId, + SecretRef = secretRef + }; + dbContext.TenantSecrets.Add(secret); + } + else + { + secret.RotatedAt = DateTimeOffset.UtcNow; + } + + var protectedPayload = tenantSecretProtector.Protect( + actor.TenantId, + secretRef, + JsonObjectOrDefault(command.SecretPayload)); + + secret.Purpose = command.Purpose.Trim(); + secret.Provider = provider; + secret.SecretKey = command.SecretKey.Trim(); + secret.Status = command.Status; + secret.EncryptionKeyId = protectedPayload.KeyId; + secret.EncryptedPayload = protectedPayload.Ciphertext; + secret.EncryptionNonce = protectedPayload.Nonce; + secret.EncryptionTag = protectedPayload.Tag; + secret.ExpiresAt = command.ExpiresAt; + + await dbContext.SaveChangesAsync(cancellationToken); + return ToSecretItem(secret); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs b/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs new file mode 100644 index 0000000..21ad4bd --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Commerce; + +public sealed partial class CommerceService +{ + public async Task CreatePaymentAsync( + CommerceActor actor, + CreateCommercePaymentCommand command, + CancellationToken cancellationToken = default) + { + await AssertActiveMemberAsync(actor, cancellationToken); + var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken); + if (order.Status != OrderStatus.Pending) + { + throw new CommerceException("Only pending orders can create payments.", "order_status_invalid"); + } + + var provider = NormalizeProvider(command.Provider); + var method = NormalizeMethod(command.Method); + var payment = await dbContext.Payments + .Where(item => + item.TenantId == actor.TenantId && + item.OrderId == order.Id && + item.Provider == provider && + item.Status == PaymentStatus.Pending) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + if (payment is null) + { + payment = new Payment + { + TenantId = actor.TenantId, + OrderId = order.Id, + Provider = provider, + Method = method, + Status = PaymentStatus.Pending, + AmountCents = order.AmountCents + }; + dbContext.Payments.Add(payment); + } + + var result = await paymentGateway.CreatePaymentAsync( + provider, + new CreatePaymentProviderRequest( + actor.TenantId, + order.OrderNo, + order.ProductName ?? order.OrderNo, + order.AmountCents, + method, + command.OpenId, + command.ReturnUrl, + command.QuitUrl, + $"/api/student/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}", + JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })), + cancellationToken); + + payment.Method = result.Method; + payment.RawPayload = result.RawPayload; + if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) + { + payment.ProviderTradeNo = result.ProviderTradeNo; + } + + if (IsPaid(result.Status)) + { + await MarkPaidAsync( + actor, + order, + payment, + result.ProviderTradeNo, + result.RawPayload, + "payment_paid", + result.ProviderTradeNo, + true, + null, + cancellationToken); + } + else + { + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = actor.TenantId, + PaymentId = payment.Id, + Provider = provider, + EventType = "payment_created", + Payload = result.RawPayload + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return ToPaymentItem(payment, order.OrderNo, result.ClientPayload); + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs b/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs new file mode 100644 index 0000000..76ebfd5 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs @@ -0,0 +1,220 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetPointTasksAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var tasks = dbContext.PointActivityTasks.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status)); + } + + var items = await tasks + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantPointTaskList(items); + } + + public async Task UpsertPointTaskAsync( + CommerceAdminActor actor, + UpsertPointTaskCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.Points <= 0 || command.MaxClaimsPerUser <= 0) + { + throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task"); + } + + var task = command.Id.HasValue + ? await dbContext.PointActivityTasks.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, + cancellationToken) + : await dbContext.PointActivityTasks.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(), + cancellationToken); + if (task is null) + { + task = new PointActivityTask { TenantId = actor.TenantId }; + dbContext.PointActivityTasks.Add(task); + } + + task.TaskKey = command.TaskKey.Trim(); + task.Title = command.Title.Trim(); + task.Description = command.Description?.Trim(); + task.TaskType = command.TaskType; + task.Status = command.Status; + task.Points = command.Points; + task.MaxClaimsPerUser = command.MaxClaimsPerUser; + task.StartsAt = command.StartsAt; + task.EndsAt = command.EndsAt; + task.SortOrder = command.SortOrder; + task.Rules = JsonObjectOrDefault(command.Rules); + task.Metadata = JsonObjectOrDefault(command.Metadata); + + await dbContext.SaveChangesAsync(cancellationToken); + return task; + } + + public async Task GetPointClaimsAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var claims = dbContext.PointActivityClaims.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (query.UserId.HasValue) + { + claims = claims.Where(item => item.UserId == query.UserId.Value); + } + + var items = await claims + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantPointClaimList(items); + } + + public async Task GetPointExchangeItemsAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var items = dbContext.PointExchangeItems.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status)); + } + + if (query.RegionId.HasValue) + { + items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); + } + + var result = await items + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantPointExchangeItemList(result); + } + + public async Task UpsertPointExchangeItemAsync( + CommerceAdminActor actor, + UpsertPointExchangeItemCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.PointsCost <= 0) + { + throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item"); + } + + var item = command.Id.HasValue + ? await dbContext.PointExchangeItems.SingleOrDefaultAsync( + entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value, + cancellationToken) + : await dbContext.PointExchangeItems.SingleOrDefaultAsync( + entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(), + cancellationToken); + if (item is null) + { + item = new PointExchangeItem { TenantId = actor.TenantId }; + dbContext.PointExchangeItems.Add(item); + } + + item.RegionId = command.RegionId; + item.ItemKey = command.ItemKey.Trim(); + item.Name = command.Name.Trim(); + item.Description = command.Description?.Trim(); + item.ItemType = command.ItemType; + item.Status = command.Status; + item.PointsCost = command.PointsCost; + item.Stock = command.Stock; + item.Days = command.Days; + item.SortOrder = command.SortOrder; + item.StartsAt = command.StartsAt; + item.EndsAt = command.EndsAt; + item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload); + item.Metadata = JsonObjectOrDefault(command.Metadata); + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + public async Task GetPointExchangeOrdersAsync( + CommerceAdminActor actor, + TenantPointQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var orders = dbContext.PointExchangeOrders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (query.UserId.HasValue) + { + orders = orders.Where(item => item.UserId == query.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status)); + } + + var result = await orders + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantPointExchangeOrderList(result); + } + + public async Task UpdatePointExchangeOrderStatusAsync( + CommerceAdminActor actor, + UpdatePointExchangeOrderStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var order = await dbContext.PointExchangeOrders + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken) + ?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found"); + order.Status = command.Status; + if (command.Status == PointExchangeOrderStatus.Completed) + { + order.CompletedAt ??= DateTimeOffset.UtcNow; + order.CancelledAt = null; + } + else if (command.Status == PointExchangeOrderStatus.Cancelled) + { + order.CancelledAt ??= DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return order; + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs new file mode 100644 index 0000000..1c867e8 --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetReconciliationBatchesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batches = dbContext.CommerceReconciliationBatches.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + batches = batches.Where(item => item.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status)); + } + + var items = await batches.OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationBatchList(items); + } + + public async Task CreateReconciliationBatchAsync( + CommerceAdminActor actor, + CreateReconciliationBatchCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batch = new CommerceReconciliationBatch + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + Provider = NormalizeProvider(command.Provider), + BillDate = command.BillDate, + BillType = command.BillType, + Source = command.Source, + SourceName = command.SourceName?.Trim(), + SourceHash = command.SourceHash.Trim(), + Status = ReconciliationBatchStatus.Pending, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceReconciliationBatches.Add(batch); + await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return batch; + } + + public async Task GetReconciliationIssuesAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issues = dbContext.CommerceReconciliationIssues.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + var provider = NormalizeProvider(query.Provider); + issues = issues.Where(item => item.Provider == provider); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status)); + } + + var items = await issues.OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationIssueList(items); + } + + public async Task UpdateReconciliationIssueAsync( + CommerceAdminActor actor, + UpdateReconciliationIssueCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.IssueId, + cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); + var fromStatus = issue.Status; + issue.Status = command.Status; + issue.ResolutionType = command.ResolutionType; + issue.ResolutionNote = command.Note?.Trim(); + issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo; + if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored) + { + issue.ResolvedBy = actor.UserId; + issue.ResolvedAt = DateTimeOffset.UtcNow; + } + + dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent + { + TenantId = actor.TenantId, + IssueId = issue.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + EventType = "status_changed", + ActorUserId = actor.UserId, + Note = command.Note, + Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo }) + }); + await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return issue; + } + + +} diff --git a/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs b/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs new file mode 100644 index 0000000..27c921b --- /dev/null +++ b/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs @@ -0,0 +1,201 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Commerce; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Commerce; + +internal sealed partial class CommerceAdminService +{ + public async Task GetRefundsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var refunds = dbContext.CommerceRefundRequests.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); + } + + var items = await refunds + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantRefundList(items); + } + + public async Task CreateRefundRequestAsync( + CommerceAdminActor actor, + CreateRefundRequestCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var order = await dbContext.Orders + .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); + if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) + { + throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); + } + + if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents) + { + throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount"); + } + + if (command.PaymentId.HasValue) + { + var paymentExists = await dbContext.Payments.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, + cancellationToken); + if (!paymentExists) + { + throw new CommerceException("Payment was not found.", "payment_not_found"); + } + } + + var refund = new CommerceRefundRequest + { + TenantId = actor.TenantId, + OrderId = order.Id, + PaymentId = command.PaymentId, + RequestedBy = actor.UserId, + RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}", + Provider = order.PayProvider, + Status = CommerceRefundStatus.Requested, + AmountCents = command.AmountCents, + Reason = command.Reason?.Trim(), + EntitlementAction = command.EntitlementAction, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceRefundRequests.Add(refund); + AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason }); + await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + + public async Task UpdateRefundStatusAsync( + CommerceAdminActor actor, + UpdateRefundStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var refund = await dbContext.CommerceRefundRequests + .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + var fromStatus = refund.Status; + if (!IsAllowedRefundTransition(fromStatus, command.Status)) + { + throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition"); + } + + refund.Status = command.Status; + refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) + ? refund.ProviderRefundNo + : command.ProviderRefundNo.Trim(); + switch (command.Status) + { + case CommerceRefundStatus.Approved: + refund.ReviewedBy = actor.UserId; + refund.ReviewedAt = DateTimeOffset.UtcNow; + break; + case CommerceRefundStatus.Processing: + refund.ProcessedBy = actor.UserId; + refund.ProcessedAt = DateTimeOffset.UtcNow; + break; + case CommerceRefundStatus.Succeeded: + refund.SucceededAt = DateTimeOffset.UtcNow; + await ApplyRefundToOrderAsync(refund, cancellationToken); + break; + case CommerceRefundStatus.Failed: + refund.FailedAt = DateTimeOffset.UtcNow; + refund.FailureReason = command.Reason; + break; + case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected: + refund.CancelledAt = DateTimeOffset.UtcNow; + break; + } + + AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo }); + await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + + public async Task GetRefundEventsAsync( + CommerceAdminActor actor, + Guid refundRequestId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var refundExists = await dbContext.CommerceRefundRequests + .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))) + .AnyAsync(cancellationToken); + if (!refundExists) + { + throw new CommerceException("Refund request was not found.", "refund_not_found"); + } + + var items = await dbContext.CommerceRefundEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantRefundEventList(items); + } + + +} diff --git a/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs b/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs new file mode 100644 index 0000000..83fcad6 --- /dev/null +++ b/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs @@ -0,0 +1,211 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class ContentManagementService +{ + public async Task> GetCollectionsAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.QuestionCollections + .AsNoTracking() + .Where(collection => collection.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + collection => collection.CreatedBy == actor.UserId, + collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value)); + + if (!filter.IncludeInactive) + { + query = query.Where(collection => collection.Status == ContentStatus.Active); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(collection => collection.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(collection => collection.EntryId == filter.EntryId.Value); + } + + if (filter.NodeId.HasValue) + { + query = query.Where(collection => collection.NodeId == filter.NodeId.Value); + } + + if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType)) + { + query = query.Where(collection => collection.CollectionType == collectionType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(collection => collection.Name.Contains(keyword)); + } + + var items = await query + .OrderBy(collection => collection.SortOrder) + .ThenBy(collection => collection.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(collection => ToCollectionItem(collection)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertCollectionAsync( + ContentManagementActor actor, + UpsertQuestionCollectionCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); + await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + + var collection = await ResolveEntityByIdOrLegacyAsync( + dbContext.QuestionCollections, + actor.TenantId, + command.Id, + command.LegacyId, + cancellationToken); + + var isNew = collection is null; + if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + collection ??= new QuestionCollection + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId + }; + + collection.RegionId = command.RegionId; + collection.EntryId = command.EntryId; + collection.NodeId = command.NodeId; + collection.SubjectId = command.SubjectId; + collection.CategoryId = command.CategoryId; + collection.QuestionBankId = command.QuestionBankId; + collection.LegacyId = Normalize(command.LegacyId); + collection.Name = command.Name.Trim(); + collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid"); + collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid"); + collection.Filters = JsonObjectOrDefault(command.Filters); + collection.TotalScore = command.TotalScore; + collection.DurationMinutes = command.DurationMinutes; + collection.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); + collection.SortOrder = command.Order ?? 0; + collection.AccessRules = JsonObjectOrDefault(command.AccessRules); + collection.Metadata = JsonObjectOrDefault(command.Metadata); + collection.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.QuestionCollections.Add(collection); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToCollectionItem(collection)); + } + + public async Task ReplaceCollectionItemsAsync( + ContentManagementActor actor, + ReplaceCollectionItemsCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var collection = await dbContext.QuestionCollections + .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) + .SingleOrDefaultAsync(cancellationToken); + + if (collection is null) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>(); + foreach (var question in command.Questions) + { + var reference = await questionReferenceService.ResolveAsync( + actor.TenantId, + actor.UserId, + question.Locator, + cancellationToken); + resolvedQuestions.Add((question, reference)); + } + + var oldItems = await dbContext.QuestionCollectionItems + .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId) + .ToArrayAsync(cancellationToken); + dbContext.QuestionCollectionItems.RemoveRange(oldItems); + + var items = resolvedQuestions + .Select((resolved, index) => new QuestionCollectionItem + { + TenantId = actor.TenantId, + CollectionId = command.CollectionId, + QuestionReferenceId = resolved.Reference.Id, + QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId, + QuestionId = resolved.Reference.QuestionId, + SectionKey = Normalize(resolved.Command.SectionKey), + SortOrder = resolved.Command.Order ?? index, + Score = resolved.Command.Score, + Required = resolved.Command.Required ?? true, + Metadata = JsonObjectOrDefault(resolved.Command.Metadata) + }) + .ToArray(); + + dbContext.QuestionCollectionItems.AddRange(items); + collection.QuestionCount = items.Length; + collection.UpdatedBy = actor.UserId; + await dbContext.SaveChangesAsync(cancellationToken); + + return new CollectionItemsReplaceResult( + command.CollectionId, + collection.QuestionCount, + items.Select(ToCollectionItemItem).ToArray()); + } + + +} diff --git a/Tiku.Infrastructure/Content/ContentManagementService.cs b/Tiku.Infrastructure/Content/ContentManagementService.cs index b28c5b7..755078f 100644 --- a/Tiku.Infrastructure/Content/ContentManagementService.cs +++ b/Tiku.Infrastructure/Content/ContentManagementService.cs @@ -14,7 +14,7 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed class ContentManagementService( +public sealed partial class ContentManagementService( TikuDbContext dbContext, IQuestionReferenceService questionReferenceService, ICurrentAccessContext currentAccessContext) : IContentManagementService @@ -22,1131 +22,5 @@ public sealed class ContentManagementService( private const int DefaultLimit = 100; private const int MaxLimit = 1000; - public async Task> GetEntriesAsync( - ContentManagementActor actor, - ContentManagementFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ContentEntries - .AsNoTracking() - .Where(entry => entry.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - entry => entry.CreatedBy == actor.UserId, - entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)); - if (!filter.IncludeInactive) - { - query = query.Where(entry => entry.IsActive); - } - - if (filter.RegionId.HasValue) - { - query = query.Where(entry => entry.RegionId == filter.RegionId.Value); - } - - if (TryParse(filter.EntryType, out ContentEntryType entryType)) - { - query = query.Where(entry => entry.EntryType == entryType); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(entry => - entry.Name.Contains(keyword) || - entry.EntryKey.Contains(keyword) || - (entry.Description != null && entry.Description.Contains(keyword))); - } - - var items = await query - .OrderBy(entry => entry.SortOrder) - .ThenBy(entry => entry.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(entry => ToEntryItem(entry)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertEntryAsync( - ContentManagementActor actor, - UpsertContentEntryCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - - var entryKey = Normalize(command.EntryKey) ?? - Normalize(command.Id?.ToString("N")) ?? - Guid.NewGuid().ToString("N"); - var entry = await ResolveEntityAsync( - dbContext.ContentEntries, - actor.TenantId, - command.Id, - item => item.EntryKey == entryKey, - cancellationToken); - - var isNew = entry is null; - if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value)) - { - throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } - - if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId)) - { - throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } - - if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { - throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } - - entry ??= new ContentEntry - { - Id = command.Id ?? Guid.NewGuid(), - TenantId = actor.TenantId, - EntryKey = entryKey, - CreatedBy = actor.UserId - }; - - entry.RegionId = command.RegionId; - entry.LegacyId = Normalize(command.LegacyId); - entry.Name = command.Name.Trim(); - entry.EntryType = Parse(command.EntryType, ContentEntryType.QuestionPractice, "entry_type_invalid"); - entry.Icon = Normalize(command.Icon); - entry.Route = Normalize(command.Route); - entry.Description = Normalize(command.Description); - entry.Visibility = Parse(command.Visibility, ContentVisibility.Public, "visibility_invalid"); - entry.AccessRules = JsonObjectOrDefault(command.AccessRules); - entry.LayoutConfig = JsonObjectOrDefault(command.LayoutConfig); - entry.SortOrder = command.Order ?? 0; - entry.IsActive = command.IsActive ?? true; - entry.UpdatedBy = actor.UserId; - - if (isNew) - { - dbContext.ContentEntries.Add(entry); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToEntryItem(entry)); - } - - public async Task> GetNodesAsync( - ContentManagementActor actor, - ContentManagementFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - if (!filter.EntryId.HasValue) - { - throw new ContentManagementException("entryId is required.", "entry_id_required"); - } - - await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ContentNodes - .AsNoTracking() - .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) - .ApplyDataScope( - scope, - node => node.CreatedBy == actor.UserId, - node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)); - - if (!filter.IncludeInactive) - { - query = query.Where(node => node.IsActive); - } - - if (filter.RegionId.HasValue) - { - query = query.Where(node => node.RegionId == filter.RegionId.Value); - } - - if (filter.ParentId is not null) - { - if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(node => node.ParentId == null); - } - else if (Guid.TryParse(filter.ParentId, out var parentId)) - { - query = query.Where(node => node.ParentId == parentId); - } - } - - if (TryParse(filter.MarkerType, out ContentMarkerType markerType)) - { - query = query.Where(node => node.MarkerType == markerType); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(node => - node.Name.Contains(keyword) || - (node.NodeKey != null && node.NodeKey.Contains(keyword))); - } - - query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase) - ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder) - : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt); - - var items = await query - .Take(ResolveLimit(filter.Limit)) - .Select(node => ToNodeItem(node)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertNodeAsync( - ContentManagementActor actor, - UpsertContentNodeCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); - await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken); - - var nodeKey = Normalize(command.NodeKey) ?? - Normalize(command.Id?.ToString("N")) ?? - Guid.NewGuid().ToString("N"); - var node = await ResolveEntityAsync( - dbContext.ContentNodes, - actor.TenantId, - command.Id, - item => item.EntryId == command.EntryId && item.NodeKey == nodeKey, - cancellationToken); - - var isNew = node is null; - if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) - { - throw new ContentManagementException("Content node was not found.", "node_not_found"); - } - - if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) - { - throw new ContentManagementException("Content node was not found.", "node_not_found"); - } - - if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { - throw new ContentManagementException("Content node was not found.", "node_not_found"); - } - - node ??= new ContentNode - { - Id = command.Id ?? Guid.NewGuid(), - TenantId = actor.TenantId, - EntryId = command.EntryId, - NodeKey = nodeKey, - CreatedBy = actor.UserId - }; - - var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken); - node.EntryId = command.EntryId; - node.RegionId = command.RegionId; - node.ParentId = command.ParentId; - node.LegacyId = Normalize(command.LegacyId); - node.Name = command.Name.Trim(); - node.NodeType = Parse(command.NodeType, ContentNodeType.Category, "node_type_invalid"); - node.MarkerType = ParseNullable(command.MarkerType, "marker_type_invalid"); - node.MarkerConfig = JsonObjectOrDefault(command.MarkerConfig); - node.Path = path.Path; - node.Depth = path.Depth; - node.SortOrder = command.Order ?? 0; - node.IsActive = command.IsActive ?? true; - node.IsSelectable = command.IsSelectable ?? true; - node.IsLeaf = command.IsLeaf ?? false; - node.AccessRules = JsonObjectOrDefault(command.AccessRules); - node.Metadata = JsonObjectOrDefault(command.Metadata); - node.UpdatedBy = actor.UserId; - - if (isNew) - { - dbContext.ContentNodes.Add(node); - } - - if (command.ParentId.HasValue) - { - var parent = await dbContext.ContentNodes.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value, - cancellationToken); - if (parent is not null) - { - parent.IsLeaf = false; - } - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToNodeItem(node)); - } - - public async Task> GetCollectionsAsync( - ContentManagementActor actor, - ContentManagementFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.QuestionCollections - .AsNoTracking() - .Where(collection => collection.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - collection => collection.CreatedBy == actor.UserId, - collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value)); - - if (!filter.IncludeInactive) - { - query = query.Where(collection => collection.Status == ContentStatus.Active); - } - - if (filter.RegionId.HasValue) - { - query = query.Where(collection => collection.RegionId == filter.RegionId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(collection => collection.EntryId == filter.EntryId.Value); - } - - if (filter.NodeId.HasValue) - { - query = query.Where(collection => collection.NodeId == filter.NodeId.Value); - } - - if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType)) - { - query = query.Where(collection => collection.CollectionType == collectionType); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(collection => collection.Name.Contains(keyword)); - } - - var items = await query - .OrderBy(collection => collection.SortOrder) - .ThenBy(collection => collection.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(collection => ToCollectionItem(collection)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertCollectionAsync( - ContentManagementActor actor, - UpsertQuestionCollectionCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); - await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); - - var collection = await ResolveEntityByIdOrLegacyAsync( - dbContext.QuestionCollections, - actor.TenantId, - command.Id, - command.LegacyId, - cancellationToken); - - var isNew = collection is null; - if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value)) - { - throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } - - if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId)) - { - throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } - - if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { - throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } - - collection ??= new QuestionCollection - { - Id = command.Id ?? Guid.NewGuid(), - TenantId = actor.TenantId, - CreatedBy = actor.UserId - }; - - collection.RegionId = command.RegionId; - collection.EntryId = command.EntryId; - collection.NodeId = command.NodeId; - collection.SubjectId = command.SubjectId; - collection.CategoryId = command.CategoryId; - collection.QuestionBankId = command.QuestionBankId; - collection.LegacyId = Normalize(command.LegacyId); - collection.Name = command.Name.Trim(); - collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid"); - collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid"); - collection.Filters = JsonObjectOrDefault(command.Filters); - collection.TotalScore = command.TotalScore; - collection.DurationMinutes = command.DurationMinutes; - collection.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); - collection.SortOrder = command.Order ?? 0; - collection.AccessRules = JsonObjectOrDefault(command.AccessRules); - collection.Metadata = JsonObjectOrDefault(command.Metadata); - collection.UpdatedBy = actor.UserId; - - if (isNew) - { - dbContext.QuestionCollections.Add(collection); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToCollectionItem(collection)); - } - - public async Task ReplaceCollectionItemsAsync( - ContentManagementActor actor, - ReplaceCollectionItemsCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var collection = await dbContext.QuestionCollections - .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId) - .ApplyDataScope( - scope, - item => item.CreatedBy == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) - .SingleOrDefaultAsync(cancellationToken); - - if (collection is null) - { - throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } - - var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>(); - foreach (var question in command.Questions) - { - var reference = await questionReferenceService.ResolveAsync( - actor.TenantId, - actor.UserId, - question.Locator, - cancellationToken); - resolvedQuestions.Add((question, reference)); - } - - var oldItems = await dbContext.QuestionCollectionItems - .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId) - .ToArrayAsync(cancellationToken); - dbContext.QuestionCollectionItems.RemoveRange(oldItems); - - var items = resolvedQuestions - .Select((resolved, index) => new QuestionCollectionItem - { - TenantId = actor.TenantId, - CollectionId = command.CollectionId, - QuestionReferenceId = resolved.Reference.Id, - QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId, - QuestionId = resolved.Reference.QuestionId, - SectionKey = Normalize(resolved.Command.SectionKey), - SortOrder = resolved.Command.Order ?? index, - Score = resolved.Command.Score, - Required = resolved.Command.Required ?? true, - Metadata = JsonObjectOrDefault(resolved.Command.Metadata) - }) - .ToArray(); - - dbContext.QuestionCollectionItems.AddRange(items); - collection.QuestionCount = items.Length; - collection.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); - - return new CollectionItemsReplaceResult( - command.CollectionId, - collection.QuestionCount, - items.Select(ToCollectionItemItem).ToArray()); - } - - public async Task> GetPracticeBlueprintsAsync( - ContentManagementActor actor, - ContentManagementFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.PracticeBlueprints - .AsNoTracking() - .Where(blueprint => blueprint.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - blueprint => blueprint.CreatedBy == actor.UserId, - blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); - - if (!filter.IncludeInactive) - { - query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); - } - - if (filter.RegionId.HasValue) - { - query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); - } - - if (filter.NodeId.HasValue) - { - query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); - } - - if (filter.CollectionId.HasValue) - { - query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); - } - - if (TryParse(filter.Mode, out PracticeMode mode)) - { - query = query.Where(blueprint => blueprint.Mode == mode); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(blueprint => blueprint.Name.Contains(keyword)); - } - - var items = await query - .OrderBy(blueprint => blueprint.SortOrder) - .ThenBy(blueprint => blueprint.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(blueprint => ToBlueprintItem(blueprint)) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertPracticeBlueprintAsync( - ContentManagementActor actor, - UpsertPracticeBlueprintCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); - await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); - await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); - - var blueprint = await ResolveEntityByIdOrLegacyAsync( - dbContext.PracticeBlueprints, - actor.TenantId, - command.Id, - command.LegacyId, - cancellationToken); - - var isNew = blueprint is null; - if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) - { - throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } - - if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) - { - throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } - - if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { - throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } - - blueprint ??= new PracticeBlueprint - { - Id = command.Id ?? Guid.NewGuid(), - TenantId = actor.TenantId, - CreatedBy = actor.UserId - }; - - blueprint.RegionId = command.RegionId; - blueprint.EntryId = command.EntryId; - blueprint.NodeId = command.NodeId; - blueprint.CollectionId = command.CollectionId; - blueprint.LegacyId = Normalize(command.LegacyId); - blueprint.Name = command.Name.Trim(); - blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid"); - blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid"); - blueprint.QuestionLimit = command.QuestionLimit; - blueprint.DurationMinutes = command.DurationMinutes; - blueprint.TotalScore = command.TotalScore; - blueprint.PassScore = command.PassScore; - blueprint.Sections = JsonArrayOrDefault(command.Sections); - blueprint.Rules = JsonObjectOrDefault(command.Rules); - blueprint.AccessRules = JsonObjectOrDefault(command.AccessRules); - blueprint.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); - blueprint.SortOrder = command.Order ?? 0; - blueprint.UpdatedBy = actor.UserId; - - if (isNew) - { - dbContext.PracticeBlueprints.Add(blueprint); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToBlueprintItem(blueprint)); - } - - public ImportFieldMappingItem GetImportFieldMapping(string importType) - { - var spec = ResolveImportSpec(importType); - return new ImportFieldMappingItem( - spec.ImportType, - spec.Title, - spec.Description, - spec.Fields, - spec.Fields.Where(field => field.Required).Select(field => field.Field).ToArray()); - } - - public ImportTemplateItem GetImportTemplate(string importType, string? format) - { - var spec = ResolveImportSpec(importType); - var normalizedFormat = string.IsNullOrWhiteSpace(format) ? "json" : format.Trim().ToLowerInvariant(); - var content = normalizedFormat switch - { - "json" => JsonSerializer.Serialize(spec.JsonExample, new JsonSerializerOptions { WriteIndented = true }), - "csv" => string.Join( - "\n", - spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))), - _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid") - }; - - return new ImportTemplateItem( - spec.ImportType, - normalizedFormat, - $"{spec.ImportType}-import-template.{normalizedFormat}", - normalizedFormat == "csv" ? "text/csv" : "application/json", - Convert.ToBase64String(Encoding.UTF8.GetBytes(content)), - content, - spec.Fields); - } - - private async Task<(string Path, int Depth)> BuildNodePathAsync( - Guid tenantId, - Guid entryId, - Guid nodeId, - Guid? parentId, - CancellationToken cancellationToken) - { - var label = $"n_{nodeId:N}"; - if (!parentId.HasValue) - { - return (label, 0); - } - - var parent = await dbContext.ContentNodes - .AsNoTracking() - .Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value) - .Select(node => new { node.Path, node.Depth }) - .SingleOrDefaultAsync(cancellationToken); - - if (parent is null) - { - throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found"); - } - - return ($"{parent.Path}.{label}", parent.Depth + 1); - } - - private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken) - { - await AssertReferenceAsync(tenantId, regionId, "region_not_found", cancellationToken); - } - - private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken) - { - await AssertReferenceAsync(tenantId, entryId, "entry_not_found", cancellationToken); - } - - private async Task AssertEntryAsync( - ContentManagementActor actor, - CurrentDataScope scope, - Guid? entryId, - CancellationToken cancellationToken) - { - if (!entryId.HasValue) - { - return; - } - - var regionIds = scope.RegionIds.ToArray(); - var exists = await dbContext.ContentEntries - .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value) - .ApplyDataScope( - scope, - entry => entry.CreatedBy == actor.UserId, - entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)) - .AnyAsync(cancellationToken); - if (!exists) - { - throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } - } - - private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) - { - await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); - } - - private async Task AssertNodeAsync( - ContentManagementActor actor, - CurrentDataScope scope, - Guid? nodeId, - CancellationToken cancellationToken) - { - if (!nodeId.HasValue) - { - return; - } - - var regionIds = scope.RegionIds.ToArray(); - var exists = await dbContext.ContentNodes - .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value) - .ApplyDataScope( - scope, - node => node.CreatedBy == actor.UserId, - node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)) - .AnyAsync(cancellationToken); - if (!exists) - { - throw new ContentManagementException("Content node was not found.", "node_not_found"); - } - } - - private async Task RequireDataScopeAsync( - ContentManagementActor actor, - CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) - { - throw new ContentManagementException("Content resource was not found.", "content_not_found"); - } - - return access.DataScope; - } - - private async Task AssertReferenceAsync( - Guid tenantId, - Guid? id, - string code, - CancellationToken cancellationToken) - where TEntity : class - { - if (!id.HasValue) - { - return; - } - - var exists = await dbContext.Set() - .AnyAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, - cancellationToken); - - if (!exists) - { - throw new ContentManagementException("Referenced entity was not found in this tenant.", code); - } - } - - private static async Task ResolveEntityAsync( - DbSet set, - Guid tenantId, - Guid? id, - System.Linq.Expressions.Expression> alternatePredicate, - CancellationToken cancellationToken) - where TEntity : class - { - if (id.HasValue) - { - var byId = await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, - cancellationToken); - if (byId is not null) - { - return byId; - } - } - - return await set - .Where(entity => EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId) - .SingleOrDefaultAsync(alternatePredicate, cancellationToken); - } - - private static async Task ResolveEntityByIdOrLegacyAsync( - DbSet set, - Guid tenantId, - Guid? id, - string? legacyId, - CancellationToken cancellationToken) - where TEntity : class - { - if (id.HasValue) - { - var byId = await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, - cancellationToken); - if (byId is not null) - { - return byId; - } - } - - var normalizedLegacyId = Normalize(legacyId); - if (normalizedLegacyId is null) - { - return null; - } - - return await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId, - cancellationToken); - } - - private static ContentEntryManagementItem ToEntryItem(ContentEntry entry) - { - return new ContentEntryManagementItem( - entry.Id, - entry.RegionId, - entry.LegacyId, - entry.EntryKey, - entry.Name, - entry.EntryType, - entry.Icon, - entry.Route, - entry.Description, - entry.Visibility, - entry.AccessRules, - entry.LayoutConfig, - entry.SortOrder, - entry.IsActive, - entry.CreatedAt, - entry.UpdatedAt); - } - - private static ContentNodeManagementItem ToNodeItem(ContentNode node) - { - return new ContentNodeManagementItem( - node.Id, - node.EntryId, - node.RegionId, - node.ParentId, - node.LegacyId, - node.NodeKey, - node.Name, - node.NodeType, - node.MarkerType, - node.MarkerConfig, - node.Path, - node.Depth, - node.SortOrder, - node.IsActive, - node.IsSelectable, - node.IsLeaf, - node.AccessRules, - node.Metadata, - node.CreatedAt, - node.UpdatedAt); - } - - private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection) - { - return new QuestionCollectionManagementItem( - collection.Id, - collection.RegionId, - collection.EntryId, - collection.NodeId, - collection.SubjectId, - collection.CategoryId, - collection.QuestionBankId, - collection.LegacyId, - collection.Name, - collection.CollectionType, - collection.SourceType, - collection.Filters, - collection.QuestionCount, - collection.TotalScore, - collection.DurationMinutes, - collection.Status, - collection.SortOrder, - collection.AccessRules, - collection.Metadata, - collection.CreatedAt, - collection.UpdatedAt); - } - - private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item) - { - return new QuestionCollectionItemManagementItem( - item.Id, - item.CollectionId, - item.QuestionId, - new QuestionLocator( - item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, - item.QuestionId), - item.SectionKey, - item.SortOrder, - item.Score, - item.Required, - item.Metadata); - } - - private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint) - { - return new PracticeBlueprintManagementItem( - blueprint.Id, - blueprint.RegionId, - blueprint.EntryId, - blueprint.NodeId, - blueprint.CollectionId, - blueprint.LegacyId, - blueprint.Name, - blueprint.Mode, - blueprint.AssemblyType, - blueprint.QuestionLimit, - blueprint.DurationMinutes, - blueprint.TotalScore, - blueprint.PassScore, - blueprint.Sections, - blueprint.Rules, - blueprint.AccessRules, - blueprint.Status, - blueprint.SortOrder, - blueprint.CreatedAt, - blueprint.UpdatedAt); - } - - private static int ResolveLimit(int? limit) - { - return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit; - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static JsonElement JsonObjectOrDefault(JsonElement value) - { - return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null - ? JsonDefaults.Object() - : value; - } - - private static JsonElement JsonArrayOrDefault(JsonElement value) - { - return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null - ? JsonDefaults.Array() - : value; - } - - private static TEnum Parse(string? value, TEnum fallback, string code) - where TEnum : struct - { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } - - if (Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } - - throw new ContentManagementException("Invalid enum value.", code); - } - - private static TEnum? ParseNullable(string? value, string code) - where TEnum : struct - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - if (Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } - - throw new ContentManagementException("Invalid enum value.", code); - } - - private static bool TryParse(string? value, out TEnum parsed) - where TEnum : struct - { - return Enum.TryParse(value, ignoreCase: true, out parsed); - } - - private static string EscapeCsv(string value) - { - return value.Contains(',') || value.Contains('"') || value.Contains('\n') - ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" - : value; - } - - private static ImportSpec ResolveImportSpec(string importType) - { - var normalized = importType.Trim().ToLowerInvariant(); - return Specs.TryGetValue(normalized, out var spec) - ? spec - : throw new ContentManagementException("Import type is not supported.", "import_type_invalid"); - } - - private sealed record ImportSpec( - string ImportType, - string Title, - string Description, - IReadOnlyCollection Fields, - string[][] CsvRows, - object JsonExample); - - private static ImportFieldSpec Field( - string field, - string label, - bool required, - string[] aliases, - string description, - object example) - { - return new ImportFieldSpec( - field, - label, - required, - aliases, - description, - JsonSerializer.SerializeToElement(example)); - } - - private static readonly IReadOnlyDictionary Specs = - new Dictionary(StringComparer.Ordinal) - { - ["questions"] = new( - "questions", - "题目导入模板", - "用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。", - [ - Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"), - Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"), - Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"), - Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }), - Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }), - Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"), - Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"), - Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2), - Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" }) - ], - [ - ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"], - ["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"] - ], - new - { - items = new[] - { - new - { - legacyId = "tj-english-2026-001", - type = "choice", - content = "多租户 SaaS 最重要的安全边界是什么?", - options = new[] { "前端隐藏", "后端权限" }, - correctOptionIndices = new[] { 1 }, - explanation = "最终权限以后端强制为准。", - difficulty = 2, - tags = new[] { "安全", "多租户" } - } - } - }), - ["vocabulary"] = new( - "vocabulary", - "单词导入模板", - "用于导入词汇单元和单词,后端会按单元归组并幂等写入。", - [ - Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"), - Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"), - Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"), - Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"), - Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.") - ], - [ - ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"], - ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"] - ], - new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }), - ["handbook"] = new( - "handbook", - "知识手册导入模板", - "用于导入手册科目、章节、小节和知识点。", - [ - Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"), - Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"), - Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"), - Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。") - ], - [ - ["subjectName", "chapterName", "title", "content", "tags"], - ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"] - ], - new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }), - ["scoreline"] = new( - "scoreline", - "分数线导入模板", - "用于导入动态字段、院校、专业和年份分数线记录。", - [ - Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"), - Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"), - Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"), - Field("year", "年份", false, ["年份"], "record 常用。", 2026), - Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 }) - ], - [ - ["kind", "schoolName", "majorName", "year", "minScore"], - ["record", "天津职业技术师范大学", "软件工程", "2026", "188"] - ], - new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }), - ["videos"] = new( - "videos", - "视频解析导入模板", - "用于导入视频解析元数据并绑定到题目。", - [ - Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"), - Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"), - Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"), - Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001") - ], - [ - ["title", "videoUrl", "legacyQuestionId", "videoType"], - ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"] - ], - new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) - }; } diff --git a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs index 4952141..bed3b6e 100644 --- a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs +++ b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs @@ -378,7 +378,3 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo .Replace("-", string.Empty, StringComparison.Ordinal); } } - -public sealed class RequiredFieldException(string message) : Exception(message); - -public sealed class ContentNavigationNotFoundException(string message) : Exception(message); diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs index 3e308f1..9d0dcc4 100644 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -18,7 +18,7 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed class DirectContentService( +public sealed partial class DirectContentService( TikuDbContext dbContext, IQuestionReferenceService questionReferenceService, ICurrentAccessContext currentAccessContext, @@ -46,2053 +46,5 @@ public sealed class DirectContentService( "videos" }; - public async Task> CreateQuestionAsync( - DirectContentActor actor, - QuestionWriteCommand command, - CancellationToken cancellationToken = default) - { - ValidateQuestionForPublication(command); - await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var question = new Question - { - Id = command.QuestionId ?? Guid.NewGuid(), - TenantId = actor.TenantId - }; - ApplyQuestion(question, command); - if (question.Status != QuestionStatus.Archived) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - actor.TenantId, - SaasQuotaMetricCatalog.PrivateQuestionCount, - cancellationToken: cancellationToken); - } - dbContext.Questions.Add(question); - await dbContext.SaveChangesAsync(cancellationToken); - - var version = BuildQuestionVersion(actor, question.Id, 1, command); - dbContext.QuestionVersions.Add(version); - question.CurrentVersionId = version.Id; - await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - return new ContentManagementResult(ToQuestionItem(question, version)); - } - - public async Task> UpdateQuestionAsync( - DirectContentActor actor, - QuestionWriteCommand command, - CancellationToken cancellationToken = default) - { - if (!command.QuestionId.HasValue) - { - throw new ContentManagementException("questionId is required.", "question_id_required"); - } - - ValidateQuestionForPublication(command); - - var question = await dbContext.Questions.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, - cancellationToken); - if (question is null) - { - throw new ContentManagementException("Question was not found.", "question_not_found"); - } - - await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var wasCounted = question.Status != QuestionStatus.Archived; - ApplyQuestion(question, command); - var isCounted = question.Status != QuestionStatus.Archived; - if (!wasCounted && isCounted) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - actor.TenantId, - SaasQuotaMetricCatalog.PrivateQuestionCount, - cancellationToken: cancellationToken); - } - QuestionVersion? version; - if (command.CreateVersion || !question.CurrentVersionId.HasValue) - { - var nextVersionNo = await dbContext.QuestionVersions - .Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id) - .Select(item => (int?)item.VersionNo) - .MaxAsync(cancellationToken) ?? 0; - version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command); - dbContext.QuestionVersions.Add(version); - question.CurrentVersionId = version.Id; - } - else - { - version = await dbContext.QuestionVersions.SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.QuestionId == question.Id && - item.Id == question.CurrentVersionId.Value, - cancellationToken); - if (version is null) - { - version = BuildQuestionVersion(actor, question.Id, 1, command); - dbContext.QuestionVersions.Add(version); - question.CurrentVersionId = version.Id; - } - else - { - ApplyQuestionVersion(version, command); - } - } - - await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); - if (wasCounted && !isCounted) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - SaasQuotaMetricCatalog.PrivateQuestionCount, - 1, - cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - return new ContentManagementResult(ToQuestionItem(question, version)); - } - - public async Task> GetVocabularyUnitsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.VocabularyUnits.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertVocabularyUnitAsync( - DirectContentActor actor, - VocabularyUnitCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found"); - item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.EntryId = command.EntryId; - item.ContentNodeId = command.ContentNodeId; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.WordCount = command.WordCount; - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.VocabularyUnits.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetVocabularyWordsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.UnitId.HasValue) - { - query = query.Where(item => item.UnitId == filter.UnitId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Word) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertVocabularyWordAsync( - DirectContentActor actor, - VocabularyWordCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.Word); - await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - var vocabularyNavigation = await ResolveVocabularyNavigationAsync( - actor.TenantId, - command.UnitId, - command.EntryId, - command.ContentNodeId, - cancellationToken); - item.UnitId = command.UnitId; - item.EntryId = vocabularyNavigation.EntryId; - item.ContentNodeId = vocabularyNavigation.ContentNodeId; - item.LegacyId = Normalize(command.LegacyId); - item.Word = command.Word.Trim(); - item.Phonetic = Normalize(command.Phonetic); - item.Meaning = Normalize(command.Meaning); - item.Example = Normalize(command.Example); - item.ExampleTranslation = Normalize(command.ExampleTranslation); - item.Difficulty = command.Difficulty; - item.Tags = JsonArrayOrDefault(command.Tags); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.VocabularyWords.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetHandbookSubjectsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.HandbookSubjects.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } - - if (filter.MajorId.HasValue) - { - query = query.Where(item => item.MajorId == filter.MajorId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Name) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertHandbookSubjectAsync( - DirectContentActor actor, - HandbookSubjectCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found"); - item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.SchoolId = command.SchoolId; - item.MajorId = command.MajorId; - item.EntryId = command.EntryId; - item.ContentNodeId = command.ContentNodeId; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.Type = ParseNullable(command.Type, "handbook_subject_type_invalid"); - item.Icon = Normalize(command.Icon); - item.Color = Normalize(command.Color); - item.Description = Normalize(command.Description); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.HandbookSubjects.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetHandbookChaptersAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.SubjectId.HasValue) - { - query = query.Where(item => item.SubjectId == filter.SubjectId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Name) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertHandbookChapterAsync( - DirectContentActor actor, - HandbookChapterCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - var chapterNavigation = await ResolveHandbookSubjectNavigationAsync( - actor.TenantId, - command.SubjectId, - command.EntryId, - command.ContentNodeId, - cancellationToken); - item.SubjectId = command.SubjectId; - item.EntryId = chapterNavigation.EntryId; - item.ContentNodeId = chapterNavigation.ContentNodeId; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.HandbookChapters.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetHandbookEntriesAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.ChapterId.HasValue) - { - query = query.Where(item => item.ChapterId == filter.ChapterId.Value); - } - - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } - - if (filter.ContentNodeId.HasValue) - { - query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Title) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertHandbookEntryAsync( - DirectContentActor actor, - HandbookEntryCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - var entryNavigation = await ResolveHandbookChapterNavigationAsync( - actor.TenantId, - command.ChapterId, - command.EntryId, - command.ContentNodeId, - cancellationToken); - item.ChapterId = command.ChapterId; - item.EntryId = entryNavigation.EntryId; - item.ContentNodeId = entryNavigation.ContentNodeId; - item.LegacyId = Normalize(command.LegacyId); - item.Title = command.Title.Trim(); - item.Summary = Normalize(command.Summary); - item.Content = Normalize(command.Content); - item.Tags = JsonArrayOrDefault(command.Tags); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.HandbookEntries.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetSchoolsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.Schools.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword)); - } - - return new CatalogList(await query - .OrderBy(item => item.Name) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertSchoolAsync( - DirectContentActor actor, - SchoolCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found"); - item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.Schools.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetMajorsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.Majors.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Name) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertMajorAsync( - DirectContentActor actor, - MajorCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found"); - item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.SchoolId = command.SchoolId; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.StudyTips = Normalize(command.StudyTips); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Majors.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetScorelineFieldsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineFields.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword)); - } - - return new CatalogList(await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.FieldName) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertScorelineFieldAsync( - DirectContentActor actor, - ScorelineFieldCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); - ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); - if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) - { - throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid"); - } - - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found"); - item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.LegacyId = Normalize(command.LegacyId); - item.FieldKey = command.FieldKey.Trim(); - item.FieldName = command.FieldName.Trim(); - item.FieldType = Normalize(command.FieldType) ?? "text"; - item.Unit = Normalize(command.Unit); - item.IsFilter = command.IsFilter ?? item.IsFilter; - item.IsRequired = command.IsRequired ?? item.IsRequired; - item.IsVisible = command.IsVisible ?? item.IsVisible; - item.IsTrend = command.IsTrend ?? item.IsTrend; - item.Options = JsonArrayOrDefault(command.Options); - item.Placeholder = Normalize(command.Placeholder); - item.Description = Normalize(command.Description); - item.SortOrder = command.Order ?? item.SortOrder; - if (isNew) - { - dbContext.ScorelineFields.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetScorelineRecordsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } - - if (filter.MajorId.HasValue) - { - query = query.Where(item => item.MajorId == filter.MajorId.Value); - } - - if (filter.Year.HasValue) - { - query = query.Where(item => item.Year == filter.Year.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => - (item.SchoolName != null && item.SchoolName.Contains(keyword)) || - (item.MajorName != null && item.MajorName.Contains(keyword))); - } - - return new CatalogList(await query - .OrderByDescending(item => item.Year) - .ThenBy(item => item.SchoolName) - .ThenBy(item => item.MajorName) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)); - } - - public async Task> UpsertScorelineRecordAsync( - DirectContentActor actor, - ScorelineRecordCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - if (command.Year is < 1900 or > 3000) - { - throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); - } - - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found"); - item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.SchoolId = command.SchoolId; - item.MajorId = command.MajorId; - item.LegacyId = Normalize(command.LegacyId); - item.Year = command.Year; - item.SchoolName = Normalize(command.SchoolName); - item.MajorName = Normalize(command.MajorName); - item.FieldValues = JsonObjectOrDefault(command.FieldValues); - if (isNew) - { - dbContext.ScorelineRecords.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task> GetScorelineYearsAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } - - var years = await query - .Select(item => item.Year) - .Distinct() - .OrderByDescending(year => year) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - return new CatalogList(years); - } - - public async Task> GetScorelineTrendAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var years = await GetScorelineYearsAsync(actor, filter, cancellationToken); - var items = new List(); - foreach (var year in years.Items) - { - var schoolCount = await dbContext.ScorelineRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Year == year) - .Select(item => item.SchoolId) - .Where(id => id.HasValue) - .Distinct() - .CountAsync(cancellationToken); - var majorCount = await dbContext.ScorelineRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Year == year) - .Select(item => item.MajorId) - .Where(id => id.HasValue) - .Distinct() - .CountAsync(cancellationToken); - items.Add(new ScorelineTrendItem(year, schoolCount, majorCount)); - } - - return new CatalogList(items); - } - - public async Task> GetVideosAsync( - DirectContentActor actor, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.SubjectId.HasValue) - { - query = query.Where(item => item.SubjectId == filter.SubjectId.Value); - } - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.IsActive); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); - } - - var items = await query - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToVideoItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertVideoAsync( - DirectContentActor actor, - VideoExplanationCommand command, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.SubjectId = command.SubjectId; - item.LegacyId = Normalize(command.LegacyId); - item.Title = command.Title.Trim(); - item.Description = Normalize(command.Description); - item.VideoUrl = Normalize(command.VideoUrl); - item.ThumbnailUrl = Normalize(command.ThumbnailUrl); - item.DurationSeconds = command.DurationSeconds; - item.KnowledgeTags = JsonArrayOrDefault(command.KnowledgeTags); - item.IsGeneral = command.IsGeneral ?? item.IsGeneral; - item.Difficulty = command.Difficulty; - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.VideoExplanations.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToVideoItem(item)); - } - - public async Task> BindQuestionVideoAsync( - DirectContentActor actor, - QuestionVideoCommand command, - CancellationToken cancellationToken = default) - { - await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); - var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( - link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, - cancellationToken); - var isNew = item is null; - item ??= new QuestionVideo { TenantId = actor.TenantId }; - item.QuestionId = command.QuestionId; - item.VideoId = command.VideoId; - item.LegacyId = Normalize(command.LegacyId); - item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid"); - item.SortOrder = command.Order ?? item.SortOrder; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.QuestionVideos.Add(item); - } - - var question = await dbContext.Questions.SingleAsync( - question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, - cancellationToken); - question.HasVideoExplanation = true; - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToQuestionVideoItem(item)); - } - - public async Task> GetOperationContentAsync( - DirectContentActor actor, - string kind, - AdminLimitFilter filter, - CancellationToken cancellationToken = default) - { - var items = NormalizeOperationKind(kind) switch - { - "banners" => (await dbContext.Banners.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) - .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - "faqs" => (await dbContext.Faqs.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) - .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - "announcements" => (await dbContext.Announcements.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - "exam-dates" => (await dbContext.ExamDates.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) - .Where(item => !filter.SchoolId.HasValue || item.SchoolId == filter.SchoolId.Value) - .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) - .OrderBy(item => item.ExamAt == null) - .ThenBy(item => item.ExamAt) - .ThenBy(item => item.SortOrder) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") - }; - - return new CatalogList(items); - } - - public async Task> UpsertOperationContentAsync( - DirectContentActor actor, - string kind, - OperationContentCommand command, - CancellationToken cancellationToken = default) - { - OperationContentItem item = NormalizeOperationKind(kind) switch - { - "banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)), - "faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)), - "announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)), - "exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)), - _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") - }; - - return new ContentManagementResult(item); - } - - public Task PreviewImportAsync( - DirectContentActor actor, - SimpleImportCommand command, - CancellationToken cancellationToken = default) - { - return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken); - } - - public Task ExecuteImportAsync( - DirectContentActor actor, - SimpleImportCommand command, - CancellationToken cancellationToken = default) - { - return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken); - } - - public async Task GetImportJobAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) - .Select(item => ToJobItem(item)) - .SingleOrDefaultAsync(cancellationToken); - if (job is null) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } - - var items = await dbContext.ContentImportItems.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) - .OrderBy(item => item.RowNo) - .Take(MaxLimit) - .Select(item => ToImportItem(item)) - .ToArrayAsync(cancellationToken); - var issues = await dbContext.ContentImportIssues.AsNoTracking() - .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) - .OrderBy(issue => issue.RowNo) - .ThenBy(issue => issue.CreatedAt) - .Take(MaxLimit) - .Select(issue => new ContentImportIssueModel( - issue.Id, - issue.JobId, - issue.ItemId, - issue.RowNo, - issue.Severity, - issue.Code, - issue.FieldPath, - issue.Message, - issue.Details)) - .ToArrayAsync(cancellationToken); - - return new ContentImportJobDetail(job, items, issues); - } - - public async Task> GetImportIssuesAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); - var issues = await dbContext.ContentImportIssues.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) - .OrderBy(item => item.RowNo) - .ThenBy(item => item.CreatedAt) - .Take(MaxLimit) - .Select(item => new ContentImportIssueModel( - item.Id, - item.JobId, - item.ItemId, - item.RowNo, - item.Severity, - item.Code, - item.FieldPath, - item.Message, - item.Details)) - .ToArrayAsync(cancellationToken); - return new CatalogList(issues); - } - - public async Task RunImportPostCheckAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == jobId, - cancellationToken); - if (job is null) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } - - var counts = JsonSerializer.SerializeToElement(new - { - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount - }); - job.Summary = JsonSerializer.SerializeToElement(new - { - postCheck = new - { - status = job.ErrorCount == 0 ? "passed" : "warning", - checkedAt = DateTimeOffset.UtcNow, - counts - } - }); - await dbContext.SaveChangesAsync(cancellationToken); - return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); - } - - public async Task GetImportPostCheckAsync( - DirectContentActor actor, - Guid jobId, - CancellationToken cancellationToken = default) - { - var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == jobId, - cancellationToken); - if (job is null) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } - - var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); - var counts = JsonSerializer.SerializeToElement(new - { - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount - }); - return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); - } - - private async Task CreateImportJobAsync( - DirectContentActor actor, - SimpleImportCommand command, - bool execute, - CancellationToken cancellationToken) - { - if (!SupportedImportTypes.Contains(command.ImportType)) - { - throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); - } - - var importType = ParseImportType(command.ImportType); - var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); - var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); - var job = new ContentImportJob - { - TenantId = actor.TenantId, - CreatedBy = actor.UserId, - TargetRegionId = command.RegionId, - TargetSubjectId = command.SubjectId, - TargetCategoryId = command.CategoryId, - TargetContentNodeId = command.ContentNodeId, - TargetQuestionBankId = command.QuestionBankId, - ImportType = importType, - SourceFormat = sourceFormat, - Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, - SourceName = Normalize(command.SourceName), - DryRun = command.DryRun, - TotalCount = items.Length, - ValidCount = items.Length, - RawPayload = JsonSerializer.SerializeToElement(items), - NormalizedPayload = JsonSerializer.SerializeToElement(items), - StartedAt = execute ? DateTimeOffset.UtcNow : null, - FinishedAt = execute ? DateTimeOffset.UtcNow : null - }; - dbContext.ContentImportJobs.Add(job); - - var importItems = new List(); - var rowNo = 1; - foreach (var payload in items) - { - var importItem = new ContentImportItem - { - TenantId = actor.TenantId, - JobId = job.Id, - RowNo = rowNo++, - ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), - Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, - SourcePayload = payload, - NormalizedPayload = payload - }; - - if (execute) - { - var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); - importItem.TargetType = target.TargetType; - importItem.TargetId = target.TargetId; - job.InsertedCount++; - } - - importItems.Add(importItem); - } - - dbContext.ContentImportItems.AddRange(importItems); - job.Summary = JsonSerializer.SerializeToElement(new - { - mode = execute ? "execute" : "preview", - supportedTypes = SupportedImportTypes, - note = "Synchronous direct migration import skeleton; async worker will be introduced later." - }); - - await dbContext.SaveChangesAsync(cancellationToken); - return new SimpleImportResult( - ToJobItem(job), - importItems.Select(ToImportItem).ToArray(), - []); - } - - private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( - DirectContentActor actor, - SimpleImportCommand command, - JsonElement payload, - CancellationToken cancellationToken) - { - switch (command.ImportType.ToLowerInvariant()) - { - case "questions": - var result = await CreateQuestionAsync(actor, new QuestionWriteCommand( - null, - command.QuestionBankId, - command.SubjectId, - command.CategoryId, - null, - command.EntryId, - command.ContentNodeId, - command.CollectionId, - GetString(payload, "legacyId"), - GetString(payload, "type") ?? "choice", - GetString(payload, "typeLabel"), - GetInt(payload, "difficulty"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetString(payload, "content") ?? GetString(payload, "title"), - GetElement(payload, "options", JsonDefaults.Array()), - GetInt(payload, "correctOptionIndex"), - GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), - GetString(payload, "answerText") ?? GetString(payload, "answer"), - GetString(payload, "explanation"), - GetElement(payload, "subQuestions", JsonDefaults.Array()), - GetString(payload, "codeLang"), - GetString(payload, "codeTemplate"), - GetString(payload, "mediaUrl"), - "Published", - GetElement(payload, "examMarkers", JsonDefaults.Object()), - GetString(payload, "sourceHash"), - true), cancellationToken); - return ("question", result.Item.Id); - case "vocabulary": - var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( - null, - null, - command.EntryId, - command.ContentNodeId, - GetString(payload, "legacyId"), - GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", - GetString(payload, "phonetic"), - GetString(payload, "meaning"), - GetString(payload, "example"), - GetString(payload, "exampleTranslation"), - GetInt(payload, "difficulty"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("vocabulary_word", word.Item.Id); - case "handbook": - var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( - null, - null, - command.EntryId, - command.ContentNodeId, - GetString(payload, "legacyId"), - GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", - GetString(payload, "summary"), - GetString(payload, "content"), - GetElement(payload, "tags", JsonDefaults.Array()), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("handbook_entry", entry.Item.Id); - case "scoreline": - var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand( - null, - command.RegionId, - GetGuid(payload, "schoolId"), - GetGuid(payload, "majorId"), - GetString(payload, "legacyId"), - GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year, - GetString(payload, "schoolName"), - GetString(payload, "majorName"), - GetElement(payload, "fieldValues", payload)), cancellationToken); - return ("scoreline_record", scoreline.Item.Id); - case "videos": - var video = await UpsertVideoAsync(actor, new VideoExplanationCommand( - null, - command.SubjectId, - GetString(payload, "legacyId"), - GetString(payload, "title") ?? "未命名视频", - GetString(payload, "description"), - GetString(payload, "videoUrl") ?? GetString(payload, "url"), - GetString(payload, "thumbnailUrl"), - GetInt(payload, "durationSeconds"), - GetElement(payload, "knowledgeTags", JsonDefaults.Array()), - GetBool(payload, "isGeneral"), - GetInt(payload, "difficulty"), - GetInt(payload, "order"), - true, - GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); - return ("video_explanation", video.Item.Id); - default: - throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); - } - } - - private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) - { - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.LegacyId = Normalize(command.LegacyId); - item.Title = Normalize(command.Title); - item.Subtitle = Normalize(command.Subtitle); - item.Content = Normalize(command.Content); - item.ButtonText = Normalize(command.ButtonText); - item.ButtonLink = Normalize(command.ButtonLink); - item.BackgroundColor = Normalize(command.BackgroundColor); - item.BorderColor = Normalize(command.BorderColor); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Banners.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return item; - } - - private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) - { - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.LegacyId = Normalize(command.LegacyId); - item.Question = Normalize(command.Question) ?? Normalize(command.Title); - item.Answer = Normalize(command.Answer) ?? Normalize(command.Content); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Faqs.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return item; - } - - private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) - { - var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.LegacyId = Normalize(command.LegacyId); - item.Content = Normalize(command.Content) ?? Normalize(command.Title); - item.Link = Normalize(command.Link); - item.BackgroundColor = Normalize(command.BackgroundColor); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Announcements.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return item; - } - - private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.RegionId = command.RegionId; - item.SchoolId = command.SchoolId; - item.LegacyId = Normalize(command.LegacyId); - item.ExamName = command.ExamName.Trim(); - item.ExamAt = command.ExamAt; - item.ExamType = Normalize(command.ExamType); - item.Description = Normalize(command.Description) ?? Normalize(command.Content); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.ExamDates.Add(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return item; - } - - private static void ApplyQuestion(Question question, QuestionWriteCommand command) - { - question.QuestionBankId = command.QuestionBankId; - question.SubjectId = command.SubjectId; - question.CategoryId = command.CategoryId; - question.NodeId = command.NodeId; - question.EntryId = command.EntryId; - question.ContentNodeId = command.ContentNodeId; - question.PrimaryCollectionId = command.PrimaryCollectionId; - question.LegacyId = Normalize(command.LegacyId); - question.Type = Normalize(command.Type) ?? question.Type; - question.TypeLabel = Normalize(command.TypeLabel); - question.Difficulty = command.Difficulty; - question.Tags = JsonArrayOrDefault(command.Tags); - question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers); - question.MediaUrl = Normalize(command.MediaUrl); - question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); - } - - private static void ValidateQuestionForPublication(QuestionWriteCommand command) - { - var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); - if (status != QuestionStatus.Published) - { - return; - } - - var type = Normalize(command.Type) ?? "choice"; - if (!QuestionGrader.HasValidAuthoritativeAnswer( - type, - command.CorrectOptionIndex, - command.CorrectOptionIndices, - command.AnswerText)) - { - throw new ContentManagementException( - "Published questions require a valid authoritative answer.", - "question_grading_rule_invalid"); - } - } - - private static QuestionVersion BuildQuestionVersion( - DirectContentActor actor, - Guid questionId, - int versionNo, - QuestionWriteCommand command) - { - var version = new QuestionVersion - { - TenantId = actor.TenantId, - QuestionId = questionId, - VersionNo = versionNo, - CreatedBy = actor.UserId - }; - ApplyQuestionVersion(version, command); - return version; - } - - private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) - { - version.Content = Normalize(command.Content); - version.Options = JsonArrayOrDefault(command.Options); - version.CorrectOptionIndex = command.CorrectOptionIndex; - version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices); - version.AnswerText = Normalize(command.AnswerText); - version.Explanation = Normalize(command.Explanation); - version.SubQuestions = JsonArrayOrDefault(command.SubQuestions); - version.CodeLang = Normalize(command.CodeLang); - version.CodeTemplate = Normalize(command.CodeTemplate); - version.SourceHash = Normalize(command.SourceHash); - } - - private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) - { - await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken); - } - - private async Task SyncPrimaryCollectionItemAsync( - DirectContentActor actor, - Question question, - CancellationToken cancellationToken) - { - if (!question.PrimaryCollectionId.HasValue) - { - return; - } - - var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.CollectionId == question.PrimaryCollectionId.Value && - item.QuestionId == question.Id, - cancellationToken); - if (existing is null) - { - var reference = await questionReferenceService.ResolveAsync( - actor.TenantId, - actor.UserId, - new QuestionLocator(QuestionSource.Tenant, question.Id), - cancellationToken); - var nextOrder = await dbContext.QuestionCollectionItems - .Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) - .Select(item => (int?)item.SortOrder) - .MaxAsync(cancellationToken) ?? -1; - dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem - { - TenantId = actor.TenantId, - CollectionId = question.PrimaryCollectionId.Value, - QuestionReferenceId = reference.Id, - QuestionOwnerTenantId = reference.QuestionOwnerTenantId, - QuestionId = question.Id, - SortOrder = nextOrder + 1 - }); - } - - var collection = await dbContext.QuestionCollections.SingleAsync( - item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value, - cancellationToken); - collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync( - item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value, - cancellationToken) + (existing is null ? 1 : 0); - collection.UpdatedBy = actor.UserId; - } - - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( - Guid tenantId, - Guid? unitId, - Guid? entryId, - Guid? contentNodeId, - CancellationToken cancellationToken) - { - if (!unitId.HasValue) - { - return (entryId, contentNodeId); - } - - var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == unitId.Value, - cancellationToken); - if (unit is null) - { - throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found"); - } - - return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); - } - - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( - Guid tenantId, - Guid? subjectId, - Guid? entryId, - Guid? contentNodeId, - CancellationToken cancellationToken) - { - if (!subjectId.HasValue) - { - return (entryId, contentNodeId); - } - - var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == subjectId.Value, - cancellationToken); - if (subject is null) - { - throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found"); - } - - return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); - } - - private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( - Guid tenantId, - Guid? chapterId, - Guid? entryId, - Guid? contentNodeId, - CancellationToken cancellationToken) - { - if (!chapterId.HasValue) - { - return (entryId, contentNodeId); - } - - var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == chapterId.Value, - cancellationToken); - if (chapter is null) - { - throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found"); - } - - return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); - } - - private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) - { - return new QuestionManagementItem( - question.Id, - version?.Id, - question.QuestionBankId, - question.SubjectId, - question.CategoryId, - question.NodeId, - question.EntryId, - question.ContentNodeId, - question.PrimaryCollectionId, - question.LegacyId, - question.Type, - question.TypeLabel, - question.Difficulty, - question.Tags, - version?.Content, - version?.Options ?? JsonDefaults.Array(), - version?.CorrectOptionIndex, - version?.CorrectOptionIndices ?? JsonDefaults.Array(), - version?.AnswerText, - version?.Explanation, - version?.SubQuestions ?? JsonDefaults.Array(), - version?.CodeLang, - version?.CodeTemplate, - question.MediaUrl, - question.HasVideoExplanation, - question.Status); - } - - private static VideoManagementItem ToVideoItem(VideoExplanation item) - { - return new VideoManagementItem( - item.Id, - item.SubjectId, - item.LegacyId, - item.Title, - item.Description, - item.VideoUrl, - item.ThumbnailUrl, - item.DurationSeconds, - item.KnowledgeTags, - item.IsGeneral, - item.Difficulty, - item.SortOrder, - item.IsActive, - item.Metadata); - } - - private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) - { - return new QuestionVideoManagementItem( - item.Id, - item.QuestionId, - item.VideoId, - item.LegacyId, - item.VideoType, - item.SortOrder, - item.Metadata); - } - - private static OperationContentItem ToOperationItem(Banner item) - { - return new OperationContentItem( - item.Id, - "banners", - item.RegionId, - null, - item.LegacyId, - item.Title, - item.Content, - null, - null, - null, - null, - item.SortOrder, - item.IsActive, - JsonSerializer.SerializeToElement(new - { - item.Subtitle, - item.ButtonText, - item.ButtonLink, - item.BackgroundColor, - item.BorderColor - })); - } - - private static OperationContentItem ToOperationItem(Faq item) - { - return new OperationContentItem( - item.Id, - "faqs", - item.RegionId, - null, - item.LegacyId, - null, - null, - item.Question, - item.Answer, - null, - null, - item.SortOrder, - item.IsActive, - JsonDefaults.Object()); - } - - private static OperationContentItem ToOperationItem(Announcement item) - { - return new OperationContentItem( - item.Id, - "announcements", - null, - null, - item.LegacyId, - null, - item.Content, - null, - null, - null, - null, - item.SortOrder, - item.IsActive, - JsonSerializer.SerializeToElement(new - { - item.Link, - item.BackgroundColor - })); - } - - private static OperationContentItem ToOperationItem(ExamDate item) - { - return new OperationContentItem( - item.Id, - "exam-dates", - item.RegionId, - item.SchoolId, - item.LegacyId, - item.ExamName, - item.Description, - null, - null, - item.ExamAt, - item.ExamType, - item.SortOrder, - item.IsActive, - item.Metadata); - } - - private static ContentImportJobItem ToJobItem(ContentImportJob job) - { - return new ContentImportJobItem( - job.Id, - job.TargetRegionId, - job.TargetSubjectId, - job.TargetCategoryId, - job.TargetContentNodeId, - job.TargetQuestionBankId, - job.ImportType, - job.SourceFormat, - job.Status, - job.SourceName, - job.SourceHash, - job.DryRun, - job.TotalCount, - job.ValidCount, - job.ErrorCount, - job.WarningCount, - job.InsertedCount, - job.UpdatedCount, - job.SkippedCount, - job.Summary, - job.ErrorMessage, - job.StartedAt, - job.FinishedAt, - job.CreatedAt, - job.UpdatedAt); - } - - private static ContentImportItemModel ToImportItem(ContentImportItem item) - { - return new ContentImportItemModel( - item.Id, - item.JobId, - item.RowNo, - item.ExternalId, - item.Status, - item.TargetType, - item.TargetId, - item.SourcePayload, - item.NormalizedPayload, - item.ContentHash, - item.IssuesCount); - } - - private async Task RequireDataScopeAsync( - DirectContentActor actor, - CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (!access.IsCurrentTenantMember || - access.UserId != actor.UserId || - access.TenantId != actor.TenantId || - !ContentPermissions.Any(access.HasTenantPermission)) - { - throw new ContentManagementException("Tenant content access was denied.", "content_access_denied"); - } - - return access.DataScope; - } - - private static void EnsureRegionWriteAllowed( - CurrentDataScope scope, - DirectContentActor actor, - Guid? currentRegionId, - Guid? targetRegionId, - bool isNew, - string notFoundCode) - { - var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId); - var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId); - if (!canAccessCurrent || !canAccessTarget) - { - throw new ContentManagementException("Content resource was not found.", notFoundCode); - } - } - - private async Task ResolveByIdOrLegacyAsync( - DbSet set, - Guid tenantId, - Guid? id, - string? legacyId, - CancellationToken cancellationToken) - where TEntity : AuditableTenantEntity - { - if (id.HasValue) - { - return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken); - } - - var normalizedLegacyId = Normalize(legacyId); - return normalizedLegacyId is null - ? null - : await set.SingleOrDefaultAsync( - item => item.TenantId == tenantId && EF.Property(item, "LegacyId") == normalizedLegacyId, - cancellationToken); - } - - private async Task AssertReferenceAsync( - Guid tenantId, - Guid? id, - string code, - CancellationToken cancellationToken) - where TEntity : class - { - if (!id.HasValue) - { - return; - } - - var exists = await dbContext.Set() - .AnyAsync(item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); - if (!exists) - { - throw new ContentManagementException("Referenced entity was not found.", code); - } - } - - private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) - { - var exists = await dbContext.ContentImportJobs.AnyAsync( - item => item.TenantId == tenantId && item.Id == jobId, - cancellationToken); - if (!exists) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } - } - - private static string NormalizeOperationKind(string kind) - { - var normalized = Normalize(kind)?.ToLowerInvariant(); - return normalized switch - { - "banner" or "banners" => "banners", - "faq" or "faqs" => "faqs", - "announcement" or "announcements" => "announcements", - "exam-date" or "exam-dates" or "examdates" => "exam-dates", - _ => normalized ?? string.Empty - }; - } - - private static ContentImportType ParseImportType(string value) - { - return value.ToLowerInvariant() switch - { - "questions" => ContentImportType.Questions, - "vocabulary" => ContentImportType.Vocabulary, - "handbook" => ContentImportType.Handbook, - "scoreline" => ContentImportType.Scoreline, - "videos" => ContentImportType.Videos, - _ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid") - }; - } - - private static TEnum Parse(string? value, TEnum fallback, string code) - where TEnum : struct - { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } - - if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) - { - return parsed; - } - - throw new ContentManagementException("Enum value is invalid.", code); - } - - private static TEnum? ParseNullable(string? value, string code) - where TEnum : struct - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) - { - return parsed; - } - - throw new ContentManagementException("Enum value is invalid.", code); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static int ResolveLimit(int? limit) - { - return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit); - } - - private static JsonElement JsonObjectOrDefault(JsonElement value) - { - return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object(); - } - - private static JsonElement JsonArrayOrDefault(JsonElement value) - { - return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array(); - } - - private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) - { - return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) - ? value - : fallback; - } - - private static string? GetString(JsonElement payload, string name) - { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } - - return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); - } - - private static int? GetInt(JsonElement payload, string name) - { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } - - return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) - ? number - : int.TryParse(value.ToString(), out number) - ? number - : null; - } - - private static Guid? GetGuid(JsonElement payload, string name) - { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } - - return value.ValueKind == JsonValueKind.String && - Guid.TryParse(value.GetString(), out var guid) - ? guid - : null; - } - - private static bool? GetBool(JsonElement payload, string name) - { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } - - return value.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, - _ => null - }; - } } diff --git a/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs b/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs new file mode 100644 index 0000000..ff88428 --- /dev/null +++ b/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetSchoolsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.Schools.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword)); + } + + return new CatalogList(await query + .OrderBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertSchoolAsync( + DirectContentActor actor, + SchoolCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found"); + item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.Schools.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetMajorsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.Majors.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertMajorAsync( + DirectContentActor actor, + MajorCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found"); + item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.StudyTips = Normalize(command.StudyTips); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Majors.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + +} diff --git a/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs b/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs new file mode 100644 index 0000000..d5ba571 --- /dev/null +++ b/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs @@ -0,0 +1,135 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class ContentManagementService +{ + public async Task> GetEntriesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ContentEntries + .AsNoTracking() + .Where(entry => entry.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + entry => entry.CreatedBy == actor.UserId, + entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)); + + if (!filter.IncludeInactive) + { + query = query.Where(entry => entry.IsActive); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(entry => entry.RegionId == filter.RegionId.Value); + } + + if (TryParse(filter.EntryType, out ContentEntryType entryType)) + { + query = query.Where(entry => entry.EntryType == entryType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(entry => + entry.Name.Contains(keyword) || + entry.EntryKey.Contains(keyword) || + (entry.Description != null && entry.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(entry => entry.SortOrder) + .ThenBy(entry => entry.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(entry => ToEntryItem(entry)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertEntryAsync( + ContentManagementActor actor, + UpsertContentEntryCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + + var entryKey = Normalize(command.EntryKey) ?? + Normalize(command.Id?.ToString("N")) ?? + Guid.NewGuid().ToString("N"); + var entry = await ResolveEntityAsync( + dbContext.ContentEntries, + actor.TenantId, + command.Id, + item => item.EntryKey == entryKey, + cancellationToken); + + var isNew = entry is null; + if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + + if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + + if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + + entry ??= new ContentEntry + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + EntryKey = entryKey, + CreatedBy = actor.UserId + }; + + entry.RegionId = command.RegionId; + entry.LegacyId = Normalize(command.LegacyId); + entry.Name = command.Name.Trim(); + entry.EntryType = Parse(command.EntryType, ContentEntryType.QuestionPractice, "entry_type_invalid"); + entry.Icon = Normalize(command.Icon); + entry.Route = Normalize(command.Route); + entry.Description = Normalize(command.Description); + entry.Visibility = Parse(command.Visibility, ContentVisibility.Public, "visibility_invalid"); + entry.AccessRules = JsonObjectOrDefault(command.AccessRules); + entry.LayoutConfig = JsonObjectOrDefault(command.LayoutConfig); + entry.SortOrder = command.Order ?? 0; + entry.IsActive = command.IsActive ?? true; + entry.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.ContentEntries.Add(entry); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToEntryItem(entry)); + } + + +} diff --git a/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs b/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs new file mode 100644 index 0000000..75e0564 --- /dev/null +++ b/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs @@ -0,0 +1,522 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class ContentManagementService +{ + private async Task<(string Path, int Depth)> BuildNodePathAsync( + Guid tenantId, + Guid entryId, + Guid nodeId, + Guid? parentId, + CancellationToken cancellationToken) + { + var label = $"n_{nodeId:N}"; + if (!parentId.HasValue) + { + return (label, 0); + } + + var parent = await dbContext.ContentNodes + .AsNoTracking() + .Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value) + .Select(node => new { node.Path, node.Depth }) + .SingleOrDefaultAsync(cancellationToken); + + if (parent is null) + { + throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found"); + } + + return ($"{parent.Path}.{label}", parent.Depth + 1); + } + + private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, regionId, "region_not_found", cancellationToken); + } + + private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, entryId, "entry_not_found", cancellationToken); + } + + private async Task AssertEntryAsync( + ContentManagementActor actor, + CurrentDataScope scope, + Guid? entryId, + CancellationToken cancellationToken) + { + if (!entryId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var exists = await dbContext.ContentEntries + .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value) + .ApplyDataScope( + scope, + entry => entry.CreatedBy == actor.UserId, + entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new ContentManagementException("Content entry was not found.", "entry_not_found"); + } + } + + private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); + } + + private async Task AssertNodeAsync( + ContentManagementActor actor, + CurrentDataScope scope, + Guid? nodeId, + CancellationToken cancellationToken) + { + if (!nodeId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var exists = await dbContext.ContentNodes + .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value) + .ApplyDataScope( + scope, + node => node.CreatedBy == actor.UserId, + node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + } + + private async Task RequireDataScopeAsync( + ContentManagementActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) + { + throw new ContentManagementException("Content resource was not found.", "content_not_found"); + } + + return access.DataScope; + } + + private async Task AssertReferenceAsync( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set() + .AnyAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + + if (!exists) + { + throw new ContentManagementException("Referenced entity was not found in this tenant.", code); + } + } + + private static async Task ResolveEntityAsync( + DbSet set, + Guid tenantId, + Guid? id, + System.Linq.Expressions.Expression> alternatePredicate, + CancellationToken cancellationToken) + where TEntity : class + { + if (id.HasValue) + { + var byId = await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + if (byId is not null) + { + return byId; + } + } + + return await set + .Where(entity => EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId) + .SingleOrDefaultAsync(alternatePredicate, cancellationToken); + } + + private static async Task ResolveEntityByIdOrLegacyAsync( + DbSet set, + Guid tenantId, + Guid? id, + string? legacyId, + CancellationToken cancellationToken) + where TEntity : class + { + if (id.HasValue) + { + var byId = await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + if (byId is not null) + { + return byId; + } + } + + var normalizedLegacyId = Normalize(legacyId); + if (normalizedLegacyId is null) + { + return null; + } + + return await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId, + cancellationToken); + } + + private static ContentEntryManagementItem ToEntryItem(ContentEntry entry) + { + return new ContentEntryManagementItem( + entry.Id, + entry.RegionId, + entry.LegacyId, + entry.EntryKey, + entry.Name, + entry.EntryType, + entry.Icon, + entry.Route, + entry.Description, + entry.Visibility, + entry.AccessRules, + entry.LayoutConfig, + entry.SortOrder, + entry.IsActive, + entry.CreatedAt, + entry.UpdatedAt); + } + + private static ContentNodeManagementItem ToNodeItem(ContentNode node) + { + return new ContentNodeManagementItem( + node.Id, + node.EntryId, + node.RegionId, + node.ParentId, + node.LegacyId, + node.NodeKey, + node.Name, + node.NodeType, + node.MarkerType, + node.MarkerConfig, + node.Path, + node.Depth, + node.SortOrder, + node.IsActive, + node.IsSelectable, + node.IsLeaf, + node.AccessRules, + node.Metadata, + node.CreatedAt, + node.UpdatedAt); + } + + private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection) + { + return new QuestionCollectionManagementItem( + collection.Id, + collection.RegionId, + collection.EntryId, + collection.NodeId, + collection.SubjectId, + collection.CategoryId, + collection.QuestionBankId, + collection.LegacyId, + collection.Name, + collection.CollectionType, + collection.SourceType, + collection.Filters, + collection.QuestionCount, + collection.TotalScore, + collection.DurationMinutes, + collection.Status, + collection.SortOrder, + collection.AccessRules, + collection.Metadata, + collection.CreatedAt, + collection.UpdatedAt); + } + + private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item) + { + return new QuestionCollectionItemManagementItem( + item.Id, + item.CollectionId, + item.QuestionId, + new QuestionLocator( + item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, + item.QuestionId), + item.SectionKey, + item.SortOrder, + item.Score, + item.Required, + item.Metadata); + } + + private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint) + { + return new PracticeBlueprintManagementItem( + blueprint.Id, + blueprint.RegionId, + blueprint.EntryId, + blueprint.NodeId, + blueprint.CollectionId, + blueprint.LegacyId, + blueprint.Name, + blueprint.Mode, + blueprint.AssemblyType, + blueprint.QuestionLimit, + blueprint.DurationMinutes, + blueprint.TotalScore, + blueprint.PassScore, + blueprint.Sections, + blueprint.Rules, + blueprint.AccessRules, + blueprint.Status, + blueprint.SortOrder, + blueprint.CreatedAt, + blueprint.UpdatedAt); + } + + private static int ResolveLimit(int? limit) + { + return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit; + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? JsonDefaults.Object() + : value; + } + + private static JsonElement JsonArrayOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? JsonDefaults.Array() + : value; + } + + private static TEnum Parse(string? value, TEnum fallback, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Invalid enum value.", code); + } + + private static TEnum? ParseNullable(string? value, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Invalid enum value.", code); + } + + private static bool TryParse(string? value, out TEnum parsed) + where TEnum : struct + { + return Enum.TryParse(value, ignoreCase: true, out parsed); + } + + private static string EscapeCsv(string value) + { + return value.Contains(',') || value.Contains('"') || value.Contains('\n') + ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" + : value; + } + + private static ImportSpec ResolveImportSpec(string importType) + { + var normalized = importType.Trim().ToLowerInvariant(); + return Specs.TryGetValue(normalized, out var spec) + ? spec + : throw new ContentManagementException("Import type is not supported.", "import_type_invalid"); + } + + private sealed record ImportSpec( + string ImportType, + string Title, + string Description, + IReadOnlyCollection Fields, + string[][] CsvRows, + object JsonExample); + + private static ImportFieldSpec Field( + string field, + string label, + bool required, + string[] aliases, + string description, + object example) + { + return new ImportFieldSpec( + field, + label, + required, + aliases, + description, + JsonSerializer.SerializeToElement(example)); + } + + private static readonly IReadOnlyDictionary Specs = + new Dictionary(StringComparer.Ordinal) + { + ["questions"] = new( + "questions", + "题目导入模板", + "用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。", + [ + Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"), + Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"), + Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"), + Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }), + Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }), + Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"), + Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"), + Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2), + Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" }) + ], + [ + ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"], + ["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"] + ], + new + { + items = new[] + { + new + { + legacyId = "tj-english-2026-001", + type = "choice", + content = "多租户 SaaS 最重要的安全边界是什么?", + options = new[] { "前端隐藏", "后端权限" }, + correctOptionIndices = new[] { 1 }, + explanation = "最终权限以后端强制为准。", + difficulty = 2, + tags = new[] { "安全", "多租户" } + } + } + }), + ["vocabulary"] = new( + "vocabulary", + "单词导入模板", + "用于导入词汇单元和单词,后端会按单元归组并幂等写入。", + [ + Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"), + Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"), + Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"), + Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"), + Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.") + ], + [ + ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"], + ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"] + ], + new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }), + ["handbook"] = new( + "handbook", + "知识手册导入模板", + "用于导入手册科目、章节、小节和知识点。", + [ + Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"), + Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"), + Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"), + Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。") + ], + [ + ["subjectName", "chapterName", "title", "content", "tags"], + ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"] + ], + new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }), + ["scoreline"] = new( + "scoreline", + "分数线导入模板", + "用于导入动态字段、院校、专业和年份分数线记录。", + [ + Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"), + Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"), + Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"), + Field("year", "年份", false, ["年份"], "record 常用。", 2026), + Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 }) + ], + [ + ["kind", "schoolName", "majorName", "year", "minScore"], + ["record", "天津职业技术师范大学", "软件工程", "2026", "188"] + ], + new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }), + ["videos"] = new( + "videos", + "视频解析导入模板", + "用于导入视频解析元数据并绑定到题目。", + [ + Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"), + Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"), + Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"), + Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001") + ], + [ + ["title", "videoUrl", "legacyQuestionId", "videoType"], + ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"] + ], + new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) + }; +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs new file mode 100644 index 0000000..8117374 --- /dev/null +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs @@ -0,0 +1,439 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) + { + return new QuestionManagementItem( + question.Id, + version?.Id, + question.QuestionBankId, + question.SubjectId, + question.CategoryId, + question.NodeId, + question.EntryId, + question.ContentNodeId, + question.PrimaryCollectionId, + question.LegacyId, + question.Type, + question.TypeLabel, + question.Difficulty, + question.Tags, + version?.Content, + version?.Options ?? JsonDefaults.Array(), + version?.CorrectOptionIndex, + version?.CorrectOptionIndices ?? JsonDefaults.Array(), + version?.AnswerText, + version?.Explanation, + version?.SubQuestions ?? JsonDefaults.Array(), + version?.CodeLang, + version?.CodeTemplate, + question.MediaUrl, + question.HasVideoExplanation, + question.Status); + } + + private static VideoManagementItem ToVideoItem(VideoExplanation item) + { + return new VideoManagementItem( + item.Id, + item.SubjectId, + item.LegacyId, + item.Title, + item.Description, + item.VideoUrl, + item.ThumbnailUrl, + item.DurationSeconds, + item.KnowledgeTags, + item.IsGeneral, + item.Difficulty, + item.SortOrder, + item.IsActive, + item.Metadata); + } + + private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) + { + return new QuestionVideoManagementItem( + item.Id, + item.QuestionId, + item.VideoId, + item.LegacyId, + item.VideoType, + item.SortOrder, + item.Metadata); + } + + private static OperationContentItem ToOperationItem(Banner item) + { + return new OperationContentItem( + item.Id, + "banners", + item.RegionId, + null, + item.LegacyId, + item.Title, + item.Content, + null, + null, + null, + null, + item.SortOrder, + item.IsActive, + JsonSerializer.SerializeToElement(new + { + item.Subtitle, + item.ButtonText, + item.ButtonLink, + item.BackgroundColor, + item.BorderColor + })); + } + + private static OperationContentItem ToOperationItem(Faq item) + { + return new OperationContentItem( + item.Id, + "faqs", + item.RegionId, + null, + item.LegacyId, + null, + null, + item.Question, + item.Answer, + null, + null, + item.SortOrder, + item.IsActive, + JsonDefaults.Object()); + } + + private static OperationContentItem ToOperationItem(Announcement item) + { + return new OperationContentItem( + item.Id, + "announcements", + null, + null, + item.LegacyId, + null, + item.Content, + null, + null, + null, + null, + item.SortOrder, + item.IsActive, + JsonSerializer.SerializeToElement(new + { + item.Link, + item.BackgroundColor + })); + } + + private static OperationContentItem ToOperationItem(ExamDate item) + { + return new OperationContentItem( + item.Id, + "exam-dates", + item.RegionId, + item.SchoolId, + item.LegacyId, + item.ExamName, + item.Description, + null, + null, + item.ExamAt, + item.ExamType, + item.SortOrder, + item.IsActive, + item.Metadata); + } + + private static ContentImportJobItem ToJobItem(ContentImportJob job) + { + return new ContentImportJobItem( + job.Id, + job.TargetRegionId, + job.TargetSubjectId, + job.TargetCategoryId, + job.TargetContentNodeId, + job.TargetQuestionBankId, + job.ImportType, + job.SourceFormat, + job.Status, + job.SourceName, + job.SourceHash, + job.DryRun, + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount, + job.Summary, + job.ErrorMessage, + job.StartedAt, + job.FinishedAt, + job.CreatedAt, + job.UpdatedAt); + } + + private static ContentImportItemModel ToImportItem(ContentImportItem item) + { + return new ContentImportItemModel( + item.Id, + item.JobId, + item.RowNo, + item.ExternalId, + item.Status, + item.TargetType, + item.TargetId, + item.SourcePayload, + item.NormalizedPayload, + item.ContentHash, + item.IssuesCount); + } + + private async Task RequireDataScopeAsync( + DirectContentActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.UserId != actor.UserId || + access.TenantId != actor.TenantId || + !ContentPermissions.Any(access.HasTenantPermission)) + { + throw new ContentManagementException("Tenant content access was denied.", "content_access_denied"); + } + + return access.DataScope; + } + + private static void EnsureRegionWriteAllowed( + CurrentDataScope scope, + DirectContentActor actor, + Guid? currentRegionId, + Guid? targetRegionId, + bool isNew, + string notFoundCode) + { + var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId); + var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId); + if (!canAccessCurrent || !canAccessTarget) + { + throw new ContentManagementException("Content resource was not found.", notFoundCode); + } + } + + private async Task ResolveByIdOrLegacyAsync( + DbSet set, + Guid tenantId, + Guid? id, + string? legacyId, + CancellationToken cancellationToken) + where TEntity : AuditableTenantEntity + { + if (id.HasValue) + { + return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken); + } + + var normalizedLegacyId = Normalize(legacyId); + return normalizedLegacyId is null + ? null + : await set.SingleOrDefaultAsync( + item => item.TenantId == tenantId && EF.Property(item, "LegacyId") == normalizedLegacyId, + cancellationToken); + } + + private async Task AssertReferenceAsync( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set() + .AnyAsync(item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); + if (!exists) + { + throw new ContentManagementException("Referenced entity was not found.", code); + } + } + + private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) + { + var exists = await dbContext.ContentImportJobs.AnyAsync( + item => item.TenantId == tenantId && item.Id == jobId, + cancellationToken); + if (!exists) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + } + + private static string NormalizeOperationKind(string kind) + { + var normalized = Normalize(kind)?.ToLowerInvariant(); + return normalized switch + { + "banner" or "banners" => "banners", + "faq" or "faqs" => "faqs", + "announcement" or "announcements" => "announcements", + "exam-date" or "exam-dates" or "examdates" => "exam-dates", + _ => normalized ?? string.Empty + }; + } + + private static ContentImportType ParseImportType(string value) + { + return value.ToLowerInvariant() switch + { + "questions" => ContentImportType.Questions, + "vocabulary" => ContentImportType.Vocabulary, + "handbook" => ContentImportType.Handbook, + "scoreline" => ContentImportType.Scoreline, + "videos" => ContentImportType.Videos, + _ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid") + }; + } + + private static TEnum Parse(string? value, TEnum fallback, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Enum value is invalid.", code); + } + + private static TEnum? ParseNullable(string? value, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Enum value is invalid.", code); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static int ResolveLimit(int? limit) + { + return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit); + } + + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object(); + } + + private static JsonElement JsonArrayOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array(); + } + + private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) + { + return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) + ? value + : fallback; + } + + private static string? GetString(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); + } + + private static int? GetInt(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) + ? number + : int.TryParse(value.ToString(), out number) + ? number + : null; + } + + private static Guid? GetGuid(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String && + Guid.TryParse(value.GetString(), out var guid) + ? guid + : null; + } + + private static bool? GetBool(JsonElement payload, string name) + { + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs new file mode 100644 index 0000000..f1da10d --- /dev/null +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs @@ -0,0 +1,499 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + private async Task CreateImportJobAsync( + DirectContentActor actor, + SimpleImportCommand command, + bool execute, + CancellationToken cancellationToken) + { + if (!SupportedImportTypes.Contains(command.ImportType)) + { + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + } + + var importType = ParseImportType(command.ImportType); + var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); + var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); + var job = new ContentImportJob + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + TargetRegionId = command.RegionId, + TargetSubjectId = command.SubjectId, + TargetCategoryId = command.CategoryId, + TargetContentNodeId = command.ContentNodeId, + TargetQuestionBankId = command.QuestionBankId, + ImportType = importType, + SourceFormat = sourceFormat, + Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, + SourceName = Normalize(command.SourceName), + DryRun = command.DryRun, + TotalCount = items.Length, + ValidCount = items.Length, + RawPayload = JsonSerializer.SerializeToElement(items), + NormalizedPayload = JsonSerializer.SerializeToElement(items), + StartedAt = execute ? DateTimeOffset.UtcNow : null, + FinishedAt = execute ? DateTimeOffset.UtcNow : null + }; + dbContext.ContentImportJobs.Add(job); + + var importItems = new List(); + var rowNo = 1; + foreach (var payload in items) + { + var importItem = new ContentImportItem + { + TenantId = actor.TenantId, + JobId = job.Id, + RowNo = rowNo++, + ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), + Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, + SourcePayload = payload, + NormalizedPayload = payload + }; + + if (execute) + { + var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); + importItem.TargetType = target.TargetType; + importItem.TargetId = target.TargetId; + job.InsertedCount++; + } + + importItems.Add(importItem); + } + + dbContext.ContentImportItems.AddRange(importItems); + job.Summary = JsonSerializer.SerializeToElement(new + { + mode = execute ? "execute" : "preview", + supportedTypes = SupportedImportTypes, + note = "Synchronous direct migration import skeleton; async worker will be introduced later." + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return new SimpleImportResult( + ToJobItem(job), + importItems.Select(ToImportItem).ToArray(), + []); + } + + private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( + DirectContentActor actor, + SimpleImportCommand command, + JsonElement payload, + CancellationToken cancellationToken) + { + switch (command.ImportType.ToLowerInvariant()) + { + case "questions": + var result = await CreateQuestionAsync(actor, new QuestionWriteCommand( + null, + command.QuestionBankId, + command.SubjectId, + command.CategoryId, + null, + command.EntryId, + command.ContentNodeId, + command.CollectionId, + GetString(payload, "legacyId"), + GetString(payload, "type") ?? "choice", + GetString(payload, "typeLabel"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetString(payload, "content") ?? GetString(payload, "title"), + GetElement(payload, "options", JsonDefaults.Array()), + GetInt(payload, "correctOptionIndex"), + GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), + GetString(payload, "answerText") ?? GetString(payload, "answer"), + GetString(payload, "explanation"), + GetElement(payload, "subQuestions", JsonDefaults.Array()), + GetString(payload, "codeLang"), + GetString(payload, "codeTemplate"), + GetString(payload, "mediaUrl"), + "Published", + GetElement(payload, "examMarkers", JsonDefaults.Object()), + GetString(payload, "sourceHash"), + true), cancellationToken); + return ("question", result.Item.Id); + case "vocabulary": + var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", + GetString(payload, "phonetic"), + GetString(payload, "meaning"), + GetString(payload, "example"), + GetString(payload, "exampleTranslation"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("vocabulary_word", word.Item.Id); + case "handbook": + var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( + null, + null, + command.EntryId, + command.ContentNodeId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", + GetString(payload, "summary"), + GetString(payload, "content"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("handbook_entry", entry.Item.Id); + case "scoreline": + var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand( + null, + command.RegionId, + GetGuid(payload, "schoolId"), + GetGuid(payload, "majorId"), + GetString(payload, "legacyId"), + GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year, + GetString(payload, "schoolName"), + GetString(payload, "majorName"), + GetElement(payload, "fieldValues", payload)), cancellationToken); + return ("scoreline_record", scoreline.Item.Id); + case "videos": + var video = await UpsertVideoAsync(actor, new VideoExplanationCommand( + null, + command.SubjectId, + GetString(payload, "legacyId"), + GetString(payload, "title") ?? "未命名视频", + GetString(payload, "description"), + GetString(payload, "videoUrl") ?? GetString(payload, "url"), + GetString(payload, "thumbnailUrl"), + GetInt(payload, "durationSeconds"), + GetElement(payload, "knowledgeTags", JsonDefaults.Array()), + GetBool(payload, "isGeneral"), + GetInt(payload, "difficulty"), + GetInt(payload, "order"), + true, + GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); + return ("video_explanation", video.Item.Id); + default: + throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); + } + } + + private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = Normalize(command.Title); + item.Subtitle = Normalize(command.Subtitle); + item.Content = Normalize(command.Content); + item.ButtonText = Normalize(command.ButtonText); + item.ButtonLink = Normalize(command.ButtonLink); + item.BackgroundColor = Normalize(command.BackgroundColor); + item.BorderColor = Normalize(command.BorderColor); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Banners.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Question = Normalize(command.Question) ?? Normalize(command.Title); + item.Answer = Normalize(command.Answer) ?? Normalize(command.Content); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Faqs.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.LegacyId = Normalize(command.LegacyId); + item.Content = Normalize(command.Content) ?? Normalize(command.Title); + item.Link = Normalize(command.Link); + item.BackgroundColor = Normalize(command.BackgroundColor); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Announcements.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.LegacyId = Normalize(command.LegacyId); + item.ExamName = command.ExamName.Trim(); + item.ExamAt = command.ExamAt; + item.ExamType = Normalize(command.ExamType); + item.Description = Normalize(command.Description) ?? Normalize(command.Content); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.ExamDates.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return item; + } + + private static void ApplyQuestion(Question question, QuestionWriteCommand command) + { + question.QuestionBankId = command.QuestionBankId; + question.SubjectId = command.SubjectId; + question.CategoryId = command.CategoryId; + question.NodeId = command.NodeId; + question.EntryId = command.EntryId; + question.ContentNodeId = command.ContentNodeId; + question.PrimaryCollectionId = command.PrimaryCollectionId; + question.LegacyId = Normalize(command.LegacyId); + question.Type = Normalize(command.Type) ?? question.Type; + question.TypeLabel = Normalize(command.TypeLabel); + question.Difficulty = command.Difficulty; + question.Tags = JsonArrayOrDefault(command.Tags); + question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers); + question.MediaUrl = Normalize(command.MediaUrl); + question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); + } + + private static void ValidateQuestionForPublication(QuestionWriteCommand command) + { + var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); + if (status != QuestionStatus.Published) + { + return; + } + + var type = Normalize(command.Type) ?? "choice"; + if (!QuestionGrader.HasValidAuthoritativeAnswer( + type, + command.CorrectOptionIndex, + command.CorrectOptionIndices, + command.AnswerText)) + { + throw new ContentManagementException( + "Published questions require a valid authoritative answer.", + "question_grading_rule_invalid"); + } + } + + private static QuestionVersion BuildQuestionVersion( + DirectContentActor actor, + Guid questionId, + int versionNo, + QuestionWriteCommand command) + { + var version = new QuestionVersion + { + TenantId = actor.TenantId, + QuestionId = questionId, + VersionNo = versionNo, + CreatedBy = actor.UserId + }; + ApplyQuestionVersion(version, command); + return version; + } + + private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) + { + version.Content = Normalize(command.Content); + version.Options = JsonArrayOrDefault(command.Options); + version.CorrectOptionIndex = command.CorrectOptionIndex; + version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices); + version.AnswerText = Normalize(command.AnswerText); + version.Explanation = Normalize(command.Explanation); + version.SubQuestions = JsonArrayOrDefault(command.SubQuestions); + version.CodeLang = Normalize(command.CodeLang); + version.CodeTemplate = Normalize(command.CodeTemplate); + version.SourceHash = Normalize(command.SourceHash); + } + + private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken); + } + + private async Task SyncPrimaryCollectionItemAsync( + DirectContentActor actor, + Question question, + CancellationToken cancellationToken) + { + if (!question.PrimaryCollectionId.HasValue) + { + return; + } + + var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.CollectionId == question.PrimaryCollectionId.Value && + item.QuestionId == question.Id, + cancellationToken); + if (existing is null) + { + var reference = await questionReferenceService.ResolveAsync( + actor.TenantId, + actor.UserId, + new QuestionLocator(QuestionSource.Tenant, question.Id), + cancellationToken); + var nextOrder = await dbContext.QuestionCollectionItems + .Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) + .Select(item => (int?)item.SortOrder) + .MaxAsync(cancellationToken) ?? -1; + dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem + { + TenantId = actor.TenantId, + CollectionId = question.PrimaryCollectionId.Value, + QuestionReferenceId = reference.Id, + QuestionOwnerTenantId = reference.QuestionOwnerTenantId, + QuestionId = question.Id, + SortOrder = nextOrder + 1 + }); + } + + var collection = await dbContext.QuestionCollections.SingleAsync( + item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value, + cancellationToken); + collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync( + item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value, + cancellationToken) + (existing is null ? 1 : 0); + collection.UpdatedBy = actor.UserId; + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( + Guid tenantId, + Guid? unitId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!unitId.HasValue) + { + return (entryId, contentNodeId); + } + + var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == unitId.Value, + cancellationToken); + if (unit is null) + { + throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found"); + } + + return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( + Guid tenantId, + Guid? subjectId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!subjectId.HasValue) + { + return (entryId, contentNodeId); + } + + var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == subjectId.Value, + cancellationToken); + if (subject is null) + { + throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found"); + } + + return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); + } + + private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( + Guid tenantId, + Guid? chapterId, + Guid? entryId, + Guid? contentNodeId, + CancellationToken cancellationToken) + { + if (!chapterId.HasValue) + { + return (entryId, contentNodeId); + } + + var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == chapterId.Value, + cancellationToken); + if (chapter is null) + { + throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found"); + } + + return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); + } + + +} diff --git a/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs b/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs new file mode 100644 index 0000000..e3af135 --- /dev/null +++ b/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs @@ -0,0 +1,271 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetHandbookSubjectsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.HandbookSubjects.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + if (filter.MajorId.HasValue) + { + query = query.Where(item => item.MajorId == filter.MajorId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookSubjectAsync( + DirectContentActor actor, + HandbookSubjectCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found"); + item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.MajorId = command.MajorId; + item.EntryId = command.EntryId; + item.ContentNodeId = command.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Type = ParseNullable(command.Type, "handbook_subject_type_invalid"); + item.Icon = Normalize(command.Icon); + item.Color = Normalize(command.Color); + item.Description = Normalize(command.Description); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookSubjects.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetHandbookChaptersAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.SubjectId.HasValue) + { + query = query.Where(item => item.SubjectId == filter.SubjectId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookChapterAsync( + DirectContentActor actor, + HandbookChapterCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var chapterNavigation = await ResolveHandbookSubjectNavigationAsync( + actor.TenantId, + command.SubjectId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.SubjectId = command.SubjectId; + item.EntryId = chapterNavigation.EntryId; + item.ContentNodeId = chapterNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookChapters.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetHandbookEntriesAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.ChapterId.HasValue) + { + query = query.Where(item => item.ChapterId == filter.ChapterId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Title) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertHandbookEntryAsync( + DirectContentActor actor, + HandbookEntryCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var entryNavigation = await ResolveHandbookChapterNavigationAsync( + actor.TenantId, + command.ChapterId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.ChapterId = command.ChapterId; + item.EntryId = entryNavigation.EntryId; + item.ContentNodeId = entryNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = command.Title.Trim(); + item.Summary = Normalize(command.Summary); + item.Content = Normalize(command.Content); + item.Tags = JsonArrayOrDefault(command.Tags); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.HandbookEntries.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + +} diff --git a/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs b/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs new file mode 100644 index 0000000..377734a --- /dev/null +++ b/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public Task PreviewImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken); + } + + public Task ExecuteImportAsync( + DirectContentActor actor, + SimpleImportCommand command, + CancellationToken cancellationToken = default) + { + return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken); + } + + public async Task GetImportJobAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) + .Select(item => ToJobItem(item)) + .SingleOrDefaultAsync(cancellationToken); + if (job is null) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + + var items = await dbContext.ContentImportItems.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .Take(MaxLimit) + .Select(item => ToImportItem(item)) + .ToArrayAsync(cancellationToken); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) + .OrderBy(issue => issue.RowNo) + .ThenBy(issue => issue.CreatedAt) + .Take(MaxLimit) + .Select(issue => new ContentImportIssueModel( + issue.Id, + issue.JobId, + issue.ItemId, + issue.RowNo, + issue.Severity, + issue.Code, + issue.FieldPath, + issue.Message, + issue.Details)) + .ToArrayAsync(cancellationToken); + + return new ContentImportJobDetail(job, items, issues); + } + + public async Task> GetImportIssuesAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) + .OrderBy(item => item.RowNo) + .ThenBy(item => item.CreatedAt) + .Take(MaxLimit) + .Select(item => new ContentImportIssueModel( + item.Id, + item.JobId, + item.ItemId, + item.RowNo, + item.Severity, + item.Code, + item.FieldPath, + item.Message, + item.Details)) + .ToArrayAsync(cancellationToken); + return new CatalogList(issues); + } + + public async Task RunImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + job.Summary = JsonSerializer.SerializeToElement(new + { + postCheck = new + { + status = job.ErrorCount == 0 ? "passed" : "warning", + checkedAt = DateTimeOffset.UtcNow, + counts + } + }); + await dbContext.SaveChangesAsync(cancellationToken); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); + } + + public async Task GetImportPostCheckAsync( + DirectContentActor actor, + Guid jobId, + CancellationToken cancellationToken = default) + { + var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == jobId, + cancellationToken); + if (job is null) + { + throw new ContentManagementException("Import job was not found.", "import_job_not_found"); + } + + var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); + var counts = JsonSerializer.SerializeToElement(new + { + job.TotalCount, + job.ValidCount, + job.ErrorCount, + job.WarningCount, + job.InsertedCount, + job.UpdatedCount, + job.SkippedCount + }); + return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); + } + + +} diff --git a/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs b/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs new file mode 100644 index 0000000..b111960 --- /dev/null +++ b/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs @@ -0,0 +1,173 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class ContentManagementService +{ + public async Task> GetNodesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + if (!filter.EntryId.HasValue) + { + throw new ContentManagementException("entryId is required.", "entry_id_required"); + } + + await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ContentNodes + .AsNoTracking() + .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) + .ApplyDataScope( + scope, + node => node.CreatedBy == actor.UserId, + node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)); + + if (!filter.IncludeInactive) + { + query = query.Where(node => node.IsActive); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(node => node.RegionId == filter.RegionId.Value); + } + + if (filter.ParentId is not null) + { + if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(node => node.ParentId == null); + } + else if (Guid.TryParse(filter.ParentId, out var parentId)) + { + query = query.Where(node => node.ParentId == parentId); + } + } + + if (TryParse(filter.MarkerType, out ContentMarkerType markerType)) + { + query = query.Where(node => node.MarkerType == markerType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(node => + node.Name.Contains(keyword) || + (node.NodeKey != null && node.NodeKey.Contains(keyword))); + } + + query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase) + ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder) + : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt); + + var items = await query + .Take(ResolveLimit(filter.Limit)) + .Select(node => ToNodeItem(node)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertNodeAsync( + ContentManagementActor actor, + UpsertContentNodeCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken); + + var nodeKey = Normalize(command.NodeKey) ?? + Normalize(command.Id?.ToString("N")) ?? + Guid.NewGuid().ToString("N"); + var node = await ResolveEntityAsync( + dbContext.ContentNodes, + actor.TenantId, + command.Id, + item => item.EntryId == command.EntryId && item.NodeKey == nodeKey, + cancellationToken); + + var isNew = node is null; + if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + + if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + + if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Content node was not found.", "node_not_found"); + } + + node ??= new ContentNode + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + EntryId = command.EntryId, + NodeKey = nodeKey, + CreatedBy = actor.UserId + }; + + var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken); + node.EntryId = command.EntryId; + node.RegionId = command.RegionId; + node.ParentId = command.ParentId; + node.LegacyId = Normalize(command.LegacyId); + node.Name = command.Name.Trim(); + node.NodeType = Parse(command.NodeType, ContentNodeType.Category, "node_type_invalid"); + node.MarkerType = ParseNullable(command.MarkerType, "marker_type_invalid"); + node.MarkerConfig = JsonObjectOrDefault(command.MarkerConfig); + node.Path = path.Path; + node.Depth = path.Depth; + node.SortOrder = command.Order ?? 0; + node.IsActive = command.IsActive ?? true; + node.IsSelectable = command.IsSelectable ?? true; + node.IsLeaf = command.IsLeaf ?? false; + node.AccessRules = JsonObjectOrDefault(command.AccessRules); + node.Metadata = JsonObjectOrDefault(command.Metadata); + node.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.ContentNodes.Add(node); + } + + if (command.ParentId.HasValue) + { + var parent = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value, + cancellationToken); + if (parent is not null) + { + parent.IsLeaf = false; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToNodeItem(node)); + } + + +} diff --git a/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs b/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs new file mode 100644 index 0000000..0172276 --- /dev/null +++ b/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetOperationContentAsync( + DirectContentActor actor, + string kind, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var items = NormalizeOperationKind(kind) switch + { + "banners" => (await dbContext.Banners.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "faqs" => (await dbContext.Faqs.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "announcements" => (await dbContext.Announcements.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + "exam-dates" => (await dbContext.ExamDates.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) + .Where(item => !filter.SchoolId.HasValue || item.SchoolId == filter.SchoolId.Value) + .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) + .OrderBy(item => item.ExamAt == null) + .ThenBy(item => item.ExamAt) + .ThenBy(item => item.SortOrder) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), + _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + }; + + return new CatalogList(items); + } + + public async Task> UpsertOperationContentAsync( + DirectContentActor actor, + string kind, + OperationContentCommand command, + CancellationToken cancellationToken = default) + { + OperationContentItem item = NormalizeOperationKind(kind) switch + { + "banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)), + "faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)), + "announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)), + "exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)), + _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + }; + + return new ContentManagementResult(item); + } + + +} diff --git a/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs b/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs new file mode 100644 index 0000000..73df18e --- /dev/null +++ b/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs @@ -0,0 +1,185 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class ContentManagementService +{ + public async Task> GetPracticeBlueprintsAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.PracticeBlueprints + .AsNoTracking() + .Where(blueprint => blueprint.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + blueprint => blueprint.CreatedBy == actor.UserId, + blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); + + if (!filter.IncludeInactive) + { + query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); + } + + if (filter.NodeId.HasValue) + { + query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); + } + + if (filter.CollectionId.HasValue) + { + query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); + } + + if (TryParse(filter.Mode, out PracticeMode mode)) + { + query = query.Where(blueprint => blueprint.Mode == mode); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(blueprint => blueprint.Name.Contains(keyword)); + } + + var items = await query + .OrderBy(blueprint => blueprint.SortOrder) + .ThenBy(blueprint => blueprint.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(blueprint => ToBlueprintItem(blueprint)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertPracticeBlueprintAsync( + ContentManagementActor actor, + UpsertPracticeBlueprintCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); + await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); + + var blueprint = await ResolveEntityByIdOrLegacyAsync( + dbContext.PracticeBlueprints, + actor.TenantId, + command.Id, + command.LegacyId, + cancellationToken); + + var isNew = blueprint is null; + if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + + if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + + if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) + { + throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); + } + + blueprint ??= new PracticeBlueprint + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId + }; + + blueprint.RegionId = command.RegionId; + blueprint.EntryId = command.EntryId; + blueprint.NodeId = command.NodeId; + blueprint.CollectionId = command.CollectionId; + blueprint.LegacyId = Normalize(command.LegacyId); + blueprint.Name = command.Name.Trim(); + blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid"); + blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid"); + blueprint.QuestionLimit = command.QuestionLimit; + blueprint.DurationMinutes = command.DurationMinutes; + blueprint.TotalScore = command.TotalScore; + blueprint.PassScore = command.PassScore; + blueprint.Sections = JsonArrayOrDefault(command.Sections); + blueprint.Rules = JsonObjectOrDefault(command.Rules); + blueprint.AccessRules = JsonObjectOrDefault(command.AccessRules); + blueprint.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); + blueprint.SortOrder = command.Order ?? 0; + blueprint.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.PracticeBlueprints.Add(blueprint); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBlueprintItem(blueprint)); + } + + public ImportFieldMappingItem GetImportFieldMapping(string importType) + { + var spec = ResolveImportSpec(importType); + return new ImportFieldMappingItem( + spec.ImportType, + spec.Title, + spec.Description, + spec.Fields, + spec.Fields.Where(field => field.Required).Select(field => field.Field).ToArray()); + } + + public ImportTemplateItem GetImportTemplate(string importType, string? format) + { + var spec = ResolveImportSpec(importType); + var normalizedFormat = string.IsNullOrWhiteSpace(format) ? "json" : format.Trim().ToLowerInvariant(); + var content = normalizedFormat switch + { + "json" => JsonSerializer.Serialize(spec.JsonExample, new JsonSerializerOptions { WriteIndented = true }), + "csv" => string.Join( + "\n", + spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))), + _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid") + }; + + return new ImportTemplateItem( + spec.ImportType, + normalizedFormat, + $"{spec.ImportType}-import-template.{normalizedFormat}", + normalizedFormat == "csv" ? "text/csv" : "application/json", + Convert.ToBase64String(Encoding.UTF8.GetBytes(content)), + content, + spec.Fields); + } + + +} diff --git a/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs b/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs new file mode 100644 index 0000000..9af2f44 --- /dev/null +++ b/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> CreateQuestionAsync( + DirectContentActor actor, + QuestionWriteCommand command, + CancellationToken cancellationToken = default) + { + ValidateQuestionForPublication(command); + await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + + var question = new Question + { + Id = command.QuestionId ?? Guid.NewGuid(), + TenantId = actor.TenantId + }; + ApplyQuestion(question, command); + if (question.Status != QuestionStatus.Archived) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + actor.TenantId, + SaasQuotaMetricCatalog.PrivateQuestionCount, + cancellationToken: cancellationToken); + } + dbContext.Questions.Add(question); + await dbContext.SaveChangesAsync(cancellationToken); + + var version = BuildQuestionVersion(actor, question.Id, 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new ContentManagementResult(ToQuestionItem(question, version)); + } + + public async Task> UpdateQuestionAsync( + DirectContentActor actor, + QuestionWriteCommand command, + CancellationToken cancellationToken = default) + { + if (!command.QuestionId.HasValue) + { + throw new ContentManagementException("questionId is required.", "question_id_required"); + } + + ValidateQuestionForPublication(command); + + var question = await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, + cancellationToken); + if (question is null) + { + throw new ContentManagementException("Question was not found.", "question_not_found"); + } + + await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var wasCounted = question.Status != QuestionStatus.Archived; + ApplyQuestion(question, command); + var isCounted = question.Status != QuestionStatus.Archived; + if (!wasCounted && isCounted) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + actor.TenantId, + SaasQuotaMetricCatalog.PrivateQuestionCount, + cancellationToken: cancellationToken); + } + QuestionVersion? version; + if (command.CreateVersion || !question.CurrentVersionId.HasValue) + { + var nextVersionNo = await dbContext.QuestionVersions + .Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo) + .MaxAsync(cancellationToken) ?? 0; + version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + } + else + { + version = await dbContext.QuestionVersions.SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.QuestionId == question.Id && + item.Id == question.CurrentVersionId.Value, + cancellationToken); + if (version is null) + { + version = BuildQuestionVersion(actor, question.Id, 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + } + else + { + ApplyQuestionVersion(version, command); + } + } + + await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + if (wasCounted && !isCounted) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + SaasQuotaMetricCatalog.PrivateQuestionCount, + 1, + cancellationToken); + } + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new ContentManagementResult(ToQuestionItem(question, version)); + } + + +} diff --git a/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs b/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs new file mode 100644 index 0000000..a5342d7 --- /dev/null +++ b/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetScorelineFieldsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ScorelineFields.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword)); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.FieldName) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertScorelineFieldAsync( + DirectContentActor actor, + ScorelineFieldCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); + ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); + if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) + { + throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid"); + } + + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found"); + item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.FieldKey = command.FieldKey.Trim(); + item.FieldName = command.FieldName.Trim(); + item.FieldType = Normalize(command.FieldType) ?? "text"; + item.Unit = Normalize(command.Unit); + item.IsFilter = command.IsFilter ?? item.IsFilter; + item.IsRequired = command.IsRequired ?? item.IsRequired; + item.IsVisible = command.IsVisible ?? item.IsVisible; + item.IsTrend = command.IsTrend ?? item.IsTrend; + item.Options = JsonArrayOrDefault(command.Options); + item.Placeholder = Normalize(command.Placeholder); + item.Description = Normalize(command.Description); + item.SortOrder = command.Order ?? item.SortOrder; + if (isNew) + { + dbContext.ScorelineFields.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetScorelineRecordsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ScorelineRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + if (filter.MajorId.HasValue) + { + query = query.Where(item => item.MajorId == filter.MajorId.Value); + } + + if (filter.Year.HasValue) + { + query = query.Where(item => item.Year == filter.Year.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => + (item.SchoolName != null && item.SchoolName.Contains(keyword)) || + (item.MajorName != null && item.MajorName.Contains(keyword))); + } + + return new CatalogList(await query + .OrderByDescending(item => item.Year) + .ThenBy(item => item.SchoolName) + .ThenBy(item => item.MajorName) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertScorelineRecordAsync( + DirectContentActor actor, + ScorelineRecordCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + if (command.Year is < 1900 or > 3000) + { + throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); + } + + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found"); + item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.SchoolId = command.SchoolId; + item.MajorId = command.MajorId; + item.LegacyId = Normalize(command.LegacyId); + item.Year = command.Year; + item.SchoolName = Normalize(command.SchoolName); + item.MajorName = Normalize(command.MajorName); + item.FieldValues = JsonObjectOrDefault(command.FieldValues); + if (isNew) + { + dbContext.ScorelineRecords.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetScorelineYearsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.ScorelineRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.SchoolId.HasValue) + { + query = query.Where(item => item.SchoolId == filter.SchoolId.Value); + } + + var years = await query + .Select(item => item.Year) + .Distinct() + .OrderByDescending(year => year) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + return new CatalogList(years); + } + + public async Task> GetScorelineTrendAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var years = await GetScorelineYearsAsync(actor, filter, cancellationToken); + var items = new List(); + foreach (var year in years.Items) + { + var schoolCount = await dbContext.ScorelineRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Year == year) + .Select(item => item.SchoolId) + .Where(id => id.HasValue) + .Distinct() + .CountAsync(cancellationToken); + var majorCount = await dbContext.ScorelineRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Year == year) + .Select(item => item.MajorId) + .Where(id => id.HasValue) + .Distinct() + .CountAsync(cancellationToken); + items.Add(new ScorelineTrendItem(year, schoolCount, majorCount)); + } + + return new CatalogList(items); + } + + +} diff --git a/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs b/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs new file mode 100644 index 0000000..152929e --- /dev/null +++ b/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetVideosAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.SubjectId.HasValue) + { + query = query.Where(item => item.SubjectId == filter.SubjectId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToVideoItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertVideoAsync( + DirectContentActor actor, + VideoExplanationCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.SubjectId = command.SubjectId; + item.LegacyId = Normalize(command.LegacyId); + item.Title = command.Title.Trim(); + item.Description = Normalize(command.Description); + item.VideoUrl = Normalize(command.VideoUrl); + item.ThumbnailUrl = Normalize(command.ThumbnailUrl); + item.DurationSeconds = command.DurationSeconds; + item.KnowledgeTags = JsonArrayOrDefault(command.KnowledgeTags); + item.IsGeneral = command.IsGeneral ?? item.IsGeneral; + item.Difficulty = command.Difficulty; + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VideoExplanations.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToVideoItem(item)); + } + + public async Task> BindQuestionVideoAsync( + DirectContentActor actor, + QuestionVideoCommand command, + CancellationToken cancellationToken = default) + { + await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); + var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( + link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, + cancellationToken); + var isNew = item is null; + item ??= new QuestionVideo { TenantId = actor.TenantId }; + item.QuestionId = command.QuestionId; + item.VideoId = command.VideoId; + item.LegacyId = Normalize(command.LegacyId); + item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid"); + item.SortOrder = command.Order ?? item.SortOrder; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.QuestionVideos.Add(item); + } + + var question = await dbContext.Questions.SingleAsync( + question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, + cancellationToken); + question.HasVideoExplanation = true; + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToQuestionVideoItem(item)); + } + + +} diff --git a/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs b/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs new file mode 100644 index 0000000..1992656 --- /dev/null +++ b/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Content; + +public sealed partial class DirectContentService +{ + public async Task> GetVocabularyUnitsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.VocabularyUnits.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertVocabularyUnitAsync( + DirectContentActor actor, + VocabularyUnitCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found"); + item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.RegionId = command.RegionId; + item.EntryId = command.EntryId; + item.ContentNodeId = command.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.WordCount = command.WordCount; + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VocabularyUnits.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task> GetVocabularyWordsAsync( + DirectContentActor actor, + AdminLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => item.UnitId == filter.UnitId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(item => item.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.IsActive); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); + } + + return new CatalogList(await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Word) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken)); + } + + public async Task> UpsertVocabularyWordAsync( + DirectContentActor actor, + VocabularyWordCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Word); + await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + var vocabularyNavigation = await ResolveVocabularyNavigationAsync( + actor.TenantId, + command.UnitId, + command.EntryId, + command.ContentNodeId, + cancellationToken); + item.UnitId = command.UnitId; + item.EntryId = vocabularyNavigation.EntryId; + item.ContentNodeId = vocabularyNavigation.ContentNodeId; + item.LegacyId = Normalize(command.LegacyId); + item.Word = command.Word.Trim(); + item.Phonetic = Normalize(command.Phonetic); + item.Meaning = Normalize(command.Meaning); + item.Example = Normalize(command.Example); + item.ExampleTranslation = Normalize(command.ExampleTranslation); + item.Difficulty = command.Difficulty; + item.Tags = JsonArrayOrDefault(command.Tags); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + item.Metadata = JsonObjectOrDefault(command.Metadata); + if (isNew) + { + dbContext.VocabularyWords.Add(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 8bba258..4b80f38 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -2,49 +2,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Npgsql; -using Tiku.Application.Assets; -using Tiku.Application.Auth; -using Tiku.Application.Backoffice; -using Tiku.Application.Catalog; -using Tiku.Application.Commerce; -using Tiku.Application.Content; -using Tiku.Application.Growth; -using Tiku.Application.Jobs; -using Tiku.Application.Learning; -using Tiku.Application.Notifications; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Profile; -using Tiku.Application.Points; -using Tiku.Application.PlatformAdmin; -using Tiku.Application.PlatformBilling; -using Tiku.Application.Scoreline; -using Tiku.Application.Storage; -using Tiku.Application.StudyContent; -using Tiku.Application.TenantAdmin; using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Auth; -using Tiku.Infrastructure.Backoffice; -using Tiku.Infrastructure.Catalog; -using Tiku.Infrastructure.Commerce; -using Tiku.Infrastructure.Content; -using Tiku.Infrastructure.Growth; -using Tiku.Infrastructure.Jobs; -using Tiku.Infrastructure.Learning; -using Tiku.Infrastructure.Notifications; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Profile; -using Tiku.Infrastructure.Points; -using Tiku.Infrastructure.PlatformAdmin; -using Tiku.Infrastructure.PlatformBilling; -using Tiku.Infrastructure.QuestionBanks; -using Tiku.Infrastructure.Scoreline; using Tiku.Infrastructure.Security; -using Tiku.Infrastructure.Storage; -using Tiku.Infrastructure.StudyContent; -using Tiku.Infrastructure.TenantAdmin; -using Tiku.Infrastructure.Tenancy; using Tiku.Domain.Identity; using StackExchange.Redis; using Tiku.Infrastructure.Observability; @@ -89,109 +50,14 @@ public static class DependencyInjection .AddDefaultTokenProviders() .AddPasswordValidator>(); services.Configure(options => options.IterationCount = 210_000); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddMemoryCache(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddHttpClient(); - services.AddHttpClient(); - services.AddScoped(); - services.AddOptions(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddOptions(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddOptions(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddHttpClient(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddOptions(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddScoped(); - services.AddScoped(); - services.AddOptions(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddOptions() - .Validate( - AliyunOssOptions.BeValid, - "Aliyun OSS presign default expiration must be positive."); - services.AddOptions() - .Validate( - ObjectStorageOptions.BeValid, - "Storage options must include a default provider, default bucket and positive max upload bytes."); - services.AddSingleton(); + services.AddPlatformCoreModule(); + services.AddAuthModule(); + services.AddContentModule(); + services.AddLearningModule(); + services.AddTenantAdminModule(); + services.AddCommerceModule(); + services.AddJobsModule(); + services.AddPlatformModule(); return services; } diff --git a/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs b/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs new file mode 100644 index 0000000..a3af549 --- /dev/null +++ b/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Growth; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Growth; + +public sealed partial class ReferralService +{ + public async Task GetStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var referrerUserId = query.ReferrerUserId ?? actor.UserId; + await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); + return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken); + } + + public async Task> GetSalesStatsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var limit = Math.Clamp(query.Limit ?? 100, 1, 500); + var referrerIdsQuery = dbContext.ReferralLeads + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); + if (query.ReferrerUserId.HasValue) + { + referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); + } + + var referrerIds = await referrerIdsQuery + .Select(item => item.ReferrerUserId!.Value) + .Distinct() + .Take(limit) + .ToArrayAsync(cancellationToken); + var stats = new List(referrerIds.Length); + foreach (var referrerId in referrerIds) + { + stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); + } + + return new ReferralList( + stats + .OrderByDescending(item => item.PaidAmountCents) + .ThenByDescending(item => item.LeadCount) + .Take(limit) + .ToArray()); + } + + public async Task GetConversionReportAsync( + ReferralAdminActor actor, + ReferralConversionQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var endDate = query.EndDate ?? today; + var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); + if (endDate < startDate) + { + throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); + } + + var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var leadsQuery = dbContext.ReferralLeads + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.BoundAt >= start && + item.BoundAt < endExclusive); + if (query.ReferrerUserId.HasValue) + { + leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); + } + + var leads = await leadsQuery + .OrderByDescending(item => item.BoundAt) + .ToArrayAsync(cancellationToken); + var paidStudentIds = await dbContext.Orders.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.Status == OrderStatus.Paid && + item.UserId != null && + item.PaidAt != null && + item.PaidAt >= start && + item.PaidAt < endExclusive) + .GroupBy(item => item.UserId!.Value) + .Select(group => new + { + UserId = group.Key, + AmountCents = group.Sum(item => item.AmountCents) + }) + .ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken); + var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId)); + var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId)); + var top = await GetSalesStatsAsync( + actor, + new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20), + cancellationToken); + + return new ReferralConversionReport( + startDate, + endDate, + leads.Length, + paidLeadCount, + paidAmountCents, + leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2), + top.Items, + leads + .Where(item => !paidStudentIds.ContainsKey(item.StudentUserId)) + .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) + .Select(item => ToLeadItem(item, false)) + .ToArray()); + } + + public async Task> GetClientsAsync( + ReferralAdminActor actor, + ReferralStatsQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var referrerUserId = query.ReferrerUserId ?? actor.UserId; + await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); + var leads = await dbContext.ReferralLeads + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.ReferrerUserId == referrerUserId) + .OrderByDescending(item => item.BoundAt) + .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) + .ToArrayAsync(cancellationToken); + return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); + } + + +} diff --git a/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs b/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs new file mode 100644 index 0000000..3c23d87 --- /dev/null +++ b/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs @@ -0,0 +1,359 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Growth; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Growth; + +public sealed partial class ReferralService +{ + private async Task BindLeadCoreAsync( + Guid tenantId, + Guid studentUserId, + Guid referrerUserId, + string refCode, + string source, + ReferralLeadBindType bindType, + bool force, + JsonElement? metadata, + CancellationToken cancellationToken) + { + await AssertActiveMemberAsync(tenantId, studentUserId, cancellationToken); + await AssertActiveMemberAsync(tenantId, referrerUserId, cancellationToken); + var now = DateTimeOffset.UtcNow; + var lead = await dbContext.ReferralLeads + .FirstOrDefaultAsync(item => + item.TenantId == tenantId && + item.StudentUserId == studentUserId, + cancellationToken); + if (lead is not null) + { + if (lead.ReferrerUserId == referrerUserId) + { + return lead; + } + + if (!force && lead.Status == ReferralLeadStatus.Protected && (lead.ProtectedUntil is null || lead.ProtectedUntil > now)) + { + throw new ReferralException("Referral lead is protected and cannot be rebound.", "referral_lead_protected"); + } + } + else + { + lead = new ReferralLead + { + TenantId = tenantId, + StudentUserId = studentUserId + }; + dbContext.ReferralLeads.Add(lead); + } + + lead.ReferrerUserId = referrerUserId; + lead.RefCode = refCode; + lead.Source = source; + lead.BindType = bindType; + lead.Status = ReferralLeadStatus.Protected; + lead.ProtectedUntil = now.AddDays(30); + lead.BoundAt = now; + lead.Metadata = metadata ?? JsonDefaults.Object(); + return lead; + } + + private async Task EnqueueCrmIfEnabledAsync( + Guid tenantId, + ReferralLead lead, + string source, + CancellationToken cancellationToken) + { + var config = await dbContext.CrmConfigs + .AsNoTracking() + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken); + if (config is null || string.IsNullOrWhiteSpace(config.Url)) + { + return null; + } + + var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture); + var idempotencyKey = $"{source}:{recordId}"; + var existing = await dbContext.CrmWebhookQueue + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken); + if (existing is not null) + { + return existing; + } + + var queue = new CrmWebhookQueueItem + { + TenantId = tenantId, + RecordId = recordId, + LeadId = recordId, + Source = source, + Provider = "webhook", + Status = CrmWebhookQueueStatus.Pending, + ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(config.DelaySeconds ?? 0, 0)), + IdempotencyKey = idempotencyKey, + TargetUrl = config.Url, + Payload = JsonSerializer.SerializeToElement(new + { + tenantId, + leadId = lead.Id, + lead.StudentUserId, + lead.ReferrerUserId, + lead.RefCode, + lead.Source, + lead.BoundAt, + config.FormName, + config.ExamType + }) + }; + dbContext.CrmWebhookQueue.Add(queue); + return queue; + } + + private async Task ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken) + { + return await dbContext.ReferralCodes + .AsNoTracking() + .FirstOrDefaultAsync(item => + item.TenantId == tenantId && + item.Code == code && + item.Status == ReferralCodeStatus.Active, + cancellationToken); + } + + private async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) + { + var exists = await dbContext.TenantMemberships.AnyAsync( + item => + item.TenantId == tenantId && + item.UserId == userId && + item.Status == MembershipStatus.Active, + cancellationToken); + if (!exists) + { + throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); + } + } + + private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || + access.TenantId != actor.TenantId || + access.UserId != actor.UserId || + !access.HasTenantPermission(BackendPermissions.TenantCrmManage)) + { + throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); + } + } + + private async Task BuildStatsAsync( + Guid tenantId, + Guid referrerUserId, + CancellationToken cancellationToken) + { + var user = await dbContext.Users.AsNoTracking() + .Where(item => item.Id == referrerUserId) + .Select(item => new { item.Name, item.UserName, item.Phone }) + .FirstOrDefaultAsync(cancellationToken); + var membership = await dbContext.TenantMemberships.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) + .Select(item => item.Role) + .FirstOrDefaultAsync(cancellationToken); + var inviteCode = await dbContext.ReferralCodes.AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.UserId == referrerUserId && + item.Status == ReferralCodeStatus.Active) + .Select(item => item.Code) + .FirstOrDefaultAsync(cancellationToken); + var leads = await dbContext.ReferralLeads.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId) + .Select(item => item.StudentUserId) + .ToArrayAsync(cancellationToken); + var paid = leads.Length == 0 + ? [] + : await dbContext.Orders.AsNoTracking() + .Where(item => + item.TenantId == tenantId && + item.Status == OrderStatus.Paid && + item.UserId != null && + leads.Contains(item.UserId.Value)) + .GroupBy(item => item.UserId!.Value) + .Select(group => new + { + UserId = group.Key, + AmountCents = group.Sum(item => item.AmountCents) + }) + .ToArrayAsync(cancellationToken); + var trackCount = await dbContext.ReferralTracks.AsNoTracking() + .CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken); + + return new ReferralStatsItem( + referrerUserId, + FirstNonBlank(user?.Name, user?.UserName, user?.Phone), + membership.ToString(), + inviteCode, + leads.Length, + paid.Length, + paid.Sum(item => item.AmountCents), + trackCount, + leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2)); + } + + private async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 20; attempt++) + { + var code = GenerateCode(); + var exists = await dbContext.ReferralCodes.AnyAsync( + item => item.TenantId == tenantId && item.Code == code, + cancellationToken); + if (!exists) + { + return code; + } + } + + throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed"); + } + + private static string GenerateCode() + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + Span bytes = stackalloc byte[8]; + RandomNumberGenerator.Fill(bytes); + Span chars = stackalloc char[8]; + for (var index = 0; index < chars.Length; index++) + { + chars[index] = alphabet[bytes[index] % alphabet.Length]; + } + + return new string(chars); + } + + private static Guid RequireUser(ReferralActor actor) + { + return actor.UserId ?? throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); + } + + private static string? NormalizeCode(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToUpperInvariant(); + } + + private static string NormalizeChoice( + string? value, + HashSet allowed, + string defaultValue, + string errorCode) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + var normalized = value.Trim().ToLowerInvariant(); + return allowed.Contains(normalized) + ? normalized + : throw new ReferralException("Referral value was invalid.", errorCode); + } + + private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) + where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); + foreach (var enumValue in Enum.GetValues()) + { + if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + { + return enumValue; + } + } + + throw new ReferralException("Referral enum value was invalid.", errorCode); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string? Truncate(string? value, int maxLength) + { + return value is null || value.Length <= maxLength ? value : value[..maxLength]; + } + + private static string? FirstNonBlank(params string?[] values) + { + return values.Select(NormalizeOptional).FirstOrDefault(value => value is not null); + } + + private static ReferralTrackItem ToTrackItem(ReferralTrack item) + { + return new ReferralTrackItem( + item.Id, + item.EventType, + item.RefCode, + item.ReferrerUserId, + item.TargetUserId, + item.Source, + item.CreatedAt); + } + + private static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed) + { + return new ReferralLeadItem( + item.Id, + item.StudentUserId, + item.ReferrerUserId, + item.RefCode, + item.Status.ToString(), + item.BoundAt, + changed); + } + + private static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item) + { + return new ReferralQrcodeItem( + item.Id, + item.RefCode, + item.Scene, + item.Page, + item.Provider, + item.QrcodeUrl, + item.Status.ToString(), + item.Metadata); + } + + private static CrmQueuePreviewItem? ToQueuePreview(CrmWebhookQueueItem? item) + { + return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source); + } + + private static ReferralTeamItem ToTeamItem(ReferralTeamEdge item) + { + return new ReferralTeamItem( + item.Id, + item.MemberUserId, + item.LeaderUserId, + item.RelationType.ToString(), + item.Status.ToString(), + item.Metadata); + } +} diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs index 10d0a2a..c2518a2 100644 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; -public sealed class ReferralService( +public sealed partial class ReferralService( TikuDbContext dbContext, IReferralQrcodeGenerator qrcodeGenerator, ICurrentAccessContext currentAccessContext) : IReferralService @@ -39,833 +39,5 @@ public sealed class ReferralService( "unknown" }; - public async Task GetOrCreateInviteCodeAsync( - ReferralActor actor, - ReferralInviteCommand command, - CancellationToken cancellationToken = default) - { - var userId = RequireUser(actor); - await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); - var existing = await dbContext.ReferralCodes - .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == userId, - cancellationToken); - if (existing is not null) - { - if (!string.IsNullOrWhiteSpace(command.Channel)) - { - existing.Channel = command.Channel.Trim(); - } - - if (!string.IsNullOrWhiteSpace(command.LandingPath)) - { - existing.LandingPath = command.LandingPath.Trim(); - } - - existing.Status = ReferralCodeStatus.Active; - await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralInviteItem(existing.Code); - } - - var code = await GenerateUniqueCodeAsync(actor.TenantId, cancellationToken); - var referralCode = new ReferralCode - { - TenantId = actor.TenantId, - UserId = userId, - Code = code, - Channel = NormalizeOptional(command.Channel), - LandingPath = NormalizeOptional(command.LandingPath), - Metadata = JsonSerializer.SerializeToElement(new { source = "referral_invite_code" }) - }; - dbContext.ReferralCodes.Add(referralCode); - await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralInviteItem(code); - } - - public async Task ResolveAsync( - ReferralActor actor, - ResolveReferralCommand command, - CancellationToken cancellationToken = default) - { - var code = NormalizeCode(command.Code); - if (code is null) - { - return new ReferralResolutionItem(false, null, null, null, null); - } - - var row = await ( - from referralCode in dbContext.ReferralCodes.AsNoTracking() - join membership in dbContext.TenantMemberships.AsNoTracking() - on new { referralCode.TenantId, referralCode.UserId } equals new { membership.TenantId, membership.UserId } - join user in dbContext.Users.AsNoTracking() - on referralCode.UserId equals user.Id - where referralCode.TenantId == actor.TenantId && - referralCode.Code == code && - referralCode.Status == ReferralCodeStatus.Active && - membership.Status == MembershipStatus.Active - select new - { - referralCode.Code, - referralCode.UserId, - membership.Role, - user.Name, - user.UserName, - user.Phone - }) - .FirstOrDefaultAsync(cancellationToken); - - return row is null - ? new ReferralResolutionItem(false, null, null, null, null) - : new ReferralResolutionItem( - true, - row.UserId, - row.Code, - row.Role.ToString(), - FirstNonBlank(row.Name, row.UserName, row.Phone)); - } - - public async Task TrackEventAsync( - ReferralActor actor, - TrackReferralEventCommand command, - string? ipAddress, - string? userAgent, - CancellationToken cancellationToken = default) - { - var code = NormalizeCode(command.RefCode) - ?? throw new ReferralException("Referral code is required.", "referral_code_required"); - var eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type"); - var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); - var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - var track = new ReferralTrack - { - TenantId = actor.TenantId, - ReferrerUserId = resolution?.UserId, - TargetUserId = actor.UserId ?? command.TargetUserId, - EventType = eventType, - RefCode = code, - Source = source, - IpAddress = NormalizeOptional(ipAddress), - UserAgent = Truncate(NormalizeOptional(userAgent), 1024), - Metadata = command.Metadata ?? JsonDefaults.Object() - }; - - ReferralLead? lead = null; - CrmWebhookQueueItem? crmQueue = null; - if (resolution is not null && track.TargetUserId.HasValue && track.TargetUserId.Value != resolution.UserId) - { - lead = await BindLeadCoreAsync( - actor.TenantId, - track.TargetUserId.Value, - resolution.UserId, - code, - source, - ReferralLeadBindType.FirstTouch, - false, - command.Metadata, - cancellationToken); - var setFirstTrack = lead.FirstTrackId is null; - if (setFirstTrack) - { - await dbContext.SaveChangesAsync(cancellationToken); - } - - dbContext.ReferralTracks.Add(track); - track.LeadId = lead.Id; - await dbContext.SaveChangesAsync(cancellationToken); - if (setFirstTrack) - { - lead.FirstTrackId = track.Id; - } - - crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); - } - else - { - dbContext.ReferralTracks.Add(track); - } - - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue)); - } - - public async Task BindAsync( - ReferralActor actor, - BindReferralCommand command, - CancellationToken cancellationToken = default) - { - var userId = RequireUser(actor); - await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); - var code = NormalizeCode(command.RefCode) - ?? throw new ReferralException("Referral code is required.", "referral_code_required"); - var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); - var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken) - ?? throw new ReferralException("Referral code was not found.", "referral_code_not_found"); - if (resolution.UserId == userId) - { - throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - } - - var existing = await dbContext.ReferralLeads - .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == userId, - cancellationToken); - var beforeReferrerId = existing?.ReferrerUserId; - var lead = await BindLeadCoreAsync( - actor.TenantId, - userId, - resolution.UserId, - code, - source, - ReferralLeadBindType.FirstTouch, - false, - command.Metadata, - cancellationToken); - var crmQueue = beforeReferrerId == lead.ReferrerUserId - ? null - : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); - } - - public async Task GetOrCreateQrcodeAsync( - ReferralActor actor, - ReferralQrcodeCommand command, - CancellationToken cancellationToken = default) - { - var userId = RequireUser(actor); - await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); - var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)).InviteCode; - var page = NormalizeOptional(command.Page) ?? "pages/index/index"; - var provider = NormalizeOptional(command.Provider) ?? "wechat-miniapp"; - var scene = NormalizeOptional(command.Scene) ?? $"ref={refCode}"; - var providedQrcodeUrl = NormalizeOptional(command.QrcodeUrl); - var generated = providedQrcodeUrl is null - ? await qrcodeGenerator.GenerateAsync( - new ReferralQrcodeGenerateRequest(actor.TenantId, userId, refCode, provider, page, scene), - cancellationToken) - : new ReferralQrcodeGenerateResult( - providedQrcodeUrl, - provider, - JsonSerializer.SerializeToElement(new { generatedBy = "external_url" })); - var metadata = command.Metadata ?? generated.Metadata; - - var item = await dbContext.ReferralQrcodes - .FirstOrDefaultAsync(entry => - entry.TenantId == actor.TenantId && - entry.Provider == generated.Provider && - entry.Scene == scene && - entry.Page == page, - cancellationToken); - if (item is null) - { - item = new ReferralQrcode - { - TenantId = actor.TenantId, - UserId = userId, - RefCode = refCode, - Scene = scene, - Page = page, - Provider = generated.Provider - }; - dbContext.ReferralQrcodes.Add(item); - } - - item.UserId = userId; - item.RefCode = refCode; - item.QrcodeUrl = generated.QrcodeUrl; - item.Status = ReferralQrcodeStatus.Ready; - item.ErrorMessage = null; - item.Metadata = metadata; - await dbContext.SaveChangesAsync(cancellationToken); - return ToQrcodeItem(item); - } - - public async Task GetStatsAsync( - ReferralAdminActor actor, - ReferralStatsQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var referrerUserId = query.ReferrerUserId ?? actor.UserId; - await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); - return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken); - } - - public async Task> GetSalesStatsAsync( - ReferralAdminActor actor, - ReferralStatsQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var limit = Math.Clamp(query.Limit ?? 100, 1, 500); - var referrerIdsQuery = dbContext.ReferralLeads - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); - if (query.ReferrerUserId.HasValue) - { - referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); - } - - var referrerIds = await referrerIdsQuery - .Select(item => item.ReferrerUserId!.Value) - .Distinct() - .Take(limit) - .ToArrayAsync(cancellationToken); - var stats = new List(referrerIds.Length); - foreach (var referrerId in referrerIds) - { - stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); - } - - return new ReferralList( - stats - .OrderByDescending(item => item.PaidAmountCents) - .ThenByDescending(item => item.LeadCount) - .Take(limit) - .ToArray()); - } - - public async Task GetConversionReportAsync( - ReferralAdminActor actor, - ReferralConversionQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - var endDate = query.EndDate ?? today; - var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); - if (endDate < startDate) - { - throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); - } - - var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - var leadsQuery = dbContext.ReferralLeads - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.BoundAt >= start && - item.BoundAt < endExclusive); - if (query.ReferrerUserId.HasValue) - { - leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); - } - - var leads = await leadsQuery - .OrderByDescending(item => item.BoundAt) - .ToArrayAsync(cancellationToken); - var paidStudentIds = await dbContext.Orders.AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.Status == OrderStatus.Paid && - item.UserId != null && - item.PaidAt != null && - item.PaidAt >= start && - item.PaidAt < endExclusive) - .GroupBy(item => item.UserId!.Value) - .Select(group => new - { - UserId = group.Key, - AmountCents = group.Sum(item => item.AmountCents) - }) - .ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken); - var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId)); - var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId)); - var top = await GetSalesStatsAsync( - actor, - new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20), - cancellationToken); - - return new ReferralConversionReport( - startDate, - endDate, - leads.Length, - paidLeadCount, - paidAmountCents, - leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2), - top.Items, - leads - .Where(item => !paidStudentIds.ContainsKey(item.StudentUserId)) - .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) - .Select(item => ToLeadItem(item, false)) - .ToArray()); - } - - public async Task> GetClientsAsync( - ReferralAdminActor actor, - ReferralStatsQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var referrerUserId = query.ReferrerUserId ?? actor.UserId; - await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); - var leads = await dbContext.ReferralLeads - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.ReferrerUserId == referrerUserId) - .OrderByDescending(item => item.BoundAt) - .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) - .ToArrayAsync(cancellationToken); - return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); - } - - public async Task ManualBindAsync( - ReferralAdminActor actor, - ManualBindReferralCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - if (command.StudentUserId == command.ReferrerUserId) - { - throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - } - - var refCode = await dbContext.ReferralCodes - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == command.ReferrerUserId && - item.Status == ReferralCodeStatus.Active) - .Select(item => item.Code) - .FirstOrDefaultAsync(cancellationToken); - if (refCode is null) - { - refCode = (await GetOrCreateInviteCodeAsync( - new ReferralActor(actor.TenantId, command.ReferrerUserId), - new ReferralInviteCommand("manual"), - cancellationToken)).InviteCode; - } - - var before = await dbContext.ReferralLeads - .AsNoTracking() - .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == command.StudentUserId, - cancellationToken); - var lead = await BindLeadCoreAsync( - actor.TenantId, - command.StudentUserId, - command.ReferrerUserId, - refCode, - NormalizeChoice(command.Source, AllowedSources, "manual", "invalid_referral_source"), - ReferralLeadBindType.Manual, - command.Force, - command.Metadata, - cancellationToken); - lead.AssignedBy = actor.UserId; - lead.AssignedAt = DateTimeOffset.UtcNow; - var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId - ? null - : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); - } - - public async Task> GetTeamAsync( - ReferralAdminActor actor, - ReferralTeamQuery query, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - var edges = dbContext.ReferralTeamEdges - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId); - if (query.LeaderUserId.HasValue) - { - edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); - } - - var items = await edges - .OrderBy(item => item.RelationType) - .ThenBy(item => item.MemberUserId) - .ToArrayAsync(cancellationToken); - return new ReferralList(items.Select(ToTeamItem).ToArray()); - } - - public async Task UpsertTeamAsync( - ReferralAdminActor actor, - UpsertReferralTeamCommand command, - CancellationToken cancellationToken = default) - { - await AssertAdminAsync(actor, cancellationToken); - await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken); - if (command.LeaderUserId.HasValue) - { - await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken); - } - - var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation"); - var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status"); - var edge = await dbContext.ReferralTeamEdges - .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.MemberUserId == command.MemberUserId && - item.RelationType == relationType, - cancellationToken); - if (edge is null) - { - edge = new ReferralTeamEdge - { - TenantId = actor.TenantId, - MemberUserId = command.MemberUserId, - RelationType = relationType - }; - dbContext.ReferralTeamEdges.Add(edge); - } - - edge.LeaderUserId = command.LeaderUserId; - edge.Status = status; - edge.Metadata = command.Metadata ?? JsonDefaults.Object(); - await dbContext.SaveChangesAsync(cancellationToken); - return ToTeamItem(edge); - } - - private async Task BindLeadCoreAsync( - Guid tenantId, - Guid studentUserId, - Guid referrerUserId, - string refCode, - string source, - ReferralLeadBindType bindType, - bool force, - JsonElement? metadata, - CancellationToken cancellationToken) - { - await AssertActiveMemberAsync(tenantId, studentUserId, cancellationToken); - await AssertActiveMemberAsync(tenantId, referrerUserId, cancellationToken); - var now = DateTimeOffset.UtcNow; - var lead = await dbContext.ReferralLeads - .FirstOrDefaultAsync(item => - item.TenantId == tenantId && - item.StudentUserId == studentUserId, - cancellationToken); - if (lead is not null) - { - if (lead.ReferrerUserId == referrerUserId) - { - return lead; - } - - if (!force && lead.Status == ReferralLeadStatus.Protected && (lead.ProtectedUntil is null || lead.ProtectedUntil > now)) - { - throw new ReferralException("Referral lead is protected and cannot be rebound.", "referral_lead_protected"); - } - } - else - { - lead = new ReferralLead - { - TenantId = tenantId, - StudentUserId = studentUserId - }; - dbContext.ReferralLeads.Add(lead); - } - - lead.ReferrerUserId = referrerUserId; - lead.RefCode = refCode; - lead.Source = source; - lead.BindType = bindType; - lead.Status = ReferralLeadStatus.Protected; - lead.ProtectedUntil = now.AddDays(30); - lead.BoundAt = now; - lead.Metadata = metadata ?? JsonDefaults.Object(); - return lead; - } - - private async Task EnqueueCrmIfEnabledAsync( - Guid tenantId, - ReferralLead lead, - string source, - CancellationToken cancellationToken) - { - var config = await dbContext.CrmConfigs - .AsNoTracking() - .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken); - if (config is null || string.IsNullOrWhiteSpace(config.Url)) - { - return null; - } - - var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture); - var idempotencyKey = $"{source}:{recordId}"; - var existing = await dbContext.CrmWebhookQueue - .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) - { - return existing; - } - - var queue = new CrmWebhookQueueItem - { - TenantId = tenantId, - RecordId = recordId, - LeadId = recordId, - Source = source, - Provider = "webhook", - Status = CrmWebhookQueueStatus.Pending, - ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(config.DelaySeconds ?? 0, 0)), - IdempotencyKey = idempotencyKey, - TargetUrl = config.Url, - Payload = JsonSerializer.SerializeToElement(new - { - tenantId, - leadId = lead.Id, - lead.StudentUserId, - lead.ReferrerUserId, - lead.RefCode, - lead.Source, - lead.BoundAt, - config.FormName, - config.ExamType - }) - }; - dbContext.CrmWebhookQueue.Add(queue); - return queue; - } - - private async Task ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken) - { - return await dbContext.ReferralCodes - .AsNoTracking() - .FirstOrDefaultAsync(item => - item.TenantId == tenantId && - item.Code == code && - item.Status == ReferralCodeStatus.Active, - cancellationToken); - } - - private async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) - { - var exists = await dbContext.TenantMemberships.AnyAsync( - item => - item.TenantId == tenantId && - item.UserId == userId && - item.Status == MembershipStatus.Active, - cancellationToken); - if (!exists) - { - throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); - } - } - - private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (!access.IsCurrentTenantMember || - access.TenantId != actor.TenantId || - access.UserId != actor.UserId || - !access.HasTenantPermission(BackendPermissions.TenantCrmManage)) - { - throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); - } - } - - private async Task BuildStatsAsync( - Guid tenantId, - Guid referrerUserId, - CancellationToken cancellationToken) - { - var user = await dbContext.Users.AsNoTracking() - .Where(item => item.Id == referrerUserId) - .Select(item => new { item.Name, item.UserName, item.Phone }) - .FirstOrDefaultAsync(cancellationToken); - var membership = await dbContext.TenantMemberships.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) - .Select(item => item.Role) - .FirstOrDefaultAsync(cancellationToken); - var inviteCode = await dbContext.ReferralCodes.AsNoTracking() - .Where(item => - item.TenantId == tenantId && - item.UserId == referrerUserId && - item.Status == ReferralCodeStatus.Active) - .Select(item => item.Code) - .FirstOrDefaultAsync(cancellationToken); - var leads = await dbContext.ReferralLeads.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId) - .Select(item => item.StudentUserId) - .ToArrayAsync(cancellationToken); - var paid = leads.Length == 0 - ? [] - : await dbContext.Orders.AsNoTracking() - .Where(item => - item.TenantId == tenantId && - item.Status == OrderStatus.Paid && - item.UserId != null && - leads.Contains(item.UserId.Value)) - .GroupBy(item => item.UserId!.Value) - .Select(group => new - { - UserId = group.Key, - AmountCents = group.Sum(item => item.AmountCents) - }) - .ToArrayAsync(cancellationToken); - var trackCount = await dbContext.ReferralTracks.AsNoTracking() - .CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken); - - return new ReferralStatsItem( - referrerUserId, - FirstNonBlank(user?.Name, user?.UserName, user?.Phone), - membership.ToString(), - inviteCode, - leads.Length, - paid.Length, - paid.Sum(item => item.AmountCents), - trackCount, - leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2)); - } - - private async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) - { - for (var attempt = 0; attempt < 20; attempt++) - { - var code = GenerateCode(); - var exists = await dbContext.ReferralCodes.AnyAsync( - item => item.TenantId == tenantId && item.Code == code, - cancellationToken); - if (!exists) - { - return code; - } - } - - throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed"); - } - - private static string GenerateCode() - { - const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - Span bytes = stackalloc byte[8]; - RandomNumberGenerator.Fill(bytes); - Span chars = stackalloc char[8]; - for (var index = 0; index < chars.Length; index++) - { - chars[index] = alphabet[bytes[index] % alphabet.Length]; - } - - return new string(chars); - } - - private static Guid RequireUser(ReferralActor actor) - { - return actor.UserId ?? throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); - } - - private static string? NormalizeCode(string? value) - { - return string.IsNullOrWhiteSpace(value) - ? null - : value.Trim().ToUpperInvariant(); - } - - private static string NormalizeChoice( - string? value, - HashSet allowed, - string defaultValue, - string errorCode) - { - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } - - var normalized = value.Trim().ToLowerInvariant(); - return allowed.Contains(normalized) - ? normalized - : throw new ReferralException("Referral value was invalid.", errorCode); - } - - private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) - where TEnum : struct, Enum - { - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } - - var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); - foreach (var enumValue in Enum.GetValues()) - { - if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) - { - return enumValue; - } - } - - throw new ReferralException("Referral enum value was invalid.", errorCode); - } - - private static string? NormalizeOptional(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static string? Truncate(string? value, int maxLength) - { - return value is null || value.Length <= maxLength ? value : value[..maxLength]; - } - - private static string? FirstNonBlank(params string?[] values) - { - return values.Select(NormalizeOptional).FirstOrDefault(value => value is not null); - } - - private static ReferralTrackItem ToTrackItem(ReferralTrack item) - { - return new ReferralTrackItem( - item.Id, - item.EventType, - item.RefCode, - item.ReferrerUserId, - item.TargetUserId, - item.Source, - item.CreatedAt); - } - - private static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed) - { - return new ReferralLeadItem( - item.Id, - item.StudentUserId, - item.ReferrerUserId, - item.RefCode, - item.Status.ToString(), - item.BoundAt, - changed); - } - - private static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item) - { - return new ReferralQrcodeItem( - item.Id, - item.RefCode, - item.Scene, - item.Page, - item.Provider, - item.QrcodeUrl, - item.Status.ToString(), - item.Metadata); - } - - private static CrmQueuePreviewItem? ToQueuePreview(CrmWebhookQueueItem? item) - { - return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source); - } - - private static ReferralTeamItem ToTeamItem(ReferralTeamEdge item) - { - return new ReferralTeamItem( - item.Id, - item.MemberUserId, - item.LeaderUserId, - item.RelationType.ToString(), - item.Status.ToString(), - item.Metadata); - } } diff --git a/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs b/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs new file mode 100644 index 0000000..39f09a4 --- /dev/null +++ b/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Growth; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Growth; + +public sealed partial class ReferralService +{ + public async Task GetOrCreateInviteCodeAsync( + ReferralActor actor, + ReferralInviteCommand command, + CancellationToken cancellationToken = default) + { + var userId = RequireUser(actor); + await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); + + var existing = await dbContext.ReferralCodes + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == userId, + cancellationToken); + if (existing is not null) + { + if (!string.IsNullOrWhiteSpace(command.Channel)) + { + existing.Channel = command.Channel.Trim(); + } + + if (!string.IsNullOrWhiteSpace(command.LandingPath)) + { + existing.LandingPath = command.LandingPath.Trim(); + } + + existing.Status = ReferralCodeStatus.Active; + await dbContext.SaveChangesAsync(cancellationToken); + return new ReferralInviteItem(existing.Code); + } + + var code = await GenerateUniqueCodeAsync(actor.TenantId, cancellationToken); + var referralCode = new ReferralCode + { + TenantId = actor.TenantId, + UserId = userId, + Code = code, + Channel = NormalizeOptional(command.Channel), + LandingPath = NormalizeOptional(command.LandingPath), + Metadata = JsonSerializer.SerializeToElement(new { source = "referral_invite_code" }) + }; + dbContext.ReferralCodes.Add(referralCode); + await dbContext.SaveChangesAsync(cancellationToken); + return new ReferralInviteItem(code); + } + + public async Task ResolveAsync( + ReferralActor actor, + ResolveReferralCommand command, + CancellationToken cancellationToken = default) + { + var code = NormalizeCode(command.Code); + if (code is null) + { + return new ReferralResolutionItem(false, null, null, null, null); + } + + var row = await ( + from referralCode in dbContext.ReferralCodes.AsNoTracking() + join membership in dbContext.TenantMemberships.AsNoTracking() + on new { referralCode.TenantId, referralCode.UserId } equals new { membership.TenantId, membership.UserId } + join user in dbContext.Users.AsNoTracking() + on referralCode.UserId equals user.Id + where referralCode.TenantId == actor.TenantId && + referralCode.Code == code && + referralCode.Status == ReferralCodeStatus.Active && + membership.Status == MembershipStatus.Active + select new + { + referralCode.Code, + referralCode.UserId, + membership.Role, + user.Name, + user.UserName, + user.Phone + }) + .FirstOrDefaultAsync(cancellationToken); + + return row is null + ? new ReferralResolutionItem(false, null, null, null, null) + : new ReferralResolutionItem( + true, + row.UserId, + row.Code, + row.Role.ToString(), + FirstNonBlank(row.Name, row.UserName, row.Phone)); + } + + public async Task TrackEventAsync( + ReferralActor actor, + TrackReferralEventCommand command, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default) + { + var code = NormalizeCode(command.RefCode) + ?? throw new ReferralException("Referral code is required.", "referral_code_required"); + var eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type"); + var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); + var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var track = new ReferralTrack + { + TenantId = actor.TenantId, + ReferrerUserId = resolution?.UserId, + TargetUserId = actor.UserId ?? command.TargetUserId, + EventType = eventType, + RefCode = code, + Source = source, + IpAddress = NormalizeOptional(ipAddress), + UserAgent = Truncate(NormalizeOptional(userAgent), 1024), + Metadata = command.Metadata ?? JsonDefaults.Object() + }; + + ReferralLead? lead = null; + CrmWebhookQueueItem? crmQueue = null; + if (resolution is not null && track.TargetUserId.HasValue && track.TargetUserId.Value != resolution.UserId) + { + lead = await BindLeadCoreAsync( + actor.TenantId, + track.TargetUserId.Value, + resolution.UserId, + code, + source, + ReferralLeadBindType.FirstTouch, + false, + command.Metadata, + cancellationToken); + var setFirstTrack = lead.FirstTrackId is null; + if (setFirstTrack) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + dbContext.ReferralTracks.Add(track); + track.LeadId = lead.Id; + await dbContext.SaveChangesAsync(cancellationToken); + if (setFirstTrack) + { + lead.FirstTrackId = track.Id; + } + + crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); + } + else + { + dbContext.ReferralTracks.Add(track); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue)); + } + + public async Task BindAsync( + ReferralActor actor, + BindReferralCommand command, + CancellationToken cancellationToken = default) + { + var userId = RequireUser(actor); + await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); + var code = NormalizeCode(command.RefCode) + ?? throw new ReferralException("Referral code is required.", "referral_code_required"); + var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source"); + var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken) + ?? throw new ReferralException("Referral code was not found.", "referral_code_not_found"); + if (resolution.UserId == userId) + { + throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); + } + + var existing = await dbContext.ReferralLeads + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.StudentUserId == userId, + cancellationToken); + var beforeReferrerId = existing?.ReferrerUserId; + var lead = await BindLeadCoreAsync( + actor.TenantId, + userId, + resolution.UserId, + code, + source, + ReferralLeadBindType.FirstTouch, + false, + command.Metadata, + cancellationToken); + var crmQueue = beforeReferrerId == lead.ReferrerUserId + ? null + : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); + } + + public async Task GetOrCreateQrcodeAsync( + ReferralActor actor, + ReferralQrcodeCommand command, + CancellationToken cancellationToken = default) + { + var userId = RequireUser(actor); + await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); + var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)).InviteCode; + var page = NormalizeOptional(command.Page) ?? "pages/index/index"; + var provider = NormalizeOptional(command.Provider) ?? "wechat-miniapp"; + var scene = NormalizeOptional(command.Scene) ?? $"ref={refCode}"; + var providedQrcodeUrl = NormalizeOptional(command.QrcodeUrl); + var generated = providedQrcodeUrl is null + ? await qrcodeGenerator.GenerateAsync( + new ReferralQrcodeGenerateRequest(actor.TenantId, userId, refCode, provider, page, scene), + cancellationToken) + : new ReferralQrcodeGenerateResult( + providedQrcodeUrl, + provider, + JsonSerializer.SerializeToElement(new { generatedBy = "external_url" })); + var metadata = command.Metadata ?? generated.Metadata; + + var item = await dbContext.ReferralQrcodes + .FirstOrDefaultAsync(entry => + entry.TenantId == actor.TenantId && + entry.Provider == generated.Provider && + entry.Scene == scene && + entry.Page == page, + cancellationToken); + if (item is null) + { + item = new ReferralQrcode + { + TenantId = actor.TenantId, + UserId = userId, + RefCode = refCode, + Scene = scene, + Page = page, + Provider = generated.Provider + }; + dbContext.ReferralQrcodes.Add(item); + } + + item.UserId = userId; + item.RefCode = refCode; + item.QrcodeUrl = generated.QrcodeUrl; + item.Status = ReferralQrcodeStatus.Ready; + item.ErrorMessage = null; + item.Metadata = metadata; + await dbContext.SaveChangesAsync(cancellationToken); + return ToQrcodeItem(item); + } + + +} diff --git a/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs new file mode 100644 index 0000000..04f4e68 --- /dev/null +++ b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Growth; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Growth; + +public sealed partial class ReferralService +{ + public async Task ManualBindAsync( + ReferralAdminActor actor, + ManualBindReferralCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + if (command.StudentUserId == command.ReferrerUserId) + { + throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); + } + + var refCode = await dbContext.ReferralCodes + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == command.ReferrerUserId && + item.Status == ReferralCodeStatus.Active) + .Select(item => item.Code) + .FirstOrDefaultAsync(cancellationToken); + if (refCode is null) + { + refCode = (await GetOrCreateInviteCodeAsync( + new ReferralActor(actor.TenantId, command.ReferrerUserId), + new ReferralInviteCommand("manual"), + cancellationToken)).InviteCode; + } + + var before = await dbContext.ReferralLeads + .AsNoTracking() + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.StudentUserId == command.StudentUserId, + cancellationToken); + var lead = await BindLeadCoreAsync( + actor.TenantId, + command.StudentUserId, + command.ReferrerUserId, + refCode, + NormalizeChoice(command.Source, AllowedSources, "manual", "invalid_referral_source"), + ReferralLeadBindType.Manual, + command.Force, + command.Metadata, + cancellationToken); + lead.AssignedBy = actor.UserId; + lead.AssignedAt = DateTimeOffset.UtcNow; + var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId + ? null + : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); + } + + public async Task> GetTeamAsync( + ReferralAdminActor actor, + ReferralTeamQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var edges = dbContext.ReferralTeamEdges + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (query.LeaderUserId.HasValue) + { + edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); + } + + var items = await edges + .OrderBy(item => item.RelationType) + .ThenBy(item => item.MemberUserId) + .ToArrayAsync(cancellationToken); + return new ReferralList(items.Select(ToTeamItem).ToArray()); + } + + public async Task UpsertTeamAsync( + ReferralAdminActor actor, + UpsertReferralTeamCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken); + if (command.LeaderUserId.HasValue) + { + await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken); + } + + var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation"); + var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status"); + var edge = await dbContext.ReferralTeamEdges + .FirstOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.MemberUserId == command.MemberUserId && + item.RelationType == relationType, + cancellationToken); + if (edge is null) + { + edge = new ReferralTeamEdge + { + TenantId = actor.TenantId, + MemberUserId = command.MemberUserId, + RelationType = relationType + }; + dbContext.ReferralTeamEdges.Add(edge); + } + + edge.LeaderUserId = command.LeaderUserId; + edge.Status = status; + edge.Metadata = command.Metadata ?? JsonDefaults.Object(); + await dbContext.SaveChangesAsync(cancellationToken); + return ToTeamItem(edge); + } + + +} diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs index 75cedd2..24abd89 100644 --- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -18,1070 +18,12 @@ using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; -internal sealed class BackgroundJobService( +internal sealed partial class BackgroundJobService( TikuDbContext dbContext, ITenantExecutionScope tenantExecutionScope, IFeatureAccessService featureAccessService) : IBackgroundJobService { private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); - public async Task EnqueueAsync( - CreateBackgroundJobCommand command, - CancellationToken cancellationToken = default) - { - var normalizedJobType = NormalizeJobType(command.JobType); - 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, - cancellationToken)).Allowed) - { - throw new InvalidOperationException("Tenant feature entitlement does not allow this background job."); - } - var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType); - var quotaConsumed = false; - if (quotaMetric is not null) - { - var quota = (await featureAccessService.GetQuotaSummaryAsync(command.TenantId, cancellationToken)) - .SingleOrDefault(value => value.MetricCode == quotaMetric); - if (quota is not null) - { - quotaConsumed = await featureAccessService.TryConsumeQuotaAsync( - command.TenantId, quotaMetric, 1, cancellationToken); - if (!quotaConsumed) - { - throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted"); - } - } - } - var job = new BackgroundJob - { - TenantId = command.TenantId, - JobType = normalizedJobType, - IdempotencyKey = idempotencyKey, - Payload = command.Payload, - RunAfter = command.RunAfter, - MaxRetries = Math.Clamp(command.MaxRetries, 0, 20) - }; - dbContext.BackgroundJobs.Add(job); - try - { - 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) - { - await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None); - } - throw; - } - return ToItem(job); - } - - private static string? ResolveQuotaMetric(string jobType) => jobType switch - { - "content_import" => SaasQuotaMetricCatalog.ImportCount, - "content_export" => SaasQuotaMetricCatalog.ExportCount, - _ => null - }; - - public async Task ProcessPendingAsync( - string workerId, - int batchSize, - bool includeImmediateJobs = true, - CancellationToken cancellationToken = default) - { - var now = DateTimeOffset.UtcNow; - var leaseExpiresAt = now.Add(LeaseDuration); - var claimedIds = await dbContext.Database.SqlQuery($""" - UPDATE background_jobs AS job - SET status = 'processing', - locked_by = {workerId}, - lock_expires_at = {leaseExpiresAt}, - started_at = COALESCE(started_at, {now}), - updated_at = {now} - WHERE job.id IN ( - SELECT candidate.id - FROM background_jobs AS candidate - WHERE ( - (candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND - (candidate.run_after IS NULL OR candidate.run_after <= {now})) OR - (candidate.status = 'processing' AND candidate.lock_expires_at <= {now}) - ) - ORDER BY candidate.created_at, candidate.id - FOR UPDATE SKIP LOCKED - LIMIT {Math.Clamp(batchSize, 1, 100)} - ) - RETURNING job.id AS "Value" - """) - .ToArrayAsync(cancellationToken); - - var processed = 0; - dbContext.ChangeTracker.Clear(); - foreach (var jobId in claimedIds) - { - cancellationToken.ThrowIfCancellationRequested(); - var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken); - if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++; - dbContext.ChangeTracker.Clear(); - } - - return processed; - } - - public async Task ProcessRequestedAsync( - Guid jobId, - Guid tenantId, - string jobType, - string workerId, - CancellationToken cancellationToken = default) - { - var normalizedJobType = NormalizeJobType(jobType); - var claimed = await dbContext.BackgroundJobs - .Where(item => item.Id == jobId && item.TenantId == tenantId && - item.JobType == normalizedJobType && item.Status == BackgroundJobStatus.Pending && - item.RunAfter == null) - .ExecuteUpdateAsync(setters => setters - .SetProperty(item => item.Status, BackgroundJobStatus.Processing) - .SetProperty(item => item.LockedBy, workerId) - .SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration)) - .SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken); - if (claimed == 0) - { - return false; - } - - dbContext.ChangeTracker.Clear(); - var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync( - item => item.Id == jobId && item.TenantId == tenantId, - cancellationToken); - if (job is null) - { - throw new InvalidOperationException("The requested background job does not exist in the target tenant."); - } - if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal)) - { - throw new InvalidOperationException("The requested background job type does not match the persisted job."); - } - return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken); - } - - private async Task ProcessJobAsync( - BackgroundJob job, - string workerId, - bool alreadyClaimed, - CancellationToken cancellationToken) - { - var startedTimestamp = Stopwatch.GetTimestamp(); - if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) || - (alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId))) - { - return false; - } - 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, - cancellationToken)).Allowed) - { - await CompleteAsync( - job, - workerId, - BackgroundJobStatus.Failed, - JsonDefaults.Object(), - "Tenant feature entitlement was revoked before job execution.", - cancellationToken); - return true; - } - - if (!alreadyClaimed) - { - var now = DateTimeOffset.UtcNow; - job.Status = BackgroundJobStatus.Processing; - job.LockedBy = workerId; - job.LockExpiresAt = now.Add(LeaseDuration); - job.StartedAt = now; - await dbContext.SaveChangesAsync(cancellationToken); - } - - try - { - var result = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - job.TenantId, SystemScopeCallerType.Worker, workerId, - $"Background job {job.JobType}", job.Id.ToString("N")), - (provider, token) => ProcessCoreAsync(provider, job, token), - cancellationToken); - 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; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - job.RetryCount++; - 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; - job.RunAfter = job.Status == BackgroundJobStatus.Pending - ? DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount)) - : null; - } - 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; - } - - private async Task CompleteAsync( - BackgroundJob job, - string workerId, - BackgroundJobStatus status, - JsonElement result, - string? lastError, - CancellationToken cancellationToken) - { - await dbContext.BackgroundJobs - .Where(value => value.Id == job.Id && value.LockedBy == workerId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(value => value.Status, status) - .SetProperty(value => value.RetryCount, job.RetryCount) - .SetProperty(value => value.RunAfter, job.RunAfter) - .SetProperty(value => value.CompletedAt, job.CompletedAt) - .SetProperty(value => value.LastError, lastError) - .SetProperty(value => value.OutputAssetId, job.OutputAssetId) - .SetProperty(value => value.Result, result) - .SetProperty(value => value.LockedBy, (string?)null) - .SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null), - cancellationToken); - } - - public async Task> ListAsync( - Guid tenantId, - string? jobType = null, - int limit = 50, - CancellationToken cancellationToken = default) - { - var query = dbContext.BackgroundJobs.AsNoTracking() - .Where(job => job.TenantId == tenantId); - if (!string.IsNullOrWhiteSpace(jobType)) - { - var normalized = NormalizeJobType(jobType); - query = query.Where(job => job.JobType == normalized); - } - - var jobs = await query - .OrderByDescending(job => job.CreatedAt) - .Take(Math.Clamp(limit, 1, 200)) - .ToArrayAsync(cancellationToken); - 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, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var scopedDbContext = scopedProvider.GetRequiredService(); - return job.JobType switch - { - "content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken), - "content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken), - "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), - _ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.") - }; - } - - private static async Task ProcessContentImportAsync( - IServiceProvider scopedProvider, - BackgroundJob job, - CancellationToken cancellationToken) - { - var directContentService = scopedProvider.GetRequiredService(); - var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty; - if (createdBy == Guid.Empty) - { - throw new InvalidOperationException("content_import job requires createdBy."); - } - - var command = new SimpleImportCommand( - GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."), - GetJsonString(job.Payload, "sourceFormat"), - GetJsonString(job.Payload, "sourceName"), - GetJsonGuid(job.Payload, "regionId"), - GetJsonGuid(job.Payload, "entryId"), - GetJsonGuid(job.Payload, "contentNodeId"), - GetJsonGuid(job.Payload, "subjectId"), - GetJsonGuid(job.Payload, "categoryId"), - GetJsonGuid(job.Payload, "questionBankId"), - GetJsonGuid(job.Payload, "collectionId"), - GetJsonArray(job.Payload, "items"), - false); - var result = await directContentService.ExecuteImportAsync( - new DirectContentActor(job.TenantId, createdBy), - command, - cancellationToken); - return JsonSerializer.SerializeToElement(new - { - importJobId = result.Job.Id, - result.Job.ImportType, - result.Job.Status, - result.Job.TotalCount, - result.Job.InsertedCount, - result.Job.UpdatedCount, - result.Job.ErrorCount - }); - } - - 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, - CancellationToken cancellationToken) - { - var exportType = GetJsonString(job.Payload, "exportType") ?? "summary"; - var assetKey = $"background-jobs/{job.Id:N}/content-export.json"; - var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == job.TenantId && item.AssetKey == assetKey, - cancellationToken); - if (asset is null) - { - asset = new ContentAsset - { - TenantId = job.TenantId, - AssetKey = assetKey, - AssetType = ContentAssetType.Document, - StorageProvider = AssetStorageProvider.ExternalUrl, - UploadStatus = AssetUploadStatus.Verified, - SecurityScanStatus = AssetSecurityScanStatus.NotRequired, - Source = "background_job" - }; - scopedDbContext.ContentAssets.Add(asset); - } - - var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); - var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); - var studentCount = await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); - asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; - asset.Title = "Content export manifest"; - asset.Description = $"Generated content export manifest for {exportType}."; - asset.ObjectKey = assetKey; - asset.MimeType = "application/json"; - asset.Metadata = JsonSerializer.SerializeToElement(new - { - exportType, - generatedAt = DateTimeOffset.UtcNow, - questionBankCount, - questionCount, - studentCount, - payload = job.Payload - }); - await scopedDbContext.SaveChangesAsync(cancellationToken); - job.OutputAssetId = asset.Id; - return JsonSerializer.SerializeToElement(new - { - outputAssetId = asset.Id, - asset.AssetKey, - questionBankCount, - questionCount, - studentCount - }); - } - - private async Task ProcessCommerceReconciliationAsync( - TikuDbContext scopedDbContext, - BackgroundJob job, - CancellationToken cancellationToken) - { - var provider = NormalizeProvider(GetJsonString(job.Payload, "provider")); - var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync( - item => - item.TenantId == job.TenantId && - item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment && - item.Provider == provider && - item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active, - cancellationToken); - if (!hasProviderConfig) - { - throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job."); - } - - var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date); - var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined); - var sourceHash = $"background-job:{job.Id:N}"; - var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync( - item => - item.TenantId == job.TenantId && - item.Provider == provider && - item.Source == ReconciliationSource.ProviderDownload && - item.SourceHash == sourceHash, - cancellationToken); - if (batch is null) - { - batch = new CommerceReconciliationBatch - { - TenantId = job.TenantId, - Provider = provider, - BillDate = billDate, - BillType = billType, - Source = ReconciliationSource.ProviderDownload, - SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}", - SourceHash = sourceHash, - Status = ReconciliationBatchStatus.Pending, - Metadata = JsonSerializer.SerializeToElement(new - { - jobId = job.Id, - note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." - }) - }; - scopedDbContext.CommerceReconciliationBatches.Add(batch); - await scopedDbContext.SaveChangesAsync(cancellationToken); - } - - return JsonSerializer.SerializeToElement(new - { - batchId = batch.Id, - provider, - billDate, - billType = billType.ToString(), - status = batch.Status.ToString() - }); - } - - private static async Task ProcessTenantDomainRecheckAsync( - IServiceProvider scopedProvider, - CancellationToken cancellationToken) - { - var lifecycleService = scopedProvider.GetRequiredService(); - var processed = await lifecycleService.ProcessPendingAsync(cancellationToken); - return JsonSerializer.SerializeToElement(new - { - processed - }); - } - - private async Task ProcessStatisticsAggregationAsync( - IServiceProvider scopedProvider, - TikuDbContext scopedDbContext, - BackgroundJob job, - CancellationToken cancellationToken) - { - var since = DateTimeOffset.UtcNow.AddDays(-7); - var activeLearnerCount = await scopedDbContext.PracticeSessions - .Where(item => item.TenantId == job.TenantId && item.StartedAt >= since) - .Select(item => item.UserId) - .Distinct() - .CountAsync(cancellationToken); - var paidOrderCount = await scopedDbContext.Orders - .CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken); - var revenueCents = await scopedDbContext.Orders - .Where(item => item.TenantId == job.TenantId && - (item.Status == OrderStatus.Paid || - item.Status == OrderStatus.PartiallyRefunded || - item.Status == OrderStatus.Refunded)) - .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); - var quotaUsage = await scopedProvider.GetRequiredService() - .ReconcileTenantAsync( - new ReconcileFeatureUsageRequest( - job.TenantId, - SystemScopeCallerType.Worker, - nameof(BackgroundJobService), - "Reconcile tenant current feature usage during statistics aggregation", - $"feature-usage-{job.Id:N}"), - cancellationToken); - return JsonSerializer.SerializeToElement(new - { - since, - activeLearnerCount, - paidOrderCount, - revenueCents, - quotaUsage - }); - } - - private static string NormalizeJobType(string jobType) - { - 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")) - ?? throw new InvalidOperationException("Background content import type is not supported."), - "content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank, - "commerce_reconciliation" => SaasFeatureCatalog.StudentStore, - "statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice, - _ => SaasFeatureCatalog.CoreBackoffice - }; - - private static string NormalizeProvider(string? provider) - { - var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant(); - return string.IsNullOrWhiteSpace(normalized) - ? throw new InvalidOperationException("Background job provider is required.") - : normalized; - } - - private static string? GetJsonString(JsonElement element, string propertyName) - { - return element.ValueKind == JsonValueKind.Object && - element.TryGetProperty(propertyName, out var property) && - property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - } - - private static Guid? GetJsonGuid(JsonElement element, string propertyName) - { - if (element.ValueKind != JsonValueKind.Object || - !element.TryGetProperty(propertyName, out var property)) - { - return null; - } - - return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value) - ? value - : null; - } - - private static IReadOnlyCollection GetJsonArray(JsonElement element, string propertyName) - { - if (element.ValueKind != JsonValueKind.Object || - !element.TryGetProperty(propertyName, out var property) || - property.ValueKind != JsonValueKind.Array) - { - return []; - } - - return property.EnumerateArray().Select(item => item.Clone()).ToArray(); - } - - private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName) - { - var value = GetJsonString(element, propertyName); - return DateOnly.TryParse(value, out var parsed) ? parsed : null; - } - - private static TEnum GetJsonEnum(JsonElement element, string propertyName, TEnum fallback) - where TEnum : struct - { - var value = GetJsonString(element, propertyName); - return Enum.TryParse(value, true, out var parsed) ? parsed : fallback; - } - - private static BackgroundJobItem ToItem(BackgroundJob job) - { - return new BackgroundJobItem( - 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/Jobs/Foundation/BackgroundJobService.Foundation.cs b/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs new file mode 100644 index 0000000..be53cf0 --- /dev/null +++ b/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs @@ -0,0 +1,124 @@ +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; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +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; + +internal sealed partial class BackgroundJobService +{ + private static string NormalizeJobType(string jobType) + { + 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")) + ?? throw new InvalidOperationException("Background content import type is not supported."), + "content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank, + "commerce_reconciliation" => SaasFeatureCatalog.StudentStore, + "statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice, + _ => SaasFeatureCatalog.CoreBackoffice + }; + + private static string NormalizeProvider(string? provider) + { + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(normalized) + ? throw new InvalidOperationException("Background job provider is required.") + : normalized; + } + + private static string? GetJsonString(JsonElement element, string propertyName) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static Guid? GetJsonGuid(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty(propertyName, out var property)) + { + return null; + } + + return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value) + ? value + : null; + } + + private static IReadOnlyCollection GetJsonArray(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.Array) + { + return []; + } + + return property.EnumerateArray().Select(item => item.Clone()).ToArray(); + } + + private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName) + { + var value = GetJsonString(element, propertyName); + return DateOnly.TryParse(value, out var parsed) ? parsed : null; + } + + private static TEnum GetJsonEnum(JsonElement element, string propertyName, TEnum fallback) + where TEnum : struct + { + var value = GetJsonString(element, propertyName); + return Enum.TryParse(value, true, out var parsed) ? parsed : fallback; + } + + private static BackgroundJobItem ToItem(BackgroundJob job) + { + return new BackgroundJobItem( + 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/Jobs/Handlers/BackgroundJobService.Handlers.cs b/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs new file mode 100644 index 0000000..0365077 --- /dev/null +++ b/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs @@ -0,0 +1,554 @@ +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; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +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; + +internal sealed partial class BackgroundJobService +{ + private async Task ProcessCoreAsync( + IServiceProvider scopedProvider, + BackgroundJob job, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var scopedDbContext = scopedProvider.GetRequiredService(); + var handlers = new Dictionary>>(StringComparer.Ordinal) + { + ["content_export"] = () => ProcessContentExportAsync(scopedDbContext, job, cancellationToken), + ["content_import"] = () => ProcessContentImportAsync(scopedProvider, job, cancellationToken), + ["asset_security_scan"] = () => ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken), + ["tenant_export"] = () => ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken), + ["statistics_aggregation"] = () => ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken), + ["commerce_reconciliation"] = () => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken), + ["tenant_domain_recheck"] = () => ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken) + }; + return handlers.TryGetValue(job.JobType, out var handler) + ? await handler() + : throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'."); + } + + private static async Task ProcessContentImportAsync( + IServiceProvider scopedProvider, + BackgroundJob job, + CancellationToken cancellationToken) + { + var directContentService = scopedProvider.GetRequiredService(); + var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty; + if (createdBy == Guid.Empty) + { + throw new InvalidOperationException("content_import job requires createdBy."); + } + + var command = new SimpleImportCommand( + GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."), + GetJsonString(job.Payload, "sourceFormat"), + GetJsonString(job.Payload, "sourceName"), + GetJsonGuid(job.Payload, "regionId"), + GetJsonGuid(job.Payload, "entryId"), + GetJsonGuid(job.Payload, "contentNodeId"), + GetJsonGuid(job.Payload, "subjectId"), + GetJsonGuid(job.Payload, "categoryId"), + GetJsonGuid(job.Payload, "questionBankId"), + GetJsonGuid(job.Payload, "collectionId"), + GetJsonArray(job.Payload, "items"), + false); + var result = await directContentService.ExecuteImportAsync( + new DirectContentActor(job.TenantId, createdBy), + command, + cancellationToken); + return JsonSerializer.SerializeToElement(new + { + importJobId = result.Job.Id, + result.Job.ImportType, + result.Job.Status, + result.Job.TotalCount, + result.Job.InsertedCount, + result.Job.UpdatedCount, + result.Job.ErrorCount + }); + } + + 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, + CancellationToken cancellationToken) + { + var exportType = GetJsonString(job.Payload, "exportType") ?? "summary"; + var assetKey = $"background-jobs/{job.Id:N}/content-export.json"; + var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == job.TenantId && item.AssetKey == assetKey, + cancellationToken); + if (asset is null) + { + asset = new ContentAsset + { + TenantId = job.TenantId, + AssetKey = assetKey, + AssetType = ContentAssetType.Document, + StorageProvider = AssetStorageProvider.ExternalUrl, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.NotRequired, + Source = "background_job" + }; + scopedDbContext.ContentAssets.Add(asset); + } + + var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var studentCount = await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; + asset.Title = "Content export manifest"; + asset.Description = $"Generated content export manifest for {exportType}."; + asset.ObjectKey = assetKey; + asset.MimeType = "application/json"; + asset.Metadata = JsonSerializer.SerializeToElement(new + { + exportType, + generatedAt = DateTimeOffset.UtcNow, + questionBankCount, + questionCount, + studentCount, + payload = job.Payload + }); + await scopedDbContext.SaveChangesAsync(cancellationToken); + job.OutputAssetId = asset.Id; + return JsonSerializer.SerializeToElement(new + { + outputAssetId = asset.Id, + asset.AssetKey, + questionBankCount, + questionCount, + studentCount + }); + } + + private async Task ProcessCommerceReconciliationAsync( + TikuDbContext scopedDbContext, + BackgroundJob job, + CancellationToken cancellationToken) + { + var provider = NormalizeProvider(GetJsonString(job.Payload, "provider")); + var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync( + item => + item.TenantId == job.TenantId && + item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment && + item.Provider == provider && + item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active, + cancellationToken); + if (!hasProviderConfig) + { + throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job."); + } + + var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date); + var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined); + var sourceHash = $"background-job:{job.Id:N}"; + var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync( + item => + item.TenantId == job.TenantId && + item.Provider == provider && + item.Source == ReconciliationSource.ProviderDownload && + item.SourceHash == sourceHash, + cancellationToken); + if (batch is null) + { + batch = new CommerceReconciliationBatch + { + TenantId = job.TenantId, + Provider = provider, + BillDate = billDate, + BillType = billType, + Source = ReconciliationSource.ProviderDownload, + SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}", + SourceHash = sourceHash, + Status = ReconciliationBatchStatus.Pending, + Metadata = JsonSerializer.SerializeToElement(new + { + jobId = job.Id, + note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." + }) + }; + scopedDbContext.CommerceReconciliationBatches.Add(batch); + await scopedDbContext.SaveChangesAsync(cancellationToken); + } + + return JsonSerializer.SerializeToElement(new + { + batchId = batch.Id, + provider, + billDate, + billType = billType.ToString(), + status = batch.Status.ToString() + }); + } + + private static async Task ProcessTenantDomainRecheckAsync( + IServiceProvider scopedProvider, + CancellationToken cancellationToken) + { + var lifecycleService = scopedProvider.GetRequiredService(); + var processed = await lifecycleService.ProcessPendingAsync(cancellationToken); + return JsonSerializer.SerializeToElement(new + { + processed + }); + } + + private async Task ProcessStatisticsAggregationAsync( + IServiceProvider scopedProvider, + TikuDbContext scopedDbContext, + BackgroundJob job, + CancellationToken cancellationToken) + { + var since = DateTimeOffset.UtcNow.AddDays(-7); + var activeLearnerCount = await scopedDbContext.PracticeSessions + .Where(item => item.TenantId == job.TenantId && item.StartedAt >= since) + .Select(item => item.UserId) + .Distinct() + .CountAsync(cancellationToken); + var paidOrderCount = await scopedDbContext.Orders + .CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken); + var revenueCents = await scopedDbContext.Orders + .Where(item => item.TenantId == job.TenantId && + (item.Status == OrderStatus.Paid || + item.Status == OrderStatus.PartiallyRefunded || + item.Status == OrderStatus.Refunded)) + .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); + var quotaUsage = await scopedProvider.GetRequiredService() + .ReconcileTenantAsync( + new ReconcileFeatureUsageRequest( + job.TenantId, + SystemScopeCallerType.Worker, + nameof(BackgroundJobService), + "Reconcile tenant current feature usage during statistics aggregation", + $"feature-usage-{job.Id:N}"), + cancellationToken); + return JsonSerializer.SerializeToElement(new + { + since, + activeLearnerCount, + paidOrderCount, + revenueCents, + quotaUsage + }); + } + + +} diff --git a/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs new file mode 100644 index 0000000..f7c43d7 --- /dev/null +++ b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs @@ -0,0 +1,165 @@ +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; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +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; + +internal sealed partial class BackgroundJobService +{ + public async Task> ListAsync( + Guid tenantId, + string? jobType = null, + int limit = 50, + CancellationToken cancellationToken = default) + { + var query = dbContext.BackgroundJobs.AsNoTracking() + .Where(job => job.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(jobType)) + { + var normalized = NormalizeJobType(jobType); + query = query.Where(job => job.JobType == normalized); + } + + var jobs = await query + .OrderByDescending(job => job.CreatedAt) + .Take(Math.Clamp(limit, 1, 200)) + .ToArrayAsync(cancellationToken); + 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 }) + }); + } + + +} diff --git a/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs new file mode 100644 index 0000000..9fc50dc --- /dev/null +++ b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs @@ -0,0 +1,222 @@ +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; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +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; + +internal sealed partial class BackgroundJobService +{ + public async Task ProcessPendingAsync( + string workerId, + int batchSize, + bool includeImmediateJobs = true, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var leaseExpiresAt = now.Add(LeaseDuration); + var claimedIds = await dbContext.Database.SqlQuery($""" + UPDATE background_jobs AS job + SET status = 'processing', + locked_by = {workerId}, + lock_expires_at = {leaseExpiresAt}, + started_at = COALESCE(started_at, {now}), + updated_at = {now} + WHERE job.id IN ( + SELECT candidate.id + FROM background_jobs AS candidate + WHERE ( + (candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND + (candidate.run_after IS NULL OR candidate.run_after <= {now})) OR + (candidate.status = 'processing' AND candidate.lock_expires_at <= {now}) + ) + ORDER BY candidate.created_at, candidate.id + FOR UPDATE SKIP LOCKED + LIMIT {Math.Clamp(batchSize, 1, 100)} + ) + RETURNING job.id AS "Value" + """) + .ToArrayAsync(cancellationToken); + + var processed = 0; + dbContext.ChangeTracker.Clear(); + foreach (var jobId in claimedIds) + { + cancellationToken.ThrowIfCancellationRequested(); + var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken); + if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++; + dbContext.ChangeTracker.Clear(); + } + + return processed; + } + + public async Task ProcessRequestedAsync( + Guid jobId, + Guid tenantId, + string jobType, + string workerId, + CancellationToken cancellationToken = default) + { + var normalizedJobType = NormalizeJobType(jobType); + var claimed = await dbContext.BackgroundJobs + .Where(item => item.Id == jobId && item.TenantId == tenantId && + item.JobType == normalizedJobType && item.Status == BackgroundJobStatus.Pending && + item.RunAfter == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.Status, BackgroundJobStatus.Processing) + .SetProperty(item => item.LockedBy, workerId) + .SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration)) + .SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken); + if (claimed == 0) + { + return false; + } + + dbContext.ChangeTracker.Clear(); + var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync( + item => item.Id == jobId && item.TenantId == tenantId, + cancellationToken); + if (job is null) + { + throw new InvalidOperationException("The requested background job does not exist in the target tenant."); + } + if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The requested background job type does not match the persisted job."); + } + return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken); + } + + private async Task ProcessJobAsync( + BackgroundJob job, + string workerId, + bool alreadyClaimed, + CancellationToken cancellationToken) + { + var startedTimestamp = Stopwatch.GetTimestamp(); + if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) || + (alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId))) + { + return false; + } + 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, + cancellationToken)).Allowed) + { + await CompleteAsync( + job, + workerId, + BackgroundJobStatus.Failed, + JsonDefaults.Object(), + "Tenant feature entitlement was revoked before job execution.", + cancellationToken); + return true; + } + + if (!alreadyClaimed) + { + var now = DateTimeOffset.UtcNow; + job.Status = BackgroundJobStatus.Processing; + job.LockedBy = workerId; + job.LockExpiresAt = now.Add(LeaseDuration); + job.StartedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + } + + try + { + var result = await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest( + job.TenantId, SystemScopeCallerType.Worker, workerId, + $"Background job {job.JobType}", job.Id.ToString("N")), + (provider, token) => ProcessCoreAsync(provider, job, token), + cancellationToken); + 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; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + job.RetryCount++; + 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; + job.RunAfter = job.Status == BackgroundJobStatus.Pending + ? DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount)) + : null; + } + 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; + } + + private async Task CompleteAsync( + BackgroundJob job, + string workerId, + BackgroundJobStatus status, + JsonElement result, + string? lastError, + CancellationToken cancellationToken) + { + await dbContext.BackgroundJobs + .Where(value => value.Id == job.Id && value.LockedBy == workerId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(value => value.Status, status) + .SetProperty(value => value.RetryCount, job.RetryCount) + .SetProperty(value => value.RunAfter, job.RunAfter) + .SetProperty(value => value.CompletedAt, job.CompletedAt) + .SetProperty(value => value.LastError, lastError) + .SetProperty(value => value.OutputAssetId, job.OutputAssetId) + .SetProperty(value => value.Result, result) + .SetProperty(value => value.LockedBy, (string?)null) + .SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null), + cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs new file mode 100644 index 0000000..f19c419 --- /dev/null +++ b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs @@ -0,0 +1,115 @@ +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; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +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; + +internal sealed partial class BackgroundJobService +{ + public async Task EnqueueAsync( + CreateBackgroundJobCommand command, + CancellationToken cancellationToken = default) + { + var normalizedJobType = NormalizeJobType(command.JobType); + 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, + cancellationToken)).Allowed) + { + throw new InvalidOperationException("Tenant feature entitlement does not allow this background job."); + } + var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType); + var quotaConsumed = false; + if (quotaMetric is not null) + { + var quota = (await featureAccessService.GetQuotaSummaryAsync(command.TenantId, cancellationToken)) + .SingleOrDefault(value => value.MetricCode == quotaMetric); + if (quota is not null) + { + quotaConsumed = await featureAccessService.TryConsumeQuotaAsync( + command.TenantId, quotaMetric, 1, cancellationToken); + if (!quotaConsumed) + { + throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted"); + } + } + } + var job = new BackgroundJob + { + TenantId = command.TenantId, + JobType = normalizedJobType, + IdempotencyKey = idempotencyKey, + Payload = command.Payload, + RunAfter = command.RunAfter, + MaxRetries = Math.Clamp(command.MaxRetries, 0, 20) + }; + dbContext.BackgroundJobs.Add(job); + try + { + 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) + { + await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None); + } + throw; + } + return ToItem(job); + } + + private static string? ResolveQuotaMetric(string jobType) => jobType switch + { + "content_import" => SaasQuotaMetricCatalog.ImportCount, + "content_export" => SaasQuotaMetricCatalog.ExportCount, + _ => null + }; + + +} diff --git a/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs b/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs new file mode 100644 index 0000000..3c31b1f --- /dev/null +++ b/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + public async Task GetStatsAsync( + LearningActor actor, + CancellationToken cancellationToken = default) + { + var answers = dbContext.AnswerRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.IsCurrent && + item.GradingStatus != AnswerGradingStatus.PendingReview && + item.GradingStatus != AnswerGradingStatus.LegacyUnverified); + return new LearningStatsItem( + await answers.CountAsync(cancellationToken), + await answers.CountAsync(item => item.IsCorrect == true, cancellationToken), + await dbContext.WrongQuestions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null, + cancellationToken), + await dbContext.FavoriteQuestions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.UserWordFavorites.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.UserWordProgress.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.PracticeSessions.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken), + await dbContext.PracticeSessionReports.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == PracticeReportStatus.Final, + cancellationToken)); + } + + public async Task> GetTrendAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit)); + var rows = await dbContext.AnswerRecords.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.IsCurrent && + item.GradingStatus != AnswerGradingStatus.PendingReview && + item.GradingStatus != AnswerGradingStatus.LegacyUnverified && + item.AnsweredAt >= since) + .Select(item => new { item.AnsweredAt, item.IsCorrect }) + .ToArrayAsync(cancellationToken); + var items = rows + .AsValueEnumerable() + .GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime)) + .OrderBy(group => group.Key) + .Select(group => new LearningTrendItem( + group.Key, + group.Count(), + group.Count(item => item.IsCorrect == true), + group.Count(item => item.IsCorrect == false))) + .ToArray(); + return new LearningList(items); + } + + public async Task GetLeaderboardAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var rows = await dbContext.AnswerRecords.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && + item.IsCurrent && + item.GradingStatus != AnswerGradingStatus.PendingReview && + item.GradingStatus != AnswerGradingStatus.LegacyUnverified) + .GroupBy(item => item.UserId) + .Select(group => new + { + UserId = group.Key, + AnswerCount = group.Count(), + CorrectCount = group.Count(item => item.IsCorrect == true), + WrongCount = group.Count(item => item.IsCorrect == false) + }) + .OrderByDescending(item => item.CorrectCount) + .ThenByDescending(item => item.AnswerCount) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = rows.Select(item => item.UserId).ToArray(); + var names = await dbContext.Users.AsNoTracking() + .Where(item => userIds.Contains(item.Id)) + .ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken); + var items = rows + .Select(item => new LearningLeaderboardItem( + item.UserId, + names.GetValueOrDefault(item.UserId), + item.AnswerCount, + item.CorrectCount, + item.WrongCount, + item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4))) + .ToArray(); + return new LearningLeaderboardResult( + "correct_count", + "all", + items, + items.FirstOrDefault(item => item.UserId == actor.UserId), + DateTimeOffset.UtcNow); + } + + +} diff --git a/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs b/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs new file mode 100644 index 0000000..d71d1c7 --- /dev/null +++ b/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs @@ -0,0 +1,187 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + public async Task SubmitAnswerAsync( + LearningActor actor, + SubmitAnswerCommand command, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) + { + throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); + } + if (command.SelectedOptionIndices?.Any(index => index < 0) == true) + { + throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values."); + } + + var now = DateTimeOffset.UtcNow; + var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken); + + if (sessionQuestion is null) + { + throw new LearningResourceNotFoundException( + "session_question_not_found", + "An active practice session question was not found."); + } + + var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Id == sessionQuestion.PracticeSessionId, cancellationToken); + if (session is null) + { + throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); + } + + var requestHash = HashAnswer(command); + var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == session.Id && + item.OperationType == "answer" && + item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + if (existingOperation is not null) + { + if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) + { + AnswerConflicts.Add(1); + throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); + } + + IdempotencyReplays.Add(1); + return existingOperation.ResponseSnapshot.Deserialize() + ?? throw new InvalidOperationException("The stored answer response is invalid."); + } + + if (session.ExpiresAt.HasValue && session.ExpiresAt <= now) + { + session.Status = PracticeSessionStatus.Expired; + session.Version++; + await dbContext.SaveChangesAsync(cancellationToken); + throw new LearningValidationException("practice_session_expired", "The practice session has expired."); + } + EnsureAnswerSessionState(session, command); + var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer => + answer.TenantId == actor.TenantId && + answer.UserId == actor.UserId && + answer.PracticeSessionId == session.Id && + answer.SessionQuestionId == sessionQuestion.Id && + answer.IsCurrent, cancellationToken); + if (current is not null) + { + current.IsCurrent = false; + } + + var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? []; + var score = sessionQuestion.Score ?? 1; + QuestionGradingResult grading; + try + { + grading = QuestionGrader.Grade(new QuestionGradingInput( + sessionQuestion.QuestionType, + sessionQuestion.CorrectOptionIndexSnapshot, + sessionQuestion.CorrectOptionIndicesSnapshot, + sessionQuestion.AnswerTextSnapshot, + sessionQuestion.GradingRulesSnapshot, + selectedIndices, + command.AnswerText, + score)); + } + catch (InvalidOperationException exception) + { + ScoringFailures.Add(1); + logger.LogWarning(exception, + "Question scoring failed for tenant {TenantId}, session {PracticeSessionId}, question {SessionQuestionId}", + actor.TenantId, session.Id, sessionQuestion.Id); + throw new LearningValidationException("question_grading_rule_invalid", exception.Message); + } + + var record = new AnswerRecord + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = sessionQuestion.PracticeSessionId, + SessionQuestionId = sessionQuestion.Id, + SelectedOptions = JsonSerializer.SerializeToElement(selectedIndices), + AnswerText = command.AnswerText, + IsCorrect = grading.IsCorrect, + GradingStatus = grading.Status, + AwardedScore = grading.AwardedScore, + Revision = (current?.Revision ?? 0) + 1, + ClientSequence = command.ClientSequence, + IdempotencyKey = command.IdempotencyKey.Trim(), + RequestHash = requestHash, + IsCurrent = true, + AnsweredAt = now, + CreatedAt = now + }; + dbContext.AnswerRecords.Add(record); + session.Version++; + session.LastClientSequence = command.ClientSequence; + var response = ToItem(record, session.Version); + dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + OperationType = "answer", + IdempotencyKey = command.IdempotencyKey.Trim(), + RequestHash = requestHash, + ResponseSnapshot = JsonSerializer.SerializeToElement(response), + CompletedAt = now + }); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); + } + catch (DbUpdateException exception) when ( + exception.InnerException is Npgsql.PostgresException postgresException && + postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation) + { + dbContext.ChangeTracker.Clear(); + var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == session.Id && + item.OperationType == "answer" && + item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal)) + { + IdempotencyReplays.Add(1); + return replay.ResponseSnapshot.Deserialize() + ?? throw new InvalidOperationException("The stored answer response is invalid."); + } + AnswerConflicts.Add(1); + throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation."); + } + + return response; + } + + +} diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs b/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs new file mode 100644 index 0000000..6411e3e --- /dev/null +++ b/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs @@ -0,0 +1,740 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + private async Task BuildPracticeAssemblyAsync( + Guid tenantId, + PracticeSessionCommand command, + CancellationToken cancellationToken) + { + var mode = NormalizeMode(command.Mode); + var assembly = new PracticeAssembly( + mode, + command.TargetType, + command.TargetId, + command.BlueprintId, + command.CollectionId, + command.EntryId, + command.ContentNodeId, + Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit), + command.DurationMinutes, + command.TotalScore); + + if (!command.BlueprintId.HasValue) + { + return assembly; + } + + var blueprint = await dbContext.PracticeBlueprints + .AsNoTracking() + .SingleOrDefaultAsync( + item => + item.TenantId == tenantId && + item.Id == command.BlueprintId.Value && + item.Status == ContentStatus.Active, + cancellationToken); + + if (blueprint is null) + { + throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found."); + } + + return assembly with + { + Mode = NormalizeMode(blueprint.Mode.ToString()), + TargetType = command.TargetType ?? "blueprint", + TargetId = command.TargetId ?? blueprint.Id, + CollectionId = command.CollectionId ?? blueprint.CollectionId, + EntryId = command.EntryId ?? blueprint.EntryId, + ContentNodeId = command.ContentNodeId ?? blueprint.NodeId, + QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit), + DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes, + TotalScore = command.TotalScore ?? blueprint.TotalScore + }; + } + + private async Task> CollectQuestionReferenceIdsAsync( + LearningActor actor, + PracticeAssembly assembly, + CancellationToken cancellationToken) + { + if (assembly.Mode == "wrong_review") + { + return await dbContext.WrongQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.ResolvedAt == null) + .OrderByDescending(item => item.WrongCount) + .ThenBy(item => item.LastWrongAt) + .Take(assembly.QuestionLimit) + .Select(item => item.QuestionReferenceId) + .ToListAsync(cancellationToken); + } + + if (assembly.Mode == "favorite_review") + { + return await dbContext.FavoriteQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId) + .OrderByDescending(item => item.CreatedAt) + .Take(assembly.QuestionLimit) + .Select(item => item.QuestionReferenceId) + .ToListAsync(cancellationToken); + } + + if (assembly.CollectionId.HasValue) + { + return await dbContext.QuestionCollectionItems + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.CollectionId == assembly.CollectionId.Value) + .OrderBy(item => item.SortOrder) + .Take(assembly.QuestionLimit) + .Select(item => item.QuestionReferenceId) + .ToListAsync(cancellationToken); + } + + var query = dbContext.Questions + .AsNoTracking() + .Where(question => + question.TenantId == actor.TenantId && + question.Status == QuestionStatus.Published); + + if (assembly.ContentNodeId.HasValue) + { + query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value); + } + else if (assembly.EntryId.HasValue) + { + query = query.Where(question => question.EntryId == assembly.EntryId.Value); + } + else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType)) + { + query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value); + } + else + { + throw new LearningValidationException("practice_target_required", "Practice target is required."); + } + + var questionIds = await query + .OrderBy(question => question.CreatedAt) + .Take(assembly.QuestionLimit) + .Select(question => question.Id) + .ToListAsync(cancellationToken); + var referenceIds = new List(questionIds.Count); + foreach (var questionId in questionIds) + { + var reference = await questionReferenceService.ResolveAsync( + actor.TenantId, + actor.UserId, + new QuestionLocator(QuestionSource.Tenant, questionId), + cancellationToken); + referenceIds.Add(reference.Id); + } + + return referenceIds; + } + + private static IQueryable ApplyLegacyTargetFilter( + IQueryable query, + string? targetType, + Guid targetId) + { + return NormalizeEnumValue(targetType) switch + { + "subject" => query.Where(question => question.SubjectId == targetId), + "category" => query.Where(question => question.CategoryId == targetId), + "node" => query.Where(question => question.NodeId == targetId), + "questionbank" => query.Where(question => question.QuestionBankId == targetId), + "contentnode" => query.Where(question => question.ContentNodeId == targetId), + "entry" => query.Where(question => question.EntryId == targetId), + _ => query.Where(_ => false) + }; + } + + private async Task> LoadQuestionSelectionsAsync( + Guid tenantId, + IReadOnlyCollection questionReferenceIds, + CancellationToken cancellationToken) + { + var rows = await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), + async (provider, token) => + { + var systemDbContext = provider.GetRequiredService(); + return await ( + from reference in systemDbContext.TenantQuestionReferences.AsNoTracking() + join question in systemDbContext.Questions.AsNoTracking() + on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } + equals new { question.TenantId, question.Id } + join version in systemDbContext.QuestionVersions.AsNoTracking() + on new + { + TenantId = reference.QuestionOwnerTenantId, + reference.QuestionId, + Id = question.CurrentVersionId + } + equals new + { + version.TenantId, + version.QuestionId, + Id = (Guid?)version.Id + } + where reference.TenantId == tenantId && + questionReferenceIds.Contains(reference.Id) && + question.Status == QuestionStatus.Published + select new QuestionSelection( + reference.Id, + reference.QuestionOwnerTenantId, + reference.QuestionId, + version.Id, + question.Type, + question.TypeLabel, + question.Difficulty, + question.Tags, + version.Content, + version.Options, + version.CorrectOptionIndex, + version.CorrectOptionIndices, + version.AnswerText, + version.Explanation)) + .ToArrayAsync(token); + }, + cancellationToken); + + var byReference = rows.ToDictionary(row => row.QuestionReferenceId); + if (byReference.Count != questionReferenceIds.Distinct().Count()) + { + throw new LearningValidationException( + "practice_question_unavailable", + "One or more practice questions have no published version."); + } + + return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray(); + } + + private Task LoadSessionQuestionItemsAsync( + Guid tenantId, + Guid practiceSessionId, + CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), + async (provider, token) => + { + var systemDbContext = provider.GetRequiredService(); + return await ( + from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking() + where sessionQuestion.TenantId == tenantId && + sessionQuestion.PracticeSessionId == practiceSessionId + orderby sessionQuestion.Position + select new PracticeSessionQuestionItem( + sessionQuestion.Id, + sessionQuestion.QuestionReferenceId, + new QuestionLocator( + sessionQuestion.QuestionOwnerTenantId == tenantId + ? QuestionSource.Tenant + : QuestionSource.Platform, + sessionQuestion.QuestionId), + sessionQuestion.QuestionId, + sessionQuestion.QuestionType, + sessionQuestion.TypeLabelSnapshot, + sessionQuestion.DifficultySnapshot, + sessionQuestion.TagsSnapshot, + sessionQuestion.QuestionVersionId, + sessionQuestion.ContentSnapshot, + sessionQuestion.OptionsSnapshot)) + .ToArrayAsync(token); + }, + cancellationToken); + } + + private async Task GetPracticeSessionAsync( + LearningActor actor, + Guid? practiceSessionId, + CancellationToken cancellationToken) + { + if (!practiceSessionId.HasValue) + { + throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); + } + + var session = await dbContext.PracticeSessions + .SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Id == practiceSessionId.Value, + cancellationToken); + + if (session is null) + { + throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); + } + + return session; + } + + private async Task BuildPracticeSessionReportAsync( + LearningActor actor, + PracticeSession session, + CancellationToken cancellationToken) + { + var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.PracticeSessionId == session.Id) + .OrderBy(item => item.Position) + .ToArrayAsync(cancellationToken); + if (sessionQuestions.Length == 0) + { + throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot."); + } + + var answers = await dbContext.AnswerRecords + .AsNoTracking() + .Where(answer => + answer.TenantId == actor.TenantId && + answer.UserId == actor.UserId && + answer.PracticeSessionId == session.Id && + answer.IsCurrent) + .ToArrayAsync(cancellationToken); + var latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId); + var totalQuestions = sessionQuestions.Length; + var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id)); + var correctCount = sessionQuestions.Count(question => + latestAnswers.TryGetValue(question.Id, out var answer) && + answer.GradingStatus == AnswerGradingStatus.Correct); + var wrongCount = sessionQuestions.Count(question => + latestAnswers.TryGetValue(question.Id, out var answer) && + answer.GradingStatus == AnswerGradingStatus.Incorrect); + var pendingReviewCount = sessionQuestions.Count(question => + latestAnswers.TryGetValue(question.Id, out var answer) && + answer.GradingStatus == AnswerGradingStatus.PendingReview); + var unansweredCount = Math.Max(0, totalQuestions - answeredCount); + var totalScore = sessionQuestions.Sum(question => question.Score ?? 1); + var score = Math.Round(answers.Sum(answer => answer.AwardedScore ?? 0), 2); + var objectivelyGradedCount = correctCount + wrongCount; + var accuracy = objectivelyGradedCount == 0 + ? 0 + : Math.Round((decimal)correctCount / objectivelyGradedCount, 4); + var isFinal = pendingReviewCount == 0; + var submittedAt = DateTimeOffset.UtcNow; + var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds); + var wrongQuestionIds = sessionQuestions + .Where(question => + latestAnswers.TryGetValue(question.Id, out var answer) && + answer.GradingStatus == AnswerGradingStatus.Incorrect) + .Select(question => question.QuestionReferenceId) + .ToArray(); + var questionResults = sessionQuestions + .Select(question => + { + latestAnswers.TryGetValue(question.Id, out var answer); + return new + { + sessionQuestionId = question.Id, + questionReferenceId = question.QuestionReferenceId, + questionId = question.QuestionId, + source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform", + answered = answer is not null, + gradingStatus = answer?.GradingStatus.ToString(), + isCorrect = isFinal ? answer?.IsCorrect : null, + score = answer?.AwardedScore, + totalScore = question.Score ?? 1, + answeredAt = answer?.AnsweredAt, + correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null, + correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(), + answerText = isFinal ? question.AnswerTextSnapshot : null, + explanation = isFinal ? question.ExplanationSnapshot : null + }; + }) + .ToArray(); + var sectionStats = new[] + { + new + { + key = "default", + title = "默认", + questionCount = totalQuestions, + answeredCount, + correctCount, + wrongCount, + unansweredCount, + score, + totalScore, + accuracy, + pendingReviewCount, + sortOrder = 0 + } + }; + + var report = new PracticeSessionReport + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + BlueprintId = session.BlueprintId, + CollectionId = session.CollectionId, + Mode = session.Mode, + TotalQuestions = totalQuestions, + AnsweredCount = answeredCount, + CorrectCount = correctCount, + WrongCount = wrongCount, + UnansweredCount = unansweredCount, + Score = score, + TotalScore = totalScore, + Accuracy = accuracy, + DurationSeconds = durationSeconds, + StartedAt = session.StartedAt, + SubmittedAt = submittedAt, + Status = isFinal ? PracticeReportStatus.Final : PracticeReportStatus.PendingReview, + Version = 1, + IsFinal = isFinal, + PendingReviewCount = pendingReviewCount, + ScoringVersion = 1, + SectionStats = JsonSerializer.SerializeToElement(sectionStats), + QuestionResults = JsonSerializer.SerializeToElement(questionResults), + WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds), + Metadata = JsonSerializer.SerializeToElement(new + { + scoringVersion = 1 + }) + }; + dbContext.PracticeSessionReports.Add(report); + dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection + { + TenantId = actor.TenantId, + ReportId = report.Id, + PracticeSessionId = session.Id, + SectionKey = "default", + SectionName = "默认", + QuestionCount = totalQuestions, + AnsweredCount = answeredCount, + CorrectCount = correctCount, + WrongCount = wrongCount, + UnansweredCount = unansweredCount, + Score = score, + TotalScore = totalScore, + Accuracy = accuracy, + SortOrder = 0 + }); + + foreach (var question in sessionQuestions.Where(question => + latestAnswers.TryGetValue(question.Id, out var answer) && + answer.GradingStatus == AnswerGradingStatus.Incorrect)) + { + var wrongQuestion = await dbContext.WrongQuestions.FindAsync( + [actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken); + if (wrongQuestion is null) + { + dbContext.WrongQuestions.Add(new WrongQuestion + { + TenantId = actor.TenantId, + UserId = actor.UserId, + QuestionReferenceId = question.QuestionReferenceId, + QuestionOwnerTenantId = question.QuestionOwnerTenantId, + QuestionId = question.QuestionId, + WrongCount = 1, + LastWrongAt = submittedAt + }); + } + else + { + wrongQuestion.WrongCount++; + wrongQuestion.LastWrongAt = submittedAt; + wrongQuestion.ResolvedAt = null; + } + } + + return report; + } + + private async Task EnsureQuestionExistsAsync( + Guid tenantId, + Guid questionId, + CancellationToken cancellationToken) + { + var exists = await dbContext.Questions.AnyAsync( + question => + question.TenantId == tenantId && + question.Id == questionId && + question.Status == QuestionStatus.Published, + cancellationToken); + + if (!exists) + { + throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); + } + } + + private async Task EnsureWordExistsAsync( + Guid tenantId, + Guid wordId, + CancellationToken cancellationToken) + { + var exists = await dbContext.VocabularyWords.AnyAsync( + word => + word.TenantId == tenantId && + word.Id == wordId && + word.IsActive, + cancellationToken); + + if (!exists) + { + throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); + } + } + + private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion) + { + return new AnswerRecordItem( + record.Id, + record.SessionQuestionId, + record.PracticeSessionId, + record.SelectedOptions, + record.AnswerText, + record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted", + record.Revision, + record.ClientSequence, + sessionVersion, + record.AnsweredAt); + } + + private static WordProgressItem ToItem(UserWordProgress item) + { + return new WordProgressItem( + item.WordId, + item.Status, + item.CorrectCount, + item.WrongCount, + item.LastReviewAt, + item.NextReviewAt, + item.ReviewCount, + item.CorrectStreak, + item.LastResult, + item.DueLevel, + item.Metadata); + } + + private static PracticeSessionItem ToItem(PracticeSession item) + { + return new PracticeSessionItem( + item.Id, + item.Mode, + item.TargetType, + item.TargetId, + item.BlueprintId, + item.CollectionId, + item.EntryId, + item.ContentNodeId, + item.QuestionCount, + item.DurationMinutes, + item.TotalScore, + item.AccessMode, + item.AccessEntitlementId, + item.ConsumedFreeQuota, + item.AccessSnapshot, + item.StartedAt, + item.FinishedAt, + item.ExpiresAt, + item.Metadata, + item.Status, + item.Version, + item.LastClientSequence); + } + + private static PracticeSessionReportItem ToItem(PracticeSessionReport item) + { + return new PracticeSessionReportItem( + item.Id, + item.PracticeSessionId, + item.BlueprintId, + item.CollectionId, + item.Mode, + item.TotalQuestions, + item.AnsweredCount, + item.CorrectCount, + item.WrongCount, + item.UnansweredCount, + item.Score, + item.TotalScore, + item.Accuracy, + item.DurationSeconds, + item.StartedAt, + item.SubmittedAt, + item.Status, + item.Version, + item.IsFinal, + item.PendingReviewCount, + item.ScoringVersion, + item.SectionStats, + item.QuestionResults, + item.WrongQuestionIds, + item.Metadata); + } + + private static List ReadGuidArray(JsonElement value) + { + if (value.ValueKind is not JsonValueKind.Array) + { + return []; + } + + return value.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id) + ? (Guid?)id + : null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + } + + private static void EnsureAnswerSessionState( + PracticeSession session, + SubmitAnswerCommand command) + { + if (session.Status != PracticeSessionStatus.Active) + { + throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers."); + } + if (session.Version != command.ExpectedSessionVersion) + { + throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); + } + if (command.ClientSequence <= session.LastClientSequence) + { + throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session."); + } + } + + private static JsonElement BuildGradingRules(QuestionSelection selection) => + JsonSerializer.SerializeToElement(new + { + version = 1, + normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase) + ? "nfkc_trim_casefold_whitespace" + : "exact" + }); + + private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new + { + command.SessionQuestionId, + command.ExpectedSessionVersion, + command.ClientSequence, + selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [], + answerText = command.AnswerText?.Trim() + })); + + private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new + { + command.PracticeSessionId, + command.ExpectedSessionVersion + })); + + private static string Hash(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now) + { + if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) + { + return "finished"; + } + + if (session.Status == PracticeSessionStatus.Expired || + session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now) + { + return "expired"; + } + + return "active"; + } + + private static string NormalizeMode(string? mode) + { + return NormalizeEnumValue(mode) switch + { + "sequential" => "sequential", + "random" => "random", + "mockexam" => "mock_exam", + "paper" => "paper", + "wrongreview" => "wrong_review", + "favoritereview" => "favorite_review", + _ => "chapter" + }; + } + + private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) + { + return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status); + } + + private static int ResolveLimit(int? limit) + { + return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + } + + private static string? NormalizeEnumValue(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal); + } + + private sealed record PracticeAssembly( + string Mode, + string? TargetType, + Guid? TargetId, + Guid? BlueprintId, + Guid? CollectionId, + Guid? EntryId, + Guid? ContentNodeId, + int QuestionLimit, + int? DurationMinutes, + decimal? TotalScore); + + private sealed record QuestionSelection( + Guid QuestionReferenceId, + Guid QuestionOwnerTenantId, + Guid QuestionId, + Guid QuestionVersionId, + string QuestionType, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + string? Content, + JsonElement Options, + int? CorrectOptionIndex, + JsonElement CorrectOptionIndices, + string? AnswerText, + string? Explanation); +} diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs index da8c971..9daa6a6 100644 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -17,7 +17,7 @@ using ZLinq; namespace Tiku.Infrastructure.Learning; -public sealed class LearningActivityService( +public sealed partial class LearningActivityService( TikuDbContext dbContext, IQuestionReferenceService questionReferenceService, IPublicQuestionAccessPolicy publicQuestionAccessPolicy, @@ -32,1764 +32,5 @@ public sealed class LearningActivityService( private static readonly Counter SubmissionConflicts = LearningMeter.CreateCounter("tiku.learning.submission.conflicts"); private static readonly Counter ScoringFailures = LearningMeter.CreateCounter("tiku.learning.scoring.failures"); - public async Task GetStatsAsync( - LearningActor actor, - CancellationToken cancellationToken = default) - { - var answers = dbContext.AnswerRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.IsCurrent && - item.GradingStatus != AnswerGradingStatus.PendingReview && - item.GradingStatus != AnswerGradingStatus.LegacyUnverified); - return new LearningStatsItem( - await answers.CountAsync(cancellationToken), - await answers.CountAsync(item => item.IsCorrect == true, cancellationToken), - await dbContext.WrongQuestions.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null, - cancellationToken), - await dbContext.FavoriteQuestions.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, - cancellationToken), - await dbContext.UserWordFavorites.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, - cancellationToken), - await dbContext.UserWordProgress.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, - cancellationToken), - await dbContext.PracticeSessions.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, - cancellationToken), - await dbContext.PracticeSessionReports.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == PracticeReportStatus.Final, - cancellationToken)); - } - public async Task> GetTrendAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit)); - var rows = await dbContext.AnswerRecords.AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.IsCurrent && - item.GradingStatus != AnswerGradingStatus.PendingReview && - item.GradingStatus != AnswerGradingStatus.LegacyUnverified && - item.AnsweredAt >= since) - .Select(item => new { item.AnsweredAt, item.IsCorrect }) - .ToArrayAsync(cancellationToken); - var items = rows - .AsValueEnumerable() - .GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime)) - .OrderBy(group => group.Key) - .Select(group => new LearningTrendItem( - group.Key, - group.Count(), - group.Count(item => item.IsCorrect == true), - group.Count(item => item.IsCorrect == false))) - .ToArray(); - return new LearningList(items); - } - - public async Task GetLeaderboardAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var rows = await dbContext.AnswerRecords.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && - item.IsCurrent && - item.GradingStatus != AnswerGradingStatus.PendingReview && - item.GradingStatus != AnswerGradingStatus.LegacyUnverified) - .GroupBy(item => item.UserId) - .Select(group => new - { - UserId = group.Key, - AnswerCount = group.Count(), - CorrectCount = group.Count(item => item.IsCorrect == true), - WrongCount = group.Count(item => item.IsCorrect == false) - }) - .OrderByDescending(item => item.CorrectCount) - .ThenByDescending(item => item.AnswerCount) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var userIds = rows.Select(item => item.UserId).ToArray(); - var names = await dbContext.Users.AsNoTracking() - .Where(item => userIds.Contains(item.Id)) - .ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken); - var items = rows - .Select(item => new LearningLeaderboardItem( - item.UserId, - names.GetValueOrDefault(item.UserId), - item.AnswerCount, - item.CorrectCount, - item.WrongCount, - item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4))) - .ToArray(); - return new LearningLeaderboardResult( - "correct_count", - "all", - items, - items.FirstOrDefault(item => item.UserId == actor.UserId), - DateTimeOffset.UtcNow); - } - - public async Task SubmitAnswerAsync( - LearningActor actor, - SubmitAnswerCommand command, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) - { - throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); - } - if (command.SelectedOptionIndices?.Any(index => index < 0) == true) - { - throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values."); - } - - var now = DateTimeOffset.UtcNow; - var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken); - - if (sessionQuestion is null) - { - throw new LearningResourceNotFoundException( - "session_question_not_found", - "An active practice session question was not found."); - } - - var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Id == sessionQuestion.PracticeSessionId, cancellationToken); - if (session is null) - { - throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); - } - - var requestHash = HashAnswer(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == session.Id && - item.OperationType == "answer" && - item.IdempotencyKey == command.IdempotencyKey, cancellationToken); - if (existingOperation is not null) - { - if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) - { - AnswerConflicts.Add(1); - throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); - } - - IdempotencyReplays.Add(1); - return existingOperation.ResponseSnapshot.Deserialize() - ?? throw new InvalidOperationException("The stored answer response is invalid."); - } - - if (session.ExpiresAt.HasValue && session.ExpiresAt <= now) - { - session.Status = PracticeSessionStatus.Expired; - session.Version++; - await dbContext.SaveChangesAsync(cancellationToken); - throw new LearningValidationException("practice_session_expired", "The practice session has expired."); - } - EnsureAnswerSessionState(session, command); - var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer => - answer.TenantId == actor.TenantId && - answer.UserId == actor.UserId && - answer.PracticeSessionId == session.Id && - answer.SessionQuestionId == sessionQuestion.Id && - answer.IsCurrent, cancellationToken); - if (current is not null) - { - current.IsCurrent = false; - } - - var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? []; - var score = sessionQuestion.Score ?? 1; - QuestionGradingResult grading; - try - { - grading = QuestionGrader.Grade(new QuestionGradingInput( - sessionQuestion.QuestionType, - sessionQuestion.CorrectOptionIndexSnapshot, - sessionQuestion.CorrectOptionIndicesSnapshot, - sessionQuestion.AnswerTextSnapshot, - sessionQuestion.GradingRulesSnapshot, - selectedIndices, - command.AnswerText, - score)); - } - catch (InvalidOperationException exception) - { - ScoringFailures.Add(1); - logger.LogWarning(exception, - "Question scoring failed for tenant {TenantId}, session {PracticeSessionId}, question {SessionQuestionId}", - actor.TenantId, session.Id, sessionQuestion.Id); - throw new LearningValidationException("question_grading_rule_invalid", exception.Message); - } - - var record = new AnswerRecord - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PracticeSessionId = sessionQuestion.PracticeSessionId, - SessionQuestionId = sessionQuestion.Id, - SelectedOptions = JsonSerializer.SerializeToElement(selectedIndices), - AnswerText = command.AnswerText, - IsCorrect = grading.IsCorrect, - GradingStatus = grading.Status, - AwardedScore = grading.AwardedScore, - Revision = (current?.Revision ?? 0) + 1, - ClientSequence = command.ClientSequence, - IdempotencyKey = command.IdempotencyKey.Trim(), - RequestHash = requestHash, - IsCurrent = true, - AnsweredAt = now, - CreatedAt = now - }; - dbContext.AnswerRecords.Add(record); - session.Version++; - session.LastClientSequence = command.ClientSequence; - var response = ToItem(record, session.Version); - dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PracticeSessionId = session.Id, - OperationType = "answer", - IdempotencyKey = command.IdempotencyKey.Trim(), - RequestHash = requestHash, - ResponseSnapshot = JsonSerializer.SerializeToElement(response), - CompletedAt = now - }); - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException) - { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); - } - catch (DbUpdateException exception) when ( - exception.InnerException is Npgsql.PostgresException postgresException && - postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation) - { - dbContext.ChangeTracker.Clear(); - var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == session.Id && - item.OperationType == "answer" && - item.IdempotencyKey == command.IdempotencyKey, cancellationToken); - if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal)) - { - IdempotencyReplays.Add(1); - return replay.ResponseSnapshot.Deserialize() - ?? throw new InvalidOperationException("The stored answer response is invalid."); - } - AnswerConflicts.Add(1); - throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation."); - } - - return response; - } - - public async Task> GetFavoriteQuestionsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var items = await dbContext.FavoriteQuestions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId) - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new FavoriteQuestionItem( - item.QuestionReferenceId, - new QuestionLocator( - item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, - item.QuestionId), - item.CreatedAt)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items); - } - - public async Task ToggleFavoriteQuestionAsync( - LearningActor actor, - QuestionActionCommand command, - CancellationToken cancellationToken = default) - { - var reference = await questionReferenceService.ResolveAsync( - actor.TenantId, - actor.UserId, - command.Locator, - cancellationToken); - - var favorite = command.Favorite ?? true; - var item = await dbContext.FavoriteQuestions.FindAsync( - [actor.TenantId, actor.UserId, reference.Id], - cancellationToken); - - if (favorite) - { - if (item is null) - { - dbContext.FavoriteQuestions.Add(new FavoriteQuestion - { - TenantId = actor.TenantId, - UserId = actor.UserId, - QuestionReferenceId = reference.Id, - QuestionOwnerTenantId = reference.QuestionOwnerTenantId, - QuestionId = reference.QuestionId, - Source = reference.Source.ToString().ToLowerInvariant(), - CreatedAt = DateTimeOffset.UtcNow - }); - } - } - else if (item is not null) - { - dbContext.FavoriteQuestions.Remove(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new LearningActionResult(true, favorite); - } - - public async Task> GetWrongQuestionsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.WrongQuestions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId); - - if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(item => item.ResolvedAt == null); - } - - var items = await query - .OrderByDescending(item => item.LastWrongAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new WrongQuestionItem( - item.QuestionReferenceId, - new QuestionLocator( - item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, - item.QuestionId), - item.WrongCount, - item.LastWrongAt, - item.ResolvedAt)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items); - } - - public async Task ResolveWrongQuestionAsync( - LearningActor actor, - QuestionActionCommand command, - CancellationToken cancellationToken = default) - { - var reference = await questionReferenceService.ResolveAsync( - actor.TenantId, - actor.UserId, - command.Locator, - cancellationToken); - var item = await dbContext.WrongQuestions.FindAsync( - [actor.TenantId, actor.UserId, reference.Id], - cancellationToken); - - if (item is null) - { - throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found."); - } - - item.ResolvedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); - return new LearningActionResult(true); - } - - public async Task GetWrongQuestionReviewPlanAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var items = await dbContext.WrongQuestions.AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.ResolvedAt == null) - .OrderByDescending(item => item.WrongCount) - .ThenBy(item => item.LastWrongAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new WrongQuestionReviewPlanItem( - item.QuestionReferenceId, - new QuestionLocator( - item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, - item.QuestionId), - item.WrongCount, - item.LastWrongAt)) - .ToArrayAsync(cancellationToken); - return new WrongQuestionReviewPlan( - items, - JsonSerializer.SerializeToElement(new - { - mode = "wrong_review", - questionCount = items.Length, - recommendedEndpoint = "/api/learning/practice-sessions" - })); - } - - public async Task> GetWordProgressAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.UserWordProgress - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId); - - if (filter.UnitId.HasValue) - { - query = query.Where(item => dbContext.VocabularyWords.Any(word => - word.TenantId == actor.TenantId && - word.Id == item.WordId && - word.UnitId == filter.UnitId.Value)); - } - - if (TryParseWordProgressStatus(filter.Status, out var status)) - { - query = query.Where(item => item.Status == status); - } - - var items = await query - .OrderBy(item => item.NextReviewAt == null) - .ThenBy(item => item.NextReviewAt) - .ThenByDescending(item => item.UpdatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new WordProgressItem( - item.WordId, - item.Status, - item.CorrectCount, - item.WrongCount, - item.LastReviewAt, - item.NextReviewAt, - item.ReviewCount, - item.CorrectStreak, - item.LastResult, - item.DueLevel, - item.Metadata)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items); - } - - public async Task UpdateWordProgressAsync( - LearningActor actor, - WordProgressCommand command, - CancellationToken cancellationToken = default) - { - await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); - - var now = DateTimeOffset.UtcNow; - var item = await dbContext.UserWordProgress - .SingleOrDefaultAsync( - progress => - progress.TenantId == actor.TenantId && - progress.UserId == actor.UserId && - progress.WordId == command.WordId, - cancellationToken); - - if (item is null) - { - item = new UserWordProgress - { - TenantId = actor.TenantId, - UserId = actor.UserId, - WordId = command.WordId - }; - dbContext.UserWordProgress.Add(item); - } - - if (TryParseWordProgressStatus(command.Status, out var status)) - { - item.Status = status; - } - else if (string.IsNullOrWhiteSpace(command.Status)) - { - item.Status = WordProgressStatus.Learning; - } - else - { - throw new LearningValidationException("invalid_word_status", "Word progress status is invalid."); - } - - var correctDelta = command.CorrectDelta ?? 0; - var wrongDelta = command.WrongDelta ?? 0; - item.CorrectCount += correctDelta; - item.WrongCount += wrongDelta; - item.ReviewCount += correctDelta + wrongDelta; - item.CorrectStreak = wrongDelta > 0 - ? 0 - : item.CorrectStreak + correctDelta; - item.LastResult = wrongDelta > 0 - ? WordReviewResult.Wrong - : correctDelta > 0 - ? WordReviewResult.Correct - : item.LastResult; - item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt; - item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt; - item.DueLevel = item.Status == WordProgressStatus.Mastered - ? WordDueLevel.Mastered - : item.WrongCount > 0 && item.CorrectStreak == 0 - ? WordDueLevel.Again - : item.DueLevel; - - await dbContext.SaveChangesAsync(cancellationToken); - return ToItem(item); - } - - public async Task GetWordReviewPlanAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var now = DateTimeOffset.UtcNow; - var query = dbContext.UserWordProgress.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); - if (filter.UnitId.HasValue) - { - query = query.Where(item => dbContext.VocabularyWords.Any(word => - word.TenantId == actor.TenantId && - word.Id == item.WordId && - word.UnitId == filter.UnitId.Value)); - } - - var items = await query - .Where(item => item.NextReviewAt == null || item.NextReviewAt <= now) - .OrderBy(item => item.NextReviewAt == null) - .ThenBy(item => item.NextReviewAt) - .ThenByDescending(item => item.WrongCount) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new WordReviewPlanItem( - item.WordId, - item.Status, - item.NextReviewAt, - item.CorrectCount, - item.WrongCount, - item.DueLevel)) - .ToArrayAsync(cancellationToken); - return new WordReviewPlan( - items, - JsonSerializer.SerializeToElement(new - { - mode = "word_review", - wordCount = items.Length - })); - } - - public Task ReviewWordAsync( - LearningActor actor, - WordReviewCommand command, - CancellationToken cancellationToken = default) - { - var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) || - string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase); - return UpdateWordProgressAsync( - actor, - new WordProgressCommand( - command.WordId, - correct ? "Reviewing" : "Learning", - correct ? 1 : 0, - correct ? 0 : 1, - command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)), - cancellationToken); - } - - public async Task GetWordStatsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var now = DateTimeOffset.UtcNow; - var query = dbContext.UserWordProgress.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); - if (filter.UnitId.HasValue) - { - query = query.Where(item => dbContext.VocabularyWords.Any(word => - word.TenantId == actor.TenantId && - word.Id == item.WordId && - word.UnitId == filter.UnitId.Value)); - } - - return new WordStatsItem( - await query.CountAsync(cancellationToken), - await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken), - await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken), - await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken), - await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken), - await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken), - await dbContext.UserWordFavorites.AsNoTracking().CountAsync( - item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, - cancellationToken)); - } - - public async Task> GetFavoriteWordsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.UserWordFavorites - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId); - - if (filter.UnitId.HasValue) - { - query = query.Where(item => dbContext.VocabularyWords.Any(word => - word.TenantId == actor.TenantId && - word.Id == item.WordId && - word.UnitId == filter.UnitId.Value)); - } - - var items = await query - .OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new FavoriteWordItem( - item.WordId, - item.Note, - item.FavoritedAt)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items); - } - - public async Task ToggleFavoriteWordAsync( - LearningActor actor, - FavoriteWordCommand command, - CancellationToken cancellationToken = default) - { - await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); - - var favorite = command.Favorite ?? true; - var item = await dbContext.UserWordFavorites - .SingleOrDefaultAsync( - favoriteWord => - favoriteWord.TenantId == actor.TenantId && - favoriteWord.UserId == actor.UserId && - favoriteWord.WordId == command.WordId, - cancellationToken); - - if (favorite) - { - if (item is null) - { - item = new UserWordFavorite - { - TenantId = actor.TenantId, - UserId = actor.UserId, - WordId = command.WordId - }; - dbContext.UserWordFavorites.Add(item); - } - - item.Note = command.Note ?? item.Note; - item.FavoritedAt ??= DateTimeOffset.UtcNow; - } - else if (item is not null) - { - dbContext.UserWordFavorites.Remove(item); - } - - await dbContext.SaveChangesAsync(cancellationToken); - return new LearningActionResult(true, favorite); - } - - public async Task CreatePracticeSessionAsync( - LearningActor actor, - PracticeSessionCommand command, - CancellationToken cancellationToken = default) - { - var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken); - var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken); - if (questionReferenceIds.Count == 0) - { - throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target."); - } - - - var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync( - reference => - reference.TenantId == actor.TenantId && - questionReferenceIds.Contains(reference.Id) && - reference.Source == QuestionSource.Platform, - cancellationToken); - if (containsPlatformQuestion) - { - await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken); - } - - var now = DateTimeOffset.UtcNow; - var session = new PracticeSession - { - TenantId = actor.TenantId, - UserId = actor.UserId, - Mode = assembly.Mode, - TargetType = assembly.TargetType, - TargetId = assembly.TargetId, - BlueprintId = assembly.BlueprintId, - CollectionId = assembly.CollectionId, - EntryId = assembly.EntryId, - ContentNodeId = assembly.ContentNodeId, - QuestionCount = questionReferenceIds.Count, - DurationMinutes = assembly.DurationMinutes, - TotalScore = assembly.TotalScore, - ExpiresAt = assembly.DurationMinutes.HasValue - ? now.AddMinutes(assembly.DurationMinutes.Value) - : null, - AccessMode = PracticeAccessMode.Free, - ConsumedFreeQuota = questionReferenceIds.Count, - AccessSnapshot = JsonSerializer.SerializeToElement(new - { - strategy = "v1_free", - requestedCount = assembly.QuestionLimit, - grantedCount = questionReferenceIds.Count - }), - Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined - ? JsonDefaults.Object() - : command.Metadata - }; - dbContext.PracticeSessions.Add(session); - - var selections = await LoadQuestionSelectionsAsync( - actor.TenantId, - questionReferenceIds, - cancellationToken); - foreach (var selection in selections) - { - if (!QuestionGrader.HasValidAuthoritativeAnswer( - selection.QuestionType, - selection.CorrectOptionIndex, - selection.CorrectOptionIndices, - selection.AnswerText)) - { - throw new LearningValidationException( - "practice_question_grading_rule_invalid", - $"Question '{selection.QuestionId}' has no valid authoritative grading rule."); - } - } - var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0 - ? session.TotalScore.Value / selections.Count - : (decimal?)null; - dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) => - new PracticeSessionQuestion - { - TenantId = actor.TenantId, - PracticeSessionId = session.Id, - QuestionReferenceId = selection.QuestionReferenceId, - QuestionOwnerTenantId = selection.QuestionOwnerTenantId, - QuestionId = selection.QuestionId, - QuestionVersionId = selection.QuestionVersionId, - Position = index, - Score = scorePerQuestion, - QuestionType = selection.QuestionType, - TypeLabelSnapshot = selection.TypeLabel, - DifficultySnapshot = selection.Difficulty, - TagsSnapshot = selection.Tags, - ContentSnapshot = selection.Content, - OptionsSnapshot = selection.Options, - CorrectOptionIndexSnapshot = selection.CorrectOptionIndex, - CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices, - AnswerTextSnapshot = selection.AnswerText, - ExplanationSnapshot = selection.Explanation, - GradingRulesSnapshot = BuildGradingRules(selection), - SnapshotVersion = 1 - })); - dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PracticeSessionId = session.Id, - EventType = PracticeAccessEventType.SessionCreated, - AccessMode = PracticeAccessEventMode.Free, - RequestedCount = assembly.QuestionLimit, - GrantedCount = selections.Count, - ConsumedFreeQuota = selections.Count, - Metadata = session.AccessSnapshot - }); - - await dbContext.SaveChangesAsync(cancellationToken); - return ToItem(session); - } - - public async Task GetPracticeSessionDetailAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); - var orderedQuestions = await LoadSessionQuestionItemsAsync( - actor.TenantId, - session.Id, - cancellationToken); - - var answers = await dbContext.AnswerRecords - .AsNoTracking() - .Where(answer => - answer.TenantId == actor.TenantId && - answer.UserId == actor.UserId && - answer.PracticeSessionId == session.Id && - answer.IsCurrent) - .ToArrayAsync(cancellationToken); - var answersByQuestion = answers - .GroupBy(answer => answer.SessionQuestionId) - .ToDictionary( - group => group.Key, - group => ToItem( - group.OrderByDescending(answer => answer.Revision).First(), - session.Version)); - - return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion); - } - - public async Task SubmitPracticeSessionAsync( - LearningActor actor, - SubmitPracticeSessionCommand command, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) - { - throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); - } - - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken); - var requestHash = HashSubmission(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == session.Id && - item.OperationType == "submit" && - item.IdempotencyKey == command.IdempotencyKey, cancellationToken); - if (existingOperation is not null) - { - if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) - { - SubmissionConflicts.Add(1); - throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); - } - - IdempotencyReplays.Add(1); - return existingOperation.ResponseSnapshot.Deserialize() - ?? throw new InvalidOperationException("The stored report response is invalid."); - } - - if (session.Status != PracticeSessionStatus.Active) - { - throw new LearningValidationException("practice_session_not_active", "Only an active practice session can be submitted."); - } - if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow) - { - session.Status = PracticeSessionStatus.Expired; - session.Version++; - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - throw new LearningValidationException("practice_session_expired", "The practice session has expired."); - } - if (session.Version != command.ExpectedSessionVersion) - { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before submitting."); - } - - session.Status = PracticeSessionStatus.Scoring; - session.Version++; - var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken); - session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview; - session.FinishedAt = report.SubmittedAt; - session.Version++; - var response = ToItem(report); - dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PracticeSessionId = session.Id, - OperationType = "submit", - IdempotencyKey = command.IdempotencyKey.Trim(), - RequestHash = requestHash, - ResponseSnapshot = JsonSerializer.SerializeToElement(response), - CompletedAt = report.SubmittedAt - }); - try - { - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException) - { - SubmissionConflicts.Add(1); - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed during submission."); - } - catch (DbUpdateException exception) when (exception.InnerException is Npgsql.NpgsqlException) - { - SubmissionConflicts.Add(1); - throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request."); - } - - return response; - } - - public async Task GetPracticeSessionReportAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - if (!filter.PracticeSessionId.HasValue) - { - throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - } - - var report = await dbContext.PracticeSessionReports - .AsNoTracking() - .SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == filter.PracticeSessionId.Value, - cancellationToken); - - if (report is null) - { - throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found."); - } - - return ToItem(report); - } - - public async Task> GetPracticeReportsAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.PracticeSessionReports - .AsNoTracking() - .Where(report => - report.TenantId == actor.TenantId && - report.UserId == actor.UserId); - - if (filter.BlueprintId.HasValue) - { - query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Mode)) - { - query = query.Where(report => report.Mode == filter.Mode.Trim()); - } - - var items = await query - .OrderByDescending(report => report.SubmittedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items.Select(ToItem).ToArray()); - } - - public async Task> GetPracticeHistoryAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.PracticeSessions - .AsNoTracking() - .Where(session => - session.TenantId == actor.TenantId && - session.UserId == actor.UserId); - - if (!string.IsNullOrWhiteSpace(filter.Mode)) - { - query = query.Where(session => session.Mode == filter.Mode.Trim()); - } - - var rows = await query - .GroupJoin( - dbContext.PracticeSessionReports.AsNoTracking(), - session => new { session.TenantId, PracticeSessionId = session.Id }, - report => new { report.TenantId, report.PracticeSessionId }, - (session, reports) => new { session, report = reports.FirstOrDefault() }) - .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var now = DateTimeOffset.UtcNow; - var items = rows - .Select(row => new PracticeHistoryItem( - row.session.Id, - row.session.Mode, - row.session.TargetType, - row.session.TargetId, - row.session.BlueprintId, - row.session.CollectionId, - row.session.EntryId, - row.session.ContentNodeId, - row.session.QuestionCount, - row.report?.AnsweredCount ?? 0, - row.report?.CorrectCount ?? 0, - row.report?.WrongCount ?? 0, - row.session.StartedAt, - row.session.FinishedAt, - row.session.ExpiresAt, - ResolvePracticeSessionHistoryStatus(row.session, now), - row.report?.Id, - row.report?.Score, - row.report?.TotalScore, - row.report?.Accuracy)) - .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim()) - .ToArray(); - - return new LearningList(items); - } - - private async Task BuildPracticeAssemblyAsync( - Guid tenantId, - PracticeSessionCommand command, - CancellationToken cancellationToken) - { - var mode = NormalizeMode(command.Mode); - var assembly = new PracticeAssembly( - mode, - command.TargetType, - command.TargetId, - command.BlueprintId, - command.CollectionId, - command.EntryId, - command.ContentNodeId, - Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit), - command.DurationMinutes, - command.TotalScore); - - if (!command.BlueprintId.HasValue) - { - return assembly; - } - - var blueprint = await dbContext.PracticeBlueprints - .AsNoTracking() - .SingleOrDefaultAsync( - item => - item.TenantId == tenantId && - item.Id == command.BlueprintId.Value && - item.Status == ContentStatus.Active, - cancellationToken); - - if (blueprint is null) - { - throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found."); - } - - return assembly with - { - Mode = NormalizeMode(blueprint.Mode.ToString()), - TargetType = command.TargetType ?? "blueprint", - TargetId = command.TargetId ?? blueprint.Id, - CollectionId = command.CollectionId ?? blueprint.CollectionId, - EntryId = command.EntryId ?? blueprint.EntryId, - ContentNodeId = command.ContentNodeId ?? blueprint.NodeId, - QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit), - DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes, - TotalScore = command.TotalScore ?? blueprint.TotalScore - }; - } - - private async Task> CollectQuestionReferenceIdsAsync( - LearningActor actor, - PracticeAssembly assembly, - CancellationToken cancellationToken) - { - if (assembly.Mode == "wrong_review") - { - return await dbContext.WrongQuestions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.ResolvedAt == null) - .OrderByDescending(item => item.WrongCount) - .ThenBy(item => item.LastWrongAt) - .Take(assembly.QuestionLimit) - .Select(item => item.QuestionReferenceId) - .ToListAsync(cancellationToken); - } - - if (assembly.Mode == "favorite_review") - { - return await dbContext.FavoriteQuestions - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId) - .OrderByDescending(item => item.CreatedAt) - .Take(assembly.QuestionLimit) - .Select(item => item.QuestionReferenceId) - .ToListAsync(cancellationToken); - } - - if (assembly.CollectionId.HasValue) - { - return await dbContext.QuestionCollectionItems - .AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.CollectionId == assembly.CollectionId.Value) - .OrderBy(item => item.SortOrder) - .Take(assembly.QuestionLimit) - .Select(item => item.QuestionReferenceId) - .ToListAsync(cancellationToken); - } - - var query = dbContext.Questions - .AsNoTracking() - .Where(question => - question.TenantId == actor.TenantId && - question.Status == QuestionStatus.Published); - - if (assembly.ContentNodeId.HasValue) - { - query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value); - } - else if (assembly.EntryId.HasValue) - { - query = query.Where(question => question.EntryId == assembly.EntryId.Value); - } - else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType)) - { - query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value); - } - else - { - throw new LearningValidationException("practice_target_required", "Practice target is required."); - } - - var questionIds = await query - .OrderBy(question => question.CreatedAt) - .Take(assembly.QuestionLimit) - .Select(question => question.Id) - .ToListAsync(cancellationToken); - var referenceIds = new List(questionIds.Count); - foreach (var questionId in questionIds) - { - var reference = await questionReferenceService.ResolveAsync( - actor.TenantId, - actor.UserId, - new QuestionLocator(QuestionSource.Tenant, questionId), - cancellationToken); - referenceIds.Add(reference.Id); - } - - return referenceIds; - } - - private static IQueryable ApplyLegacyTargetFilter( - IQueryable query, - string? targetType, - Guid targetId) - { - return NormalizeEnumValue(targetType) switch - { - "subject" => query.Where(question => question.SubjectId == targetId), - "category" => query.Where(question => question.CategoryId == targetId), - "node" => query.Where(question => question.NodeId == targetId), - "questionbank" => query.Where(question => question.QuestionBankId == targetId), - "contentnode" => query.Where(question => question.ContentNodeId == targetId), - "entry" => query.Where(question => question.EntryId == targetId), - _ => query.Where(_ => false) - }; - } - - private async Task> LoadQuestionSelectionsAsync( - Guid tenantId, - IReadOnlyCollection questionReferenceIds, - CancellationToken cancellationToken) - { - var rows = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), - "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), - async (provider, token) => - { - var systemDbContext = provider.GetRequiredService(); - return await ( - from reference in systemDbContext.TenantQuestionReferences.AsNoTracking() - join question in systemDbContext.Questions.AsNoTracking() - on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } - equals new { question.TenantId, question.Id } - join version in systemDbContext.QuestionVersions.AsNoTracking() - on new - { - TenantId = reference.QuestionOwnerTenantId, - reference.QuestionId, - Id = question.CurrentVersionId - } - equals new - { - version.TenantId, - version.QuestionId, - Id = (Guid?)version.Id - } - where reference.TenantId == tenantId && - questionReferenceIds.Contains(reference.Id) && - question.Status == QuestionStatus.Published - select new QuestionSelection( - reference.Id, - reference.QuestionOwnerTenantId, - reference.QuestionId, - version.Id, - question.Type, - question.TypeLabel, - question.Difficulty, - question.Tags, - version.Content, - version.Options, - version.CorrectOptionIndex, - version.CorrectOptionIndices, - version.AnswerText, - version.Explanation)) - .ToArrayAsync(token); - }, - cancellationToken); - - var byReference = rows.ToDictionary(row => row.QuestionReferenceId); - if (byReference.Count != questionReferenceIds.Distinct().Count()) - { - throw new LearningValidationException( - "practice_question_unavailable", - "One or more practice questions have no published version."); - } - - return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray(); - } - - private Task LoadSessionQuestionItemsAsync( - Guid tenantId, - Guid practiceSessionId, - CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), - "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), - async (provider, token) => - { - var systemDbContext = provider.GetRequiredService(); - return await ( - from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking() - where sessionQuestion.TenantId == tenantId && - sessionQuestion.PracticeSessionId == practiceSessionId - orderby sessionQuestion.Position - select new PracticeSessionQuestionItem( - sessionQuestion.Id, - sessionQuestion.QuestionReferenceId, - new QuestionLocator( - sessionQuestion.QuestionOwnerTenantId == tenantId - ? QuestionSource.Tenant - : QuestionSource.Platform, - sessionQuestion.QuestionId), - sessionQuestion.QuestionId, - sessionQuestion.QuestionType, - sessionQuestion.TypeLabelSnapshot, - sessionQuestion.DifficultySnapshot, - sessionQuestion.TagsSnapshot, - sessionQuestion.QuestionVersionId, - sessionQuestion.ContentSnapshot, - sessionQuestion.OptionsSnapshot)) - .ToArrayAsync(token); - }, - cancellationToken); - } - - private async Task GetPracticeSessionAsync( - LearningActor actor, - Guid? practiceSessionId, - CancellationToken cancellationToken) - { - if (!practiceSessionId.HasValue) - { - throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - } - - var session = await dbContext.PracticeSessions - .SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Id == practiceSessionId.Value, - cancellationToken); - - if (session is null) - { - throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); - } - - return session; - } - - private async Task BuildPracticeSessionReportAsync( - LearningActor actor, - PracticeSession session, - CancellationToken cancellationToken) - { - var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking() - .Where(item => - item.TenantId == actor.TenantId && - item.PracticeSessionId == session.Id) - .OrderBy(item => item.Position) - .ToArrayAsync(cancellationToken); - if (sessionQuestions.Length == 0) - { - throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot."); - } - - var answers = await dbContext.AnswerRecords - .AsNoTracking() - .Where(answer => - answer.TenantId == actor.TenantId && - answer.UserId == actor.UserId && - answer.PracticeSessionId == session.Id && - answer.IsCurrent) - .ToArrayAsync(cancellationToken); - var latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId); - var totalQuestions = sessionQuestions.Length; - var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id)); - var correctCount = sessionQuestions.Count(question => - latestAnswers.TryGetValue(question.Id, out var answer) && - answer.GradingStatus == AnswerGradingStatus.Correct); - var wrongCount = sessionQuestions.Count(question => - latestAnswers.TryGetValue(question.Id, out var answer) && - answer.GradingStatus == AnswerGradingStatus.Incorrect); - var pendingReviewCount = sessionQuestions.Count(question => - latestAnswers.TryGetValue(question.Id, out var answer) && - answer.GradingStatus == AnswerGradingStatus.PendingReview); - var unansweredCount = Math.Max(0, totalQuestions - answeredCount); - var totalScore = sessionQuestions.Sum(question => question.Score ?? 1); - var score = Math.Round(answers.Sum(answer => answer.AwardedScore ?? 0), 2); - var objectivelyGradedCount = correctCount + wrongCount; - var accuracy = objectivelyGradedCount == 0 - ? 0 - : Math.Round((decimal)correctCount / objectivelyGradedCount, 4); - var isFinal = pendingReviewCount == 0; - var submittedAt = DateTimeOffset.UtcNow; - var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds); - var wrongQuestionIds = sessionQuestions - .Where(question => - latestAnswers.TryGetValue(question.Id, out var answer) && - answer.GradingStatus == AnswerGradingStatus.Incorrect) - .Select(question => question.QuestionReferenceId) - .ToArray(); - var questionResults = sessionQuestions - .Select(question => - { - latestAnswers.TryGetValue(question.Id, out var answer); - return new - { - sessionQuestionId = question.Id, - questionReferenceId = question.QuestionReferenceId, - questionId = question.QuestionId, - source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform", - answered = answer is not null, - gradingStatus = answer?.GradingStatus.ToString(), - isCorrect = isFinal ? answer?.IsCorrect : null, - score = answer?.AwardedScore, - totalScore = question.Score ?? 1, - answeredAt = answer?.AnsweredAt, - correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null, - correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(), - answerText = isFinal ? question.AnswerTextSnapshot : null, - explanation = isFinal ? question.ExplanationSnapshot : null - }; - }) - .ToArray(); - var sectionStats = new[] - { - new - { - key = "default", - title = "默认", - questionCount = totalQuestions, - answeredCount, - correctCount, - wrongCount, - unansweredCount, - score, - totalScore, - accuracy, - pendingReviewCount, - sortOrder = 0 - } - }; - - var report = new PracticeSessionReport - { - TenantId = actor.TenantId, - UserId = actor.UserId, - PracticeSessionId = session.Id, - BlueprintId = session.BlueprintId, - CollectionId = session.CollectionId, - Mode = session.Mode, - TotalQuestions = totalQuestions, - AnsweredCount = answeredCount, - CorrectCount = correctCount, - WrongCount = wrongCount, - UnansweredCount = unansweredCount, - Score = score, - TotalScore = totalScore, - Accuracy = accuracy, - DurationSeconds = durationSeconds, - StartedAt = session.StartedAt, - SubmittedAt = submittedAt, - Status = isFinal ? PracticeReportStatus.Final : PracticeReportStatus.PendingReview, - Version = 1, - IsFinal = isFinal, - PendingReviewCount = pendingReviewCount, - ScoringVersion = 1, - SectionStats = JsonSerializer.SerializeToElement(sectionStats), - QuestionResults = JsonSerializer.SerializeToElement(questionResults), - WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds), - Metadata = JsonSerializer.SerializeToElement(new - { - scoringVersion = 1 - }) - }; - dbContext.PracticeSessionReports.Add(report); - dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection - { - TenantId = actor.TenantId, - ReportId = report.Id, - PracticeSessionId = session.Id, - SectionKey = "default", - SectionName = "默认", - QuestionCount = totalQuestions, - AnsweredCount = answeredCount, - CorrectCount = correctCount, - WrongCount = wrongCount, - UnansweredCount = unansweredCount, - Score = score, - TotalScore = totalScore, - Accuracy = accuracy, - SortOrder = 0 - }); - - foreach (var question in sessionQuestions.Where(question => - latestAnswers.TryGetValue(question.Id, out var answer) && - answer.GradingStatus == AnswerGradingStatus.Incorrect)) - { - var wrongQuestion = await dbContext.WrongQuestions.FindAsync( - [actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken); - if (wrongQuestion is null) - { - dbContext.WrongQuestions.Add(new WrongQuestion - { - TenantId = actor.TenantId, - UserId = actor.UserId, - QuestionReferenceId = question.QuestionReferenceId, - QuestionOwnerTenantId = question.QuestionOwnerTenantId, - QuestionId = question.QuestionId, - WrongCount = 1, - LastWrongAt = submittedAt - }); - } - else - { - wrongQuestion.WrongCount++; - wrongQuestion.LastWrongAt = submittedAt; - wrongQuestion.ResolvedAt = null; - } - } - - return report; - } - - private async Task EnsureQuestionExistsAsync( - Guid tenantId, - Guid questionId, - CancellationToken cancellationToken) - { - var exists = await dbContext.Questions.AnyAsync( - question => - question.TenantId == tenantId && - question.Id == questionId && - question.Status == QuestionStatus.Published, - cancellationToken); - - if (!exists) - { - throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); - } - } - - private async Task EnsureWordExistsAsync( - Guid tenantId, - Guid wordId, - CancellationToken cancellationToken) - { - var exists = await dbContext.VocabularyWords.AnyAsync( - word => - word.TenantId == tenantId && - word.Id == wordId && - word.IsActive, - cancellationToken); - - if (!exists) - { - throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); - } - } - - private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion) - { - return new AnswerRecordItem( - record.Id, - record.SessionQuestionId, - record.PracticeSessionId, - record.SelectedOptions, - record.AnswerText, - record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted", - record.Revision, - record.ClientSequence, - sessionVersion, - record.AnsweredAt); - } - - private static WordProgressItem ToItem(UserWordProgress item) - { - return new WordProgressItem( - item.WordId, - item.Status, - item.CorrectCount, - item.WrongCount, - item.LastReviewAt, - item.NextReviewAt, - item.ReviewCount, - item.CorrectStreak, - item.LastResult, - item.DueLevel, - item.Metadata); - } - - private static PracticeSessionItem ToItem(PracticeSession item) - { - return new PracticeSessionItem( - item.Id, - item.Mode, - item.TargetType, - item.TargetId, - item.BlueprintId, - item.CollectionId, - item.EntryId, - item.ContentNodeId, - item.QuestionCount, - item.DurationMinutes, - item.TotalScore, - item.AccessMode, - item.AccessEntitlementId, - item.ConsumedFreeQuota, - item.AccessSnapshot, - item.StartedAt, - item.FinishedAt, - item.ExpiresAt, - item.Metadata, - item.Status, - item.Version, - item.LastClientSequence); - } - - private static PracticeSessionReportItem ToItem(PracticeSessionReport item) - { - return new PracticeSessionReportItem( - item.Id, - item.PracticeSessionId, - item.BlueprintId, - item.CollectionId, - item.Mode, - item.TotalQuestions, - item.AnsweredCount, - item.CorrectCount, - item.WrongCount, - item.UnansweredCount, - item.Score, - item.TotalScore, - item.Accuracy, - item.DurationSeconds, - item.StartedAt, - item.SubmittedAt, - item.Status, - item.Version, - item.IsFinal, - item.PendingReviewCount, - item.ScoringVersion, - item.SectionStats, - item.QuestionResults, - item.WrongQuestionIds, - item.Metadata); - } - - private static List ReadGuidArray(JsonElement value) - { - if (value.ValueKind is not JsonValueKind.Array) - { - return []; - } - - return value.EnumerateArray() - .Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id) - ? (Guid?)id - : null) - .Where(id => id.HasValue) - .Select(id => id!.Value) - .ToList(); - } - - private static void EnsureAnswerSessionState( - PracticeSession session, - SubmitAnswerCommand command) - { - if (session.Status != PracticeSessionStatus.Active) - { - throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers."); - } - if (session.Version != command.ExpectedSessionVersion) - { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); - } - if (command.ClientSequence <= session.LastClientSequence) - { - throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session."); - } - } - - private static JsonElement BuildGradingRules(QuestionSelection selection) => - JsonSerializer.SerializeToElement(new - { - version = 1, - normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase) - ? "nfkc_trim_casefold_whitespace" - : "exact" - }); - - private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new - { - command.SessionQuestionId, - command.ExpectedSessionVersion, - command.ClientSequence, - selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [], - answerText = command.AnswerText?.Trim() - })); - - private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new - { - command.PracticeSessionId, - command.ExpectedSessionVersion - })); - - private static string Hash(string value) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - - private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now) - { - if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) - { - return "finished"; - } - - if (session.Status == PracticeSessionStatus.Expired || - session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now) - { - return "expired"; - } - - return "active"; - } - - private static string NormalizeMode(string? mode) - { - return NormalizeEnumValue(mode) switch - { - "sequential" => "sequential", - "random" => "random", - "mockexam" => "mock_exam", - "paper" => "paper", - "wrongreview" => "wrong_review", - "favoritereview" => "favorite_review", - _ => "chapter" - }; - } - - private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) - { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status); - } - - private static int ResolveLimit(int? limit) - { - return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); - } - - private static string? NormalizeEnumValue(string? value) - { - return string.IsNullOrWhiteSpace(value) - ? null - : value.Replace("_", string.Empty, StringComparison.Ordinal) - .Replace("-", string.Empty, StringComparison.Ordinal); - } - - private sealed record PracticeAssembly( - string Mode, - string? TargetType, - Guid? TargetId, - Guid? BlueprintId, - Guid? CollectionId, - Guid? EntryId, - Guid? ContentNodeId, - int QuestionLimit, - int? DurationMinutes, - decimal? TotalScore); - - private sealed record QuestionSelection( - Guid QuestionReferenceId, - Guid QuestionOwnerTenantId, - Guid QuestionId, - Guid QuestionVersionId, - string QuestionType, - string? TypeLabel, - int? Difficulty, - JsonElement Tags, - string? Content, - JsonElement Options, - int? CorrectOptionIndex, - JsonElement CorrectOptionIndices, - string? AnswerText, - string? Explanation); } - -public class LearningException(string code, string message) : Exception(message) -{ - public string Code { get; } = code; -} - -public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message); - -public sealed class LearningValidationException(string code, string message) : LearningException(code, message); diff --git a/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs b/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs new file mode 100644 index 0000000..3e27fbe --- /dev/null +++ b/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs @@ -0,0 +1,366 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + public async Task CreatePracticeSessionAsync( + LearningActor actor, + PracticeSessionCommand command, + CancellationToken cancellationToken = default) + { + var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken); + var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken); + if (questionReferenceIds.Count == 0) + { + throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target."); + } + + + var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync( + reference => + reference.TenantId == actor.TenantId && + questionReferenceIds.Contains(reference.Id) && + reference.Source == QuestionSource.Platform, + cancellationToken); + if (containsPlatformQuestion) + { + await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken); + } + + var now = DateTimeOffset.UtcNow; + var session = new PracticeSession + { + TenantId = actor.TenantId, + UserId = actor.UserId, + Mode = assembly.Mode, + TargetType = assembly.TargetType, + TargetId = assembly.TargetId, + BlueprintId = assembly.BlueprintId, + CollectionId = assembly.CollectionId, + EntryId = assembly.EntryId, + ContentNodeId = assembly.ContentNodeId, + QuestionCount = questionReferenceIds.Count, + DurationMinutes = assembly.DurationMinutes, + TotalScore = assembly.TotalScore, + ExpiresAt = assembly.DurationMinutes.HasValue + ? now.AddMinutes(assembly.DurationMinutes.Value) + : null, + AccessMode = PracticeAccessMode.Free, + ConsumedFreeQuota = questionReferenceIds.Count, + AccessSnapshot = JsonSerializer.SerializeToElement(new + { + strategy = "v1_free", + requestedCount = assembly.QuestionLimit, + grantedCount = questionReferenceIds.Count + }), + Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined + ? JsonDefaults.Object() + : command.Metadata + }; + dbContext.PracticeSessions.Add(session); + + var selections = await LoadQuestionSelectionsAsync( + actor.TenantId, + questionReferenceIds, + cancellationToken); + foreach (var selection in selections) + { + if (!QuestionGrader.HasValidAuthoritativeAnswer( + selection.QuestionType, + selection.CorrectOptionIndex, + selection.CorrectOptionIndices, + selection.AnswerText)) + { + throw new LearningValidationException( + "practice_question_grading_rule_invalid", + $"Question '{selection.QuestionId}' has no valid authoritative grading rule."); + } + } + var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0 + ? session.TotalScore.Value / selections.Count + : (decimal?)null; + dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) => + new PracticeSessionQuestion + { + TenantId = actor.TenantId, + PracticeSessionId = session.Id, + QuestionReferenceId = selection.QuestionReferenceId, + QuestionOwnerTenantId = selection.QuestionOwnerTenantId, + QuestionId = selection.QuestionId, + QuestionVersionId = selection.QuestionVersionId, + Position = index, + Score = scorePerQuestion, + QuestionType = selection.QuestionType, + TypeLabelSnapshot = selection.TypeLabel, + DifficultySnapshot = selection.Difficulty, + TagsSnapshot = selection.Tags, + ContentSnapshot = selection.Content, + OptionsSnapshot = selection.Options, + CorrectOptionIndexSnapshot = selection.CorrectOptionIndex, + CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices, + AnswerTextSnapshot = selection.AnswerText, + ExplanationSnapshot = selection.Explanation, + GradingRulesSnapshot = BuildGradingRules(selection), + SnapshotVersion = 1 + })); + dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + EventType = PracticeAccessEventType.SessionCreated, + AccessMode = PracticeAccessEventMode.Free, + RequestedCount = assembly.QuestionLimit, + GrantedCount = selections.Count, + ConsumedFreeQuota = selections.Count, + Metadata = session.AccessSnapshot + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(session); + } + + public async Task GetPracticeSessionDetailAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); + var orderedQuestions = await LoadSessionQuestionItemsAsync( + actor.TenantId, + session.Id, + cancellationToken); + + var answers = await dbContext.AnswerRecords + .AsNoTracking() + .Where(answer => + answer.TenantId == actor.TenantId && + answer.UserId == actor.UserId && + answer.PracticeSessionId == session.Id && + answer.IsCurrent) + .ToArrayAsync(cancellationToken); + var answersByQuestion = answers + .GroupBy(answer => answer.SessionQuestionId) + .ToDictionary( + group => group.Key, + group => ToItem( + group.OrderByDescending(answer => answer.Revision).First(), + session.Version)); + + return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion); + } + + public async Task SubmitPracticeSessionAsync( + LearningActor actor, + SubmitPracticeSessionCommand command, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) + { + throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken); + var requestHash = HashSubmission(command); + var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == session.Id && + item.OperationType == "submit" && + item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + if (existingOperation is not null) + { + if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) + { + SubmissionConflicts.Add(1); + throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); + } + + IdempotencyReplays.Add(1); + return existingOperation.ResponseSnapshot.Deserialize() + ?? throw new InvalidOperationException("The stored report response is invalid."); + } + + if (session.Status != PracticeSessionStatus.Active) + { + throw new LearningValidationException("practice_session_not_active", "Only an active practice session can be submitted."); + } + if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow) + { + session.Status = PracticeSessionStatus.Expired; + session.Version++; + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + throw new LearningValidationException("practice_session_expired", "The practice session has expired."); + } + if (session.Version != command.ExpectedSessionVersion) + { + throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before submitting."); + } + + session.Status = PracticeSessionStatus.Scoring; + session.Version++; + var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken); + session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview; + session.FinishedAt = report.SubmittedAt; + session.Version++; + var response = ToItem(report); + dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + OperationType = "submit", + IdempotencyKey = command.IdempotencyKey.Trim(), + RequestHash = requestHash, + ResponseSnapshot = JsonSerializer.SerializeToElement(response), + CompletedAt = report.SubmittedAt + }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + SubmissionConflicts.Add(1); + throw new LearningValidationException("practice_session_version_conflict", "The practice session changed during submission."); + } + catch (DbUpdateException exception) when (exception.InnerException is Npgsql.NpgsqlException) + { + SubmissionConflicts.Add(1); + throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request."); + } + + return response; + } + + public async Task GetPracticeSessionReportAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + if (!filter.PracticeSessionId.HasValue) + { + throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); + } + + var report = await dbContext.PracticeSessionReports + .AsNoTracking() + .SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == filter.PracticeSessionId.Value, + cancellationToken); + + if (report is null) + { + throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found."); + } + + return ToItem(report); + } + + public async Task> GetPracticeReportsAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.PracticeSessionReports + .AsNoTracking() + .Where(report => + report.TenantId == actor.TenantId && + report.UserId == actor.UserId); + + if (filter.BlueprintId.HasValue) + { + query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Mode)) + { + query = query.Where(report => report.Mode == filter.Mode.Trim()); + } + + var items = await query + .OrderByDescending(report => report.SubmittedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items.Select(ToItem).ToArray()); + } + + public async Task> GetPracticeHistoryAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.PracticeSessions + .AsNoTracking() + .Where(session => + session.TenantId == actor.TenantId && + session.UserId == actor.UserId); + + if (!string.IsNullOrWhiteSpace(filter.Mode)) + { + query = query.Where(session => session.Mode == filter.Mode.Trim()); + } + + var rows = await query + .GroupJoin( + dbContext.PracticeSessionReports.AsNoTracking(), + session => new { session.TenantId, PracticeSessionId = session.Id }, + report => new { report.TenantId, report.PracticeSessionId }, + (session, reports) => new { session, report = reports.FirstOrDefault() }) + .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var now = DateTimeOffset.UtcNow; + var items = rows + .Select(row => new PracticeHistoryItem( + row.session.Id, + row.session.Mode, + row.session.TargetType, + row.session.TargetId, + row.session.BlueprintId, + row.session.CollectionId, + row.session.EntryId, + row.session.ContentNodeId, + row.session.QuestionCount, + row.report?.AnsweredCount ?? 0, + row.report?.CorrectCount ?? 0, + row.report?.WrongCount ?? 0, + row.session.StartedAt, + row.session.FinishedAt, + row.session.ExpiresAt, + ResolvePracticeSessionHistoryStatus(row.session, now), + row.report?.Id, + row.report?.Score, + row.report?.TotalScore, + row.report?.Accuracy)) + .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim()) + .ToArray(); + + return new LearningList(items); + } + + +} diff --git a/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs b/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs new file mode 100644 index 0000000..c048583 --- /dev/null +++ b/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs @@ -0,0 +1,174 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + public async Task> GetFavoriteQuestionsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var items = await dbContext.FavoriteQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId) + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new FavoriteQuestionItem( + item.QuestionReferenceId, + new QuestionLocator( + item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, + item.QuestionId), + item.CreatedAt)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items); + } + + public async Task ToggleFavoriteQuestionAsync( + LearningActor actor, + QuestionActionCommand command, + CancellationToken cancellationToken = default) + { + var reference = await questionReferenceService.ResolveAsync( + actor.TenantId, + actor.UserId, + command.Locator, + cancellationToken); + + var favorite = command.Favorite ?? true; + var item = await dbContext.FavoriteQuestions.FindAsync( + [actor.TenantId, actor.UserId, reference.Id], + cancellationToken); + + if (favorite) + { + if (item is null) + { + dbContext.FavoriteQuestions.Add(new FavoriteQuestion + { + TenantId = actor.TenantId, + UserId = actor.UserId, + QuestionReferenceId = reference.Id, + QuestionOwnerTenantId = reference.QuestionOwnerTenantId, + QuestionId = reference.QuestionId, + Source = reference.Source.ToString().ToLowerInvariant(), + CreatedAt = DateTimeOffset.UtcNow + }); + } + } + else if (item is not null) + { + dbContext.FavoriteQuestions.Remove(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new LearningActionResult(true, favorite); + } + + public async Task> GetWrongQuestionsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.WrongQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId); + + if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(item => item.ResolvedAt == null); + } + + var items = await query + .OrderByDescending(item => item.LastWrongAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WrongQuestionItem( + item.QuestionReferenceId, + new QuestionLocator( + item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, + item.QuestionId), + item.WrongCount, + item.LastWrongAt, + item.ResolvedAt)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items); + } + + public async Task ResolveWrongQuestionAsync( + LearningActor actor, + QuestionActionCommand command, + CancellationToken cancellationToken = default) + { + var reference = await questionReferenceService.ResolveAsync( + actor.TenantId, + actor.UserId, + command.Locator, + cancellationToken); + var item = await dbContext.WrongQuestions.FindAsync( + [actor.TenantId, actor.UserId, reference.Id], + cancellationToken); + + if (item is null) + { + throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found."); + } + + item.ResolvedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return new LearningActionResult(true); + } + + public async Task GetWrongQuestionReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var items = await dbContext.WrongQuestions.AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.ResolvedAt == null) + .OrderByDescending(item => item.WrongCount) + .ThenBy(item => item.LastWrongAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WrongQuestionReviewPlanItem( + item.QuestionReferenceId, + new QuestionLocator( + item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, + item.QuestionId), + item.WrongCount, + item.LastWrongAt)) + .ToArrayAsync(cancellationToken); + return new WrongQuestionReviewPlan( + items, + JsonSerializer.SerializeToElement(new + { + mode = "wrong_review", + questionCount = items.Length, + recommendedEndpoint = "/api/student/learning/practice-sessions" + })); + } + + +} diff --git a/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs b/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs new file mode 100644 index 0000000..ab27995 --- /dev/null +++ b/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs @@ -0,0 +1,291 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; +using ZLinq; + +namespace Tiku.Infrastructure.Learning; + +public sealed partial class LearningActivityService +{ + public async Task> GetWordProgressAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.UserWordProgress + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId); + + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + if (TryParseWordProgressStatus(filter.Status, out var status)) + { + query = query.Where(item => item.Status == status); + } + + var items = await query + .OrderBy(item => item.NextReviewAt == null) + .ThenBy(item => item.NextReviewAt) + .ThenByDescending(item => item.UpdatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WordProgressItem( + item.WordId, + item.Status, + item.CorrectCount, + item.WrongCount, + item.LastReviewAt, + item.NextReviewAt, + item.ReviewCount, + item.CorrectStreak, + item.LastResult, + item.DueLevel, + item.Metadata)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items); + } + + public async Task UpdateWordProgressAsync( + LearningActor actor, + WordProgressCommand command, + CancellationToken cancellationToken = default) + { + await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); + + var now = DateTimeOffset.UtcNow; + var item = await dbContext.UserWordProgress + .SingleOrDefaultAsync( + progress => + progress.TenantId == actor.TenantId && + progress.UserId == actor.UserId && + progress.WordId == command.WordId, + cancellationToken); + + if (item is null) + { + item = new UserWordProgress + { + TenantId = actor.TenantId, + UserId = actor.UserId, + WordId = command.WordId + }; + dbContext.UserWordProgress.Add(item); + } + + if (TryParseWordProgressStatus(command.Status, out var status)) + { + item.Status = status; + } + else if (string.IsNullOrWhiteSpace(command.Status)) + { + item.Status = WordProgressStatus.Learning; + } + else + { + throw new LearningValidationException("invalid_word_status", "Word progress status is invalid."); + } + + var correctDelta = command.CorrectDelta ?? 0; + var wrongDelta = command.WrongDelta ?? 0; + item.CorrectCount += correctDelta; + item.WrongCount += wrongDelta; + item.ReviewCount += correctDelta + wrongDelta; + item.CorrectStreak = wrongDelta > 0 + ? 0 + : item.CorrectStreak + correctDelta; + item.LastResult = wrongDelta > 0 + ? WordReviewResult.Wrong + : correctDelta > 0 + ? WordReviewResult.Correct + : item.LastResult; + item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt; + item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt; + item.DueLevel = item.Status == WordProgressStatus.Mastered + ? WordDueLevel.Mastered + : item.WrongCount > 0 && item.CorrectStreak == 0 + ? WordDueLevel.Again + : item.DueLevel; + + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(item); + } + + public async Task GetWordReviewPlanAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var query = dbContext.UserWordProgress.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + var items = await query + .Where(item => item.NextReviewAt == null || item.NextReviewAt <= now) + .OrderBy(item => item.NextReviewAt == null) + .ThenBy(item => item.NextReviewAt) + .ThenByDescending(item => item.WrongCount) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new WordReviewPlanItem( + item.WordId, + item.Status, + item.NextReviewAt, + item.CorrectCount, + item.WrongCount, + item.DueLevel)) + .ToArrayAsync(cancellationToken); + return new WordReviewPlan( + items, + JsonSerializer.SerializeToElement(new + { + mode = "word_review", + wordCount = items.Length + })); + } + + public Task ReviewWordAsync( + LearningActor actor, + WordReviewCommand command, + CancellationToken cancellationToken = default) + { + var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) || + string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase); + return UpdateWordProgressAsync( + actor, + new WordProgressCommand( + command.WordId, + correct ? "Reviewing" : "Learning", + correct ? 1 : 0, + correct ? 0 : 1, + command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)), + cancellationToken); + } + + public async Task GetWordStatsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var query = dbContext.UserWordProgress.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + return new WordStatsItem( + await query.CountAsync(cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken), + await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken), + await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken), + await dbContext.UserWordFavorites.AsNoTracking().CountAsync( + item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken)); + } + + public async Task> GetFavoriteWordsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.UserWordFavorites + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId); + + if (filter.UnitId.HasValue) + { + query = query.Where(item => dbContext.VocabularyWords.Any(word => + word.TenantId == actor.TenantId && + word.Id == item.WordId && + word.UnitId == filter.UnitId.Value)); + } + + var items = await query + .OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new FavoriteWordItem( + item.WordId, + item.Note, + item.FavoritedAt)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items); + } + + public async Task ToggleFavoriteWordAsync( + LearningActor actor, + FavoriteWordCommand command, + CancellationToken cancellationToken = default) + { + await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); + + var favorite = command.Favorite ?? true; + var item = await dbContext.UserWordFavorites + .SingleOrDefaultAsync( + favoriteWord => + favoriteWord.TenantId == actor.TenantId && + favoriteWord.UserId == actor.UserId && + favoriteWord.WordId == command.WordId, + cancellationToken); + + if (favorite) + { + if (item is null) + { + item = new UserWordFavorite + { + TenantId = actor.TenantId, + UserId = actor.UserId, + WordId = command.WordId + }; + dbContext.UserWordFavorites.Add(item); + } + + item.Note = command.Note ?? item.Note; + item.FavoritedAt ??= DateTimeOffset.UtcNow; + } + else if (item is not null) + { + dbContext.UserWordFavorites.Remove(item); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new LearningActionResult(true, favorite); + } + + +} diff --git a/Tiku.Infrastructure/Modules/AuthModule.cs b/Tiku.Infrastructure/Modules/AuthModule.cs new file mode 100644 index 0000000..e7c3f4f --- /dev/null +++ b/Tiku.Infrastructure/Modules/AuthModule.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure; + +internal static class AuthModule +{ + internal static IServiceCollection AddAuthModule(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/CommerceModule.cs b/Tiku.Infrastructure/Modules/CommerceModule.cs new file mode 100644 index 0000000..3beb1ed --- /dev/null +++ b/Tiku.Infrastructure/Modules/CommerceModule.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Commerce; +using Tiku.Application.Growth; +using Tiku.Application.Notifications; +using Tiku.Application.Points; +using Tiku.Infrastructure.Commerce; +using Tiku.Infrastructure.Growth; +using Tiku.Infrastructure.Notifications; +using Tiku.Infrastructure.Points; + +namespace Tiku.Infrastructure; + +internal static class CommerceModule +{ + internal static IServiceCollection AddCommerceModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/ContentModule.cs b/Tiku.Infrastructure/Modules/ContentModule.cs new file mode 100644 index 0000000..98840b8 --- /dev/null +++ b/Tiku.Infrastructure/Modules/ContentModule.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Application.Profile; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Scoreline; +using Tiku.Application.Storage; +using Tiku.Application.StudyContent; +using Tiku.Infrastructure.Assets; +using Tiku.Infrastructure.Catalog; +using Tiku.Infrastructure.Content; +using Tiku.Infrastructure.Profile; +using Tiku.Infrastructure.QuestionBanks; +using Tiku.Infrastructure.Scoreline; +using Tiku.Infrastructure.Storage; +using Tiku.Infrastructure.StudyContent; + +namespace Tiku.Infrastructure; + +internal static class ContentModule +{ + internal static IServiceCollection AddContentModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); + services.AddScoped(); + services.AddOptions() + .Validate(AliyunOssOptions.BeValid, "Aliyun OSS presign default expiration must be positive."); + services.AddOptions() + .Validate(ObjectStorageOptions.BeValid, + "Storage options must include a default provider, default bucket and positive max upload bytes."); + services.AddSingleton(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/JobsModule.cs b/Tiku.Infrastructure/Modules/JobsModule.cs new file mode 100644 index 0000000..4fb04b1 --- /dev/null +++ b/Tiku.Infrastructure/Modules/JobsModule.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Jobs; +using Tiku.Infrastructure.Jobs; + +namespace Tiku.Infrastructure; + +internal static class JobsModule +{ + internal static IServiceCollection AddJobsModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/LearningModule.cs b/Tiku.Infrastructure/Modules/LearningModule.cs new file mode 100644 index 0000000..6ac9528 --- /dev/null +++ b/Tiku.Infrastructure/Modules/LearningModule.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Learning; +using Tiku.Infrastructure.Learning; + +namespace Tiku.Infrastructure; + +internal static class LearningModule +{ + internal static IServiceCollection AddLearningModule(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/PlatformCoreModule.cs b/Tiku.Infrastructure/Modules/PlatformCoreModule.cs new file mode 100644 index 0000000..37cfea8 --- /dev/null +++ b/Tiku.Infrastructure/Modules/PlatformCoreModule.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Backoffice; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Infrastructure.Backoffice; +using Tiku.Infrastructure.Observability; +using Tiku.Infrastructure.Security; +using Tiku.Infrastructure.Tenancy; + +namespace Tiku.Infrastructure; + +internal static class PlatformCoreModule +{ + internal static IServiceCollection AddPlatformCoreModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddMemoryCache(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(); + services.AddHttpClient(); + services.AddScoped(); + services.AddOptions(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/PlatformModule.cs b/Tiku.Infrastructure/Modules/PlatformModule.cs new file mode 100644 index 0000000..26b8a48 --- /dev/null +++ b/Tiku.Infrastructure/Modules/PlatformModule.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.PlatformAdmin.Operations; +using Tiku.Application.PlatformBilling; +using Tiku.Infrastructure.PlatformAdmin; +using Tiku.Infrastructure.PlatformAdmin.Operations; +using Tiku.Infrastructure.PlatformBilling; + +namespace Tiku.Infrastructure; + +internal static class PlatformModule +{ + internal static IServiceCollection AddPlatformModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHttpClient(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); + return services; + } +} diff --git a/Tiku.Infrastructure/Modules/TenantAdminModule.cs b/Tiku.Infrastructure/Modules/TenantAdminModule.cs new file mode 100644 index 0000000..dfe94c3 --- /dev/null +++ b/Tiku.Infrastructure/Modules/TenantAdminModule.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.TenantAdmin; + +namespace Tiku.Infrastructure; + +internal static class TenantAdminModule +{ + internal static IServiceCollection AddTenantAdminModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs new file mode 100644 index 0000000..58ccfd3 --- /dev/null +++ b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Security; + +namespace Tiku.Infrastructure.Observability; + +internal sealed class DependencyReadinessProbe( + TikuDbContext dbContext, + IRedisSecurityStore redisSecurityStore) : IDependencyReadinessProbe +{ + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + var database = await dbContext.Database.CanConnectAsync(cancellationToken); + var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken); + return new DependencyReadiness(database && redis, DateTimeOffset.UtcNow); + } +} diff --git a/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs b/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs new file mode 100644 index 0000000..8f65b3c --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet Regions => Set(); + public DbSet RegionModules => Set(); + public DbSet ModuleNodes => Set(); + public DbSet Schools => Set(); + public DbSet Majors => Set(); + public DbSet Subjects => Set(); + public DbSet Categories => Set(); + public DbSet TaxonomyNodes => Set(); + public DbSet QuestionTaxonomyAssignments => Set(); + public DbSet ScorelineFields => Set(); + public DbSet ScorelineRecords => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs b/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs new file mode 100644 index 0000000..ac6abaf --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet Products => Set(); + public DbSet SvipPlans => Set(); + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + public DbSet Payments => Set(); + public DbSet PaymentEvents => Set(); + public DbSet Entitlements => Set(); + public DbSet CodeBatches => Set(); + public DbSet ActivationCodes => Set(); + public DbSet Coupons => Set(); + public DbSet CouponRedemptions => Set(); + public DbSet CommerceRefundRequests => Set(); + public DbSet CommerceRefundEvents => Set(); + public DbSet CommerceReconciliationBatches => Set(); + public DbSet CommerceReconciliationItems => Set(); + public DbSet CommerceReconciliationIssues => Set(); + public DbSet CommerceReconciliationIssueEvents => Set(); + public DbSet CommerceAdjustmentVouchers => Set(); + public DbSet CommerceAdjustmentVoucherEvents => Set(); + public DbSet PointActivityTasks => Set(); + public DbSet PointActivityClaims => Set(); + public DbSet PointExchangeItems => Set(); + public DbSet PointExchangeOrders => Set(); + public DbSet ReferralTracks => Set(); + public DbSet ReferralCodes => Set(); + public DbSet ReferralLeads => Set(); + public DbSet ReferralTeamEdges => Set(); + public DbSet ReferralQrcodes => Set(); + public DbSet CrmConfigs => Set(); + public DbSet CrmWebhookQueue => Set(); + public DbSet CrmWebhookLogs => Set(); + public DbSet TenantCommissionSettings => Set(); + public DbSet CommissionSettlements => Set(); + public DbSet CommissionSettlementItems => Set(); + public DbSet CommissionSettlementProofs => Set(); + public DbSet CommissionSettlementExportEvents => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs b/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs new file mode 100644 index 0000000..818e8bc --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet QuestionBanks => Set(); + public DbSet Questions => Set(); + public DbSet QuestionVersions => Set(); + public DbSet ContentEntries => Set(); + public DbSet ContentNodes => Set(); + public DbSet QuestionCollections => Set(); + public DbSet QuestionCollectionItems => Set(); + public DbSet PracticeBlueprints => Set(); + public DbSet VocabularyUnits => Set(); + public DbSet VocabularyWords => Set(); + public DbSet UserWordProgress => Set(); + public DbSet UserWordFavorites => Set(); + public DbSet HandbookSubjects => Set(); + public DbSet HandbookChapters => Set(); + public DbSet HandbookEntries => Set(); + public DbSet QuestionTypeGroups => Set(); + public DbSet SubjectShares => Set(); + public DbSet ContentAssets => Set(); + public DbSet ContentAssetAccessEvents => Set(); + public DbSet ContentAssetSecurityScanEvents => Set(); + public DbSet ContentImportJobs => Set(); + public DbSet ContentImportItems => Set(); + public DbSet ContentImportIssues => Set(); + public DbSet Images => Set(); + public DbSet AppAssets => Set(); + public DbSet VideoExplanations => Set(); + public DbSet QuestionVideos => Set(); + public DbSet VideoPlaybackProgress => Set(); + public DbSet TenantQuestionBankPreferences => Set(); + public DbSet TenantQuestionReferences => Set(); + public DbSet AiRecommendationReports => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs b/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs new file mode 100644 index 0000000..d0a0c11 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet PracticeSessions => Set(); + public DbSet PracticeSessionQuestions => Set(); + public DbSet AnswerRecords => Set(); + public DbSet LearningOperationIdempotencies => Set(); + public DbSet FavoriteQuestions => Set(); + public DbSet WrongQuestions => Set(); + public DbSet RecentPractices => Set(); + public DbSet ExamDates => Set(); + public DbSet Reports => Set(); + public DbSet ReportStatusEvents => Set(); + public DbSet UserScoreEvents => Set(); + public DbSet PracticeDailyUsages => Set(); + public DbSet PracticeAccessEvents => Set(); + public DbSet PracticeSessionReports => Set(); + public DbSet PracticeSessionReportSections => Set(); + public DbSet DashboardDailyStats => Set(); + public DbSet RevenueDailyStats => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs b/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs new file mode 100644 index 0000000..3e90898 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet Banners => Set(); + public DbSet Faqs => Set(); + public DbSet Announcements => Set(); + public DbSet AuditLogs => Set(); + public DbSet BackendPermissions => Set(); + public DbSet BackendMenus => Set(); + public DbSet TenantBackendRoles => Set(); + public DbSet TenantBackendRolePermissions => Set(); + public DbSet TenantBackendRoleMenus => Set(); + public DbSet TenantBackendUserRoles => Set(); + public DbSet PlatformBackendRoles => Set(); + public DbSet PlatformBackendRolePermissions => Set(); + public DbSet PlatformBackendRoleMenus => Set(); + public DbSet PlatformBackendUserRoles => Set(); + public DbSet AuthorizationScopeVersions => Set(); + public DbSet AuthorizationCacheInvalidations => Set(); + public DbSet BackgroundJobs => Set(); + public DbSet TenantLifecycleOperations => Set(); + public DbSet WorkerHeartbeats => Set(); + public DbSet UserNotifications => Set(); + public DbSet Badges => Set(); + public DbSet UserBadges => Set(); + public DbSet TenantContentNotifications => Set(); + public DbSet TenantThemeTemplates => Set(); + public DbSet TenantThemeConfigs => Set(); + public DbSet TenantBillingProfiles => Set(); + public DbSet TenantBillingPolicies => Set(); + public DbSet TenantOwnerActivationGrants => Set(); + public DbSet PlatformOperationIdempotencies => Set(); + public DbSet PlatformBillingInvoiceReminders => Set(); + public DbSet PlatformAuditAlertRules => Set(); + public DbSet PlatformAuditAlerts => Set(); + public DbSet PlatformBillingDunningNotificationChannels => Set(); + public DbSet PlatformBillingDunningNotificationEvents => Set(); + public DbSet PlatformPaymentApps => Set(); + public DbSet PlatformPaymentChannels => Set(); + public DbSet PlatformApprovalPolicies => Set(); + public DbSet PlatformApprovalRequests => Set(); + public DbSet PlatformConfigurationDefinitions => Set(); + public DbSet PlatformConfigurationVersions => Set(); + public DbSet PlatformNotificationTemplates => Set(); + public DbSet PlatformNotificationDeliveries => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs b/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs new file mode 100644 index 0000000..3d476a0 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet SaasFeatures => Set(); + public DbSet PermissionModules => Set(); + public DbSet SaasOfferings => Set(); + public DbSet SaasOfferingVersions => Set(); + public DbSet SaasOfferingVersionFeatures => Set(); + public DbSet SaasFeatureLimitDefinitions => Set(); + public DbSet SaasOfferingVersionLimits => Set(); + public DbSet TenantSaasSubscriptions => Set(); + public DbSet TenantSaasSubscriptionItems => Set(); + public DbSet TenantFeatureOverrides => Set(); + public DbSet TenantFeatureUsages => Set(); + public DbSet PlatformBillingQuotes => Set(); + public DbSet PlatformBillingQuoteItems => Set(); + public DbSet PlatformBillingOrders => Set(); + public DbSet PlatformBillingOrderItems => Set(); + public DbSet PlatformBillingPayments => Set(); + public DbSet PlatformBillingPaymentEvents => Set(); + public DbSet PlatformBillingRefunds => Set(); + public DbSet PlatformBillingInvoices => Set(); + public DbSet PocketBaseImportRuns => Set(); + public DbSet PocketBaseRawRecords => Set(); + public DbSet PocketBaseImportIssues => Set(); + +} diff --git a/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs b/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs new file mode 100644 index 0000000..29e1c9b --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using System.Reflection; +using Tiku.Application.Security; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Growth; +using Tiku.Domain.Identity; +using Tiku.Domain.Import; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; + +namespace Tiku.Infrastructure.Persistence; + +public sealed partial class TikuDbContext +{ + public DbSet Tenants => Set(); + public new DbSet Users => Set(); + public DbSet DataProtectionKeys => Set(); + public DbSet UserIdentities => Set(); + public DbSet TenantMemberships => Set(); + public DbSet TenantDomains => Set(); + public DbSet TenantBrandings => Set(); + public DbSet TenantSettings => Set(); + public DbSet TenantAuthPolicies => Set(); + public DbSet TenantFrontendConfigs => Set(); + public DbSet TenantExternalProviders => Set(); + public DbSet TenantSecrets => Set(); + public DbSet SmsVerificationCodes => Set(); + public DbSet SmsChannels => Set(); + public DbSet SmsTemplates => Set(); + public DbSet SmsSendLogs => Set(); + public DbSet AuthLoginEvents => Set(); + public DbSet AuthSessions => Set(); + public DbSet AuthChallenges => Set(); + public DbSet SmsSendRateLimits => Set(); + public DbSet TenantClasses => Set(); + public DbSet TenantClassMembers => Set(); + public DbSet TenantStudentNotes => Set(); + public DbSet TenantStudentFollowups => Set(); + public DbSet StudentProfiles => Set(); +} diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index 86a8753..7b5b965 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -19,7 +19,7 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; -public sealed class TikuDbContext( +public sealed partial class TikuDbContext( DbContextOptions options, ITenantContext tenantContext) : IdentityUserContext(options), IDataProtectionKeyContext { @@ -40,191 +40,6 @@ public sealed class TikuDbContext( context.InitializeSystem(null, "Direct DbContext construction for model tooling"); return context; } - public DbSet Tenants => Set(); - public new DbSet Users => Set(); - public DbSet DataProtectionKeys => Set(); - public DbSet UserIdentities => Set(); - public DbSet TenantMemberships => Set(); - public DbSet TenantDomains => Set(); - public DbSet TenantBrandings => Set(); - public DbSet TenantSettings => Set(); - public DbSet TenantAuthPolicies => Set(); - public DbSet TenantFrontendConfigs => Set(); - public DbSet TenantExternalProviders => Set(); - public DbSet TenantSecrets => Set(); - public DbSet SmsVerificationCodes => Set(); - public DbSet SmsChannels => Set(); - public DbSet SmsTemplates => Set(); - public DbSet SmsSendLogs => Set(); - public DbSet AuthLoginEvents => Set(); - public DbSet AuthSessions => Set(); - public DbSet AuthChallenges => Set(); - public DbSet SmsSendRateLimits => Set(); - public DbSet TenantClasses => Set(); - public DbSet TenantClassMembers => Set(); - public DbSet TenantStudentNotes => Set(); - public DbSet TenantStudentFollowups => Set(); - public DbSet StudentProfiles => Set(); - public DbSet Regions => Set(); - public DbSet RegionModules => Set(); - public DbSet ModuleNodes => Set(); - public DbSet Schools => Set(); - public DbSet Majors => Set(); - public DbSet Subjects => Set(); - public DbSet Categories => Set(); - public DbSet TaxonomyNodes => Set(); - public DbSet QuestionTaxonomyAssignments => Set(); - public DbSet ScorelineFields => Set(); - public DbSet ScorelineRecords => Set(); - public DbSet QuestionBanks => Set(); - public DbSet Questions => Set(); - public DbSet QuestionVersions => Set(); - public DbSet ContentEntries => Set(); - public DbSet ContentNodes => Set(); - public DbSet QuestionCollections => Set(); - public DbSet QuestionCollectionItems => Set(); - public DbSet PracticeBlueprints => Set(); - public DbSet VocabularyUnits => Set(); - public DbSet VocabularyWords => Set(); - public DbSet UserWordProgress => Set(); - public DbSet UserWordFavorites => Set(); - public DbSet HandbookSubjects => Set(); - public DbSet HandbookChapters => Set(); - public DbSet HandbookEntries => Set(); - public DbSet QuestionTypeGroups => Set(); - public DbSet SubjectShares => Set(); - public DbSet ContentAssets => Set(); - public DbSet ContentAssetAccessEvents => Set(); - public DbSet ContentAssetSecurityScanEvents => Set(); - public DbSet ContentImportJobs => Set(); - public DbSet ContentImportItems => Set(); - public DbSet ContentImportIssues => Set(); - public DbSet Images => Set(); - public DbSet AppAssets => Set(); - public DbSet VideoExplanations => Set(); - public DbSet QuestionVideos => Set(); - public DbSet VideoPlaybackProgress => Set(); - public DbSet TenantQuestionBankPreferences => Set(); - public DbSet TenantQuestionReferences => Set(); - public DbSet AiRecommendationReports => Set(); - public DbSet PracticeSessions => Set(); - public DbSet PracticeSessionQuestions => Set(); - public DbSet AnswerRecords => Set(); - public DbSet LearningOperationIdempotencies => Set(); - public DbSet FavoriteQuestions => Set(); - public DbSet WrongQuestions => Set(); - public DbSet RecentPractices => Set(); - public DbSet ExamDates => Set(); - public DbSet Reports => Set(); - public DbSet ReportStatusEvents => Set(); - public DbSet UserScoreEvents => Set(); - public DbSet PracticeDailyUsages => Set(); - public DbSet PracticeAccessEvents => Set(); - public DbSet PracticeSessionReports => Set(); - public DbSet PracticeSessionReportSections => Set(); - public DbSet DashboardDailyStats => Set(); - public DbSet RevenueDailyStats => Set(); - public DbSet Products => Set(); - public DbSet SvipPlans => Set(); - public DbSet Orders => Set(); - public DbSet OrderItems => Set(); - public DbSet Payments => Set(); - public DbSet PaymentEvents => Set(); - public DbSet Entitlements => Set(); - public DbSet CodeBatches => Set(); - public DbSet ActivationCodes => Set(); - public DbSet Coupons => Set(); - public DbSet CouponRedemptions => Set(); - public DbSet CommerceRefundRequests => Set(); - public DbSet CommerceRefundEvents => Set(); - public DbSet CommerceReconciliationBatches => Set(); - public DbSet CommerceReconciliationItems => Set(); - public DbSet CommerceReconciliationIssues => Set(); - public DbSet CommerceReconciliationIssueEvents => Set(); - public DbSet CommerceAdjustmentVouchers => Set(); - public DbSet CommerceAdjustmentVoucherEvents => Set(); - public DbSet PointActivityTasks => Set(); - public DbSet PointActivityClaims => Set(); - public DbSet PointExchangeItems => Set(); - public DbSet PointExchangeOrders => Set(); - public DbSet ReferralTracks => Set(); - public DbSet ReferralCodes => Set(); - public DbSet ReferralLeads => Set(); - public DbSet ReferralTeamEdges => Set(); - public DbSet ReferralQrcodes => Set(); - public DbSet CrmConfigs => Set(); - public DbSet CrmWebhookQueue => Set(); - public DbSet CrmWebhookLogs => Set(); - public DbSet TenantCommissionSettings => Set(); - public DbSet CommissionSettlements => Set(); - public DbSet CommissionSettlementItems => Set(); - public DbSet CommissionSettlementProofs => Set(); - public DbSet CommissionSettlementExportEvents => Set(); - public DbSet Banners => Set(); - public DbSet Faqs => Set(); - public DbSet Announcements => Set(); - public DbSet AuditLogs => Set(); - public DbSet BackendPermissions => Set(); - public DbSet BackendMenus => Set(); - public DbSet TenantBackendRoles => Set(); - public DbSet TenantBackendRolePermissions => Set(); - public DbSet TenantBackendRoleMenus => Set(); - public DbSet TenantBackendUserRoles => Set(); - public DbSet PlatformBackendRoles => Set(); - public DbSet PlatformBackendRolePermissions => Set(); - public DbSet PlatformBackendRoleMenus => Set(); - public DbSet PlatformBackendUserRoles => Set(); - public DbSet AuthorizationScopeVersions => Set(); - public DbSet AuthorizationCacheInvalidations => Set(); - public DbSet BackgroundJobs => Set(); - public DbSet TenantLifecycleOperations => Set(); - public DbSet WorkerHeartbeats => Set(); - public DbSet UserNotifications => Set(); - public DbSet Badges => Set(); - public DbSet UserBadges => Set(); - public DbSet TenantContentNotifications => Set(); - public DbSet TenantThemeTemplates => Set(); - public DbSet TenantThemeConfigs => Set(); - public DbSet TenantBillingProfiles => Set(); - public DbSet TenantBillingPolicies => Set(); - public DbSet TenantOwnerActivationGrants => Set(); - public DbSet PlatformOperationIdempotencies => Set(); - public DbSet PlatformBillingInvoiceReminders => Set(); - public DbSet PlatformAuditAlertRules => Set(); - public DbSet PlatformAuditAlerts => Set(); - public DbSet PlatformBillingDunningNotificationChannels => Set(); - public DbSet PlatformBillingDunningNotificationEvents => Set(); - public DbSet PlatformPaymentApps => Set(); - public DbSet PlatformPaymentChannels => Set(); - public DbSet PlatformApprovalPolicies => Set(); - public DbSet PlatformApprovalRequests => Set(); - public DbSet PlatformConfigurationDefinitions => Set(); - public DbSet PlatformConfigurationVersions => Set(); - public DbSet PlatformNotificationTemplates => Set(); - public DbSet PlatformNotificationDeliveries => Set(); - public DbSet SaasFeatures => Set(); - public DbSet PermissionModules => Set(); - public DbSet SaasOfferings => Set(); - public DbSet SaasOfferingVersions => Set(); - public DbSet SaasOfferingVersionFeatures => Set(); - public DbSet SaasFeatureLimitDefinitions => Set(); - public DbSet SaasOfferingVersionLimits => Set(); - public DbSet TenantSaasSubscriptions => Set(); - public DbSet TenantSaasSubscriptionItems => Set(); - public DbSet TenantFeatureOverrides => Set(); - public DbSet TenantFeatureUsages => Set(); - public DbSet PlatformBillingQuotes => Set(); - public DbSet PlatformBillingQuoteItems => Set(); - public DbSet PlatformBillingOrders => Set(); - public DbSet PlatformBillingOrderItems => Set(); - public DbSet PlatformBillingPayments => Set(); - public DbSet PlatformBillingPaymentEvents => Set(); - public DbSet PlatformBillingRefunds => Set(); - public DbSet PlatformBillingInvoices => Set(); - public DbSet PocketBaseImportRuns => Set(); - public DbSet PocketBaseRawRecords => Set(); - public DbSet PocketBaseImportIssues => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs b/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs new file mode 100644 index 0000000..d922fb9 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetAuditLogsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit log list", async dbContext => + { + var logs = dbContext.AuditLogs.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); + } + + return new PlatformAuditLogList(await logs + .OrderByDescending(log => log.CreatedAt) + .Take(Limit(query.Limit)) + .Select(log => new PlatformAuditLogItem( + log.Id, + log.TenantId, + log.ActorUserId, + log.Action, + log.TargetType, + log.TargetId, + log.Details, + log.IpAddress, + log.UserAgent, + log.CreatedAt)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task GetAuditAlertsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit alert list", async dbContext => + { + var alerts = dbContext.PlatformAuditAlerts.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status)); + } + + return new PlatformAuditAlertList(await alerts + .OrderByDescending(alert => alert.LastSeenAt) + .Take(Limit(query.Limit)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task UpdateAuditAlertStatusAsync( + PlatformAdminActor actor, + UpdatePlatformAuditAlertStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit alert status update", async dbContext => + { + var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken) + ?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found"); + alert.Status = command.Status; + alert.ResolutionNote = Normalize(command.ResolutionNote); + if (command.Status == PlatformAuditAlertStatus.Acknowledged) + { + alert.AcknowledgedBy = actor.UserId; + alert.AcknowledgedAt ??= DateTimeOffset.UtcNow; + } + else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored) + { + alert.ResolvedBy = actor.UserId; + alert.ResolvedAt ??= DateTimeOffset.UtcNow; + } + + AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote }); + await dbContext.SaveChangesAsync(cancellationToken); + return alert; + }, cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs b/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs new file mode 100644 index 0000000..3fa6f80 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetOverviewAsync( + PlatformAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken); + return await ExecuteSystemAsync("platform overview", async dbContext => + { + var row = await dbContext.Database.SqlQuery($""" + SELECT + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount", + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount", + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount", + (SELECT count(*)::integer FROM orders) AS "OrderCount", + (SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount", + (SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents", + (SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount", + (SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount", + (SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount" + """).SingleAsync(cancellationToken); + return new PlatformOverview( + row.TenantCount, + row.ActiveTenantCount, + row.SuspendedTenantCount, + row.OrderCount, + row.PaidOrderCount, + row.RevenueCents, + row.QuestionBankCount, + row.QuestionCount, + row.LearningActiveUserCount); + }, cancellationToken); + } + + private sealed class PlatformOverviewRow + { + public int TenantCount { get; init; } + public int ActiveTenantCount { get; init; } + public int SuspendedTenantCount { get; init; } + public int OrderCount { get; init; } + public int PaidOrderCount { get; init; } + public int RevenueCents { get; init; } + public int QuestionBankCount { get; init; } + public int QuestionCount { get; init; } + public int LearningActiveUserCount { get; init; } + } + + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs b/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs new file mode 100644 index 0000000..2bbb2ec --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs @@ -0,0 +1,231 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetBillingDunningChannelsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel list", async dbContext => + { + var channels = dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + channels = channels.Where(channel => + channel.ChannelCode.Contains(search) || + channel.Name.Contains(search)); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + var enabled = ParseEnabledStatus(query.Status); + channels = channels.Where(channel => channel.Enabled == enabled); + } + + return new PlatformBillingDunningChannelList(await channels + .OrderByDescending(channel => channel.Enabled) + .ThenBy(channel => channel.MinReminderLevel) + .ThenBy(channel => channel.ChannelCode) + .Take(Limit(query.Limit)) + .Select(channel => ToDunningChannelItem(channel)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task UpsertBillingDunningChannelAsync( + PlatformAdminActor actor, + UpsertPlatformBillingDunningChannelCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext => + { + var code = NormalizeCode(command.ChannelCode); + var channel = command.ChannelId.HasValue + ? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) + : await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); + if (channel is null) + { + channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code }; + dbContext.PlatformBillingDunningNotificationChannels.Add(channel); + } + + channel.ChannelCode = code; + channel.Name = command.Name.Trim(); + channel.Description = Normalize(command.Description); + channel.Enabled = command.Enabled; + channel.Provider = command.Provider; + channel.WebhookUrl = command.WebhookUrl.Trim(); + channel.SecretRef = Normalize(command.SecretRef); + channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]); + channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]); + channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20); + channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray(); + channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60); + channel.Metadata = JsonObjectOrDefault(command.Metadata); + AddAudit(dbContext, actor, "platform.billing_dunning_channel.upserted", channel.Id, new + { + channel.ChannelCode, + channel.Name, + channel.Enabled, + channel.Provider, + Webhook = MaskWebhook(channel.WebhookUrl), + channel.SecretRef + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningChannelItem(channel); + }, cancellationToken); + } + + public async Task DisableBillingDunningChannelAsync( + PlatformAdminActor actor, + DisablePlatformBillingDunningChannelCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => + { + var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found"); + channel.Enabled = false; + AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new + { + channel.ChannelCode, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningChannelItem(channel); + }, cancellationToken); + } + + public async Task GetBillingDunningEventsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event list", async dbContext => + { + var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); + } + + return new PlatformBillingDunningEventList(await events + .OrderByDescending(item => item.CreatedAt) + .Take(Limit(query.Limit)) + .Select(item => ToDunningEventItem(item)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task GetBillingDunningEventDetailAsync( + PlatformAdminActor actor, + Guid eventId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event detail", async dbContext => + { + var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + return ToDunningEventItem(item); + }, cancellationToken); + } + + public async Task RetryBillingDunningEventAsync( + PlatformAdminActor actor, + RetryPlatformBillingDunningEventCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event retry", async dbContext => + { + var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + item.Status = PlatformBillingDunningNotificationStatus.Pending; + item.NextAttemptAt = DateTimeOffset.UtcNow; + item.LastError = null; + AddAudit(dbContext, actor, "platform.billing_dunning_event.retry_requested", item.Id, new + { + item.TenantId, + item.ChannelId, + item.ReminderId, + item.InvoiceId, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningEventItem(item); + }, cancellationToken); + } + + public Task AcknowledgeBillingDunningEventAsync( + PlatformAdminActor actor, + ResolvePlatformBillingDunningEventCommand command, + CancellationToken cancellationToken = default) => + ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken); + + public Task IgnoreBillingDunningEventAsync( + PlatformAdminActor actor, + ResolvePlatformBillingDunningEventCommand command, + CancellationToken cancellationToken = default) => + ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken); + + private async Task ResolveBillingDunningEventAsync( + PlatformAdminActor actor, + ResolvePlatformBillingDunningEventCommand command, + PlatformBillingDunningNotificationStatus status, + CancellationToken cancellationToken) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event resolve", async dbContext => + { + var item = await dbContext.PlatformBillingDunningNotificationEvents + .SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + if (item.Status == PlatformBillingDunningNotificationStatus.Processing || + item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored) + { + throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid"); + } + var fromStatus = item.Status; + item.Status = status; + item.NextAttemptAt = null; + AddAudit(dbContext, actor, + status == PlatformBillingDunningNotificationStatus.Acknowledged + ? "platform.billing_dunning_event.acknowledged" + : "platform.billing_dunning_event.ignored", + item.Id, + new { item.TenantId, FromStatus = fromStatus, ToStatus = status, command.Reason }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningEventItem(item); + }, cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs new file mode 100644 index 0000000..9424110 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs @@ -0,0 +1,499 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + private async Task AssertPlatformPermissionAsync( + PlatformAdminActor actor, + string permissionCode, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode)) + { + throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied"); + } + } + + private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) => + exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: { } constraintName + } && constraintName.StartsWith( + "ix_platform_operation_idempotencies_actor_user_id_scope_", + StringComparison.Ordinal); + + private async Task ProvisioningReplayResultAsync( + TikuDbContext dbContext, + Guid tenantId, + CancellationToken cancellationToken) + { + var tenant = await dbContext.Tenants.AsNoTracking() + .SingleAsync(value => value.Id == tenantId, cancellationToken); + var ownerId = tenant.OwnerUserId + ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); + var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken); + var primaryDomain = await dbContext.TenantDomains.AsNoTracking() + .SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken); + var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking() + .Where(value => value.TenantId == tenant.Id) + .OrderByDescending(value => value.CurrentPeriodEnd) + .Select(value => (DateTimeOffset?)value.CurrentPeriodEnd) + .FirstOrDefaultAsync(cancellationToken); + return new PlatformTenantProvisioningResult( + ToTenantItem(tenant, 0, subscriptionExpiresAt), + ownerId, + owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(), + owner.ForcePasswordChange, + ToDomainItemWithInstructions(primaryDomain), + await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken), + true); + } + + private static async Task OwnerActivationReplayResultAsync( + TikuDbContext dbContext, + Guid activationId, + CancellationToken cancellationToken) + { + var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() + .SingleAsync(value => value.Id == activationId, cancellationToken); + return new PlatformOwnerActivationLinkResult(grant.Id, null, grant.ExpiresAt, true); + } + + private async Task OwnerActivationStatusAsync( + TikuDbContext dbContext, + Tenant tenant, + CancellationToken cancellationToken) + { + if (tenant.OwnerUserId is not { } ownerId) + { + return new PlatformOwnerActivationStatus("domain_pending", null, null); + } + var activated = await dbContext.Users.AsNoTracking().AnyAsync(value => + value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken); + if (activated) + { + return new PlatformOwnerActivationStatus("activated", null, null); + } + var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() + .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && + value.ConsumedAt == null && value.RevokedAt == null) + .OrderByDescending(value => value.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (grant is not null) + { + return new PlatformOwnerActivationStatus( + grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired", + grant.Id, + grant.ExpiresAt); + } + var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => + value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, + cancellationToken); + return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null); + } + + private Task ExecuteSystemAsync( + string reason, + Func> operation, + CancellationToken cancellationToken) + { + return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); + } + + private Task ExecuteSystemAsync( + string reason, + Func> operation, + CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest( + null, + SystemScopeCallerType.Platform, + nameof(PlatformAdminService), + reason, + Guid.NewGuid().ToString("N"), + IsGlobal: true), + async (provider, _) => await operation(provider, provider.GetRequiredService()), + cancellationToken); + } + + private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) + { + var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken); + if (!exists) + { + throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + } + } + + private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) + { + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, + Action = action, + TargetType = action.Split('.')[1], + TargetId = targetId.ToString("N"), + Details = JsonSerializer.SerializeToElement(details) + }); + } + + private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) => + new( + tenant.Id, + tenant.Slug, + tenant.Name, + tenant.LegalName, + tenant.Status, + tenant.Mode, + tenant.BillingStatus, + subscriptionExpiresAt, + domainCount, + tenant.CreatedAt, + tenant.UpdatedAt); + + private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) => + new( + domain.Id, + domain.TenantId, + domain.Host, + domain.DomainType, + domain.Status, + domain.IsPrimary, + domain.VerifiedAt, + domain.DnsVerifiedAt, + domain.TlsReadyAt, + domain.LastCheckedAt, + domain.LastFailureReason, + null, + null, + null); + + private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) => + ToDomainItem(domain) with + { + VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}", + VerificationToken = domain.VerificationToken, + CnameTarget = domains.AllowedCnameTargets.FirstOrDefault() + }; + + private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) => + new( + subscription.Id, + subscription.TenantId, + subscription.BaseOfferingVersionId, + subscription.Status, + subscription.StartsAt, + subscription.CurrentPeriodStart, + subscription.CurrentPeriodEnd, + subscription.CancelAtPeriodEnd, + subscription.Metadata); + + private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) => + new( + profile.TenantId, + profile.BillingName, + profile.TaxId, + profile.ContactName, + MaskPhone(profile.ContactPhone), + profile.ContactEmail, + profile.BillingAddress, + profile.InvoiceTitle, + profile.InvoiceType, + profile.BankName, + profile.BankAccountMasked, + profile.Metadata); + + private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) => + new( + policy.TenantId, + policy.CollectionMode, + policy.DefaultPaymentProvider, + policy.AutoGenerateRenewal, + policy.RenewalLeadDays, + policy.CreatedAt, + policy.UpdatedAt); + + private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) => + new( + channel.Id, + channel.ChannelCode, + channel.Name, + channel.Description, + channel.Enabled, + channel.Provider, + MaskWebhook(channel.WebhookUrl), + channel.SecretRef, + channel.ReminderTypes, + channel.ReminderChannels, + channel.MinReminderLevel, + channel.TenantIds, + channel.TimeoutSeconds, + channel.Metadata, + channel.CreatedAt, + channel.UpdatedAt); + + private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) => + new( + item.Id, + item.TenantId, + item.ChannelId, + item.ReminderId, + item.InvoiceId, + item.Provider, + item.Status, + item.Attempts, + item.ScheduledAt, + item.NextAttemptAt, + item.LastAttemptAt, + item.SentAt, + item.LastError, + item.LastHttpCode, + item.LastResponseSummary, + item.RequestPayload, + item.Metadata, + item.CreatedAt, + item.UpdatedAt); + + private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) => + new( + user.Id, + user.Name, + MaskPhone(user.Phone), + user.Email, + user.Status, + roleCodes, + user.CreatedAt, + user.UpdatedAt); + + private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200); + + private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant(); + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string Required(string? value, string name) => + string.IsNullOrWhiteSpace(value) + ? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required") + : value.Trim(); + + private static string ProvisioningRequestHash(CreatePlatformTenantCommand command) + { + var payload = JsonSerializer.Serialize(new + { + command.Slug, + command.Name, + command.LegalName, + command.Status, + command.BillingStatus, + command.Metadata, + command.PrimaryDomainHost, + command.OwnerEmail, + command.OwnerPhone, + command.OwnerName, + command.InitialOfferingVersionId, + command.TrialDays, + command.CollectionMode, + command.DefaultPaymentProvider, + command.AutoGenerateRenewal, + command.RenewalLeadDays + }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); + } + + private static string OwnerActivationRequestHash(IssuePlatformOwnerActivationLinkCommand command) + { + var payload = JsonSerializer.Serialize(new + { + command.TenantId, + Reason = command.Reason.Trim(), + command.ReplaceExisting + }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); + } + + private static TenantDomain CreatePrimaryDomain(Guid tenantId, string host) + { + try + { + return TenantDomainProvisioning.CreatePrimary(tenantId, host); + } + catch (ArgumentException exception) + { + throw new PlatformAdminException(exception.Message, "tenant_domain_invalid"); + } + } + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private string BuildOwnerActivationUrl(string host, Guid activationId, string token) + => TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); + + private static JsonElement JsonObjectOrDefault(JsonElement value) => + value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); + + private static string? MaskPhone(string? phone) + { + var value = Normalize(phone); + return value is { Length: >= 7 } + ? $"{value[..3]}****{value[^4..]}" + : value; + } + + private static string? MaskBankAccount(string? account) + { + var value = Normalize(account); + return value is { Length: > 8 } + ? $"****{value[^4..]}" + : value; + } + + private static string MaskWebhook(string webhookUrl) + { + var value = Normalize(webhookUrl) ?? string.Empty; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}"; + } + + return $"{uri.Scheme}://{uri.Host}/****"; + } + + private static string[] NormalizeArray(IReadOnlyCollection values, string[] fallback) + { + var normalized = values + .Select(Normalize) + .Where(value => value is not null) + .Select(value => value!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return normalized.Length == 0 ? fallback : normalized; + } + + private static bool ParseEnabledStatus(string status) + { + return NormalizeCode(status) switch + { + "enabled" or "active" or "true" => true, + "disabled" or "inactive" or "false" => false, + _ => throw InvalidStatus(status) + }; + } + + private static TenantStatus ParseTenantStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static TenantDomainStatus ParseDomainStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static async Task EnsureTenantOwnerRoleAsync( + TikuDbContext dbContext, + Guid tenantId, + Guid ownerUserId, + CancellationToken cancellationToken) + { + var featureCodes = SaasFeatureCatalog.All.ToArray(); + var existingFeatureCodes = await dbContext.SaasFeatures + .Where(value => featureCodes.Contains(value.Code)) + .Select(value => value.Code) + .ToArrayAsync(cancellationToken); + dbContext.SaasFeatures.AddRange(featureCodes + .Except(existingFeatureCodes, StringComparer.Ordinal) + .Select((code, index) => new SaasFeature + { + Code = code, + Name = code, + Category = code.Split('.')[0], + IsCore = code == SaasFeatureCatalog.CoreBackoffice, + Status = SaasFeatureStatus.Active, + SortOrder = index * 10 + })); + + var moduleCodes = BackendPermissions.Tenant + .Select(PermissionModuleCatalog.ResolvePermissionModuleCode) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var existingModuleCodes = await dbContext.PermissionModules + .Where(value => moduleCodes.Contains(value.Code)) + .Select(value => value.Code) + .ToArrayAsync(cancellationToken); + dbContext.PermissionModules.AddRange(moduleCodes + .Except(existingModuleCodes, StringComparer.Ordinal) + .Select(code => new PermissionModule + { + Code = code, + Name = code, + Area = BackendPermissionArea.Tenant, + RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] + })); + + var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions + .Where(value => tenantPermissionCodes.Contains(value.Code)) + .Select(value => value.Code) + .ToArrayAsync(cancellationToken); + dbContext.BackendPermissions.AddRange(tenantPermissionCodes + .Except(existingPermissionCodes, StringComparer.Ordinal) + .Select(code => new BackendPermission + { + Code = code, + Name = code, + Area = BackendPermissionArea.Tenant, + PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), + IsSystem = true + })); + + var role = new TenantBackendRole + { + TenantId = tenantId, + Code = "tenant_owner", + Name = "租户所有者", + Status = BackendRoleStatus.Active, + IsSystem = true, + Description = "系统内置租户所有者角色", + DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) + }; + dbContext.TenantBackendRoles.Add(role); + dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionCode = code + })); + dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + { + TenantId = tenantId, + UserId = ownerUserId, + RoleId = role.Id + }); + } + + private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformAdminException InvalidStatus(string status) => + new($"Unsupported status '{status}'.", "invalid_status"); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs new file mode 100644 index 0000000..491487e --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Tiku.Application.Assets; +using Tiku.Application.PlatformAdmin.Operations; +using Tiku.Application.Security; +using Tiku.Application.Storage; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Storage; + +namespace Tiku.Infrastructure.PlatformAdmin.Operations; + +internal sealed class PlatformOperationsQueryService( + TikuDbContext dbContext, + IRedisSecurityStore redisSecurityStore, + IAssetSecurityScanner assetSecurityScanner, + IObjectStorageService objectStorageService, + IOptions aliyunOssOptions) : IPlatformOperationsQueryService +{ + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + 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 heartbeat = await dbContext.WorkerHeartbeats.AsNoTracking() + .MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken); + var workerReady = heartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2); + return new PlatformDependencyHealth( + database && redis && clamAv && workerReady && storageConfigured, + database, + redisSecurityStore.IsConfigured, + redis, + workerReady, + heartbeat, + clamAv, + storageProvider, + storageConfigured, + DateTimeOffset.UtcNow); + } + + public async Task> GetWorkersAsync( + CancellationToken cancellationToken = default) + { + var staleBefore = DateTimeOffset.UtcNow.AddMinutes(-2); + return await dbContext.WorkerHeartbeats.AsNoTracking() + .OrderBy(item => item.WorkerId) + .ThenBy(item => item.Processor) + .Select(item => new PlatformWorkerState( + item.WorkerId, + item.Processor, + item.StartedAt, + item.LastHeartbeatAt, + item.LastIterationStartedAt, + item.LastIterationCompletedAt, + item.LastSucceededAt, + item.LastError, + item.IsRunning, + item.LastHeartbeatAt < staleBefore)) + .ToArrayAsync(cancellationToken); + } + + public async Task GetJobMetricsAsync(CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var counts = await dbContext.BackgroundJobs.AsNoTracking() + .GroupBy(item => item.Status) + .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) + .ToArrayAsync(cancellationToken); + var oldest = await dbContext.BackgroundJobs.AsNoTracking() + .Where(item => item.Status == BackgroundJobStatus.Pending) + .MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken); + var expired = await dbContext.BackgroundJobs.AsNoTracking() + .CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken); + return new PlatformJobMetrics( + counts, + oldest, + oldest.HasValue ? Math.Max(0, (now - oldest.Value).TotalSeconds) : 0, + expired, + now); + } + + public async Task GetGovernanceMetricsAsync( + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var approvals = await dbContext.PlatformApprovalRequests.AsNoTracking() + .GroupBy(item => item.Status) + .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) + .ToArrayAsync(cancellationToken); + var expired = await dbContext.PlatformApprovalRequests.AsNoTracking() + .CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken); + var drafts = await dbContext.PlatformConfigurationVersions.AsNoTracking() + .CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken); + var notifications = await dbContext.PlatformNotificationDeliveries.AsNoTracking() + .GroupBy(item => item.Status) + .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) + .ToArrayAsync(cancellationToken); + return new PlatformGovernanceMetrics(approvals, expired, drafts, notifications, now); + } +} diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs index 3b5fd15..eab151b 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs @@ -20,7 +20,7 @@ using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; -internal sealed class PlatformAdminService( +internal sealed partial class PlatformAdminService( ICurrentAccessContext currentAccessContext, ITenantExecutionScope tenantExecutionScope, IOptions provisioningOptions, @@ -28,1561 +28,5 @@ internal sealed class PlatformAdminService( { private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value; private readonly DomainLifecycleOptions domains = domainOptions.Value; - public async Task GetOverviewAsync( - PlatformAdminActor actor, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken); - return await ExecuteSystemAsync("platform overview", async dbContext => - { - var row = await dbContext.Database.SqlQuery($""" - SELECT - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount", - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount", - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount", - (SELECT count(*)::integer FROM orders) AS "OrderCount", - (SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount", - (SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents", - (SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount", - (SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount", - (SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount" - """).SingleAsync(cancellationToken); - return new PlatformOverview( - row.TenantCount, - row.ActiveTenantCount, - row.SuspendedTenantCount, - row.OrderCount, - row.PaidOrderCount, - row.RevenueCents, - row.QuestionBankCount, - row.QuestionCount, - row.LearningActiveUserCount); - }, cancellationToken); - } - private sealed class PlatformOverviewRow - { - public int TenantCount { get; init; } - public int ActiveTenantCount { get; init; } - public int SuspendedTenantCount { get; init; } - public int OrderCount { get; init; } - public int PaidOrderCount { get; init; } - public int RevenueCents { get; init; } - public int QuestionBankCount { get; init; } - public int QuestionCount { get; init; } - public int LearningActiveUserCount { get; init; } - } - - public async Task GetTenantsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant list", async dbContext => - { - var tenants = dbContext.Tenants.AsNoTracking() - .Where(tenant => tenant.Mode != TenantMode.PlatformOwned); - if (!string.IsNullOrWhiteSpace(query.Search)) - { - var search = query.Search.Trim(); - tenants = tenants.Where(tenant => - tenant.Slug.Contains(search) || - tenant.Name.Contains(search) || - (tenant.LegalName != null && tenant.LegalName.Contains(search))); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status)); - } - - var rows = await tenants - .OrderByDescending(tenant => tenant.CreatedAt) - .Take(Limit(query.Limit)) - .Select(tenant => new - { - Tenant = tenant, - DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id), - SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions - .Where(subscription => subscription.TenantId == tenant.Id) - .OrderByDescending(subscription => subscription.CurrentPeriodEnd) - .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) - .FirstOrDefault() - }) - .ToArrayAsync(cancellationToken); - - return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); - }, cancellationToken); - } - - public async Task GetTenantDetailAsync( - PlatformAdminActor actor, - Guid tenantId, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant detail", async dbContext => - { - var tenant = await dbContext.Tenants.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); - var domains = await dbContext.TenantDomains.AsNoTracking() - .Where(domain => domain.TenantId == tenantId) - .OrderByDescending(domain => domain.IsPrimary) - .ThenBy(domain => domain.Host) - .ToArrayAsync(cancellationToken); - var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking() - .Where(subscription => subscription.TenantId == tenantId) - .OrderByDescending(subscription => subscription.CreatedAt) - .ToArrayAsync(cancellationToken); - var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking() - .SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken); - var billingPolicy = await dbContext.TenantBillingPolicies.AsNoTracking() - .SingleOrDefaultAsync(policy => policy.TenantId == tenantId, cancellationToken); - - return new PlatformTenantDetail( - ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd), - domains.Select(ToDomainItemWithInstructions).ToArray(), - subscriptions.Select(ToSubscriptionItem).ToArray(), - billingProfile is null ? null : ToBillingProfileItem(billingProfile), - billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy), - await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken)); - }, cancellationToken); - } - - public async Task CreateTenantAsync( - PlatformAdminActor actor, - CreatePlatformTenantCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key"); - var requestHash = ProvisioningRequestHash(command); - try - { - return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) => - { - var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() - .SingleOrDefaultAsync(value => - value.ActorUserId == actor.UserId && - value.Scope == "platform.tenant.create" && - value.IdempotencyKey == idempotencyKey, - cancellationToken); - if (existingRequest is not null) - { - if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { - throw new PlatformAdminException( - "Idempotency key was already used with a different request.", - "idempotency_conflict"); - } - return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); - } - var tenantId = Guid.NewGuid(); - dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency - { - ActorUserId = actor.UserId, - Scope = "platform.tenant.create", - IdempotencyKey = idempotencyKey, - RequestHash = requestHash, - ResourceId = tenantId - }); - await dbContext.SaveChangesAsync(cancellationToken); - var slug = NormalizeCode(command.Slug); - if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken)) - { - throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists"); - } - var ownerEmail = Normalize(command.OwnerEmail); - var ownerPhone = Normalize(command.OwnerPhone); - var ownerIdentifier = ownerEmail ?? ownerPhone; - if (ownerIdentifier is null) - { - throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required"); - } - if (await dbContext.Users.AnyAsync(user => - (ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) || - (ownerPhone != null && user.Phone == ownerPhone), cancellationToken)) - { - throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists"); - } - - SaasOfferingVersion? initialVersion = null; - if (command.InitialOfferingVersionId.HasValue) - { - initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking() - .SingleOrDefaultAsync(value => - value.Id == command.InitialOfferingVersionId && - value.Status == SaasOfferingVersionStatus.Published, - cancellationToken) - ?? throw new PlatformAdminException("Initial offering version was not found or published.", "saas_offering_version_not_found"); - var offeringType = await dbContext.SaasOfferings.AsNoTracking() - .Where(value => value.Id == initialVersion.OfferingId) - .Select(value => value.Type) - .SingleAsync(cancellationToken); - if (offeringType != SaasOfferingType.BasePlan) - { - throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required"); - } - } - else - { - var now = DateTimeOffset.UtcNow; - initialVersion = await ( - from offering in dbContext.SaasOfferings.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id equals version.OfferingId - where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) && - offering.Type == SaasOfferingType.BasePlan && - offering.Status == SaasOfferingStatus.Active && - version.Status == SaasOfferingVersionStatus.Published && - (version.EffectiveAt == null || version.EffectiveAt <= now) - orderby version.Version descending - select version).FirstOrDefaultAsync(cancellationToken) - ?? throw new PlatformAdminException( - "The default base offering does not have an effective published version.", - "default_offering_unavailable"); - } - - var tenant = new Tenant - { - Id = tenantId, - Slug = slug, - Name = command.Name.Trim(), - LegalName = Normalize(command.LegalName), - Status = command.Status, - Mode = TenantMode.Saas, - BillingStatus = initialVersion is null ? command.BillingStatus : BillingStatus.Trial, - Metadata = JsonObjectOrDefault(command.Metadata) - }; - dbContext.Tenants.Add(tenant); - dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion - { - Realm = AuthRealm.Tenant, - TenantId = tenant.Id - }); - var owner = new User - { - Email = ownerEmail, - NormalizedEmail = ownerEmail?.ToUpperInvariant(), - UserName = ownerIdentifier, - NormalizedUserName = ownerIdentifier.ToUpperInvariant(), - Phone = ownerPhone, - PhoneNumber = ownerPhone, - Name = command.OwnerName.Trim(), - PrimaryRole = "tenant_owner", - Status = UserStatus.Active, - ForcePasswordChange = true, - EmailConfirmed = ownerEmail is not null, - PhoneNumberConfirmed = ownerPhone is not null - }; - var userManager = provider.GetRequiredService>(); - var createOwner = await userManager.CreateAsync(owner); - if (!createOwner.Succeeded) - { - throw new PlatformAdminException( - string.Join("; ", createOwner.Errors.Select(error => error.Description)), - "tenant_owner_password_invalid"); - } - tenant.OwnerUserId = owner.Id; - dbContext.TenantMemberships.Add(new TenantMembership - { - TenantId = tenant.Id, - UserId = owner.Id, - Role = TenantRole.TenantOwner, - Status = MembershipStatus.Active - }); - dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy - { - TenantId = tenant.Id, - AllowExternalStudentSelfRegistration = false - }); - var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost); - if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, cancellationToken)) - { - throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); - } - dbContext.TenantDomains.Add(primaryDomain); - dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name)); - await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken); - var policy = new TenantBillingPolicy - { - TenantId = tenant.Id, - CollectionMode = command.CollectionMode, - DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider), - AutoGenerateRenewal = command.AutoGenerateRenewal, - RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90) - }; - dbContext.TenantBillingPolicies.Add(policy); - - DateTimeOffset? subscriptionExpiresAt = null; - { - var now = DateTimeOffset.UtcNow; - var trialDays = command.TrialDays ?? provisioning.DefaultTrialDays; - subscriptionExpiresAt = now.AddDays(Math.Clamp(trialDays, 1, 365)); - var subscription = new TenantSaasSubscription - { - TenantId = tenant.Id, - BaseOfferingVersionId = initialVersion!.Id, - Status = TenantSaasSubscriptionStatus.Trial, - StartsAt = now, - CurrentPeriodStart = now, - CurrentPeriodEnd = subscriptionExpiresAt.Value, - LifecycleVersion = 1 - }; - dbContext.TenantSaasSubscriptions.Add(subscription); - dbContext.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem - { - TenantId = tenant.Id, - SubscriptionId = subscription.Id, - OfferingVersionId = initialVersion.Id, - ItemType = TenantSaasSubscriptionItemType.BasePlan, - Status = TenantSaasSubscriptionItemStatus.Active, - StartsAt = now, - EndsAt = subscriptionExpiresAt.Value - }); - } - AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); - await dbContext.SaveChangesAsync(cancellationToken); - return new PlatformTenantProvisioningResult( - ToTenantItem(tenant, 1, subscriptionExpiresAt), - owner.Id, - ownerIdentifier, - owner.ForcePasswordChange, - ToDomainItemWithInstructions(primaryDomain), - new PlatformOwnerActivationStatus("domain_pending", null, null), - false); - }, cancellationToken); - } - catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception)) - { - return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext => - { - var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() - .SingleAsync(value => value.ActorUserId == actor.UserId && - value.Scope == "platform.tenant.create" && - value.IdempotencyKey == idempotencyKey, - cancellationToken); - if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { - throw new PlatformAdminException( - "Idempotency key was already used with a different request.", - "idempotency_conflict"); - } - return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); - }, cancellationToken); - } - } - - public async Task ReplacePrimaryDomainAsync( - PlatformAdminActor actor, - ReplacePlatformPrimaryDomainCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Primary domain replacement reason is required.", "domain_change_reason_required"); - } - - return await ExecuteSystemAsync("platform primary domain replace", async dbContext => - { - var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => - value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); - var existing = await dbContext.TenantDomains - .Where(value => value.TenantId == tenant.Id && value.IsPrimary) - .ToArrayAsync(cancellationToken); - foreach (var domain in existing) - { - domain.IsPrimary = false; - domain.Status = TenantDomainStatus.Disabled; - } - - var now = DateTimeOffset.UtcNow; - await dbContext.TenantOwnerActivationGrants - .Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null) - .ExecuteUpdateAsync(setters => setters - .SetProperty(value => value.RevokedAt, now) - .SetProperty(value => value.RevokedBy, actor.UserId) - .SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason), - cancellationToken); - var next = CreatePrimaryDomain(tenant.Id, command.Host); - if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken)) - { - throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); - } - dbContext.TenantDomains.Add(next); - AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id, - new { next.Id, next.Host, command.Reason }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToDomainItemWithInstructions(next); - }, cancellationToken); - } - - public async Task IssueOwnerActivationLinkAsync( - PlatformAdminActor actor, - IssuePlatformOwnerActivationLinkCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key"); - if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Owner activation issuance reason is required.", "owner_activation_reason_required"); - } - - return await ExecuteSystemAsync("platform owner activation link issue", async dbContext => - { - var lockKey = $"owner-activation:{command.TenantId:N}"; - await dbContext.Database.ExecuteSqlInterpolatedAsync( - $"select pg_advisory_xact_lock(hashtextextended({lockKey}, 0))", - cancellationToken); - var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() - .SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId && - value.Scope == "platform.tenant.owner_activation.issue" && - value.IdempotencyKey == idempotencyKey, cancellationToken); - var requestHash = OwnerActivationRequestHash(command); - if (existingRequest is not null) - { - if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { - throw new PlatformAdminException( - "Idempotency key was already used with a different request.", "idempotency_conflict"); - } - return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); - } - - var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => - value.Id == command.TenantId && value.Status == TenantStatus.Active && - value.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active"); - var ownerId = tenant.OwnerUserId - ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); - var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken); - if (owner.PasswordHash is not null || !owner.ForcePasswordChange) - { - throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated"); - } - - var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value => - value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, - cancellationToken) - ?? throw new PlatformAdminException("The primary domain is not active.", "primary_domain_not_active"); - var now = DateTimeOffset.UtcNow; - var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => - value.TenantId == tenant.Id && - (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) && - value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken); - if (!subscriptionActive) - { - throw new PlatformAdminException("An active trial or subscription is required.", "subscription_inactive"); - } - - var current = await dbContext.TenantOwnerActivationGrants - .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && - value.ConsumedAt == null && value.RevokedAt == null) - .OrderByDescending(value => value.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting) - { - throw new PlatformAdminException("An owner activation link is already active.", "owner_activation_already_issued"); - } - if (current is not null) - { - current.RevokedAt = now; - current.RevokedBy = actor.UserId; - current.RevocationReason = command.ReplaceExisting - ? command.Reason.Trim() - : "Expired activation link replaced."; - } - - var token = Base64Url(RandomNumberGenerator.GetBytes(32)); - var grant = new TenantOwnerActivationGrant - { - TenantId = tenant.Id, - UserId = ownerId, - CreatedBy = actor.UserId, - DomainId = primaryDomain.Id, - TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(), - ExpiresAt = now.AddMinutes(provisioning.OwnerActivationMinutes) - }; - dbContext.TenantOwnerActivationGrants.Add(grant); - dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency - { - ActorUserId = actor.UserId, - Scope = "platform.tenant.owner_activation.issue", - IdempotencyKey = idempotencyKey, - RequestHash = requestHash, - ResourceId = grant.Id - }); - AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id, - new { grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason }); - await dbContext.SaveChangesAsync(cancellationToken); - return new PlatformOwnerActivationLinkResult( - grant.Id, - BuildOwnerActivationUrl(primaryDomain.Host, grant.Id, token), - grant.ExpiresAt, - false); - }, cancellationToken); - } - - public async Task UpdateTenantStatusAsync( - PlatformAdminActor actor, - UpdatePlatformTenantStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) => - { - var tenant = await dbContext.Tenants - .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); - var fromStatus = tenant.Status; - tenant.Status = command.Status; - AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new - { - FromStatus = fromStatus, - ToStatus = tenant.Status, - BillingStatus = tenant.BillingStatus, - command.Reason - }); - await dbContext.SaveChangesAsync(cancellationToken); - await provider.GetRequiredService() - .InvalidateAsync(tenant.Id, cancellationToken); - await provider.GetRequiredService() - .InvalidateTenantAsync(tenant.Id, cancellationToken); - var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); - var expiresAt = await dbContext.TenantSaasSubscriptions - .Where(subscription => subscription.TenantId == tenant.Id) - .OrderByDescending(subscription => subscription.CurrentPeriodEnd) - .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) - .FirstOrDefaultAsync(cancellationToken); - return ToTenantItem(tenant, domainCount, expiresAt); - }, cancellationToken); - } - - public async Task UpsertTenantBillingProfileAsync( - PlatformAdminActor actor, - UpsertPlatformTenantBillingProfileCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext => - { - await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); - var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync( - item => item.TenantId == command.TenantId, - cancellationToken); - if (profile is null) - { - profile = new TenantBillingProfile { TenantId = command.TenantId }; - dbContext.TenantBillingProfiles.Add(profile); - } - - profile.BillingName = Normalize(command.BillingName); - profile.TaxId = Normalize(command.TaxId); - profile.ContactName = Normalize(command.ContactName); - profile.ContactPhone = Normalize(command.ContactPhone); - profile.ContactEmail = Normalize(command.ContactEmail); - profile.BillingAddress = Normalize(command.BillingAddress); - profile.InvoiceTitle = Normalize(command.InvoiceTitle); - profile.InvoiceType = command.InvoiceType; - profile.BankName = Normalize(command.BankName); - profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked); - profile.Metadata = JsonObjectOrDefault(command.Metadata); - AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToBillingProfileItem(profile); - }, cancellationToken); - } - - public async Task GetTenantBillingPolicyAsync( - PlatformAdminActor actor, - Guid tenantId, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext => - { - await RequireTenantAsync(dbContext, tenantId, cancellationToken); - var policy = await dbContext.TenantBillingPolicies.AsNoTracking() - .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken) - ?? new TenantBillingPolicy { TenantId = tenantId }; - return ToBillingPolicyItem(policy); - }, cancellationToken); - } - - public async Task UpsertTenantBillingPolicyAsync( - PlatformAdminActor actor, - UpsertTenantBillingPolicyCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext => - { - await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); - if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required"); - } - var policy = await dbContext.TenantBillingPolicies - .SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken); - if (policy is null) - { - policy = new TenantBillingPolicy { TenantId = command.TenantId }; - dbContext.TenantBillingPolicies.Add(policy); - } - policy.CollectionMode = command.CollectionMode; - policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider); - policy.AutoGenerateRenewal = command.AutoGenerateRenewal; - policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90); - AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new - { - policy.CollectionMode, - policy.DefaultPaymentProvider, - policy.AutoGenerateRenewal, - policy.RenewalLeadDays, - command.Reason - }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToBillingPolicyItem(policy); - }, cancellationToken); - } - - public async Task GetDomainsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform domain list", async dbContext => - { - var domains = dbContext.TenantDomains.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status)); - } - - if (!string.IsNullOrWhiteSpace(query.Search)) - { - var search = query.Search.Trim(); - domains = domains.Where(domain => domain.Host.Contains(search)); - } - - return new PlatformDomainList(await domains - .OrderByDescending(domain => domain.UpdatedAt) - .Take(Limit(query.Limit)) - .Select(domain => ToDomainItem(domain)) - .ToArrayAsync(cancellationToken)); - }, cancellationToken); - } - - public async Task RecheckDomainAsync( - PlatformAdminActor actor, - Guid domainId, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform domain recheck", async dbContext => - { - var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) - ?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found"); - domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending; - domain.LastCheckedAt = DateTimeOffset.UtcNow; - domain.LastFailureReason = null; - dbContext.BackgroundJobs.Add(new BackgroundJob - { - TenantId = domain.TenantId, - JobType = "tenant_domain_recheck", - Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }), - MaxRetries = 3 - }); - AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host }); - await dbContext.SaveChangesAsync(cancellationToken); - return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value); - }, cancellationToken); - } - - public async Task GetStaffAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); - return await ExecuteSystemAsync("platform staff list", async dbContext => - { - var roleRows = await ( - from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id - join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id - select new { user, role.Code }) - .ToArrayAsync(cancellationToken); - var items = roleRows - .GroupBy(row => row.user.Id) - .Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())) - .OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString()) - .Take(Limit(query.Limit)) - .ToArray(); - return new PlatformStaffList(items); - }, cancellationToken); - } - - public async Task UpsertStaffAsync( - PlatformAdminActor actor, - UpsertPlatformStaffCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); - return await ExecuteSystemAsync("platform staff upsert", async (provider, dbContext) => - { - var user = command.UserId.HasValue - ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken) - : await dbContext.Users.SingleOrDefaultAsync(item => - (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || - (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), - cancellationToken); - if (user is null) - { - user = new User - { - Email = Normalize(command.Email), - NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(), - UserName = Normalize(command.Email) ?? Normalize(command.Phone), - NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(), - Phone = Normalize(command.Phone), - PhoneNumber = Normalize(command.Phone), - Name = Normalize(command.Name), - PrimaryRole = "platform_admin", - Status = command.Status, - ForcePasswordChange = true - }; - dbContext.Users.Add(user); - } - else - { - user.Email = Normalize(command.Email) ?? user.Email; - user.NormalizedEmail = user.Email?.ToUpperInvariant(); - user.Phone = Normalize(command.Phone) ?? user.Phone; - user.PhoneNumber = user.Phone; - user.Name = Normalize(command.Name) ?? user.Name; - user.Status = command.Status; - user.PrimaryRole = "platform_admin"; - } - - var roleIds = command.RoleIds.Distinct().ToArray(); - var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); - if (roleCount != roleIds.Length) - { - throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found"); - } - - await dbContext.SaveChangesAsync(cancellationToken); - await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken); - dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole - { - UserId = user.Id, - RoleId = roleId - })); - AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); - await dbContext.SaveChangesAsync(cancellationToken); - var invalidator = provider.GetRequiredService(); - await invalidator.InvalidateUserAsync(user.Id, cancellationToken); - await invalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); - var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking() - .Where(role => roleIds.Contains(role.Id)) - .Select(role => role.Code) - .ToArrayAsync(cancellationToken); - return ToStaffItem(user, roleCodes); - }, cancellationToken); - } - - public async Task UpdateStaffStatusAsync( - PlatformAdminActor actor, - UpdatePlatformStaffStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); - return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) => - { - var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken) - ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); - var from = user.Status; - user.Status = command.Status; - AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason }); - await dbContext.SaveChangesAsync(cancellationToken); - await provider.GetRequiredService() - .InvalidateUserAsync(user.Id, cancellationToken); - var roleCodes = await ( - from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id - where binding.UserId == user.Id - select role.Code) - .ToArrayAsync(cancellationToken); - return ToStaffItem(user, roleCodes); - }, cancellationToken); - } - - public async Task GetAuditLogsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); - return await ExecuteSystemAsync("platform audit log list", async dbContext => - { - var logs = dbContext.AuditLogs.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Search)) - { - var search = query.Search.Trim(); - logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); - } - - return new PlatformAuditLogList(await logs - .OrderByDescending(log => log.CreatedAt) - .Take(Limit(query.Limit)) - .Select(log => new PlatformAuditLogItem( - log.Id, - log.TenantId, - log.ActorUserId, - log.Action, - log.TargetType, - log.TargetId, - log.Details, - log.IpAddress, - log.UserAgent, - log.CreatedAt)) - .ToArrayAsync(cancellationToken)); - }, cancellationToken); - } - - public async Task GetAuditAlertsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); - return await ExecuteSystemAsync("platform audit alert list", async dbContext => - { - var alerts = dbContext.PlatformAuditAlerts.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status)); - } - - return new PlatformAuditAlertList(await alerts - .OrderByDescending(alert => alert.LastSeenAt) - .Take(Limit(query.Limit)) - .ToArrayAsync(cancellationToken)); - }, cancellationToken); - } - - public async Task UpdateAuditAlertStatusAsync( - PlatformAdminActor actor, - UpdatePlatformAuditAlertStatusCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); - return await ExecuteSystemAsync("platform audit alert status update", async dbContext => - { - var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken) - ?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found"); - alert.Status = command.Status; - alert.ResolutionNote = Normalize(command.ResolutionNote); - if (command.Status == PlatformAuditAlertStatus.Acknowledged) - { - alert.AcknowledgedBy = actor.UserId; - alert.AcknowledgedAt ??= DateTimeOffset.UtcNow; - } - else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored) - { - alert.ResolvedBy = actor.UserId; - alert.ResolvedAt ??= DateTimeOffset.UtcNow; - } - - AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote }); - await dbContext.SaveChangesAsync(cancellationToken); - return alert; - }, cancellationToken); - } - - public async Task GetBillingDunningChannelsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning channel list", async dbContext => - { - var channels = dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Search)) - { - var search = query.Search.Trim(); - channels = channels.Where(channel => - channel.ChannelCode.Contains(search) || - channel.Name.Contains(search)); - } - - if (!string.IsNullOrWhiteSpace(query.Status)) - { - var enabled = ParseEnabledStatus(query.Status); - channels = channels.Where(channel => channel.Enabled == enabled); - } - - return new PlatformBillingDunningChannelList(await channels - .OrderByDescending(channel => channel.Enabled) - .ThenBy(channel => channel.MinReminderLevel) - .ThenBy(channel => channel.ChannelCode) - .Take(Limit(query.Limit)) - .Select(channel => ToDunningChannelItem(channel)) - .ToArrayAsync(cancellationToken)); - }, cancellationToken); - } - - public async Task UpsertBillingDunningChannelAsync( - PlatformAdminActor actor, - UpsertPlatformBillingDunningChannelCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext => - { - var code = NormalizeCode(command.ChannelCode); - var channel = command.ChannelId.HasValue - ? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) - : await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); - if (channel is null) - { - channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code }; - dbContext.PlatformBillingDunningNotificationChannels.Add(channel); - } - - channel.ChannelCode = code; - channel.Name = command.Name.Trim(); - channel.Description = Normalize(command.Description); - channel.Enabled = command.Enabled; - channel.Provider = command.Provider; - channel.WebhookUrl = command.WebhookUrl.Trim(); - channel.SecretRef = Normalize(command.SecretRef); - channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]); - channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]); - channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20); - channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray(); - channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60); - channel.Metadata = JsonObjectOrDefault(command.Metadata); - AddAudit(dbContext, actor, "platform.billing_dunning_channel.upserted", channel.Id, new - { - channel.ChannelCode, - channel.Name, - channel.Enabled, - channel.Provider, - Webhook = MaskWebhook(channel.WebhookUrl), - channel.SecretRef - }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToDunningChannelItem(channel); - }, cancellationToken); - } - - public async Task DisableBillingDunningChannelAsync( - PlatformAdminActor actor, - DisablePlatformBillingDunningChannelCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => - { - var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found"); - channel.Enabled = false; - AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new - { - channel.ChannelCode, - command.Reason - }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToDunningChannelItem(channel); - }, cancellationToken); - } - - public async Task GetBillingDunningEventsAsync( - PlatformAdminActor actor, - PlatformAdminQuery query, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning event list", async dbContext => - { - var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); - } - - return new PlatformBillingDunningEventList(await events - .OrderByDescending(item => item.CreatedAt) - .Take(Limit(query.Limit)) - .Select(item => ToDunningEventItem(item)) - .ToArrayAsync(cancellationToken)); - }, cancellationToken); - } - - public async Task GetBillingDunningEventDetailAsync( - PlatformAdminActor actor, - Guid eventId, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning event detail", async dbContext => - { - var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); - return ToDunningEventItem(item); - }, cancellationToken); - } - - public async Task RetryBillingDunningEventAsync( - PlatformAdminActor actor, - RetryPlatformBillingDunningEventCommand command, - CancellationToken cancellationToken = default) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning event retry", async dbContext => - { - var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); - item.Status = PlatformBillingDunningNotificationStatus.Pending; - item.NextAttemptAt = DateTimeOffset.UtcNow; - item.LastError = null; - AddAudit(dbContext, actor, "platform.billing_dunning_event.retry_requested", item.Id, new - { - item.TenantId, - item.ChannelId, - item.ReminderId, - item.InvoiceId, - command.Reason - }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToDunningEventItem(item); - }, cancellationToken); - } - - public Task AcknowledgeBillingDunningEventAsync( - PlatformAdminActor actor, - ResolvePlatformBillingDunningEventCommand command, - CancellationToken cancellationToken = default) => - ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken); - - public Task IgnoreBillingDunningEventAsync( - PlatformAdminActor actor, - ResolvePlatformBillingDunningEventCommand command, - CancellationToken cancellationToken = default) => - ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken); - - private async Task ResolveBillingDunningEventAsync( - PlatformAdminActor actor, - ResolvePlatformBillingDunningEventCommand command, - PlatformBillingDunningNotificationStatus status, - CancellationToken cancellationToken) - { - await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); - return await ExecuteSystemAsync("platform dunning event resolve", async dbContext => - { - var item = await dbContext.PlatformBillingDunningNotificationEvents - .SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); - if (item.Status == PlatformBillingDunningNotificationStatus.Processing || - item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored) - { - throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid"); - } - var fromStatus = item.Status; - item.Status = status; - item.NextAttemptAt = null; - AddAudit(dbContext, actor, - status == PlatformBillingDunningNotificationStatus.Acknowledged - ? "platform.billing_dunning_event.acknowledged" - : "platform.billing_dunning_event.ignored", - item.Id, - new { item.TenantId, FromStatus = fromStatus, ToStatus = status, command.Reason }); - await dbContext.SaveChangesAsync(cancellationToken); - return ToDunningEventItem(item); - }, cancellationToken); - } - - private async Task AssertPlatformPermissionAsync( - PlatformAdminActor actor, - string permissionCode, - CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode)) - { - throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied"); - } - } - - private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) => - exception.InnerException is PostgresException - { - SqlState: PostgresErrorCodes.UniqueViolation, - ConstraintName: { } constraintName - } && constraintName.StartsWith( - "ix_platform_operation_idempotencies_actor_user_id_scope_", - StringComparison.Ordinal); - - private async Task ProvisioningReplayResultAsync( - TikuDbContext dbContext, - Guid tenantId, - CancellationToken cancellationToken) - { - var tenant = await dbContext.Tenants.AsNoTracking() - .SingleAsync(value => value.Id == tenantId, cancellationToken); - var ownerId = tenant.OwnerUserId - ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); - var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken); - var primaryDomain = await dbContext.TenantDomains.AsNoTracking() - .SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken); - var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking() - .Where(value => value.TenantId == tenant.Id) - .OrderByDescending(value => value.CurrentPeriodEnd) - .Select(value => (DateTimeOffset?)value.CurrentPeriodEnd) - .FirstOrDefaultAsync(cancellationToken); - return new PlatformTenantProvisioningResult( - ToTenantItem(tenant, 0, subscriptionExpiresAt), - ownerId, - owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(), - owner.ForcePasswordChange, - ToDomainItemWithInstructions(primaryDomain), - await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken), - true); - } - - private static async Task OwnerActivationReplayResultAsync( - TikuDbContext dbContext, - Guid activationId, - CancellationToken cancellationToken) - { - var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() - .SingleAsync(value => value.Id == activationId, cancellationToken); - return new PlatformOwnerActivationLinkResult(grant.Id, null, grant.ExpiresAt, true); - } - - private async Task OwnerActivationStatusAsync( - TikuDbContext dbContext, - Tenant tenant, - CancellationToken cancellationToken) - { - if (tenant.OwnerUserId is not { } ownerId) - { - return new PlatformOwnerActivationStatus("domain_pending", null, null); - } - var activated = await dbContext.Users.AsNoTracking().AnyAsync(value => - value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken); - if (activated) - { - return new PlatformOwnerActivationStatus("activated", null, null); - } - var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() - .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && - value.ConsumedAt == null && value.RevokedAt == null) - .OrderByDescending(value => value.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); - if (grant is not null) - { - return new PlatformOwnerActivationStatus( - grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired", - grant.Id, - grant.ExpiresAt); - } - var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => - value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, - cancellationToken); - return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null); - } - - private Task ExecuteSystemAsync( - string reason, - Func> operation, - CancellationToken cancellationToken) - { - return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); - } - - private Task ExecuteSystemAsync( - string reason, - Func> operation, - CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - null, - SystemScopeCallerType.Platform, - nameof(PlatformAdminService), - reason, - Guid.NewGuid().ToString("N"), - IsGlobal: true), - async (provider, _) => await operation(provider, provider.GetRequiredService()), - cancellationToken); - } - - private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) - { - var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken); - if (!exists) - { - throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); - } - } - - private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) - { - dbContext.AuditLogs.Add(new AuditLog - { - ActorUserId = actor.UserId, - Action = action, - TargetType = action.Split('.')[1], - TargetId = targetId.ToString("N"), - Details = JsonSerializer.SerializeToElement(details) - }); - } - - private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) => - new( - tenant.Id, - tenant.Slug, - tenant.Name, - tenant.LegalName, - tenant.Status, - tenant.Mode, - tenant.BillingStatus, - subscriptionExpiresAt, - domainCount, - tenant.CreatedAt, - tenant.UpdatedAt); - - private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) => - new( - domain.Id, - domain.TenantId, - domain.Host, - domain.DomainType, - domain.Status, - domain.IsPrimary, - domain.VerifiedAt, - domain.DnsVerifiedAt, - domain.TlsReadyAt, - domain.LastCheckedAt, - domain.LastFailureReason, - null, - null, - null); - - private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) => - ToDomainItem(domain) with - { - VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}", - VerificationToken = domain.VerificationToken, - CnameTarget = domains.AllowedCnameTargets.FirstOrDefault() - }; - - private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) => - new( - subscription.Id, - subscription.TenantId, - subscription.BaseOfferingVersionId, - subscription.Status, - subscription.StartsAt, - subscription.CurrentPeriodStart, - subscription.CurrentPeriodEnd, - subscription.CancelAtPeriodEnd, - subscription.Metadata); - - private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) => - new( - profile.TenantId, - profile.BillingName, - profile.TaxId, - profile.ContactName, - MaskPhone(profile.ContactPhone), - profile.ContactEmail, - profile.BillingAddress, - profile.InvoiceTitle, - profile.InvoiceType, - profile.BankName, - profile.BankAccountMasked, - profile.Metadata); - - private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) => - new( - policy.TenantId, - policy.CollectionMode, - policy.DefaultPaymentProvider, - policy.AutoGenerateRenewal, - policy.RenewalLeadDays, - policy.CreatedAt, - policy.UpdatedAt); - - private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) => - new( - channel.Id, - channel.ChannelCode, - channel.Name, - channel.Description, - channel.Enabled, - channel.Provider, - MaskWebhook(channel.WebhookUrl), - channel.SecretRef, - channel.ReminderTypes, - channel.ReminderChannels, - channel.MinReminderLevel, - channel.TenantIds, - channel.TimeoutSeconds, - channel.Metadata, - channel.CreatedAt, - channel.UpdatedAt); - - private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) => - new( - item.Id, - item.TenantId, - item.ChannelId, - item.ReminderId, - item.InvoiceId, - item.Provider, - item.Status, - item.Attempts, - item.ScheduledAt, - item.NextAttemptAt, - item.LastAttemptAt, - item.SentAt, - item.LastError, - item.LastHttpCode, - item.LastResponseSummary, - item.RequestPayload, - item.Metadata, - item.CreatedAt, - item.UpdatedAt); - - private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) => - new( - user.Id, - user.Name, - MaskPhone(user.Phone), - user.Email, - user.Status, - roleCodes, - user.CreatedAt, - user.UpdatedAt); - - private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200); - - private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant(); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static string Required(string? value, string name) => - string.IsNullOrWhiteSpace(value) - ? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required") - : value.Trim(); - - private static string ProvisioningRequestHash(CreatePlatformTenantCommand command) - { - var payload = JsonSerializer.Serialize(new - { - command.Slug, - command.Name, - command.LegalName, - command.Status, - command.BillingStatus, - command.Metadata, - command.PrimaryDomainHost, - command.OwnerEmail, - command.OwnerPhone, - command.OwnerName, - command.InitialOfferingVersionId, - command.TrialDays, - command.CollectionMode, - command.DefaultPaymentProvider, - command.AutoGenerateRenewal, - command.RenewalLeadDays - }); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); - } - - private static string OwnerActivationRequestHash(IssuePlatformOwnerActivationLinkCommand command) - { - var payload = JsonSerializer.Serialize(new - { - command.TenantId, - Reason = command.Reason.Trim(), - command.ReplaceExisting - }); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); - } - - private static TenantDomain CreatePrimaryDomain(Guid tenantId, string host) - { - try - { - return TenantDomainProvisioning.CreatePrimary(tenantId, host); - } - catch (ArgumentException exception) - { - throw new PlatformAdminException(exception.Message, "tenant_domain_invalid"); - } - } - - private static string Base64Url(byte[] value) => - Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - private string BuildOwnerActivationUrl(string host, Guid activationId, string token) - => TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); - - private static JsonElement JsonObjectOrDefault(JsonElement value) => - value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); - - private static string? MaskPhone(string? phone) - { - var value = Normalize(phone); - return value is { Length: >= 7 } - ? $"{value[..3]}****{value[^4..]}" - : value; - } - - private static string? MaskBankAccount(string? account) - { - var value = Normalize(account); - return value is { Length: > 8 } - ? $"****{value[^4..]}" - : value; - } - - private static string MaskWebhook(string webhookUrl) - { - var value = Normalize(webhookUrl) ?? string.Empty; - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) - { - return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}"; - } - - return $"{uri.Scheme}://{uri.Host}/****"; - } - - private static string[] NormalizeArray(IReadOnlyCollection values, string[] fallback) - { - var normalized = values - .Select(Normalize) - .Where(value => value is not null) - .Select(value => value!) - .Distinct(StringComparer.Ordinal) - .ToArray(); - return normalized.Length == 0 ? fallback : normalized; - } - - private static bool ParseEnabledStatus(string status) - { - return NormalizeCode(status) switch - { - "enabled" or "active" or "true" => true, - "disabled" or "inactive" or "false" => false, - _ => throw InvalidStatus(status) - }; - } - - private static TenantStatus ParseTenantStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); - - private static TenantDomainStatus ParseDomainStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); - - private static async Task EnsureTenantOwnerRoleAsync( - TikuDbContext dbContext, - Guid tenantId, - Guid ownerUserId, - CancellationToken cancellationToken) - { - var featureCodes = SaasFeatureCatalog.All.ToArray(); - var existingFeatureCodes = await dbContext.SaasFeatures - .Where(value => featureCodes.Contains(value.Code)) - .Select(value => value.Code) - .ToArrayAsync(cancellationToken); - dbContext.SaasFeatures.AddRange(featureCodes - .Except(existingFeatureCodes, StringComparer.Ordinal) - .Select((code, index) => new SaasFeature - { - Code = code, - Name = code, - Category = code.Split('.')[0], - IsCore = code == SaasFeatureCatalog.CoreBackoffice, - Status = SaasFeatureStatus.Active, - SortOrder = index * 10 - })); - - var moduleCodes = BackendPermissions.Tenant - .Select(PermissionModuleCatalog.ResolvePermissionModuleCode) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var existingModuleCodes = await dbContext.PermissionModules - .Where(value => moduleCodes.Contains(value.Code)) - .Select(value => value.Code) - .ToArrayAsync(cancellationToken); - dbContext.PermissionModules.AddRange(moduleCodes - .Except(existingModuleCodes, StringComparer.Ordinal) - .Select(code => new PermissionModule - { - Code = code, - Name = code, - Area = BackendPermissionArea.Tenant, - RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] - })); - - var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); - var existingPermissionCodes = await dbContext.BackendPermissions - .Where(value => tenantPermissionCodes.Contains(value.Code)) - .Select(value => value.Code) - .ToArrayAsync(cancellationToken); - dbContext.BackendPermissions.AddRange(tenantPermissionCodes - .Except(existingPermissionCodes, StringComparer.Ordinal) - .Select(code => new BackendPermission - { - Code = code, - Name = code, - Area = BackendPermissionArea.Tenant, - PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), - IsSystem = true - })); - - var role = new TenantBackendRole - { - TenantId = tenantId, - Code = "tenant_owner", - Name = "租户所有者", - Status = BackendRoleStatus.Active, - IsSystem = true, - Description = "系统内置租户所有者角色", - DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) - }; - dbContext.TenantBackendRoles.Add(role); - dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission - { - TenantId = tenantId, - RoleId = role.Id, - PermissionCode = code - })); - dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole - { - TenantId = tenantId, - UserId = ownerUserId, - RoleId = role.Id - }); - } - - private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); - - private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); - - private static PlatformAdminException InvalidStatus(string status) => - new($"Unsupported status '{status}'.", "invalid_status"); } diff --git a/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs b/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs new file mode 100644 index 0000000..6761650 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetStaffAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff list", async dbContext => + { + var roleRows = await ( + from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id + select new { user, role.Code }) + .ToArrayAsync(cancellationToken); + var items = roleRows + .GroupBy(row => row.user.Id) + .Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())) + .OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString()) + .Take(Limit(query.Limit)) + .ToArray(); + return new PlatformStaffList(items); + }, cancellationToken); + } + + public async Task UpsertStaffAsync( + PlatformAdminActor actor, + UpsertPlatformStaffCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff upsert", async (provider, dbContext) => + { + var user = command.UserId.HasValue + ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken) + : await dbContext.Users.SingleOrDefaultAsync(item => + (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || + (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), + cancellationToken); + if (user is null) + { + user = new User + { + Email = Normalize(command.Email), + NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(), + UserName = Normalize(command.Email) ?? Normalize(command.Phone), + NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(), + Phone = Normalize(command.Phone), + PhoneNumber = Normalize(command.Phone), + Name = Normalize(command.Name), + PrimaryRole = "platform_admin", + Status = command.Status, + ForcePasswordChange = true + }; + dbContext.Users.Add(user); + } + else + { + user.Email = Normalize(command.Email) ?? user.Email; + user.NormalizedEmail = user.Email?.ToUpperInvariant(); + user.Phone = Normalize(command.Phone) ?? user.Phone; + user.PhoneNumber = user.Phone; + user.Name = Normalize(command.Name) ?? user.Name; + user.Status = command.Status; + user.PrimaryRole = "platform_admin"; + } + + var roleIds = command.RoleIds.Distinct().ToArray(); + var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); + if (roleCount != roleIds.Length) + { + throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found"); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken); + dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole + { + UserId = user.Id, + RoleId = roleId + })); + AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); + await dbContext.SaveChangesAsync(cancellationToken); + var invalidator = provider.GetRequiredService(); + await invalidator.InvalidateUserAsync(user.Id, cancellationToken); + await invalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); + var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking() + .Where(role => roleIds.Contains(role.Id)) + .Select(role => role.Code) + .ToArrayAsync(cancellationToken); + return ToStaffItem(user, roleCodes); + }, cancellationToken); + } + + public async Task UpdateStaffStatusAsync( + PlatformAdminActor actor, + UpdatePlatformStaffStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) => + { + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken) + ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); + var from = user.Status; + user.Status = command.Status; + AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason }); + await dbContext.SaveChangesAsync(cancellationToken); + await provider.GetRequiredService() + .InvalidateUserAsync(user.Id, cancellationToken); + var roleCodes = await ( + from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id + where binding.UserId == user.Id + select role.Code) + .ToArrayAsync(cancellationToken); + return ToStaffItem(user, roleCodes); + }, cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs b/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs new file mode 100644 index 0000000..d6e8b56 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetDomainsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform domain list", async dbContext => + { + var domains = dbContext.TenantDomains.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + domains = domains.Where(domain => domain.Host.Contains(search)); + } + + return new PlatformDomainList(await domains + .OrderByDescending(domain => domain.UpdatedAt) + .Take(Limit(query.Limit)) + .Select(domain => ToDomainItem(domain)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task RecheckDomainAsync( + PlatformAdminActor actor, + Guid domainId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform domain recheck", async dbContext => + { + var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) + ?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found"); + domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending; + domain.LastCheckedAt = DateTimeOffset.UtcNow; + domain.LastFailureReason = null; + dbContext.BackgroundJobs.Add(new BackgroundJob + { + TenantId = domain.TenantId, + JobType = "tenant_domain_recheck", + Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }), + MaxRetries = 3 + }); + AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host }); + await dbContext.SaveChangesAsync(cancellationToken); + return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value); + }, cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs new file mode 100644 index 0000000..7ceef54 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs @@ -0,0 +1,603 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed partial class PlatformAdminService +{ + public async Task GetTenantsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant list", async dbContext => + { + var tenants = dbContext.Tenants.AsNoTracking() + .Where(tenant => tenant.Mode != TenantMode.PlatformOwned); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + tenants = tenants.Where(tenant => + tenant.Slug.Contains(search) || + tenant.Name.Contains(search) || + (tenant.LegalName != null && tenant.LegalName.Contains(search))); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status)); + } + + var rows = await tenants + .OrderByDescending(tenant => tenant.CreatedAt) + .Take(Limit(query.Limit)) + .Select(tenant => new + { + Tenant = tenant, + DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id), + SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions + .Where(subscription => subscription.TenantId == tenant.Id) + .OrderByDescending(subscription => subscription.CurrentPeriodEnd) + .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) + .FirstOrDefault() + }) + .ToArrayAsync(cancellationToken); + + return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); + }, cancellationToken); + } + + public async Task GetTenantDetailAsync( + PlatformAdminActor actor, + Guid tenantId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant detail", async dbContext => + { + var tenant = await dbContext.Tenants.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + var domains = await dbContext.TenantDomains.AsNoTracking() + .Where(domain => domain.TenantId == tenantId) + .OrderByDescending(domain => domain.IsPrimary) + .ThenBy(domain => domain.Host) + .ToArrayAsync(cancellationToken); + var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking() + .Where(subscription => subscription.TenantId == tenantId) + .OrderByDescending(subscription => subscription.CreatedAt) + .ToArrayAsync(cancellationToken); + var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking() + .SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken); + var billingPolicy = await dbContext.TenantBillingPolicies.AsNoTracking() + .SingleOrDefaultAsync(policy => policy.TenantId == tenantId, cancellationToken); + + return new PlatformTenantDetail( + ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd), + domains.Select(ToDomainItemWithInstructions).ToArray(), + subscriptions.Select(ToSubscriptionItem).ToArray(), + billingProfile is null ? null : ToBillingProfileItem(billingProfile), + billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy), + await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken)); + }, cancellationToken); + } + + public async Task CreateTenantAsync( + PlatformAdminActor actor, + CreatePlatformTenantCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key"); + var requestHash = ProvisioningRequestHash(command); + try + { + return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) => + { + var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() + .SingleOrDefaultAsync(value => + value.ActorUserId == actor.UserId && + value.Scope == "platform.tenant.create" && + value.IdempotencyKey == idempotencyKey, + cancellationToken); + if (existingRequest is not null) + { + if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) + { + throw new PlatformAdminException( + "Idempotency key was already used with a different request.", + "idempotency_conflict"); + } + return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); + } + var tenantId = Guid.NewGuid(); + dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency + { + ActorUserId = actor.UserId, + Scope = "platform.tenant.create", + IdempotencyKey = idempotencyKey, + RequestHash = requestHash, + ResourceId = tenantId + }); + await dbContext.SaveChangesAsync(cancellationToken); + var slug = NormalizeCode(command.Slug); + if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken)) + { + throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists"); + } + var ownerEmail = Normalize(command.OwnerEmail); + var ownerPhone = Normalize(command.OwnerPhone); + var ownerIdentifier = ownerEmail ?? ownerPhone; + if (ownerIdentifier is null) + { + throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required"); + } + if (await dbContext.Users.AnyAsync(user => + (ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) || + (ownerPhone != null && user.Phone == ownerPhone), cancellationToken)) + { + throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists"); + } + + SaasOfferingVersion? initialVersion = null; + if (command.InitialOfferingVersionId.HasValue) + { + initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking() + .SingleOrDefaultAsync(value => + value.Id == command.InitialOfferingVersionId && + value.Status == SaasOfferingVersionStatus.Published, + cancellationToken) + ?? throw new PlatformAdminException("Initial offering version was not found or published.", "saas_offering_version_not_found"); + var offeringType = await dbContext.SaasOfferings.AsNoTracking() + .Where(value => value.Id == initialVersion.OfferingId) + .Select(value => value.Type) + .SingleAsync(cancellationToken); + if (offeringType != SaasOfferingType.BasePlan) + { + throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required"); + } + } + else + { + var now = DateTimeOffset.UtcNow; + initialVersion = await ( + from offering in dbContext.SaasOfferings.AsNoTracking() + join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id equals version.OfferingId + where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) && + offering.Type == SaasOfferingType.BasePlan && + offering.Status == SaasOfferingStatus.Active && + version.Status == SaasOfferingVersionStatus.Published && + (version.EffectiveAt == null || version.EffectiveAt <= now) + orderby version.Version descending + select version).FirstOrDefaultAsync(cancellationToken) + ?? throw new PlatformAdminException( + "The default base offering does not have an effective published version.", + "default_offering_unavailable"); + } + + var tenant = new Tenant + { + Id = tenantId, + Slug = slug, + Name = command.Name.Trim(), + LegalName = Normalize(command.LegalName), + Status = command.Status, + Mode = TenantMode.Saas, + BillingStatus = initialVersion is null ? command.BillingStatus : BillingStatus.Trial, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.Tenants.Add(tenant); + dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion + { + Realm = AuthRealm.Tenant, + TenantId = tenant.Id + }); + var owner = new User + { + Email = ownerEmail, + NormalizedEmail = ownerEmail?.ToUpperInvariant(), + UserName = ownerIdentifier, + NormalizedUserName = ownerIdentifier.ToUpperInvariant(), + Phone = ownerPhone, + PhoneNumber = ownerPhone, + Name = command.OwnerName.Trim(), + PrimaryRole = "tenant_owner", + Status = UserStatus.Active, + ForcePasswordChange = true, + EmailConfirmed = ownerEmail is not null, + PhoneNumberConfirmed = ownerPhone is not null + }; + var userManager = provider.GetRequiredService>(); + var createOwner = await userManager.CreateAsync(owner); + if (!createOwner.Succeeded) + { + throw new PlatformAdminException( + string.Join("; ", createOwner.Errors.Select(error => error.Description)), + "tenant_owner_password_invalid"); + } + tenant.OwnerUserId = owner.Id; + dbContext.TenantMemberships.Add(new TenantMembership + { + TenantId = tenant.Id, + UserId = owner.Id, + Role = TenantRole.TenantOwner, + Status = MembershipStatus.Active + }); + dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy + { + TenantId = tenant.Id, + AllowExternalStudentSelfRegistration = false + }); + var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost); + if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, cancellationToken)) + { + throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); + } + dbContext.TenantDomains.Add(primaryDomain); + dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name)); + await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken); + var policy = new TenantBillingPolicy + { + TenantId = tenant.Id, + CollectionMode = command.CollectionMode, + DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider), + AutoGenerateRenewal = command.AutoGenerateRenewal, + RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90) + }; + dbContext.TenantBillingPolicies.Add(policy); + + DateTimeOffset? subscriptionExpiresAt = null; + { + var now = DateTimeOffset.UtcNow; + var trialDays = command.TrialDays ?? provisioning.DefaultTrialDays; + subscriptionExpiresAt = now.AddDays(Math.Clamp(trialDays, 1, 365)); + var subscription = new TenantSaasSubscription + { + TenantId = tenant.Id, + BaseOfferingVersionId = initialVersion!.Id, + Status = TenantSaasSubscriptionStatus.Trial, + StartsAt = now, + CurrentPeriodStart = now, + CurrentPeriodEnd = subscriptionExpiresAt.Value, + LifecycleVersion = 1 + }; + dbContext.TenantSaasSubscriptions.Add(subscription); + dbContext.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem + { + TenantId = tenant.Id, + SubscriptionId = subscription.Id, + OfferingVersionId = initialVersion.Id, + ItemType = TenantSaasSubscriptionItemType.BasePlan, + Status = TenantSaasSubscriptionItemStatus.Active, + StartsAt = now, + EndsAt = subscriptionExpiresAt.Value + }); + } + AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); + await dbContext.SaveChangesAsync(cancellationToken); + return new PlatformTenantProvisioningResult( + ToTenantItem(tenant, 1, subscriptionExpiresAt), + owner.Id, + ownerIdentifier, + owner.ForcePasswordChange, + ToDomainItemWithInstructions(primaryDomain), + new PlatformOwnerActivationStatus("domain_pending", null, null), + false); + }, cancellationToken); + } + catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception)) + { + return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext => + { + var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() + .SingleAsync(value => value.ActorUserId == actor.UserId && + value.Scope == "platform.tenant.create" && + value.IdempotencyKey == idempotencyKey, + cancellationToken); + if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) + { + throw new PlatformAdminException( + "Idempotency key was already used with a different request.", + "idempotency_conflict"); + } + return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); + }, cancellationToken); + } + } + + public async Task ReplacePrimaryDomainAsync( + PlatformAdminActor actor, + ReplacePlatformPrimaryDomainCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + if (string.IsNullOrWhiteSpace(command.Reason)) + { + throw new PlatformAdminException("Primary domain replacement reason is required.", "domain_change_reason_required"); + } + + return await ExecuteSystemAsync("platform primary domain replace", async dbContext => + { + var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => + value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + var existing = await dbContext.TenantDomains + .Where(value => value.TenantId == tenant.Id && value.IsPrimary) + .ToArrayAsync(cancellationToken); + foreach (var domain in existing) + { + domain.IsPrimary = false; + domain.Status = TenantDomainStatus.Disabled; + } + + var now = DateTimeOffset.UtcNow; + await dbContext.TenantOwnerActivationGrants + .Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(value => value.RevokedAt, now) + .SetProperty(value => value.RevokedBy, actor.UserId) + .SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason), + cancellationToken); + var next = CreatePrimaryDomain(tenant.Id, command.Host); + if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken)) + { + throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); + } + dbContext.TenantDomains.Add(next); + AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id, + new { next.Id, next.Host, command.Reason }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDomainItemWithInstructions(next); + }, cancellationToken); + } + + public async Task IssueOwnerActivationLinkAsync( + PlatformAdminActor actor, + IssuePlatformOwnerActivationLinkCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key"); + if (string.IsNullOrWhiteSpace(command.Reason)) + { + throw new PlatformAdminException("Owner activation issuance reason is required.", "owner_activation_reason_required"); + } + + return await ExecuteSystemAsync("platform owner activation link issue", async dbContext => + { + var lockKey = $"owner-activation:{command.TenantId:N}"; + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $"select pg_advisory_xact_lock(hashtextextended({lockKey}, 0))", + cancellationToken); + var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() + .SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId && + value.Scope == "platform.tenant.owner_activation.issue" && + value.IdempotencyKey == idempotencyKey, cancellationToken); + var requestHash = OwnerActivationRequestHash(command); + if (existingRequest is not null) + { + if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) + { + throw new PlatformAdminException( + "Idempotency key was already used with a different request.", "idempotency_conflict"); + } + return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); + } + + var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => + value.Id == command.TenantId && value.Status == TenantStatus.Active && + value.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active"); + var ownerId = tenant.OwnerUserId + ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); + var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken); + if (owner.PasswordHash is not null || !owner.ForcePasswordChange) + { + throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated"); + } + + var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value => + value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, + cancellationToken) + ?? throw new PlatformAdminException("The primary domain is not active.", "primary_domain_not_active"); + var now = DateTimeOffset.UtcNow; + var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => + value.TenantId == tenant.Id && + (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) && + value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken); + if (!subscriptionActive) + { + throw new PlatformAdminException("An active trial or subscription is required.", "subscription_inactive"); + } + + var current = await dbContext.TenantOwnerActivationGrants + .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && + value.ConsumedAt == null && value.RevokedAt == null) + .OrderByDescending(value => value.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting) + { + throw new PlatformAdminException("An owner activation link is already active.", "owner_activation_already_issued"); + } + if (current is not null) + { + current.RevokedAt = now; + current.RevokedBy = actor.UserId; + current.RevocationReason = command.ReplaceExisting + ? command.Reason.Trim() + : "Expired activation link replaced."; + } + + var token = Base64Url(RandomNumberGenerator.GetBytes(32)); + var grant = new TenantOwnerActivationGrant + { + TenantId = tenant.Id, + UserId = ownerId, + CreatedBy = actor.UserId, + DomainId = primaryDomain.Id, + TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(), + ExpiresAt = now.AddMinutes(provisioning.OwnerActivationMinutes) + }; + dbContext.TenantOwnerActivationGrants.Add(grant); + dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency + { + ActorUserId = actor.UserId, + Scope = "platform.tenant.owner_activation.issue", + IdempotencyKey = idempotencyKey, + RequestHash = requestHash, + ResourceId = grant.Id + }); + AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id, + new { grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason }); + await dbContext.SaveChangesAsync(cancellationToken); + return new PlatformOwnerActivationLinkResult( + grant.Id, + BuildOwnerActivationUrl(primaryDomain.Host, grant.Id, token), + grant.ExpiresAt, + false); + }, cancellationToken); + } + + public async Task UpdateTenantStatusAsync( + PlatformAdminActor actor, + UpdatePlatformTenantStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) => + { + var tenant = await dbContext.Tenants + .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + var fromStatus = tenant.Status; + tenant.Status = command.Status; + AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new + { + FromStatus = fromStatus, + ToStatus = tenant.Status, + BillingStatus = tenant.BillingStatus, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + await provider.GetRequiredService() + .InvalidateAsync(tenant.Id, cancellationToken); + await provider.GetRequiredService() + .InvalidateTenantAsync(tenant.Id, cancellationToken); + var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); + var expiresAt = await dbContext.TenantSaasSubscriptions + .Where(subscription => subscription.TenantId == tenant.Id) + .OrderByDescending(subscription => subscription.CurrentPeriodEnd) + .Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd) + .FirstOrDefaultAsync(cancellationToken); + return ToTenantItem(tenant, domainCount, expiresAt); + }, cancellationToken); + } + + public async Task UpsertTenantBillingProfileAsync( + PlatformAdminActor actor, + UpsertPlatformTenantBillingProfileCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext => + { + await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); + var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync( + item => item.TenantId == command.TenantId, + cancellationToken); + if (profile is null) + { + profile = new TenantBillingProfile { TenantId = command.TenantId }; + dbContext.TenantBillingProfiles.Add(profile); + } + + profile.BillingName = Normalize(command.BillingName); + profile.TaxId = Normalize(command.TaxId); + profile.ContactName = Normalize(command.ContactName); + profile.ContactPhone = Normalize(command.ContactPhone); + profile.ContactEmail = Normalize(command.ContactEmail); + profile.BillingAddress = Normalize(command.BillingAddress); + profile.InvoiceTitle = Normalize(command.InvoiceTitle); + profile.InvoiceType = command.InvoiceType; + profile.BankName = Normalize(command.BankName); + profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked); + profile.Metadata = JsonObjectOrDefault(command.Metadata); + AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToBillingProfileItem(profile); + }, cancellationToken); + } + + public async Task GetTenantBillingPolicyAsync( + PlatformAdminActor actor, + Guid tenantId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext => + { + await RequireTenantAsync(dbContext, tenantId, cancellationToken); + var policy = await dbContext.TenantBillingPolicies.AsNoTracking() + .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken) + ?? new TenantBillingPolicy { TenantId = tenantId }; + return ToBillingPolicyItem(policy); + }, cancellationToken); + } + + public async Task UpsertTenantBillingPolicyAsync( + PlatformAdminActor actor, + UpsertTenantBillingPolicyCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext => + { + await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); + if (string.IsNullOrWhiteSpace(command.Reason)) + { + throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required"); + } + var policy = await dbContext.TenantBillingPolicies + .SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken); + if (policy is null) + { + policy = new TenantBillingPolicy { TenantId = command.TenantId }; + dbContext.TenantBillingPolicies.Add(policy); + } + policy.CollectionMode = command.CollectionMode; + policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider); + policy.AutoGenerateRenewal = command.AutoGenerateRenewal; + policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90); + AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new + { + policy.CollectionMode, + policy.DefaultPaymentProvider, + policy.AutoGenerateRenewal, + policy.RenewalLeadDays, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToBillingPolicyItem(policy); + }, cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs index 029fbec..982ab14 100644 --- a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs +++ b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs @@ -547,7 +547,7 @@ internal sealed class TenantBillingService( { throw Error("Platform billing public base URL is not configured.", "platform_billing_public_url_missing"); } - return $"{baseUrl}/api/platform-billing/callbacks/{provider}"; + return $"{baseUrl}/api/integrations/platform-billing/callbacks/{provider}"; } private static PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value) => diff --git a/Tiku.Infrastructure/Profile/ProfileService.cs b/Tiku.Infrastructure/Profile/ProfileService.cs index a877876..a9f8e89 100644 --- a/Tiku.Infrastructure/Profile/ProfileService.cs +++ b/Tiku.Infrastructure/Profile/ProfileService.cs @@ -679,8 +679,3 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService return new Guid(guidBytes); } } - -public sealed class ProfileException(string message, string code) : Exception(message) -{ - public string Code { get; } = code; -} diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs index e8f9992..60f49b0 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs @@ -430,7 +430,3 @@ public sealed class QuestionBankQueryService( return Math.Clamp(limit ?? defaultLimit, 1, maxLimit); } } - -public sealed class QuestionBankRequiredFieldException(string message) : Exception(message); - -public sealed class QuestionBankNotFoundException(string message) : Exception(message); diff --git a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs index f4ca5e7..8820195 100644 --- a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs +++ b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs @@ -230,8 +230,3 @@ public sealed partial class ScorelineQueryService( [GeneratedRegex("^[A-Za-z][A-Za-z0-9_]{0,63}$")] private static partial Regex FieldKeyRegex(); } - -public sealed class ScorelineQueryException(string message, string code) : Exception(message) -{ - public string Code { get; } = code; -} diff --git a/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs new file mode 100644 index 0000000..22b778f --- /dev/null +++ b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Tenancy; +using Tiku.Domain.Common; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Tenancy; + +internal sealed class PublicTenantConfigurationQuery(TikuDbContext dbContext) + : IPublicTenantConfigurationQuery +{ + public async Task GetAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var branding = await dbContext.TenantBrandings.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var settings = await dbContext.TenantSettings.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var theme = await dbContext.TenantThemeConfigs.AsNoTracking() + .SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Status == TenantThemeConfigStatus.Published, + cancellationToken); + + return new PublicTenantConfiguration( + branding?.BrandName, + branding?.ShortName, + branding?.Slogan, + branding?.LogoUrl, + branding?.FaviconUrl, + branding?.ServiceWechat, + branding?.ServiceAccountName, + IsNonEmptyObject(theme?.ActiveTheme) ? theme!.ActiveTheme.Clone() : CloneOrDefault(branding?.Theme), + IsNonEmptyObject(theme?.ActivePublicAssets) ? theme!.ActivePublicAssets.Clone() : CloneOrDefault(branding?.PublicAssets), + CloneOrDefault(settings?.FeatureFlags), + CloneOrDefault(settings?.AdminFeatureFlags), + CloneOrDefault(settings?.PublicConfig)); + } + + private static bool IsNonEmptyObject(System.Text.Json.JsonElement? value) => + value is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.EnumerateObject().Any(); + + private static System.Text.Json.JsonElement CloneOrDefault(System.Text.Json.JsonElement? value) => + value.HasValue ? value.Value.Clone() : JsonDefaults.Object(); +} diff --git a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs index 9342626..9106262 100644 --- a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs @@ -14,7 +14,7 @@ namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantLifecycleService( TikuDbContext dbContext, - IBackgroundJobService backgroundJobService, + IBackgroundJobQueue backgroundJobService, IAuthSessionStore sessionStore, ITenantRuntimeCacheInvalidator runtimeCacheInvalidator, ITenantPublicCacheInvalidator publicCacheInvalidator, diff --git a/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs b/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs new file mode 100644 index 0000000..0d4a861 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs @@ -0,0 +1,299 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task GetClassesAsync( + TenantAdminActor actor, + TenantAdminClassFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantClasses.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); + if (filter.RegionId.HasValue) + { + query = query.Where(item => item.RegionId == filter.RegionId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_class_status")); + } + else + { + query = query.Where(item => item.Status != TenantRecordStatus.Archived); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => + item.Name.Contains(keyword) || + (item.Code != null && item.Code.Contains(keyword))); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => new + { + Class = item, + RegionName = dbContext.Regions + .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId) + .Select(region => region.Name) + .FirstOrDefault(), + StudentCount = dbContext.TenantClassMembers.Count(member => + member.TenantId == actor.TenantId && + member.ClassId == item.Id && + member.Status == TenantClassMemberStatus.Active && + member.MemberType == TenantClassMemberType.Student), + StaffCount = dbContext.TenantClassMembers.Count(member => + member.TenantId == actor.TenantId && + member.ClassId == item.Id && + member.Status == TenantClassMemberStatus.Active && + member.MemberType != TenantClassMemberType.Student) + }) + .ToArrayAsync(cancellationToken); + + return new TenantAdminClassList( + items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), + Scoped: scope.Mode != DataScopeMode.All); + } + + public async Task> UpsertClassAsync( + TenantAdminActor actor, + UpsertTenantAdminClassCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + + var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id)) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + + if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id)) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + + item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; + item.RegionId = command.RegionId; + item.LegacyId = Normalize(command.LegacyId); + item.Code = Normalize(command.Code); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status"); + item.SortOrder = command.Order ?? item.SortOrder; + item.Metadata = JsonObjectOrDefault(command.Metadata); + item.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.TenantClasses.Add(item); + } + + await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var regionName = item.RegionId.HasValue + ? await dbContext.Regions + .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value) + .Select(region => region.Name) + .FirstOrDefaultAsync(cancellationToken) + : null; + return new ContentManagementResult(ToClassItem(item, regionName, 0, 0)); + } + + public async Task> DisableClassAsync( + TenantAdminActor actor, + Guid classId, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var item = await dbContext.TenantClasses + .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId) + .ApplyDataScope( + scope, + entity => entity.CreatedBy == actor.UserId, + entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + + item.Status = TenantRecordStatus.Disabled; + item.UpdatedBy = actor.UserId; + await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToClassItem(item, null, 0, 0)); + } + + public async Task> GetClassMembersAsync( + TenantAdminActor actor, + TenantAdminClassMemberFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken); + var classIds = scope.ClassIds.ToArray(); + var regionIds = scope.RegionIds.ToArray(); + var query = dbContext.TenantClassMembers.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, + item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass => + tenantClass.TenantId == actor.TenantId && + tenantClass.Id == item.ClassId && + tenantClass.RegionId.HasValue && + regionIds.Contains(tenantClass.RegionId.Value))); + + if (!string.IsNullOrWhiteSpace(filter.MemberType)) + { + query = query.Where(item => item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); + } + + query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); + + var items = await query + .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 : + item.MemberType == TenantClassMemberType.Teacher ? 1 : + item.MemberType == TenantClassMemberType.Assistant ? 2 : 9) + .ThenBy(item => item.JoinedAt) + .Take(ResolveLimit(filter.Limit)) + .Join( + dbContext.Users.AsNoTracking(), + member => member.UserId, + user => user.Id, + (member, user) => ToClassMemberItem(member, ToUserSummary(user))) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertClassMemberAsync( + TenantAdminActor actor, + UpsertTenantAdminClassMemberCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type"); + var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"); + var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken); + await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); + if (memberType == TenantClassMemberType.Student) + { + await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); + } + + var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => + member.TenantId == actor.TenantId && + member.ClassId == command.ClassId && + member.UserId == user.Id && + member.MemberType == memberType, + cancellationToken); + var isNew = item is null; + item ??= new TenantClassMember + { + TenantId = actor.TenantId, + ClassId = command.ClassId, + UserId = user.Id, + MemberType = memberType, + CreatedBy = actor.UserId + }; + item.Status = status; + item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; + item.Metadata = JsonObjectOrDefault(command.Metadata); + item.UpdatedBy = actor.UserId; + if (isNew) + { + dbContext.TenantClassMembers.Add(item); + } + + await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); + } + + public async Task> RemoveClassMemberAsync( + TenantAdminActor actor, + Guid classMemberId, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var classIds = scope.ClassIds.ToArray(); + var regionIds = scope.RegionIds.ToArray(); + var item = await dbContext.TenantClassMembers + .Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId) + .ApplyDataScope( + scope, + member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId, + member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass => + tenantClass.TenantId == actor.TenantId && + tenantClass.Id == member.ClassId && + tenantClass.RegionId.HasValue && + regionIds.Contains(tenantClass.RegionId.Value))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); + } + + var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken); + item.Status = TenantClassMemberStatus.Removed; + item.LeftAt = DateTimeOffset.UtcNow; + item.UpdatedBy = actor.UserId; + await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs new file mode 100644 index 0000000..0997c6f --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task GetOverviewAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var now = DateTimeOffset.UtcNow; + var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); + var scopedClasses = dbContext.TenantClasses.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); + var scopedStudents = dbContext.StudentProfiles.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + var scopedOrders = dbContext.Orders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + + var studentCount = await scopedStudents.CountAsync(cancellationToken); + var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); + var staffCount = await dbContext.TenantMemberships.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.Status == MembershipStatus.Active && + item.Role != TenantRole.Student, + cancellationToken); + var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.FinishedAt == null && + (!item.ExpiresAt.HasValue || item.ExpiresAt > now), + cancellationToken); + var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() + .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); + var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), + cancellationToken); + var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() + .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); + var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); + var revenueCents = await scopedOrders + .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded) + .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); + var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + (item.Status == CommerceRefundStatus.Requested || + item.Status == CommerceRefundStatus.Approved || + item.Status == CommerceRefundStatus.Processing), + cancellationToken); + var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.Status != ReconciliationIssueStatus.Resolved && + item.Status != ReconciliationIssueStatus.Ignored, + cancellationToken); + + return new TenantAdminOverviewItem( + studentCount, + classCount, + staffCount, + activePracticeCount, + todayPracticeCount, + pendingFollowupCount, + unreadNotificationCount, + paidOrderCount, + revenueCents, + pendingRefundCount, + openReconciliationIssueCount, + now); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs new file mode 100644 index 0000000..cb4aa62 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs @@ -0,0 +1,500 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task> GetDomainsAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var items = await dbContext.TenantDomains.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .OrderByDescending(item => item.IsPrimary) + .ThenBy(item => item.CreatedAt) + .Select(item => ToDomainItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> CreateDomainAsync( + TenantAdminActor actor, + CreateTenantDomainCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + TenantDomain generated; + try + { + generated = TenantDomainProvisioning.CreatePrimary(actor.TenantId, command.Host); + } + catch (ArgumentException exception) + { + throw new TenantAdminDirectException(exception.Message, "invalid_domain_host"); + } + var host = generated.Host; + if (command.IsPrimary) + { + var primaryDomains = await dbContext.TenantDomains + .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) + .ToArrayAsync(cancellationToken); + foreach (var domain in primaryDomains) + { + domain.IsPrimary = false; + } + } + + var item = new TenantDomain + { + TenantId = actor.TenantId, + Host = host, + DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"), + Status = TenantDomainStatus.Pending, + IsPrimary = command.IsPrimary, + VerificationToken = generated.VerificationToken + }; + dbContext.TenantDomains.Add(item); + await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToDomainItem(item)); + } + + public async Task> GetAuthProvidersAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var items = await providerConfigService.GetProvidersAsync( + actor.TenantId, + TenantExternalProviderCapability.Identity, + cancellationToken: cancellationToken); + return new CatalogList(items.Select(ToAuthProviderItem).ToArray()); + } + + public async Task> UpsertAuthProviderAsync( + TenantAdminActor actor, + UpsertTenantIdentityProviderCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); + var item = await providerConfigService.UpsertProviderAsync( + actor.TenantId, + new UpsertTenantExternalProviderCommand( + TenantExternalProviderCapability.Identity, + command.Provider, + ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"), + command.DisplayName, + command.SecretRef, + command.Priority, + command.ConfigPublic, + JsonObjectOrDefault(default)), + cancellationToken); + + await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToAuthProviderItem(item)); + } + + public async Task> GetBadgesAsync( + TenantAdminActor actor, + TenantAdminBadgeFilter filter, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Category)) + { + query = query.Where(item => item.Category == filter.Category.Trim()); + } + + if (!filter.IncludeInactive) + { + query = query.Where(item => item.IsActive); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Level ?? int.MaxValue) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToBadgeItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertBadgeAsync( + TenantAdminActor actor, + UpsertTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var isNew = item is null; + item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; + item.LegacyId = Normalize(command.LegacyId); + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.Category = Normalize(command.Category) ?? "custom"; + item.IconUrl = Normalize(command.IconUrl); + item.Level = command.Level; + item.UnlockType = Normalize(command.UnlockType) ?? "manual"; + item.ConditionField = Normalize(command.ConditionField); + item.ConditionOperator = Normalize(command.ConditionOperator); + item.ConditionValue = command.ConditionValue; + item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); + item.SortOrder = command.Order ?? item.SortOrder; + item.IsActive = command.IsActive ?? item.IsActive; + if (isNew) + { + dbContext.Badges.Add(item); + } + + await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBadgeItem(item)); + } + + public async Task> GetBadgeGrantsAsync( + TenantAdminActor actor, + TenantAdminBadgeGrantFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.UserBadges.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (filter.BadgeId.HasValue) + { + query = query.Where(item => item.BadgeId == filter.BadgeId.Value); + } + + var grants = await query + .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = grants.Select(item => item.UserId).OfType().Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); + var badgeIds = grants.Select(item => item.BadgeId).OfType().Distinct().ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + var badges = await dbContext.Badges.AsNoTracking() + .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id)) + .ToDictionaryAsync(badge => badge.Id, cancellationToken); + + return new CatalogList(grants.Select(grant => + ToBadgeGrantItem( + grant, + grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, + grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null, + grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray()); + } + + public async Task> GrantBadgeAsync( + TenantAdminActor actor, + GrantTenantAdminBadgeCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var badge = await dbContext.Badges.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, + cancellationToken); + if (badge is null) + { + throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); + } + + if (!badge.IsActive) + { + throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); + } + + await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); + var grant = await dbContext.UserBadges.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, + cancellationToken); + var isNew = grant is null; + grant ??= new UserBadge + { + TenantId = actor.TenantId, + UserId = command.UserId, + BadgeId = command.BadgeId, + GrantedBy = actor.UserId, + GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow + }; + grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId; + grant.Note = Normalize(command.Note) ?? grant.Note; + grant.GrantedBy ??= actor.UserId; + grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; + if (isNew) + { + dbContext.UserBadges.Add(grant); + } + + var dedupeKey = $"badge:{grant.Id:N}"; + await notificationProvider.UpsertInAppAsync( + new InAppNotificationRequest( + actor.TenantId, + command.UserId, + "badge_granted", + NotificationSeverity.Success, + $"获得勋章:{badge.Name}", + Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。", + actor.UserId, + "查看勋章", + "/profile?tab=badges", + "user_badges", + grant.Id, + dedupeKey, + JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })), + cancellationToken); + + await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); + return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); + } + + public async Task> GetNotificationsAsync( + TenantAdminActor actor, + TenantAdminNotificationFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.UserNotifications.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_notification_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.NotificationType)) + { + query = query.Where(item => item.NotificationType == filter.NotificationType.Trim()); + } + + var items = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToNotificationItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertNotificationAsync( + TenantAdminActor actor, + UpsertTenantAdminNotificationCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); + await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); + + var item = await notificationProvider.UpsertInAppAsync( + new InAppNotificationRequest( + actor.TenantId, + command.UserId, + command.NotificationType, + ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"), + command.Title, + command.Message, + actor.UserId, + command.ActionLabel, + command.ActionPath, + command.SourceType, + command.SourceId, + command.DedupeKey, + JsonObjectOrDefault(command.Metadata)), + cancellationToken); + + await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToNotificationItem(item)); + } + + public async Task> GetFeedbacksAsync( + TenantAdminActor actor, + TenantAdminFeedbackFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.Reports.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))); + if (filter.UserId.HasValue) + { + query = query.Where(item => item.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.Type)) + { + query = query.Where(item => item.Type == ParseEnum(filter.Type, "invalid_feedback_type")); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => + (item.Title != null && item.Title.Contains(keyword)) || + (item.Description != null && item.Description.Contains(keyword)) || + (item.Contact != null && item.Contact.Contains(keyword))); + } + + var reports = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = reports.Select(item => item.UserId).OfType().Distinct().ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + return new CatalogList(reports.Select(report => + ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray()); + } + + public async Task> UpdateFeedbackAsync( + TenantAdminActor actor, + UpdateTenantAdminFeedbackCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var report = await dbContext.Reports + .Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, + item => item.UserId.HasValue && + (dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId.Value && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId.Value && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId)))) + .FirstOrDefaultAsync(cancellationToken); + if (report is null) + { + throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); + } + + var fromStatus = report.Status; + report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status"); + report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority"); + report.Resolution = Normalize(command.Resolution) ?? report.Resolution; + if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed) + { + report.HandledBy = actor.UserId; + report.HandledAt = DateTimeOffset.UtcNow; + } + + dbContext.ReportStatusEvents.Add(new ReportStatusEvent + { + TenantId = actor.TenantId, + ReportId = report.Id, + FromStatus = fromStatus, + ToStatus = report.Status, + Note = Normalize(command.Note), + ActorUserId = actor.UserId, + Metadata = JsonObjectOrDefault(command.Metadata) + }); + await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var user = report.UserId.HasValue + ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) + : null; + return new ContentManagementResult(ToFeedbackItem(report, user)); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs new file mode 100644 index 0000000..56c9492 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs @@ -0,0 +1,675 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) + { + User? user = null; + if (command.UserId.HasValue) + { + user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); + if (user is null) + { + throw new TenantAdminDirectException("User was not found.", "user_not_found"); + } + } + else + { + var phone = Normalize(command.Phone); + var email = Normalize(command.Email); + var username = Normalize(command.Username); + user = await dbContext.Users.FirstOrDefaultAsync(item => + (phone != null && item.Phone == phone) || + (email != null && item.Email == email) || + (username != null && item.UserName == username), + cancellationToken); + + if (user is null) + { + if (phone is null && email is null && username is null && Normalize(command.Name) is null) + { + throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required"); + } + + user = new User + { + UserName = username ?? phone ?? email, + Email = email, + Phone = phone, + Name = Normalize(command.Name) ?? username ?? phone ?? email, + PrimaryRole = primaryRole, + RawProfile = JsonDefaults.Object() + }; + dbContext.Users.Add(user); + } + } + + user.UserName = Normalize(command.Username) ?? user.UserName; + user.Email = Normalize(command.Email) ?? user.Email; + user.Phone = Normalize(command.Phone) ?? user.Phone; + user.Name = Normalize(command.Name) ?? user.Name; + user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl; + user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole; + return user; + } + + private async Task EnsureMembershipAsync( + Guid tenantId, + Guid userId, + TenantRole role, + CancellationToken cancellationToken) + { + var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => + item.TenantId == tenantId && item.UserId == userId && item.Role == role, + cancellationToken); + if (membership is null) + { + var metricCode = QuotaMetricForRole(role); + if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + tenantId, + metricCode, + cancellationToken: cancellationToken); + } + membership = new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = role, + Status = MembershipStatus.Active + }; + dbContext.TenantMemberships.Add(membership); + } + else + { + if (membership.Status != MembershipStatus.Active) + { + var metricCode = QuotaMetricForRole(role); + if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + tenantId, + metricCode, + cancellationToken: cancellationToken); + } + } + membership.Status = MembershipStatus.Active; + } + + return membership; + } + + private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student + ? SaasQuotaMetricCatalog.StudentCount + : SaasQuotaMetricCatalog.StaffCount; + + private Task IsUserCountedForMetricAsync( + Guid tenantId, + Guid userId, + string metricCode, + CancellationToken cancellationToken, + Guid? excludedMembershipId = null) + { + var query = dbContext.TenantMemberships.AsNoTracking().Where(item => + item.TenantId == tenantId && + item.UserId == userId && + item.Status == MembershipStatus.Active); + if (excludedMembershipId.HasValue) + { + query = query.Where(item => item.Id != excludedMembershipId.Value); + } + + return metricCode == SaasQuotaMetricCatalog.StudentCount + ? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken) + : query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken); + } + + private async Task EnsureStudentProfileAsync( + Guid tenantId, + Guid userId, + Guid? regionId, + Guid? schoolId, + Guid? majorId, + string? avatarPreset, + JsonElement stats, + JsonElement progress, + JsonElement moduleSelections, + CancellationToken cancellationToken) + { + var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => + item.TenantId == tenantId && item.UserId == userId, + cancellationToken); + if (profile is null) + { + profile = new StudentProfile + { + TenantId = tenantId, + UserId = userId, + Stats = JsonDefaults.Object(), + Progress = JsonDefaults.Object(), + ModuleSelections = JsonDefaults.Object(), + RecentActivities = JsonDefaults.Array() + }; + dbContext.StudentProfiles.Add(profile); + } + + profile.RegionId = regionId ?? profile.RegionId; + profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId; + profile.SelectedMajorId = majorId ?? profile.SelectedMajorId; + profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset; + profile.Stats = JsonObjectOrDefault(stats); + profile.Progress = JsonObjectOrDefault(progress); + profile.ModuleSelections = JsonObjectOrDefault(moduleSelections); + return profile; + } + + private async Task> BuildStudentImportPreviewAsync( + TenantAdminActor actor, + CurrentDataScope scope, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken) + { + var items = new List(); + var rowNo = 0; + foreach (var row in command.Rows.Take(1000)) + { + rowNo++; + string? reason = null; + var phone = Normalize(row.User.Phone); + var email = Normalize(row.User.Email); + var name = Normalize(row.User.Name); + if (row.User.UserId is null && phone is null && email is null && name is null) + { + reason = "user_required"; + } + else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId)) + { + reason = "data_scope_denied"; + } + else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) + { + reason = "region_not_found"; + } + else if (row.ClassId.HasValue) + { + try + { + await AssertClassAsync(actor, scope, row.ClassId, cancellationToken); + } + catch (TenantAdminDirectException exception) + { + reason = exception.Code; + } + } + + items.Add(new TenantAdminStudentImportPreviewItem( + rowNo, + reason is null, + reason, + phone, + email, + name, + row.RegionId, + row.ClassId)); + } + + return items; + } + + private async Task UpsertClassMemberCoreAsync( + TenantAdminActor actor, + Guid classId, + Guid userId, + TenantClassMemberType memberType, + TenantClassMemberStatus status, + JsonElement metadata, + CancellationToken cancellationToken) + { + var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => + member.TenantId == actor.TenantId && + member.ClassId == classId && + member.UserId == userId && + member.MemberType == memberType, + cancellationToken); + if (item is null) + { + item = new TenantClassMember + { + TenantId = actor.TenantId, + ClassId = classId, + UserId = userId, + MemberType = memberType, + JoinedAt = DateTimeOffset.UtcNow + }; + dbContext.TenantClassMembers.Add(item); + } + + item.Status = status; + item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null; + item.Metadata = JsonObjectOrDefault(metadata); + return item; + } + + private async Task> GetSupervisionRulesCoreAsync( + Guid tenantId, + CancellationToken cancellationToken) + { + var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (settings is null || + settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || + !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) || + rulesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return JsonSerializer.Deserialize(rulesElement.GetRawText()) ?? []; + } + + private async Task SaveSupervisionRulesCoreAsync( + Guid tenantId, + IReadOnlyCollection rules, + CancellationToken cancellationToken) + { + var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (settings is null) + { + settings = new TenantSettings { TenantId = tenantId }; + dbContext.TenantSettings.Add(settings); + } + + var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object + ? JsonSerializer.Deserialize>(settings.AdminFeatureFlags.GetRawText()) ?? [] + : []; + existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules); + settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing); + } + + private async Task> BuildSupervisionRiskStudentsAsync( + TenantAdminActor actor, + CurrentDataScope scope, + CancellationToken cancellationToken) + { + var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)) + .Where(rule => rule.Enabled) + .ToArray(); + if (rules.Length == 0) + { + return []; + } + + var regionIds = scope.RegionIds.ToArray(); + var students = await dbContext.StudentProfiles.AsNoTracking() + .Where(profile => profile.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + profile => profile.UserId == actor.UserId, + profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) + .Select(profile => new + { + Profile = profile, + User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() + }) + .ToArrayAsync(cancellationToken); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var result = new List(); + foreach (var row in students) + { + var hitRules = new List(); + var reasons = new List(); + foreach (var rule in rules) + { + if (rule.DaysWithoutCheckIn.HasValue) + { + var days = row.Profile.LastCheckInDate.HasValue + ? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber + : int.MaxValue; + if (days >= rule.DaysWithoutCheckIn.Value) + { + hitRules.Add(rule.Code); + reasons.Add($"{rule.Title}: {days} days without check-in"); + } + } + + if (rule.MaxQuestionsAnsweredToday.HasValue && + row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value) + { + hitRules.Add(rule.Code); + reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}"); + } + } + + if (hitRules.Count > 0) + { + result.Add(new TenantSupervisionRiskStudentItem( + row.Profile.UserId, + row.User?.Name, + MaskPhone(row.User?.Phone), + row.Profile.RegionId, + hitRules.Distinct(StringComparer.Ordinal).ToArray(), + reasons.Distinct(StringComparer.Ordinal).ToArray())); + } + } + + return result; + } + + private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) + { + var exists = await dbContext.TenantMemberships.AnyAsync( + item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student, + cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Student was not found.", "student_not_found"); + } + } + + private async Task AssertStudentAsync( + TenantAdminActor actor, + CurrentDataScope scope, + Guid userId, + CancellationToken cancellationToken) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var exists = await dbContext.TenantMemberships + .Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Student was not found.", "student_not_found"); + } + } + + private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) + { + if (!userId.HasValue) + { + return; + } + + var exists = await dbContext.TenantMemberships.AnyAsync( + item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active, + cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + } + + private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) + { + await sessionStore.RevokeRealmAsync( + userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken); + } + + private async Task EnsureTenantOwnerBackendRoleAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) + { + const string roleCode = "tenant_owner"; + var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync( + item => item.TenantId == tenantId && item.Code == roleCode, + cancellationToken); + if (role is null) + { + role = new TenantBackendRole + { + TenantId = tenantId, + Code = roleCode, + Name = "租户所有者", + Status = BackendRoleStatus.Active, + IsSystem = true, + Description = "系统内置租户所有者角色", + DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }) + }; + dbContext.TenantBackendRoles.Add(role); + } + else + { + role.Status = BackendRoleStatus.Active; + role.IsSystem = true; + role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }); + } + + var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions + .Where(permission => tenantPermissionCodes.Contains(permission.Code)) + .Select(permission => permission.Code) + .ToArrayAsync(cancellationToken); + foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) + { + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = permissionCode, + Name = permissionCode, + Area = BackendPermissionArea.Tenant, + PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), + IsSystem = true + }); + } + + var boundPermissionCodes = await dbContext.TenantBackendRolePermissions + .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) + .Select(binding => binding.PermissionCode) + .ToArrayAsync(cancellationToken); + dbContext.TenantBackendRolePermissions.AddRange( + tenantPermissionCodes + .Except(boundPermissionCodes, StringComparer.Ordinal) + .Select(permissionCode => new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionCode = permissionCode + })); + + if (!await dbContext.TenantBackendUserRoles.AnyAsync( + binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, + cancellationToken)) + { + dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + { + TenantId = tenantId, + UserId = userId, + RoleId = role.Id + }); + } + } + + private async Task EnsureBrandingThemeAsync( + Guid tenantId, + JsonElement theme, + JsonElement publicAssets, + CancellationToken cancellationToken) + { + var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (branding is null) + { + var tenantName = await dbContext.Tenants + .Where(tenant => tenant.Id == tenantId) + .Select(tenant => tenant.Name) + .FirstOrDefaultAsync(cancellationToken); + branding = new TenantBranding + { + TenantId = tenantId, + BrandName = tenantName ?? "租户题库" + }; + dbContext.TenantBrandings.Add(branding); + } + + branding.Theme = theme.Clone(); + branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets); + } + + private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) + { + if (!classId.HasValue) + { + return; + } + + var exists = await dbContext.TenantClasses.AnyAsync( + item => item.TenantId == tenantId && item.Id == classId.Value, + cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + } + + private async Task AssertClassAsync( + TenantAdminActor actor, + CurrentDataScope scope, + Guid? classId, + CancellationToken cancellationToken) + { + if (!classId.HasValue) + { + return; + } + + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var exists = await dbContext.TenantClasses + .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) + .AnyAsync(cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Class was not found.", "class_not_found"); + } + } + + private async Task RequireDataScopeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + + return access.DataScope; + } + + private async Task RequireAllDataScopeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + if (scope.Mode != DataScopeMode.All) + { + throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found"); + } + } + + private async Task AssertReferenceAsync( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : TenantEntity + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set().AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); + if (!exists) + { + throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); + } + } + + private async Task ResolveTenantEntityAsync( + DbSet set, + Guid tenantId, + Guid? id, + string? legacyId, + CancellationToken cancellationToken) + where TEntity : AuditableTenantEntity + { + if (id.HasValue) + { + return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); + } + + legacyId = Normalize(legacyId); + if (legacyId is null) + { + return null; + } + + return typeof(TEntity).GetProperty("LegacyId") is null + ? null + : await set.FirstOrDefaultAsync( + entity => entity.TenantId == tenantId && EF.Property(entity, "LegacyId") == legacyId, + cancellationToken); + } + + private async Task AddAuditAsync( + TenantAdminActor actor, + string action, + string targetType, + Guid targetId, + CancellationToken cancellationToken) + { + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = actor.TenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonDefaults.Object() + }); + await Task.CompletedTask.WaitAsync(cancellationToken); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs new file mode 100644 index 0000000..ea6f510 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs @@ -0,0 +1,599 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount) + { + return new TenantAdminClassItem( + item.Id, + item.RegionId, + regionName, + item.LegacyId, + item.Code, + item.Name, + item.Description, + item.Status, + item.SortOrder, + item.Metadata, + studentCount, + staffCount, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user) + { + return new TenantAdminClassMemberItem( + item.Id, + item.ClassId, + item.UserId, + item.MemberType, + item.Status, + item.JoinedAt, + item.LeftAt, + item.Metadata, + user); + } + + private static TenantAdminStudentItem ToStudentItem( + TenantMembership membership, + User user, + StudentProfile? profile, + IReadOnlyDictionary regions, + IReadOnlyDictionary schools, + IReadOnlyDictionary majors, + IReadOnlyCollection classes) + { + return new TenantAdminStudentItem( + membership.Id, + membership.UserId, + membership.Status, + ToUserSummary(user), + profile?.Id, + profile?.AvatarPreset ?? "male", + profile?.RegionId, + profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null, + profile?.SelectedSchoolId, + profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null, + profile?.SelectedMajorId, + profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null, + profile?.Stats ?? JsonDefaults.Object(), + profile?.Progress ?? JsonDefaults.Object(), + profile?.ModuleSelections ?? JsonDefaults.Object(), + classes, + membership.CreatedAt, + membership.UpdatedAt); + } + + private static TenantAdminUserSummary ToUserSummary(User user) + { + return new TenantAdminUserSummary( + user.Id, + user.UserName, + user.Email, + user.Phone, + user.Name, + user.AvatarUrl, + user.PrimaryRole); + } + + private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item) + { + return new TenantAdminStudentNoteItem( + item.Id, + item.StudentUserId, + item.NoteType, + item.Content, + item.Visibility, + item.IsPinned, + item.Metadata, + item.CreatedBy, + item.UpdatedBy, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item) + { + return new TenantAdminStudentFollowupItem( + item.Id, + item.StudentUserId, + item.AssignedToUserId, + item.ClassId, + item.Title, + item.Description, + item.FollowupType, + item.Priority, + item.Status, + item.DueAt, + item.CompletedAt, + item.CompletedBy, + item.Metadata, + item.CreatedBy, + item.UpdatedBy, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user) + { + return new TenantAdminMemberItem( + membership.Id, + membership.UserId, + membership.Role, + membership.Status, + membership.LegacyRole, + ToUserSummary(user), + membership.CreatedAt, + membership.UpdatedAt); + } + + private static TenantBrandingItem ToBrandingItem(TenantBranding item) + { + return new TenantBrandingItem( + item.TenantId, + item.BrandName, + item.ShortName, + item.Slogan, + item.OrganizationName, + item.LogoUrl, + item.FaviconUrl, + item.ServiceWechat, + item.ServiceAccountName, + item.Theme, + item.PublicAssets, + item.UpdatedAt); + } + + private static TenantSettingsItem ToSettingsItem(TenantSettings item) + { + return new TenantSettingsItem( + item.TenantId, + item.FeatureFlags, + item.AdminFeatureFlags, + item.PublicConfig, + item.UpdatedAt); + } + + private static TenantThemeItem ToThemeItem(TenantThemeConfig item) + { + return new TenantThemeItem( + item.TenantId, + item.ActiveTemplateCode, + item.ActiveTheme, + item.ActivePublicAssets, + item.DraftTemplateCode, + item.DraftTheme, + item.DraftPublicAssets, + item.Status, + item.PublishedAt, + item.PublishedBy, + item.DraftUpdatedBy, + item.UpdatedAt); + } + + private static TenantDomainItem ToDomainItem(TenantDomain item) + { + return new TenantDomainItem( + item.Id, + item.Host, + item.DomainType, + item.Status, + item.IsPrimary, + item.VerificationToken, + item.VerifiedAt, + item.LastCheckedAt, + item.DnsVerifiedAt, + item.TlsReadyAt, + item.LastFailureReason, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item) + { + return new TenantIdentityProviderItem( + item.Id, + item.Provider, + item.Status, + item.DisplayName, + item.SecretRef, + item.Priority, + item.ConfigPublic, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminBadgeItem ToBadgeItem(Badge item) + { + return new TenantAdminBadgeItem( + item.Id, + item.LegacyId, + item.Name, + item.Description, + item.Category, + item.IconUrl, + item.Level, + item.UnlockType, + item.ConditionField, + item.ConditionOperator, + item.ConditionValue, + item.ConditionExtra, + item.SortOrder, + item.IsActive, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge) + { + return new TenantAdminBadgeGrantItem( + grant.Id, + grant.LegacyId, + grant.UserId, + user?.Name ?? user?.UserName, + user?.Phone, + grant.BadgeId, + badge?.Name, + badge?.Category, + badge?.IconUrl, + badge?.Level, + grant.GrantedBy, + grantedBy?.Name ?? grantedBy?.UserName, + grant.Note, + grant.GrantedAt, + grant.CreatedAt, + grant.UpdatedAt); + } + + private static TenantAdminNotificationItem ToNotificationItem(UserNotification item) + { + return new TenantAdminNotificationItem( + item.Id, + item.UserId, + item.NotificationType, + item.Status, + item.Severity, + item.Title, + item.Message, + item.ActionLabel, + item.ActionPath, + item.SourceType, + item.SourceId, + item.DedupeKey, + item.Metadata, + item.CreatedBy, + item.ReadAt, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user) + { + return new TenantAdminFeedbackItem( + report.Id, + report.UserId, + user?.Name ?? user?.UserName, + user?.Phone, + report.QuestionId, + report.Type, + report.Title, + report.Category, + report.Description, + report.Status, + report.Priority, + report.HandledBy, + report.HandledAt, + report.Resolution, + report.Contact, + report.Attachments, + report.Metadata, + report.CreatedAt, + report.UpdatedAt); + } + + private static int ResolveLimit(int? limit) + { + return Math.Clamp(limit ?? 100, 1, 500); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant(); + } + + private static string? MaskPhone(string? phone) + { + var value = Normalize(phone); + return value is { Length: >= 7 } + ? $"{value[..3]}****{value[^4..]}" + : value; + } + + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); + } + + private static JsonElement PermissionObject(JsonElement value) + { + var result = new Dictionary(StringComparer.Ordinal); + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value"); + } + + foreach (var property in value.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) + { + throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value"); + } + + if (property.Name != "*" && !IsPermissionKey(property.Name)) + { + throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key"); + } + + result[property.Name] = property.Value.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(result); + } + + private static JsonElement AccessMap(JsonElement value, string codePrefix) + { + var result = new Dictionary(StringComparer.Ordinal); + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}"); + } + + foreach (var property in value.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) + { + throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}"); + } + + if (!IsAccessKey(property.Name)) + { + throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key"); + } + + result[property.Name] = property.Value.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(result); + } + + private static JsonElement DataScope(JsonElement value) + { + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope"); + } + + var allowed = new HashSet(StringComparer.Ordinal) + { + "mode", + "regionIds", + "contentNodeIds", + "classIds", + "ownLeadsOnly", + "teamScope", + "metadata" + }; + foreach (var property in value.EnumerateObject()) + { + if (!allowed.Contains(property.Name)) + { + throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key"); + } + } + + return value.Clone(); + } + + private async Task AssertGrantableAsync( + TenantAdminActor actor, + TenantRole role, + CancellationToken cancellationToken) + { + if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) + { + return; + } + + var isOwnerRoleHolder = await ( + from binding in dbContext.TenantBackendUserRoles.AsNoTracking() + join backendRole in dbContext.TenantBackendRoles.AsNoTracking() + on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } + where binding.TenantId == actor.TenantId && + binding.UserId == actor.UserId && + backendRole.Code == "tenant_owner" && + backendRole.IsSystem && + backendRole.Status == BackendRoleStatus.Active + select binding.Id) + .AnyAsync(cancellationToken); + if (!isOwnerRoleHolder) + { + throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); + } + } + + private static string RoleToPrimaryRole(TenantRole role) + { + return role switch + { + TenantRole.Student => "student", + TenantRole.Teacher => "teacher", + TenantRole.Sales => "sales", + TenantRole.Agent => "agent", + TenantRole.TenantOperator => "tenant_operator", + TenantRole.TenantAdmin => "tenant_admin", + TenantRole.TenantOwner => "tenant_owner", + _ => "student" + }; + } + + private static string NormalizeRoleCode(string value) + { + var code = new string(value.Trim().ToLowerInvariant() + .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') + .ToArray()) + .Trim('-'); + while (code.Contains("--", StringComparison.Ordinal)) + { + code = code.Replace("--", "-", StringComparison.Ordinal); + } + + if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0])) + { + throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code"); + } + + return code; + } + + private static string NormalizeDomain(string value) + { + var host = Normalize(value)?.ToLowerInvariant() + .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required"); + if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal)) + { + throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host"); + } + + return host; + } + + private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second) + { + var result = new Dictionary(StringComparer.Ordinal); + if (first.ValueKind == JsonValueKind.Object) + { + foreach (var property in first.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + } + + if (second.ValueKind == JsonValueKind.Object) + { + foreach (var property in second.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + } + + return JsonSerializer.SerializeToElement(result); + } + + private static void AssertNoSecrets(JsonElement value, string code) + { + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return; + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}"); + } + + foreach (var property in value.EnumerateObject()) + { + var key = property.Name.ToLowerInvariant(); + if (key.Contains("secret", StringComparison.Ordinal) || + key.Contains("password", StringComparison.Ordinal) || + key.Contains("token", StringComparison.Ordinal) || + key.Contains("private", StringComparison.Ordinal) || + key is "appsecret" or "app_secret" or "accesskeysecret") + { + throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret"); + } + } + } + + private static bool IsPermissionKey(string key) + { + return key.Length <= 100 && + key.Contains(':', StringComparison.Ordinal) && + key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*'); + } + + private static bool IsAccessKey(string key) + { + return key.Length <= 100 && + key.Length > 0 && + char.IsAsciiLetter(key[0]) && + key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-'); + } + + private static TEnum ParseEnum(string? value, TEnum fallback, string code) + where TEnum : struct, Enum + { + return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum(value, code); + } + + private static TEnum ParseEnum(string value, string code) + where TEnum : struct, Enum + { + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal); + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + throw new TenantAdminDirectException("Enum value was invalid.", code); + } +} diff --git a/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs new file mode 100644 index 0000000..adc2534 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs @@ -0,0 +1,249 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task> GetMembersAsync( + TenantAdminActor actor, + TenantAdminMemberFilter filter, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Role)) + { + query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => dbContext.Users.Any(user => + user.Id == item.UserId && + ((user.UserName != null && user.UserName.Contains(keyword)) || + (user.Phone != null && user.Phone.Contains(keyword)) || + (user.Email != null && user.Email.Contains(keyword)) || + (user.Name != null && user.Name.Contains(keyword))))); + } + + var memberships = await query + .OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 : + item.Role == TenantRole.TenantAdmin ? 1 : + item.Role == TenantRole.TenantOperator ? 2 : + item.Role == TenantRole.Teacher ? 3 : 9) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = memberships.Select(item => item.UserId).ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + + return new CatalogList(memberships.Select(item => + { + users.TryGetValue(item.UserId, out var user); + return ToMemberItem(item, user ?? new User { Id = item.UserId }); + }).ToArray()); + } + + public async Task> UpsertMemberAsync( + TenantAdminActor actor, + UpsertTenantAdminMemberCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role"); + var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status"); + await AssertGrantableAsync(actor, role, cancellationToken); + var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); + var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); + if (user.Id == actor.UserId && status == MembershipStatus.Disabled) + { + throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); + } + + TenantMembership? membership = null; + if (command.MembershipId.HasValue) + { + membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, + cancellationToken); + if (membership is null) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + } + else + { + membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role, + cancellationToken); + } + + var isNew = membership is null; + var previousStatus = membership?.Status.ToString() ?? "none"; + var wasStaffCounted = await IsUserCountedForMetricAsync( + actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken); + var wasStudentCounted = await IsUserCountedForMetricAsync( + actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken); + Guid? excludedMembershipId = isNew ? null : membership!.Id; + var otherStaffCounted = await IsUserCountedForMetricAsync( + actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId); + var otherStudentCounted = await IsUserCountedForMetricAsync( + actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId); + var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student); + var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student); + if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner) + { + throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required"); + } + + if (!wasStaffCounted && willStaffBeCounted) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StaffCount, + cancellationToken: cancellationToken); + } + if (!wasStudentCounted && willStudentBeCounted) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StudentCount, + cancellationToken: cancellationToken); + } + + membership ??= new TenantMembership + { + TenantId = actor.TenantId, + UserId = user.Id + }; + membership.UserId = user.Id; + membership.Role = role; + membership.Status = status; + if (isNew) + { + dbContext.TenantMemberships.Add(membership); + } + + if (status != MembershipStatus.Active) + { + await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); + } + else if (role == TenantRole.TenantOwner) + { + await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); + } + + await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (wasStaffCounted && !willStaffBeCounted) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StaffCount, + 1, + cancellationToken); + } + if (wasStudentCounted && !willStudentBeCounted) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StudentCount, + 1, + cancellationToken); + } + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + await authorizationStateInvalidator.InvalidateMembershipAsync( + actor.TenantId, membership.UserId, cancellationToken); + await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken); + return new ContentManagementResult(ToMemberItem(membership, user)); + } + + public async Task> DisableMemberAsync( + TenantAdminActor actor, + Guid membershipId, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == membershipId, + cancellationToken); + if (membership is null) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + + if (membership.UserId == actor.UserId) + { + throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); + } + + await AssertGrantableAsync(actor, membership.Role, cancellationToken); + var previousStatus = membership.Status; + var metricCode = QuotaMetricForRole(membership.Role); + var wasCounted = await IsUserCountedForMetricAsync( + actor.TenantId, membership.UserId, metricCode, cancellationToken); + var otherMembershipCounted = await IsUserCountedForMetricAsync( + actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id); + membership.Status = MembershipStatus.Disabled; + await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken); + await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + metricCode, + 1, + cancellationToken); + } + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + await authorizationStateInvalidator.InvalidateMembershipAsync( + actor.TenantId, membership.UserId, cancellationToken); + var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); + return new ContentManagementResult(ToMemberItem(membership, user)); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs new file mode 100644 index 0000000..6f2a6a3 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task> GetAuditLogsAsync( + TenantAdminActor actor, + TenantAdminAuditLogFilter filter, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Action)) + { + var action = filter.Action.Trim(); + query = query.Where(item => item.Action.StartsWith(action)); + } + + if (!string.IsNullOrWhiteSpace(filter.TargetType)) + { + query = query.Where(item => item.TargetType == filter.TargetType.Trim()); + } + + if (filter.ActorUserId.HasValue) + { + query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); + } + + var logs = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => actorIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + + return new CatalogList(logs.Select(item => + { + var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser) + ? actorUser + : null; + return new TenantAdminAuditLogItem( + item.Id, + item.ActorUserId, + item.Action, + item.TargetType, + item.TargetId, + item.Details, + item.IpAddress, + item.UserAgent, + user?.Name ?? user?.UserName, + user?.Phone, + item.CreatedAt); + }).ToArray()); + } + + public async Task> UpsertBrandingAsync( + TenantAdminActor actor, + UpsertTenantBrandingCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); + var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantBranding { TenantId = actor.TenantId }; + dbContext.TenantBrandings.Add(item); + } + + item.BrandName = command.BrandName.Trim(); + item.ShortName = Normalize(command.ShortName); + item.Slogan = Normalize(command.Slogan); + item.OrganizationName = Normalize(command.OrganizationName); + item.LogoUrl = Normalize(command.LogoUrl); + item.FaviconUrl = Normalize(command.FaviconUrl); + item.ServiceWechat = Normalize(command.ServiceWechat); + item.ServiceAccountName = Normalize(command.ServiceAccountName); + await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBrandingItem(item)); + } + + public async Task> UpsertSettingsAsync( + TenantAdminActor actor, + UpsertTenantSettingsCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + AssertNoSecrets(command.PublicConfig, "public_config"); + var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantSettings { TenantId = actor.TenantId }; + dbContext.TenantSettings.Add(item); + } + + item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags); + item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags); + item.PublicConfig = JsonObjectOrDefault(command.PublicConfig); + await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToSettingsItem(item)); + } + + public async Task> GetThemeTemplatesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + await Task.CompletedTask.WaitAsync(cancellationToken); + var items = await dbContext.TenantThemeTemplates.AsNoTracking() + .Where(item => item.Status == TenantThemeTemplateStatus.Active) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Code) + .Select(item => new TenantThemeTemplateItem( + item.Code, + item.Name, + item.Description, + item.PreviewImageUrl, + item.Theme, + item.PublicAssets, + item.SortOrder)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> GetThemeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var item = await dbContext.TenantThemeConfigs.AsNoTracking() + .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + if (item is not null) + { + return new ContentManagementResult(ToThemeItem(item)); + } + + var branding = await dbContext.TenantBrandings.AsNoTracking() + .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); + return new ContentManagementResult(new TenantThemeItem( + actor.TenantId, + null, + branding?.Theme ?? JsonDefaults.Object(), + branding?.PublicAssets ?? JsonDefaults.Object(), + null, + JsonDefaults.Object(), + JsonDefaults.Object(), + TenantThemeConfigStatus.Published, + null, + null, + null, + branding?.UpdatedAt ?? DateTimeOffset.UtcNow)); + } + + public async Task> PreviewThemeAsync( + TenantAdminActor actor, + PreviewTenantThemeCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); + var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, + cancellationToken); + if (template is null) + { + throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); + } + + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantThemeConfig { TenantId = actor.TenantId }; + dbContext.TenantThemeConfigs.Add(item); + } + + item.DraftTemplateCode = template.Code; + item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme); + item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); + item.Status = TenantThemeConfigStatus.Draft; + item.DraftUpdatedBy = actor.UserId; + await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToThemeItem(item)); + } + + public async Task> PublishThemeAsync( + TenantAdminActor actor, + PublishTenantThemeCommand command, + CancellationToken cancellationToken = default) + { + await RequireAllDataScopeAsync(actor, cancellationToken); + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + JsonElement activeTheme; + JsonElement activeAssets; + string? templateCode; + if (command.UseDraft) + { + if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode)) + { + throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found"); + } + + activeTheme = item.DraftTheme; + activeAssets = item.DraftPublicAssets; + templateCode = item.DraftTemplateCode; + } + else + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); + var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active, + cancellationToken); + if (template is null) + { + throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); + } + + item ??= new TenantThemeConfig { TenantId = actor.TenantId }; + if (dbContext.Entry(item).State == EntityState.Detached) + { + dbContext.TenantThemeConfigs.Add(item); + } + + activeTheme = MergeJsonObjects(template.Theme, command.Theme); + activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); + templateCode = template.Code; + } + + item ??= new TenantThemeConfig { TenantId = actor.TenantId }; + if (dbContext.Entry(item).State == EntityState.Detached) + { + dbContext.TenantThemeConfigs.Add(item); + } + + item.ActiveTemplateCode = templateCode; + item.ActiveTheme = activeTheme.Clone(); + item.ActivePublicAssets = activeAssets.Clone(); + item.DraftTemplateCode = null; + item.DraftTheme = JsonDefaults.Object(); + item.DraftPublicAssets = JsonDefaults.Object(); + item.Status = TenantThemeConfigStatus.Published; + item.PublishedAt = DateTimeOffset.UtcNow; + item.PublishedBy = actor.UserId; + await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken); + await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToThemeItem(item)); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs new file mode 100644 index 0000000..911f012 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task> GetStudentNotesAsync( + TenantAdminActor actor, + TenantAdminStudentActivityFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantStudentNotes.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); + if (filter.StudentUserId.HasValue) + { + query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); + } + + var items = await query + .OrderByDescending(item => item.IsPinned) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToNoteItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertStudentNoteAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentNoteCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); + await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); + TenantStudentNote? item = null; + if (command.Id.HasValue) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + item = await dbContext.TenantStudentNotes + .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value) + .ApplyDataScope( + scope, + note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId, + note => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == note.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == note.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); + } + } + + var isNew = item is null; + item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; + item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); + item.Content = command.Content.Trim(); + item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility"); + item.IsPinned = command.IsPinned ?? item.IsPinned; + item.Metadata = JsonObjectOrDefault(command.Metadata); + item.UpdatedBy = actor.UserId; + if (isNew) + { + dbContext.TenantStudentNotes.Add(item); + } + + await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToNoteItem(item)); + } + + public async Task> GetStudentFollowupsAsync( + TenantAdminActor actor, + TenantAdminStudentActivityFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantStudentFollowups.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, + item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || + dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); + if (filter.StudentUserId.HasValue) + { + query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); + } + + var items = await query + .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) + .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue) + .ThenByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToFollowupItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertStudentFollowupAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentFollowupCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); + await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); + + TenantStudentFollowup? item = null; + if (command.Id.HasValue) + { + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + item = await dbContext.TenantStudentFollowups + .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) + .ApplyDataScope( + scope, + followup => followup.StudentUserId == actor.UserId || + followup.AssignedToUserId == actor.UserId || + followup.CreatedBy == actor.UserId, + followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || + dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == followup.StudentUserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == followup.StudentUserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); + } + } + + var isNew = item is null; + item ??= new TenantStudentFollowup + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + StudentUserId = command.StudentUserId, + CreatedBy = actor.UserId + }; + item.AssignedToUserId = command.AssignedToUserId; + item.ClassId = command.ClassId; + item.Title = command.Title.Trim(); + item.Description = Normalize(command.Description); + item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type"); + item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority"); + item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status"); + item.DueAt = command.DueAt; + item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null; + item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; + item.Metadata = JsonObjectOrDefault(command.Metadata); + item.UpdatedBy = actor.UserId; + if (isNew) + { + dbContext.TenantStudentFollowups.Add(item); + } + + await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToFollowupItem(item)); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs b/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs new file mode 100644 index 0000000..d387d65 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs @@ -0,0 +1,415 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task GetStudentsAsync( + TenantAdminActor actor, + TenantAdminStudentFilter filter, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var query = dbContext.TenantMemberships.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))); + + if (filter.ClassId.HasValue) + { + await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); + query = query.Where(item => dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.ClassId == filter.ClassId.Value && + member.UserId == item.UserId && + member.MemberType == TenantClassMemberType.Student && + member.Status == TenantClassMemberStatus.Active)); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId == filter.RegionId.Value)); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => dbContext.Users.Any(user => + user.Id == item.UserId && + ((user.UserName != null && user.UserName.Contains(keyword)) || + (user.Phone != null && user.Phone.Contains(keyword)) || + (user.Email != null && user.Email.Contains(keyword)) || + (user.Name != null && user.Name.Contains(keyword))))); + } + + var memberships = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = memberships.Select(item => item.UserId).ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + var profiles = await dbContext.StudentProfiles.AsNoTracking() + .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId)) + .ToDictionaryAsync(profile => profile.UserId, cancellationToken); + var regions = await dbContext.Regions.AsNoTracking() + .Where(region => region.TenantId == actor.TenantId) + .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken); + var schools = await dbContext.Schools.AsNoTracking() + .Where(school => school.TenantId == actor.TenantId) + .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken); + var majors = await dbContext.Majors.AsNoTracking() + .Where(major => major.TenantId == actor.TenantId) + .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken); + var classes = await ( + from member in dbContext.TenantClassMembers.AsNoTracking() + join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id + where member.TenantId == actor.TenantId && + tenantClass.TenantId == actor.TenantId && + userIds.Contains(member.UserId) && + member.Status == TenantClassMemberStatus.Active && + member.MemberType == TenantClassMemberType.Student + orderby tenantClass.SortOrder, tenantClass.CreatedAt descending + select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code }) + .ToArrayAsync(cancellationToken); + var classLookup = classes + .GroupBy(item => item.UserId) + .ToDictionary( + group => group.Key, + group => group.Select(item => new TenantAdminStudentClassSummary( + item.ClassId, + item.Name, + item.Code, + item.MemberType, + item.JoinedAt)).ToArray() as IReadOnlyCollection); + + return new TenantAdminStudentList( + memberships.Select(membership => + { + users.TryGetValue(membership.UserId, out var user); + profiles.TryGetValue(membership.UserId, out var profile); + classLookup.TryGetValue(membership.UserId, out var studentClasses); + return ToStudentItem( + membership, + user ?? new User { Id = membership.UserId }, + profile, + regions, + schools, + majors, + studentClasses ?? []); + }).ToArray(), + Scoped: scope.Mode != DataScopeMode.All); + } + + public async Task> UpsertStudentAsync( + TenantAdminActor actor, + UpsertTenantAdminStudentCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var user = await ResolveUserAsync(command.User, "student", cancellationToken); + if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId)) + { + throw new TenantAdminDirectException("Student was not found.", "student_not_found"); + } + if (command.RawProfile.ValueKind == JsonValueKind.Object) + { + user.RawProfile = command.RawProfile.Clone(); + } + + var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); + var profile = await EnsureStudentProfileAsync( + actor.TenantId, + user.Id, + command.RegionId, + command.SelectedSchoolId, + command.SelectedMajorId, + Normalize(command.AvatarPreset), + command.Stats, + command.Progress, + command.ModuleSelections, + cancellationToken); + + await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return new ContentManagementResult( + ToStudentItem( + membership, + user, + profile, + new Dictionary(), + new Dictionary(), + new Dictionary(), + [])); + } + + public async Task> UpdateStudentStatusAsync( + TenantAdminActor actor, + UpdateTenantAdminStudentStatusCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var membership = await dbContext.TenantMemberships + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == command.UserId && + item.Role == TenantRole.Student) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => dbContext.StudentProfiles.Any(profile => + profile.TenantId == actor.TenantId && + profile.UserId == item.UserId && + profile.RegionId.HasValue && + regionIds.Contains(profile.RegionId.Value)) || + dbContext.TenantClassMembers.Any(member => + member.TenantId == actor.TenantId && + member.UserId == item.UserId && + member.Status == TenantClassMemberStatus.Active && + classIds.Contains(member.ClassId))) + .FirstOrDefaultAsync(cancellationToken); + if (membership is null) + { + throw new TenantAdminDirectException("Student membership was not found.", "student_not_found"); + } + + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var wasCounted = await IsUserCountedForMetricAsync( + actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken); + var otherMembershipCounted = await IsUserCountedForMetricAsync( + actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id); + var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active; + if (!wasCounted && willBeCounted) + { + await featureAccessService.ConsumeQuotaIfConfiguredAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StudentCount, + cancellationToken: cancellationToken); + } + membership.Status = status; + if (status != MembershipStatus.Active) + { + await sessionStore.RevokeRealmAsync( + command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); + } + + await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (wasCounted && !willBeCounted) + { + await featureAccessService.ReleaseQuotaAsync( + actor.TenantId, + SaasQuotaMetricCatalog.StudentCount, + 1, + cancellationToken); + } + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + await authorizationStateInvalidator.InvalidateMembershipAsync( + actor.TenantId, membership.UserId, cancellationToken); + return new ContentManagementResult( + new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); + } + + public async Task PreviewStudentImportAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); + return new TenantAdminStudentImportPreview( + command.Rows.Count, + items.Count(item => item.Valid), + items.Count(item => !item.Valid), + items); + } + + public async Task ImportStudentsAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); + var invalidItems = previewItems.Where(item => !item.Valid).ToArray(); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + var createdOrUpdated = 0; + var classAssigned = 0; + var rowNo = 0; + foreach (var row in command.Rows) + { + rowNo++; + if (invalidItems.Any(item => item.RowNo == rowNo)) + { + continue; + } + + var user = await ResolveUserAsync(row.User, "student", cancellationToken); + if (row.RawProfile.ValueKind == JsonValueKind.Object) + { + user.RawProfile = row.RawProfile.Clone(); + } + + await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); + await EnsureStudentProfileAsync( + actor.TenantId, + user.Id, + row.RegionId, + null, + null, + Normalize(row.AvatarPreset), + JsonDefaults.Object(), + JsonDefaults.Object(), + JsonDefaults.Object(), + cancellationToken); + createdOrUpdated++; + + if (row.ClassId.HasValue) + { + await UpsertClassMemberCoreAsync( + actor, + row.ClassId.Value, + user.Id, + TenantClassMemberType.Student, + TenantClassMemberStatus.Active, + JsonSerializer.SerializeToElement(new { source = "student_import" }), + cancellationToken); + classAssigned++; + } + } + + await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems); + } + + public async Task BulkAssignClassAsync( + TenantAdminActor actor, + TenantAdminBulkAssignClassCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); + var failed = new List(); + var succeeded = 0; + foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) + { + try + { + await AssertStudentAsync(actor, scope, userId, cancellationToken); + await UpsertClassMemberCoreAsync( + actor, + command.ClassId, + userId, + TenantClassMemberType.Student, + TenantClassMemberStatus.Active, + JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }), + cancellationToken); + succeeded++; + } + catch (TenantAdminDirectException) + { + failed.Add(userId); + } + } + + await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); + } + + public async Task BulkUpdateStudentStatusAsync( + TenantAdminActor actor, + TenantAdminBulkStatusCommand command, + CancellationToken cancellationToken = default) + { + var failed = new List(); + var succeeded = 0; + foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) + { + try + { + await UpdateStudentStatusAsync( + actor, + new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason), + cancellationToken); + succeeded++; + } + catch (TenantAdminDirectException) + { + failed.Add(userId); + } + } + + await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs new file mode 100644 index 0000000..2b75216 --- /dev/null +++ b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Auth; +using Tiku.Application.Content; +using Tiku.Application.Notifications; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Application.TenantAdmin; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Tenancy; +using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; + +namespace Tiku.Infrastructure.TenantAdmin; + +public sealed partial class TenantAdminDirectService +{ + public async Task> GetSupervisionRulesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + return new CatalogList(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); + } + + public async Task> UpsertSupervisionRuleAsync( + TenantAdminActor actor, + UpsertTenantSupervisionRuleCommand command, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var code = NormalizeCode(command.Code); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList(); + var item = new TenantSupervisionRuleItem( + code, + command.Title.Trim(), + command.Enabled, + command.DaysWithoutCheckIn, + command.MaxQuestionsAnsweredToday, + Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(), + Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(), + JsonObjectOrDefault(command.Metadata)); + rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); + rules.Add(item); + await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); + await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task PreviewSupervisionAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken)); + } + + public async Task GenerateSupervisionFollowupsAsync( + TenantAdminActor actor, + TenantSupervisionGenerateCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); + var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken); + if (command.UserIds is { Count: > 0 }) + { + var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet(); + riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray(); + } + + var created = 0; + foreach (var student in riskStudents) + { + if (await dbContext.TenantStudentFollowups.AnyAsync(item => + item.TenantId == actor.TenantId && + item.StudentUserId == student.UserId && + item.Status != StudentFollowupStatus.Done && + item.FollowupType == StudentFollowupType.Risk, + cancellationToken)) + { + continue; + } + + dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup + { + TenantId = actor.TenantId, + StudentUserId = student.UserId, + AssignedToUserId = command.AssignedToUserId, + Title = "学习风险督导", + Description = string.Join(";", student.Reasons), + FollowupType = StudentFollowupType.Risk, + Priority = StudentFollowupPriority.High, + Status = StudentFollowupStatus.Open, + DueAt = command.DueAt, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId, + Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes }) + }); + created++; + } + + await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantSupervisionGenerateResult(created); + } + + public async Task GetFollowupReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var now = DateTimeOffset.UtcNow; + return new TenantFollowupReport( + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => + item.TenantId == actor.TenantId && + item.DueAt.HasValue && + item.DueAt < now && + item.Status != StudentFollowupStatus.Done && + item.Status != StudentFollowupStatus.Cancelled, + cancellationToken)); + } + + public async Task GetFeedbackReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + return new TenantFeedbackReport( + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); + } + + public async Task GetPointRiskReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var negativeScoreUsers = await dbContext.Users + .Where(user => user.Score < 0 && + dbContext.TenantMemberships.Any(member => + member.TenantId == actor.TenantId && + member.UserId == user.Id && + member.Status == MembershipStatus.Active)) + .CountAsync(cancellationToken); + var since = DateTimeOffset.UtcNow.AddDays(-7); + var highClaimUsers = await dbContext.PointActivityClaims + .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) + .GroupBy(item => item.UserId) + .Where(group => group.Sum(item => item.Points) >= 1000) + .CountAsync(cancellationToken); + var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, + cancellationToken); + return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); + } + + +} diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index a15549b..57f5238 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -24,7 +24,7 @@ using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus namespace Tiku.Infrastructure.TenantAdmin; -public sealed class TenantAdminDirectService( +public sealed partial class TenantAdminDirectService( TikuDbContext dbContext, ITenantExternalProviderConfigService providerConfigService, INotificationProvider notificationProvider, @@ -33,3247 +33,5 @@ public sealed class TenantAdminDirectService( IFeatureAccessService featureAccessService, IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService { - public async Task GetOverviewAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var now = DateTimeOffset.UtcNow; - var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); - var scopedClasses = dbContext.TenantClasses.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); - var scopedStudents = dbContext.StudentProfiles.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - var scopedOrders = dbContext.Orders.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - var studentCount = await scopedStudents.CountAsync(cancellationToken); - var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); - var staffCount = await dbContext.TenantMemberships.AsNoTracking() - .CountAsync(item => - item.TenantId == actor.TenantId && - item.Status == MembershipStatus.Active && - item.Role != TenantRole.Student, - cancellationToken); - var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() - .CountAsync(item => - item.TenantId == actor.TenantId && - item.FinishedAt == null && - (!item.ExpiresAt.HasValue || item.ExpiresAt > now), - cancellationToken); - var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() - .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); - var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() - .CountAsync(item => - item.TenantId == actor.TenantId && - (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), - cancellationToken); - var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() - .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); - var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); - var revenueCents = await scopedOrders - .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded) - .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); - var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking() - .CountAsync(item => - item.TenantId == actor.TenantId && - (item.Status == CommerceRefundStatus.Requested || - item.Status == CommerceRefundStatus.Approved || - item.Status == CommerceRefundStatus.Processing), - cancellationToken); - var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking() - .CountAsync(item => - item.TenantId == actor.TenantId && - item.Status != ReconciliationIssueStatus.Resolved && - item.Status != ReconciliationIssueStatus.Ignored, - cancellationToken); - - return new TenantAdminOverviewItem( - studentCount, - classCount, - staffCount, - activePracticeCount, - todayPracticeCount, - pendingFollowupCount, - unreadNotificationCount, - paidOrderCount, - revenueCents, - pendingRefundCount, - openReconciliationIssueCount, - now); - } - - public async Task GetClassesAsync( - TenantAdminActor actor, - TenantAdminClassFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantClasses.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_class_status")); - } - else - { - query = query.Where(item => item.Status != TenantRecordStatus.Archived); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => - item.Name.Contains(keyword) || - (item.Code != null && item.Code.Contains(keyword))); - } - - var items = await query - .OrderBy(item => item.SortOrder) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => new - { - Class = item, - RegionName = dbContext.Regions - .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId) - .Select(region => region.Name) - .FirstOrDefault(), - StudentCount = dbContext.TenantClassMembers.Count(member => - member.TenantId == actor.TenantId && - member.ClassId == item.Id && - member.Status == TenantClassMemberStatus.Active && - member.MemberType == TenantClassMemberType.Student), - StaffCount = dbContext.TenantClassMembers.Count(member => - member.TenantId == actor.TenantId && - member.ClassId == item.Id && - member.Status == TenantClassMemberStatus.Active && - member.MemberType != TenantClassMemberType.Student) - }) - .ToArrayAsync(cancellationToken); - - return new TenantAdminClassList( - items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), - Scoped: scope.Mode != DataScopeMode.All); - } - - public async Task> UpsertClassAsync( - TenantAdminActor actor, - UpsertTenantAdminClassCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - - var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id)) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - - if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id)) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - - item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; - item.RegionId = command.RegionId; - item.LegacyId = Normalize(command.LegacyId); - item.Code = Normalize(command.Code); - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status"); - item.SortOrder = command.Order ?? item.SortOrder; - item.Metadata = JsonObjectOrDefault(command.Metadata); - item.UpdatedBy = actor.UserId; - - if (isNew) - { - dbContext.TenantClasses.Add(item); - } - - await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - - var regionName = item.RegionId.HasValue - ? await dbContext.Regions - .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value) - .Select(region => region.Name) - .FirstOrDefaultAsync(cancellationToken) - : null; - return new ContentManagementResult(ToClassItem(item, regionName, 0, 0)); - } - - public async Task> DisableClassAsync( - TenantAdminActor actor, - Guid classId, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var item = await dbContext.TenantClasses - .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId) - .ApplyDataScope( - scope, - entity => entity.CreatedBy == actor.UserId, - entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) - .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - - item.Status = TenantRecordStatus.Disabled; - item.UpdatedBy = actor.UserId; - await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToClassItem(item, null, 0, 0)); - } - - public async Task> GetClassMembersAsync( - TenantAdminActor actor, - TenantAdminClassMemberFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken); - var classIds = scope.ClassIds.ToArray(); - var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.TenantClassMembers.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, - item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass => - tenantClass.TenantId == actor.TenantId && - tenantClass.Id == item.ClassId && - tenantClass.RegionId.HasValue && - regionIds.Contains(tenantClass.RegionId.Value))); - - if (!string.IsNullOrWhiteSpace(filter.MemberType)) - { - query = query.Where(item => item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); - } - - query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); - - var items = await query - .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 : - item.MemberType == TenantClassMemberType.Teacher ? 1 : - item.MemberType == TenantClassMemberType.Assistant ? 2 : 9) - .ThenBy(item => item.JoinedAt) - .Take(ResolveLimit(filter.Limit)) - .Join( - dbContext.Users.AsNoTracking(), - member => member.UserId, - user => user.Id, - (member, user) => ToClassMemberItem(member, ToUserSummary(user))) - .ToArrayAsync(cancellationToken); - - return new CatalogList(items); - } - - public async Task> UpsertClassMemberAsync( - TenantAdminActor actor, - UpsertTenantAdminClassMemberCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type"); - var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"); - var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken); - await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); - if (memberType == TenantClassMemberType.Student) - { - await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); - } - - var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => - member.TenantId == actor.TenantId && - member.ClassId == command.ClassId && - member.UserId == user.Id && - member.MemberType == memberType, - cancellationToken); - var isNew = item is null; - item ??= new TenantClassMember - { - TenantId = actor.TenantId, - ClassId = command.ClassId, - UserId = user.Id, - MemberType = memberType, - CreatedBy = actor.UserId - }; - item.Status = status; - item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; - item.Metadata = JsonObjectOrDefault(command.Metadata); - item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantClassMembers.Add(item); - } - - await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); - } - - public async Task> RemoveClassMemberAsync( - TenantAdminActor actor, - Guid classMemberId, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var classIds = scope.ClassIds.ToArray(); - var regionIds = scope.RegionIds.ToArray(); - var item = await dbContext.TenantClassMembers - .Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId) - .ApplyDataScope( - scope, - member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId, - member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass => - tenantClass.TenantId == actor.TenantId && - tenantClass.Id == member.ClassId && - tenantClass.RegionId.HasValue && - regionIds.Contains(tenantClass.RegionId.Value))) - .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); - } - - var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken); - item.Status = TenantClassMemberStatus.Removed; - item.LeftAt = DateTimeOffset.UtcNow; - item.UpdatedBy = actor.UserId; - await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); - } - - public async Task GetStudentsAsync( - TenantAdminActor actor, - TenantAdminStudentFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantMemberships.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))); - - if (filter.ClassId.HasValue) - { - await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); - query = query.Where(item => dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.ClassId == filter.ClassId.Value && - member.UserId == item.UserId && - member.MemberType == TenantClassMemberType.Student && - member.Status == TenantClassMemberStatus.Active)); - } - - if (filter.RegionId.HasValue) - { - query = query.Where(item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId && - profile.RegionId == filter.RegionId.Value)); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => dbContext.Users.Any(user => - user.Id == item.UserId && - ((user.UserName != null && user.UserName.Contains(keyword)) || - (user.Phone != null && user.Phone.Contains(keyword)) || - (user.Email != null && user.Email.Contains(keyword)) || - (user.Name != null && user.Name.Contains(keyword))))); - } - - var memberships = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var userIds = memberships.Select(item => item.UserId).ToArray(); - var users = await dbContext.Users.AsNoTracking() - .Where(user => userIds.Contains(user.Id)) - .ToDictionaryAsync(user => user.Id, cancellationToken); - var profiles = await dbContext.StudentProfiles.AsNoTracking() - .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId)) - .ToDictionaryAsync(profile => profile.UserId, cancellationToken); - var regions = await dbContext.Regions.AsNoTracking() - .Where(region => region.TenantId == actor.TenantId) - .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken); - var schools = await dbContext.Schools.AsNoTracking() - .Where(school => school.TenantId == actor.TenantId) - .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken); - var majors = await dbContext.Majors.AsNoTracking() - .Where(major => major.TenantId == actor.TenantId) - .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken); - var classes = await ( - from member in dbContext.TenantClassMembers.AsNoTracking() - join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id - where member.TenantId == actor.TenantId && - tenantClass.TenantId == actor.TenantId && - userIds.Contains(member.UserId) && - member.Status == TenantClassMemberStatus.Active && - member.MemberType == TenantClassMemberType.Student - orderby tenantClass.SortOrder, tenantClass.CreatedAt descending - select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code }) - .ToArrayAsync(cancellationToken); - var classLookup = classes - .GroupBy(item => item.UserId) - .ToDictionary( - group => group.Key, - group => group.Select(item => new TenantAdminStudentClassSummary( - item.ClassId, - item.Name, - item.Code, - item.MemberType, - item.JoinedAt)).ToArray() as IReadOnlyCollection); - - return new TenantAdminStudentList( - memberships.Select(membership => - { - users.TryGetValue(membership.UserId, out var user); - profiles.TryGetValue(membership.UserId, out var profile); - classLookup.TryGetValue(membership.UserId, out var studentClasses); - return ToStudentItem( - membership, - user ?? new User { Id = membership.UserId }, - profile, - regions, - schools, - majors, - studentClasses ?? []); - }).ToArray(), - Scoped: scope.Mode != DataScopeMode.All); - } - - public async Task> UpsertStudentAsync( - TenantAdminActor actor, - UpsertTenantAdminStudentCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var user = await ResolveUserAsync(command.User, "student", cancellationToken); - if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId)) - { - throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } - if (command.RawProfile.ValueKind == JsonValueKind.Object) - { - user.RawProfile = command.RawProfile.Clone(); - } - - var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); - var profile = await EnsureStudentProfileAsync( - actor.TenantId, - user.Id, - command.RegionId, - command.SelectedSchoolId, - command.SelectedMajorId, - Normalize(command.AvatarPreset), - command.Stats, - command.Progress, - command.ModuleSelections, - cancellationToken); - - await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - - return new ContentManagementResult( - ToStudentItem( - membership, - user, - profile, - new Dictionary(), - new Dictionary(), - new Dictionary(), - [])); - } - - public async Task> UpdateStudentStatusAsync( - TenantAdminActor actor, - UpdateTenantAdminStudentStatusCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var membership = await dbContext.TenantMemberships - .Where(item => - item.TenantId == actor.TenantId && - item.UserId == command.UserId && - item.Role == TenantRole.Student) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))) - .FirstOrDefaultAsync(cancellationToken); - if (membership is null) - { - throw new TenantAdminDirectException("Student membership was not found.", "student_not_found"); - } - - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var wasCounted = await IsUserCountedForMetricAsync( - actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken); - var otherMembershipCounted = await IsUserCountedForMetricAsync( - actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id); - var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active; - if (!wasCounted && willBeCounted) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StudentCount, - cancellationToken: cancellationToken); - } - membership.Status = status; - if (status != MembershipStatus.Active) - { - await sessionStore.RevokeRealmAsync( - command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); - } - - await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (wasCounted && !willBeCounted) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StudentCount, - 1, - cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - await authorizationStateInvalidator.InvalidateMembershipAsync( - actor.TenantId, membership.UserId, cancellationToken); - return new ContentManagementResult( - new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); - } - - public async Task PreviewStudentImportAsync( - TenantAdminActor actor, - TenantAdminStudentImportCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); - return new TenantAdminStudentImportPreview( - command.Rows.Count, - items.Count(item => item.Valid), - items.Count(item => !item.Valid), - items); - } - - public async Task ImportStudentsAsync( - TenantAdminActor actor, - TenantAdminStudentImportCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); - var invalidItems = previewItems.Where(item => !item.Valid).ToArray(); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var createdOrUpdated = 0; - var classAssigned = 0; - var rowNo = 0; - foreach (var row in command.Rows) - { - rowNo++; - if (invalidItems.Any(item => item.RowNo == rowNo)) - { - continue; - } - - var user = await ResolveUserAsync(row.User, "student", cancellationToken); - if (row.RawProfile.ValueKind == JsonValueKind.Object) - { - user.RawProfile = row.RawProfile.Clone(); - } - - await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); - await EnsureStudentProfileAsync( - actor.TenantId, - user.Id, - row.RegionId, - null, - null, - Normalize(row.AvatarPreset), - JsonDefaults.Object(), - JsonDefaults.Object(), - JsonDefaults.Object(), - cancellationToken); - createdOrUpdated++; - - if (row.ClassId.HasValue) - { - await UpsertClassMemberCoreAsync( - actor, - row.ClassId.Value, - user.Id, - TenantClassMemberType.Student, - TenantClassMemberStatus.Active, - JsonSerializer.SerializeToElement(new { source = "student_import" }), - cancellationToken); - classAssigned++; - } - } - - await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems); - } - - public async Task BulkAssignClassAsync( - TenantAdminActor actor, - TenantAdminBulkAssignClassCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); - var failed = new List(); - var succeeded = 0; - foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) - { - try - { - await AssertStudentAsync(actor, scope, userId, cancellationToken); - await UpsertClassMemberCoreAsync( - actor, - command.ClassId, - userId, - TenantClassMemberType.Student, - TenantClassMemberStatus.Active, - JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }), - cancellationToken); - succeeded++; - } - catch (TenantAdminDirectException) - { - failed.Add(userId); - } - } - - await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); - } - - public async Task BulkUpdateStudentStatusAsync( - TenantAdminActor actor, - TenantAdminBulkStatusCommand command, - CancellationToken cancellationToken = default) - { - var failed = new List(); - var succeeded = 0; - foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) - { - try - { - await UpdateStudentStatusAsync( - actor, - new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason), - cancellationToken); - succeeded++; - } - catch (TenantAdminDirectException) - { - failed.Add(userId); - } - } - - await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); - } - - public async Task> GetSupervisionRulesAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireDataScopeAsync(actor, cancellationToken); - return new CatalogList(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); - } - - public async Task> UpsertSupervisionRuleAsync( - TenantAdminActor actor, - UpsertTenantSupervisionRuleCommand command, - CancellationToken cancellationToken = default) - { - await RequireDataScopeAsync(actor, cancellationToken); - var code = NormalizeCode(command.Code); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList(); - var item = new TenantSupervisionRuleItem( - code, - command.Title.Trim(), - command.Enabled, - command.DaysWithoutCheckIn, - command.MaxQuestionsAnsweredToday, - Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(), - Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(), - JsonObjectOrDefault(command.Metadata)); - rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); - rules.Add(item); - await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); - await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(item); - } - - public async Task PreviewSupervisionAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken)); - } - - public async Task GenerateSupervisionFollowupsAsync( - TenantAdminActor actor, - TenantSupervisionGenerateCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); - var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken); - if (command.UserIds is { Count: > 0 }) - { - var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet(); - riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray(); - } - - var created = 0; - foreach (var student in riskStudents) - { - if (await dbContext.TenantStudentFollowups.AnyAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == student.UserId && - item.Status != StudentFollowupStatus.Done && - item.FollowupType == StudentFollowupType.Risk, - cancellationToken)) - { - continue; - } - - dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup - { - TenantId = actor.TenantId, - StudentUserId = student.UserId, - AssignedToUserId = command.AssignedToUserId, - Title = "学习风险督导", - Description = string.Join(";", student.Reasons), - FollowupType = StudentFollowupType.Risk, - Priority = StudentFollowupPriority.High, - Status = StudentFollowupStatus.Open, - DueAt = command.DueAt, - CreatedBy = actor.UserId, - UpdatedBy = actor.UserId, - Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes }) - }); - created++; - } - - await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new TenantSupervisionGenerateResult(created); - } - - public async Task GetFollowupReportAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireDataScopeAsync(actor, cancellationToken); - var now = DateTimeOffset.UtcNow; - return new TenantFollowupReport( - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => - item.TenantId == actor.TenantId && - item.DueAt.HasValue && - item.DueAt < now && - item.Status != StudentFollowupStatus.Done && - item.Status != StudentFollowupStatus.Cancelled, - cancellationToken)); - } - - public async Task GetFeedbackReportAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireDataScopeAsync(actor, cancellationToken); - return new TenantFeedbackReport( - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); - } - - public async Task GetPointRiskReportAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireDataScopeAsync(actor, cancellationToken); - var negativeScoreUsers = await dbContext.Users - .Where(user => user.Score < 0 && - dbContext.TenantMemberships.Any(member => - member.TenantId == actor.TenantId && - member.UserId == user.Id && - member.Status == MembershipStatus.Active)) - .CountAsync(cancellationToken); - var since = DateTimeOffset.UtcNow.AddDays(-7); - var highClaimUsers = await dbContext.PointActivityClaims - .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) - .GroupBy(item => item.UserId) - .Where(group => group.Sum(item => item.Points) >= 1000) - .CountAsync(cancellationToken); - var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync( - item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, - cancellationToken); - return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); - } - - public async Task> GetStudentNotesAsync( - TenantAdminActor actor, - TenantAdminStudentActivityFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantStudentNotes.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.StudentUserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.StudentUserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))); - if (filter.StudentUserId.HasValue) - { - query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); - } - - var items = await query - .OrderByDescending(item => item.IsPinned) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToNoteItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertStudentNoteAsync( - TenantAdminActor actor, - UpsertTenantAdminStudentNoteCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); - await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); - TenantStudentNote? item = null; - if (command.Id.HasValue) - { - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - item = await dbContext.TenantStudentNotes - .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value) - .ApplyDataScope( - scope, - note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId, - note => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == note.StudentUserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == note.StudentUserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))) - .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); - } - } - - var isNew = item is null; - item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; - item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); - item.Content = command.Content.Trim(); - item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility"); - item.IsPinned = command.IsPinned ?? item.IsPinned; - item.Metadata = JsonObjectOrDefault(command.Metadata); - item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantStudentNotes.Add(item); - } - - await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToNoteItem(item)); - } - - public async Task> GetStudentFollowupsAsync( - TenantAdminActor actor, - TenantAdminStudentActivityFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantStudentFollowups.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, - item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || - dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.StudentUserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.StudentUserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))); - if (filter.StudentUserId.HasValue) - { - query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); - } - - var items = await query - .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) - .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToFollowupItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertStudentFollowupAsync( - TenantAdminActor actor, - UpsertTenantAdminStudentFollowupCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); - await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); - await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); - - TenantStudentFollowup? item = null; - if (command.Id.HasValue) - { - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - item = await dbContext.TenantStudentFollowups - .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) - .ApplyDataScope( - scope, - followup => followup.StudentUserId == actor.UserId || - followup.AssignedToUserId == actor.UserId || - followup.CreatedBy == actor.UserId, - followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || - dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == followup.StudentUserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == followup.StudentUserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))) - .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); - } - } - - var isNew = item is null; - item ??= new TenantStudentFollowup - { - Id = command.Id ?? Guid.NewGuid(), - TenantId = actor.TenantId, - StudentUserId = command.StudentUserId, - CreatedBy = actor.UserId - }; - item.AssignedToUserId = command.AssignedToUserId; - item.ClassId = command.ClassId; - item.Title = command.Title.Trim(); - item.Description = Normalize(command.Description); - item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type"); - item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority"); - item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status"); - item.DueAt = command.DueAt; - item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null; - item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; - item.Metadata = JsonObjectOrDefault(command.Metadata); - item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantStudentFollowups.Add(item); - } - - await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToFollowupItem(item)); - } - - public async Task> GetMembersAsync( - TenantAdminActor actor, - TenantAdminMemberFilter filter, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(filter.Role)) - { - query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); - } - - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => dbContext.Users.Any(user => - user.Id == item.UserId && - ((user.UserName != null && user.UserName.Contains(keyword)) || - (user.Phone != null && user.Phone.Contains(keyword)) || - (user.Email != null && user.Email.Contains(keyword)) || - (user.Name != null && user.Name.Contains(keyword))))); - } - - var memberships = await query - .OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 : - item.Role == TenantRole.TenantAdmin ? 1 : - item.Role == TenantRole.TenantOperator ? 2 : - item.Role == TenantRole.Teacher ? 3 : 9) - .ThenBy(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var userIds = memberships.Select(item => item.UserId).ToArray(); - var users = await dbContext.Users.AsNoTracking() - .Where(user => userIds.Contains(user.Id)) - .ToDictionaryAsync(user => user.Id, cancellationToken); - - return new CatalogList(memberships.Select(item => - { - users.TryGetValue(item.UserId, out var user); - return ToMemberItem(item, user ?? new User { Id = item.UserId }); - }).ToArray()); - } - - public async Task> UpsertMemberAsync( - TenantAdminActor actor, - UpsertTenantAdminMemberCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role"); - var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status"); - await AssertGrantableAsync(actor, role, cancellationToken); - var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); - var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); - if (user.Id == actor.UserId && status == MembershipStatus.Disabled) - { - throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); - } - - TenantMembership? membership = null; - if (command.MembershipId.HasValue) - { - membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, - cancellationToken); - if (membership is null) - { - throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } - } - else - { - membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role, - cancellationToken); - } - - var isNew = membership is null; - var previousStatus = membership?.Status.ToString() ?? "none"; - var wasStaffCounted = await IsUserCountedForMetricAsync( - actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken); - var wasStudentCounted = await IsUserCountedForMetricAsync( - actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken); - Guid? excludedMembershipId = isNew ? null : membership!.Id; - var otherStaffCounted = await IsUserCountedForMetricAsync( - actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId); - var otherStudentCounted = await IsUserCountedForMetricAsync( - actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId); - var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student); - var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student); - if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner) - { - throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required"); - } - - if (!wasStaffCounted && willStaffBeCounted) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StaffCount, - cancellationToken: cancellationToken); - } - if (!wasStudentCounted && willStudentBeCounted) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StudentCount, - cancellationToken: cancellationToken); - } - - membership ??= new TenantMembership - { - TenantId = actor.TenantId, - UserId = user.Id - }; - membership.UserId = user.Id; - membership.Role = role; - membership.Status = status; - if (isNew) - { - dbContext.TenantMemberships.Add(membership); - } - - if (status != MembershipStatus.Active) - { - await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); - } - else if (role == TenantRole.TenantOwner) - { - await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); - } - - await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (wasStaffCounted && !willStaffBeCounted) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StaffCount, - 1, - cancellationToken); - } - if (wasStudentCounted && !willStudentBeCounted) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - SaasQuotaMetricCatalog.StudentCount, - 1, - cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - await authorizationStateInvalidator.InvalidateMembershipAsync( - actor.TenantId, membership.UserId, cancellationToken); - await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken); - return new ContentManagementResult(ToMemberItem(membership, user)); - } - - public async Task> DisableMemberAsync( - TenantAdminActor actor, - Guid membershipId, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) - : null; - var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == membershipId, - cancellationToken); - if (membership is null) - { - throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } - - if (membership.UserId == actor.UserId) - { - throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); - } - - await AssertGrantableAsync(actor, membership.Role, cancellationToken); - var previousStatus = membership.Status; - var metricCode = QuotaMetricForRole(membership.Role); - var wasCounted = await IsUserCountedForMetricAsync( - actor.TenantId, membership.UserId, metricCode, cancellationToken); - var otherMembershipCounted = await IsUserCountedForMetricAsync( - actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id); - membership.Status = MembershipStatus.Disabled; - await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken); - await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted) - { - await featureAccessService.ReleaseQuotaAsync( - actor.TenantId, - metricCode, - 1, - cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } - await authorizationStateInvalidator.InvalidateMembershipAsync( - actor.TenantId, membership.UserId, cancellationToken); - var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); - return new ContentManagementResult(ToMemberItem(membership, user)); - } - - public async Task> GetAuditLogsAsync( - TenantAdminActor actor, - TenantAdminAuditLogFilter filter, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(filter.Action)) - { - var action = filter.Action.Trim(); - query = query.Where(item => item.Action.StartsWith(action)); - } - - if (!string.IsNullOrWhiteSpace(filter.TargetType)) - { - query = query.Where(item => item.TargetType == filter.TargetType.Trim()); - } - - if (filter.ActorUserId.HasValue) - { - query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); - } - - var logs = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray(); - var users = await dbContext.Users.AsNoTracking() - .Where(user => actorIds.Contains(user.Id)) - .ToDictionaryAsync(user => user.Id, cancellationToken); - - return new CatalogList(logs.Select(item => - { - var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser) - ? actorUser - : null; - return new TenantAdminAuditLogItem( - item.Id, - item.ActorUserId, - item.Action, - item.TargetType, - item.TargetId, - item.Details, - item.IpAddress, - item.UserAgent, - user?.Name ?? user?.UserName, - user?.Phone, - item.CreatedAt); - }).ToArray()); - } - - public async Task> UpsertBrandingAsync( - TenantAdminActor actor, - UpsertTenantBrandingCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); - var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); - if (item is null) - { - item = new TenantBranding { TenantId = actor.TenantId }; - dbContext.TenantBrandings.Add(item); - } - - item.BrandName = command.BrandName.Trim(); - item.ShortName = Normalize(command.ShortName); - item.Slogan = Normalize(command.Slogan); - item.OrganizationName = Normalize(command.OrganizationName); - item.LogoUrl = Normalize(command.LogoUrl); - item.FaviconUrl = Normalize(command.FaviconUrl); - item.ServiceWechat = Normalize(command.ServiceWechat); - item.ServiceAccountName = Normalize(command.ServiceAccountName); - await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToBrandingItem(item)); - } - - public async Task> UpsertSettingsAsync( - TenantAdminActor actor, - UpsertTenantSettingsCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - AssertNoSecrets(command.PublicConfig, "public_config"); - var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); - if (item is null) - { - item = new TenantSettings { TenantId = actor.TenantId }; - dbContext.TenantSettings.Add(item); - } - - item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags); - item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags); - item.PublicConfig = JsonObjectOrDefault(command.PublicConfig); - await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToSettingsItem(item)); - } - - public async Task> GetThemeTemplatesAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - await Task.CompletedTask.WaitAsync(cancellationToken); - var items = await dbContext.TenantThemeTemplates.AsNoTracking() - .Where(item => item.Status == TenantThemeTemplateStatus.Active) - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Code) - .Select(item => new TenantThemeTemplateItem( - item.Code, - item.Name, - item.Description, - item.PreviewImageUrl, - item.Theme, - item.PublicAssets, - item.SortOrder)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> GetThemeAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var item = await dbContext.TenantThemeConfigs.AsNoTracking() - .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); - if (item is not null) - { - return new ContentManagementResult(ToThemeItem(item)); - } - - var branding = await dbContext.TenantBrandings.AsNoTracking() - .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); - return new ContentManagementResult(new TenantThemeItem( - actor.TenantId, - null, - branding?.Theme ?? JsonDefaults.Object(), - branding?.PublicAssets ?? JsonDefaults.Object(), - null, - JsonDefaults.Object(), - JsonDefaults.Object(), - TenantThemeConfigStatus.Published, - null, - null, - null, - branding?.UpdatedAt ?? DateTimeOffset.UtcNow)); - } - - public async Task> PreviewThemeAsync( - TenantAdminActor actor, - PreviewTenantThemeCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); - var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( - item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, - cancellationToken); - if (template is null) - { - throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); - } - - var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); - if (item is null) - { - item = new TenantThemeConfig { TenantId = actor.TenantId }; - dbContext.TenantThemeConfigs.Add(item); - } - - item.DraftTemplateCode = template.Code; - item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme); - item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); - item.Status = TenantThemeConfigStatus.Draft; - item.DraftUpdatedBy = actor.UserId; - await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToThemeItem(item)); - } - - public async Task> PublishThemeAsync( - TenantAdminActor actor, - PublishTenantThemeCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); - JsonElement activeTheme; - JsonElement activeAssets; - string? templateCode; - if (command.UseDraft) - { - if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode)) - { - throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found"); - } - - activeTheme = item.DraftTheme; - activeAssets = item.DraftPublicAssets; - templateCode = item.DraftTemplateCode; - } - else - { - ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); - var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( - theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active, - cancellationToken); - if (template is null) - { - throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); - } - - item ??= new TenantThemeConfig { TenantId = actor.TenantId }; - if (dbContext.Entry(item).State == EntityState.Detached) - { - dbContext.TenantThemeConfigs.Add(item); - } - - activeTheme = MergeJsonObjects(template.Theme, command.Theme); - activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); - templateCode = template.Code; - } - - item ??= new TenantThemeConfig { TenantId = actor.TenantId }; - if (dbContext.Entry(item).State == EntityState.Detached) - { - dbContext.TenantThemeConfigs.Add(item); - } - - item.ActiveTemplateCode = templateCode; - item.ActiveTheme = activeTheme.Clone(); - item.ActivePublicAssets = activeAssets.Clone(); - item.DraftTemplateCode = null; - item.DraftTheme = JsonDefaults.Object(); - item.DraftPublicAssets = JsonDefaults.Object(); - item.Status = TenantThemeConfigStatus.Published; - item.PublishedAt = DateTimeOffset.UtcNow; - item.PublishedBy = actor.UserId; - await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken); - await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToThemeItem(item)); - } - - public async Task> GetDomainsAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var items = await dbContext.TenantDomains.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .OrderByDescending(item => item.IsPrimary) - .ThenBy(item => item.CreatedAt) - .Select(item => ToDomainItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> CreateDomainAsync( - TenantAdminActor actor, - CreateTenantDomainCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - TenantDomain generated; - try - { - generated = TenantDomainProvisioning.CreatePrimary(actor.TenantId, command.Host); - } - catch (ArgumentException exception) - { - throw new TenantAdminDirectException(exception.Message, "invalid_domain_host"); - } - var host = generated.Host; - if (command.IsPrimary) - { - var primaryDomains = await dbContext.TenantDomains - .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) - .ToArrayAsync(cancellationToken); - foreach (var domain in primaryDomains) - { - domain.IsPrimary = false; - } - } - - var item = new TenantDomain - { - TenantId = actor.TenantId, - Host = host, - DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"), - Status = TenantDomainStatus.Pending, - IsPrimary = command.IsPrimary, - VerificationToken = generated.VerificationToken - }; - dbContext.TenantDomains.Add(item); - await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToDomainItem(item)); - } - - public async Task> GetAuthProvidersAsync( - TenantAdminActor actor, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var items = await providerConfigService.GetProvidersAsync( - actor.TenantId, - TenantExternalProviderCapability.Identity, - cancellationToken: cancellationToken); - return new CatalogList(items.Select(ToAuthProviderItem).ToArray()); - } - - public async Task> UpsertAuthProviderAsync( - TenantAdminActor actor, - UpsertTenantIdentityProviderCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); - var item = await providerConfigService.UpsertProviderAsync( - actor.TenantId, - new UpsertTenantExternalProviderCommand( - TenantExternalProviderCapability.Identity, - command.Provider, - ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"), - command.DisplayName, - command.SecretRef, - command.Priority, - command.ConfigPublic, - JsonObjectOrDefault(default)), - cancellationToken); - - await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToAuthProviderItem(item)); - } - - public async Task> GetBadgesAsync( - TenantAdminActor actor, - TenantAdminBadgeFilter filter, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (!string.IsNullOrWhiteSpace(filter.Category)) - { - query = query.Where(item => item.Category == filter.Category.Trim()); - } - - if (!filter.IncludeInactive) - { - query = query.Where(item => item.IsActive); - } - - var items = await query - .OrderBy(item => item.SortOrder) - .ThenBy(item => item.Level ?? int.MaxValue) - .ThenByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToBadgeItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertBadgeAsync( - TenantAdminActor actor, - UpsertTenantAdminBadgeCommand command, - CancellationToken cancellationToken = default) - { - await RequireAllDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); - var isNew = item is null; - item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; - item.LegacyId = Normalize(command.LegacyId); - item.Name = command.Name.Trim(); - item.Description = Normalize(command.Description); - item.Category = Normalize(command.Category) ?? "custom"; - item.IconUrl = Normalize(command.IconUrl); - item.Level = command.Level; - item.UnlockType = Normalize(command.UnlockType) ?? "manual"; - item.ConditionField = Normalize(command.ConditionField); - item.ConditionOperator = Normalize(command.ConditionOperator); - item.ConditionValue = command.ConditionValue; - item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); - item.SortOrder = command.Order ?? item.SortOrder; - item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Badges.Add(item); - } - - await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToBadgeItem(item)); - } - - public async Task> GetBadgeGrantsAsync( - TenantAdminActor actor, - TenantAdminBadgeGrantFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.UserBadges.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId, - item => item.UserId.HasValue && - (dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId.Value && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId.Value && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId)))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } - - if (filter.BadgeId.HasValue) - { - query = query.Where(item => item.BadgeId == filter.BadgeId.Value); - } - - var grants = await query - .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var userIds = grants.Select(item => item.UserId).OfType().Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); - var badgeIds = grants.Select(item => item.BadgeId).OfType().Distinct().ToArray(); - var users = await dbContext.Users.AsNoTracking() - .Where(user => userIds.Contains(user.Id)) - .ToDictionaryAsync(user => user.Id, cancellationToken); - var badges = await dbContext.Badges.AsNoTracking() - .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id)) - .ToDictionaryAsync(badge => badge.Id, cancellationToken); - - return new CatalogList(grants.Select(grant => - ToBadgeGrantItem( - grant, - grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, - grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null, - grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray()); - } - - public async Task> GrantBadgeAsync( - TenantAdminActor actor, - GrantTenantAdminBadgeCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var badge = await dbContext.Badges.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, - cancellationToken); - if (badge is null) - { - throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); - } - - if (!badge.IsActive) - { - throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); - } - - await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); - var grant = await dbContext.UserBadges.FirstOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, - cancellationToken); - var isNew = grant is null; - grant ??= new UserBadge - { - TenantId = actor.TenantId, - UserId = command.UserId, - BadgeId = command.BadgeId, - GrantedBy = actor.UserId, - GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow - }; - grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId; - grant.Note = Normalize(command.Note) ?? grant.Note; - grant.GrantedBy ??= actor.UserId; - grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; - if (isNew) - { - dbContext.UserBadges.Add(grant); - } - - var dedupeKey = $"badge:{grant.Id:N}"; - await notificationProvider.UpsertInAppAsync( - new InAppNotificationRequest( - actor.TenantId, - command.UserId, - "badge_granted", - NotificationSeverity.Success, - $"获得勋章:{badge.Name}", - Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。", - actor.UserId, - "查看勋章", - "/profile?tab=badges", - "user_badges", - grant.Id, - dedupeKey, - JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })), - cancellationToken); - - await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); - return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); - } - - public async Task> GetNotificationsAsync( - TenantAdminActor actor, - TenantAdminNotificationFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.UserNotifications.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_notification_status")); - } - - if (!string.IsNullOrWhiteSpace(filter.NotificationType)) - { - query = query.Where(item => item.NotificationType == filter.NotificationType.Trim()); - } - - var items = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .Select(item => ToNotificationItem(item)) - .ToArrayAsync(cancellationToken); - return new CatalogList(items); - } - - public async Task> UpsertNotificationAsync( - TenantAdminActor actor, - UpsertTenantAdminNotificationCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - ArgumentException.ThrowIfNullOrWhiteSpace(command.Message); - await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); - - var item = await notificationProvider.UpsertInAppAsync( - new InAppNotificationRequest( - actor.TenantId, - command.UserId, - command.NotificationType, - ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"), - command.Title, - command.Message, - actor.UserId, - command.ActionLabel, - command.ActionPath, - command.SourceType, - command.SourceId, - command.DedupeKey, - JsonObjectOrDefault(command.Metadata)), - cancellationToken); - - await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return new ContentManagementResult(ToNotificationItem(item)); - } - - public async Task> GetFeedbacksAsync( - TenantAdminActor actor, - TenantAdminFeedbackFilter filter, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var query = dbContext.Reports.AsNoTracking() - .Where(item => item.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, - item => item.UserId.HasValue && - (dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId.Value && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId.Value && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId)))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } - - if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); - } - - if (!string.IsNullOrWhiteSpace(filter.Type)) - { - query = query.Where(item => item.Type == ParseEnum(filter.Type, "invalid_feedback_type")); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => - (item.Title != null && item.Title.Contains(keyword)) || - (item.Description != null && item.Description.Contains(keyword)) || - (item.Contact != null && item.Contact.Contains(keyword))); - } - - var reports = await query - .OrderByDescending(item => item.CreatedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var userIds = reports.Select(item => item.UserId).OfType().Distinct().ToArray(); - var users = await dbContext.Users.AsNoTracking() - .Where(user => userIds.Contains(user.Id)) - .ToDictionaryAsync(user => user.Id, cancellationToken); - return new CatalogList(reports.Select(report => - ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray()); - } - - public async Task> UpdateFeedbackAsync( - TenantAdminActor actor, - UpdateTenantAdminFeedbackCommand command, - CancellationToken cancellationToken = default) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var report = await dbContext.Reports - .Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId || item.HandledBy == actor.UserId, - item => item.UserId.HasValue && - (dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId.Value && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId.Value && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId)))) - .FirstOrDefaultAsync(cancellationToken); - if (report is null) - { - throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); - } - - var fromStatus = report.Status; - report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status"); - report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority"); - report.Resolution = Normalize(command.Resolution) ?? report.Resolution; - if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed) - { - report.HandledBy = actor.UserId; - report.HandledAt = DateTimeOffset.UtcNow; - } - - dbContext.ReportStatusEvents.Add(new ReportStatusEvent - { - TenantId = actor.TenantId, - ReportId = report.Id, - FromStatus = fromStatus, - ToStatus = report.Status, - Note = Normalize(command.Note), - ActorUserId = actor.UserId, - Metadata = JsonObjectOrDefault(command.Metadata) - }); - await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - var user = report.UserId.HasValue - ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) - : null; - return new ContentManagementResult(ToFeedbackItem(report, user)); - } - - private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) - { - User? user = null; - if (command.UserId.HasValue) - { - user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); - if (user is null) - { - throw new TenantAdminDirectException("User was not found.", "user_not_found"); - } - } - else - { - var phone = Normalize(command.Phone); - var email = Normalize(command.Email); - var username = Normalize(command.Username); - user = await dbContext.Users.FirstOrDefaultAsync(item => - (phone != null && item.Phone == phone) || - (email != null && item.Email == email) || - (username != null && item.UserName == username), - cancellationToken); - - if (user is null) - { - if (phone is null && email is null && username is null && Normalize(command.Name) is null) - { - throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required"); - } - - user = new User - { - UserName = username ?? phone ?? email, - Email = email, - Phone = phone, - Name = Normalize(command.Name) ?? username ?? phone ?? email, - PrimaryRole = primaryRole, - RawProfile = JsonDefaults.Object() - }; - dbContext.Users.Add(user); - } - } - - user.UserName = Normalize(command.Username) ?? user.UserName; - user.Email = Normalize(command.Email) ?? user.Email; - user.Phone = Normalize(command.Phone) ?? user.Phone; - user.Name = Normalize(command.Name) ?? user.Name; - user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl; - user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole; - return user; - } - - private async Task EnsureMembershipAsync( - Guid tenantId, - Guid userId, - TenantRole role, - CancellationToken cancellationToken) - { - var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == userId && item.Role == role, - cancellationToken); - if (membership is null) - { - var metricCode = QuotaMetricForRole(role); - if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - tenantId, - metricCode, - cancellationToken: cancellationToken); - } - membership = new TenantMembership - { - TenantId = tenantId, - UserId = userId, - Role = role, - Status = MembershipStatus.Active - }; - dbContext.TenantMemberships.Add(membership); - } - else - { - if (membership.Status != MembershipStatus.Active) - { - var metricCode = QuotaMetricForRole(role); - if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) - { - await featureAccessService.ConsumeQuotaIfConfiguredAsync( - tenantId, - metricCode, - cancellationToken: cancellationToken); - } - } - membership.Status = MembershipStatus.Active; - } - - return membership; - } - - private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student - ? SaasQuotaMetricCatalog.StudentCount - : SaasQuotaMetricCatalog.StaffCount; - - private Task IsUserCountedForMetricAsync( - Guid tenantId, - Guid userId, - string metricCode, - CancellationToken cancellationToken, - Guid? excludedMembershipId = null) - { - var query = dbContext.TenantMemberships.AsNoTracking().Where(item => - item.TenantId == tenantId && - item.UserId == userId && - item.Status == MembershipStatus.Active); - if (excludedMembershipId.HasValue) - { - query = query.Where(item => item.Id != excludedMembershipId.Value); - } - - return metricCode == SaasQuotaMetricCatalog.StudentCount - ? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken) - : query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken); - } - - private async Task EnsureStudentProfileAsync( - Guid tenantId, - Guid userId, - Guid? regionId, - Guid? schoolId, - Guid? majorId, - string? avatarPreset, - JsonElement stats, - JsonElement progress, - JsonElement moduleSelections, - CancellationToken cancellationToken) - { - var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == userId, - cancellationToken); - if (profile is null) - { - profile = new StudentProfile - { - TenantId = tenantId, - UserId = userId, - Stats = JsonDefaults.Object(), - Progress = JsonDefaults.Object(), - ModuleSelections = JsonDefaults.Object(), - RecentActivities = JsonDefaults.Array() - }; - dbContext.StudentProfiles.Add(profile); - } - - profile.RegionId = regionId ?? profile.RegionId; - profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId; - profile.SelectedMajorId = majorId ?? profile.SelectedMajorId; - profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset; - profile.Stats = JsonObjectOrDefault(stats); - profile.Progress = JsonObjectOrDefault(progress); - profile.ModuleSelections = JsonObjectOrDefault(moduleSelections); - return profile; - } - - private async Task> BuildStudentImportPreviewAsync( - TenantAdminActor actor, - CurrentDataScope scope, - TenantAdminStudentImportCommand command, - CancellationToken cancellationToken) - { - var items = new List(); - var rowNo = 0; - foreach (var row in command.Rows.Take(1000)) - { - rowNo++; - string? reason = null; - var phone = Normalize(row.User.Phone); - var email = Normalize(row.User.Email); - var name = Normalize(row.User.Name); - if (row.User.UserId is null && phone is null && email is null && name is null) - { - reason = "user_required"; - } - else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId)) - { - reason = "data_scope_denied"; - } - else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) - { - reason = "region_not_found"; - } - else if (row.ClassId.HasValue) - { - try - { - await AssertClassAsync(actor, scope, row.ClassId, cancellationToken); - } - catch (TenantAdminDirectException exception) - { - reason = exception.Code; - } - } - - items.Add(new TenantAdminStudentImportPreviewItem( - rowNo, - reason is null, - reason, - phone, - email, - name, - row.RegionId, - row.ClassId)); - } - - return items; - } - - private async Task UpsertClassMemberCoreAsync( - TenantAdminActor actor, - Guid classId, - Guid userId, - TenantClassMemberType memberType, - TenantClassMemberStatus status, - JsonElement metadata, - CancellationToken cancellationToken) - { - var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => - member.TenantId == actor.TenantId && - member.ClassId == classId && - member.UserId == userId && - member.MemberType == memberType, - cancellationToken); - if (item is null) - { - item = new TenantClassMember - { - TenantId = actor.TenantId, - ClassId = classId, - UserId = userId, - MemberType = memberType, - JoinedAt = DateTimeOffset.UtcNow - }; - dbContext.TenantClassMembers.Add(item); - } - - item.Status = status; - item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null; - item.Metadata = JsonObjectOrDefault(metadata); - return item; - } - - private async Task> GetSupervisionRulesCoreAsync( - Guid tenantId, - CancellationToken cancellationToken) - { - var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - if (settings is null || - settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || - !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) || - rulesElement.ValueKind != JsonValueKind.Array) - { - return []; - } - - return JsonSerializer.Deserialize(rulesElement.GetRawText()) ?? []; - } - - private async Task SaveSupervisionRulesCoreAsync( - Guid tenantId, - IReadOnlyCollection rules, - CancellationToken cancellationToken) - { - var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - if (settings is null) - { - settings = new TenantSettings { TenantId = tenantId }; - dbContext.TenantSettings.Add(settings); - } - - var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object - ? JsonSerializer.Deserialize>(settings.AdminFeatureFlags.GetRawText()) ?? [] - : []; - existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules); - settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing); - } - - private async Task> BuildSupervisionRiskStudentsAsync( - TenantAdminActor actor, - CurrentDataScope scope, - CancellationToken cancellationToken) - { - var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)) - .Where(rule => rule.Enabled) - .ToArray(); - if (rules.Length == 0) - { - return []; - } - - var regionIds = scope.RegionIds.ToArray(); - var students = await dbContext.StudentProfiles.AsNoTracking() - .Where(profile => profile.TenantId == actor.TenantId) - .ApplyDataScope( - scope, - profile => profile.UserId == actor.UserId, - profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) - .Select(profile => new - { - Profile = profile, - User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() - }) - .ToArrayAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - var result = new List(); - foreach (var row in students) - { - var hitRules = new List(); - var reasons = new List(); - foreach (var rule in rules) - { - if (rule.DaysWithoutCheckIn.HasValue) - { - var days = row.Profile.LastCheckInDate.HasValue - ? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber - : int.MaxValue; - if (days >= rule.DaysWithoutCheckIn.Value) - { - hitRules.Add(rule.Code); - reasons.Add($"{rule.Title}: {days} days without check-in"); - } - } - - if (rule.MaxQuestionsAnsweredToday.HasValue && - row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value) - { - hitRules.Add(rule.Code); - reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}"); - } - } - - if (hitRules.Count > 0) - { - result.Add(new TenantSupervisionRiskStudentItem( - row.Profile.UserId, - row.User?.Name, - MaskPhone(row.User?.Phone), - row.Profile.RegionId, - hitRules.Distinct(StringComparer.Ordinal).ToArray(), - reasons.Distinct(StringComparer.Ordinal).ToArray())); - } - } - - return result; - } - - private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) - { - var exists = await dbContext.TenantMemberships.AnyAsync( - item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student, - cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } - } - - private async Task AssertStudentAsync( - TenantAdminActor actor, - CurrentDataScope scope, - Guid userId, - CancellationToken cancellationToken) - { - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var exists = await dbContext.TenantMemberships - .Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => - profile.TenantId == actor.TenantId && - profile.UserId == item.UserId && - profile.RegionId.HasValue && - regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => - member.TenantId == actor.TenantId && - member.UserId == item.UserId && - member.Status == TenantClassMemberStatus.Active && - classIds.Contains(member.ClassId))) - .AnyAsync(cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } - } - - private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) - { - if (!userId.HasValue) - { - return; - } - - var exists = await dbContext.TenantMemberships.AnyAsync( - item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active, - cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } - } - - private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) - { - await sessionStore.RevokeRealmAsync( - userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken); - } - - private async Task EnsureTenantOwnerBackendRoleAsync( - Guid tenantId, - Guid userId, - CancellationToken cancellationToken) - { - const string roleCode = "tenant_owner"; - var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync( - item => item.TenantId == tenantId && item.Code == roleCode, - cancellationToken); - if (role is null) - { - role = new TenantBackendRole - { - TenantId = tenantId, - Code = roleCode, - Name = "租户所有者", - Status = BackendRoleStatus.Active, - IsSystem = true, - Description = "系统内置租户所有者角色", - DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }) - }; - dbContext.TenantBackendRoles.Add(role); - } - else - { - role.Status = BackendRoleStatus.Active; - role.IsSystem = true; - role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }); - } - - var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); - var existingPermissionCodes = await dbContext.BackendPermissions - .Where(permission => tenantPermissionCodes.Contains(permission.Code)) - .Select(permission => permission.Code) - .ToArrayAsync(cancellationToken); - foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) - { - dbContext.BackendPermissions.Add(new BackendPermission - { - Code = permissionCode, - Name = permissionCode, - Area = BackendPermissionArea.Tenant, - PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), - IsSystem = true - }); - } - - var boundPermissionCodes = await dbContext.TenantBackendRolePermissions - .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) - .Select(binding => binding.PermissionCode) - .ToArrayAsync(cancellationToken); - dbContext.TenantBackendRolePermissions.AddRange( - tenantPermissionCodes - .Except(boundPermissionCodes, StringComparer.Ordinal) - .Select(permissionCode => new TenantBackendRolePermission - { - TenantId = tenantId, - RoleId = role.Id, - PermissionCode = permissionCode - })); - - if (!await dbContext.TenantBackendUserRoles.AnyAsync( - binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, - cancellationToken)) - { - dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole - { - TenantId = tenantId, - UserId = userId, - RoleId = role.Id - }); - } - } - - private async Task EnsureBrandingThemeAsync( - Guid tenantId, - JsonElement theme, - JsonElement publicAssets, - CancellationToken cancellationToken) - { - var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - if (branding is null) - { - var tenantName = await dbContext.Tenants - .Where(tenant => tenant.Id == tenantId) - .Select(tenant => tenant.Name) - .FirstOrDefaultAsync(cancellationToken); - branding = new TenantBranding - { - TenantId = tenantId, - BrandName = tenantName ?? "租户题库" - }; - dbContext.TenantBrandings.Add(branding); - } - - branding.Theme = theme.Clone(); - branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets); - } - - private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) - { - if (!classId.HasValue) - { - return; - } - - var exists = await dbContext.TenantClasses.AnyAsync( - item => item.TenantId == tenantId && item.Id == classId.Value, - cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - } - - private async Task AssertClassAsync( - TenantAdminActor actor, - CurrentDataScope scope, - Guid? classId, - CancellationToken cancellationToken) - { - if (!classId.HasValue) - { - return; - } - - var regionIds = scope.RegionIds.ToArray(); - var classIds = scope.ClassIds.ToArray(); - var exists = await dbContext.TenantClasses - .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value) - .ApplyDataScope( - scope, - item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) - .AnyAsync(cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - } - - private async Task RequireDataScopeAsync( - TenantAdminActor actor, - CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) - { - throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } - - return access.DataScope; - } - - private async Task RequireAllDataScopeAsync( - TenantAdminActor actor, - CancellationToken cancellationToken) - { - var scope = await RequireDataScopeAsync(actor, cancellationToken); - if (scope.Mode != DataScopeMode.All) - { - throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found"); - } - } - - private async Task AssertReferenceAsync( - Guid tenantId, - Guid? id, - string code, - CancellationToken cancellationToken) - where TEntity : TenantEntity - { - if (!id.HasValue) - { - return; - } - - var exists = await dbContext.Set().AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); - } - } - - private async Task ResolveTenantEntityAsync( - DbSet set, - Guid tenantId, - Guid? id, - string? legacyId, - CancellationToken cancellationToken) - where TEntity : AuditableTenantEntity - { - if (id.HasValue) - { - return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); - } - - legacyId = Normalize(legacyId); - if (legacyId is null) - { - return null; - } - - return typeof(TEntity).GetProperty("LegacyId") is null - ? null - : await set.FirstOrDefaultAsync( - entity => entity.TenantId == tenantId && EF.Property(entity, "LegacyId") == legacyId, - cancellationToken); - } - - private async Task AddAuditAsync( - TenantAdminActor actor, - string action, - string targetType, - Guid targetId, - CancellationToken cancellationToken) - { - dbContext.AuditLogs.Add(new AuditLog - { - TenantId = actor.TenantId, - ActorUserId = actor.UserId, - Action = action, - TargetType = targetType, - TargetId = targetId.ToString(), - Details = JsonDefaults.Object() - }); - await Task.CompletedTask.WaitAsync(cancellationToken); - } - - private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount) - { - return new TenantAdminClassItem( - item.Id, - item.RegionId, - regionName, - item.LegacyId, - item.Code, - item.Name, - item.Description, - item.Status, - item.SortOrder, - item.Metadata, - studentCount, - staffCount, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user) - { - return new TenantAdminClassMemberItem( - item.Id, - item.ClassId, - item.UserId, - item.MemberType, - item.Status, - item.JoinedAt, - item.LeftAt, - item.Metadata, - user); - } - - private static TenantAdminStudentItem ToStudentItem( - TenantMembership membership, - User user, - StudentProfile? profile, - IReadOnlyDictionary regions, - IReadOnlyDictionary schools, - IReadOnlyDictionary majors, - IReadOnlyCollection classes) - { - return new TenantAdminStudentItem( - membership.Id, - membership.UserId, - membership.Status, - ToUserSummary(user), - profile?.Id, - profile?.AvatarPreset ?? "male", - profile?.RegionId, - profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null, - profile?.SelectedSchoolId, - profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null, - profile?.SelectedMajorId, - profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null, - profile?.Stats ?? JsonDefaults.Object(), - profile?.Progress ?? JsonDefaults.Object(), - profile?.ModuleSelections ?? JsonDefaults.Object(), - classes, - membership.CreatedAt, - membership.UpdatedAt); - } - - private static TenantAdminUserSummary ToUserSummary(User user) - { - return new TenantAdminUserSummary( - user.Id, - user.UserName, - user.Email, - user.Phone, - user.Name, - user.AvatarUrl, - user.PrimaryRole); - } - - private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item) - { - return new TenantAdminStudentNoteItem( - item.Id, - item.StudentUserId, - item.NoteType, - item.Content, - item.Visibility, - item.IsPinned, - item.Metadata, - item.CreatedBy, - item.UpdatedBy, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item) - { - return new TenantAdminStudentFollowupItem( - item.Id, - item.StudentUserId, - item.AssignedToUserId, - item.ClassId, - item.Title, - item.Description, - item.FollowupType, - item.Priority, - item.Status, - item.DueAt, - item.CompletedAt, - item.CompletedBy, - item.Metadata, - item.CreatedBy, - item.UpdatedBy, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user) - { - return new TenantAdminMemberItem( - membership.Id, - membership.UserId, - membership.Role, - membership.Status, - membership.LegacyRole, - ToUserSummary(user), - membership.CreatedAt, - membership.UpdatedAt); - } - - private static TenantBrandingItem ToBrandingItem(TenantBranding item) - { - return new TenantBrandingItem( - item.TenantId, - item.BrandName, - item.ShortName, - item.Slogan, - item.OrganizationName, - item.LogoUrl, - item.FaviconUrl, - item.ServiceWechat, - item.ServiceAccountName, - item.Theme, - item.PublicAssets, - item.UpdatedAt); - } - - private static TenantSettingsItem ToSettingsItem(TenantSettings item) - { - return new TenantSettingsItem( - item.TenantId, - item.FeatureFlags, - item.AdminFeatureFlags, - item.PublicConfig, - item.UpdatedAt); - } - - private static TenantThemeItem ToThemeItem(TenantThemeConfig item) - { - return new TenantThemeItem( - item.TenantId, - item.ActiveTemplateCode, - item.ActiveTheme, - item.ActivePublicAssets, - item.DraftTemplateCode, - item.DraftTheme, - item.DraftPublicAssets, - item.Status, - item.PublishedAt, - item.PublishedBy, - item.DraftUpdatedBy, - item.UpdatedAt); - } - - private static TenantDomainItem ToDomainItem(TenantDomain item) - { - return new TenantDomainItem( - item.Id, - item.Host, - item.DomainType, - item.Status, - item.IsPrimary, - item.VerificationToken, - item.VerifiedAt, - item.LastCheckedAt, - item.DnsVerifiedAt, - item.TlsReadyAt, - item.LastFailureReason, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item) - { - return new TenantIdentityProviderItem( - item.Id, - item.Provider, - item.Status, - item.DisplayName, - item.SecretRef, - item.Priority, - item.ConfigPublic, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminBadgeItem ToBadgeItem(Badge item) - { - return new TenantAdminBadgeItem( - item.Id, - item.LegacyId, - item.Name, - item.Description, - item.Category, - item.IconUrl, - item.Level, - item.UnlockType, - item.ConditionField, - item.ConditionOperator, - item.ConditionValue, - item.ConditionExtra, - item.SortOrder, - item.IsActive, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge) - { - return new TenantAdminBadgeGrantItem( - grant.Id, - grant.LegacyId, - grant.UserId, - user?.Name ?? user?.UserName, - user?.Phone, - grant.BadgeId, - badge?.Name, - badge?.Category, - badge?.IconUrl, - badge?.Level, - grant.GrantedBy, - grantedBy?.Name ?? grantedBy?.UserName, - grant.Note, - grant.GrantedAt, - grant.CreatedAt, - grant.UpdatedAt); - } - - private static TenantAdminNotificationItem ToNotificationItem(UserNotification item) - { - return new TenantAdminNotificationItem( - item.Id, - item.UserId, - item.NotificationType, - item.Status, - item.Severity, - item.Title, - item.Message, - item.ActionLabel, - item.ActionPath, - item.SourceType, - item.SourceId, - item.DedupeKey, - item.Metadata, - item.CreatedBy, - item.ReadAt, - item.CreatedAt, - item.UpdatedAt); - } - - private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user) - { - return new TenantAdminFeedbackItem( - report.Id, - report.UserId, - user?.Name ?? user?.UserName, - user?.Phone, - report.QuestionId, - report.Type, - report.Title, - report.Category, - report.Description, - report.Status, - report.Priority, - report.HandledBy, - report.HandledAt, - report.Resolution, - report.Contact, - report.Attachments, - report.Metadata, - report.CreatedAt, - report.UpdatedAt); - } - - private static int ResolveLimit(int? limit) - { - return Math.Clamp(limit ?? 100, 1, 500); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static string NormalizeCode(string value) - { - return value.Trim().ToLowerInvariant(); - } - - private static string? MaskPhone(string? phone) - { - var value = Normalize(phone); - return value is { Length: >= 7 } - ? $"{value[..3]}****{value[^4..]}" - : value; - } - - private static JsonElement JsonObjectOrDefault(JsonElement value) - { - return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); - } - - private static JsonElement PermissionObject(JsonElement value) - { - var result = new Dictionary(StringComparer.Ordinal); - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } - - if (value.ValueKind != JsonValueKind.Object) - { - throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value"); - } - - foreach (var property in value.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) - { - throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value"); - } - - if (property.Name != "*" && !IsPermissionKey(property.Name)) - { - throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key"); - } - - result[property.Name] = property.Value.GetBoolean(); - } - - return JsonSerializer.SerializeToElement(result); - } - - private static JsonElement AccessMap(JsonElement value, string codePrefix) - { - var result = new Dictionary(StringComparer.Ordinal); - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } - - if (value.ValueKind != JsonValueKind.Object) - { - throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}"); - } - - foreach (var property in value.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) - { - throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}"); - } - - if (!IsAccessKey(property.Name)) - { - throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key"); - } - - result[property.Name] = property.Value.GetBoolean(); - } - - return JsonSerializer.SerializeToElement(result); - } - - private static JsonElement DataScope(JsonElement value) - { - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } - - if (value.ValueKind != JsonValueKind.Object) - { - throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope"); - } - - var allowed = new HashSet(StringComparer.Ordinal) - { - "mode", - "regionIds", - "contentNodeIds", - "classIds", - "ownLeadsOnly", - "teamScope", - "metadata" - }; - foreach (var property in value.EnumerateObject()) - { - if (!allowed.Contains(property.Name)) - { - throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key"); - } - } - - return value.Clone(); - } - - private async Task AssertGrantableAsync( - TenantAdminActor actor, - TenantRole role, - CancellationToken cancellationToken) - { - if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) - { - return; - } - - var isOwnerRoleHolder = await ( - from binding in dbContext.TenantBackendUserRoles.AsNoTracking() - join backendRole in dbContext.TenantBackendRoles.AsNoTracking() - on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } - where binding.TenantId == actor.TenantId && - binding.UserId == actor.UserId && - backendRole.Code == "tenant_owner" && - backendRole.IsSystem && - backendRole.Status == BackendRoleStatus.Active - select binding.Id) - .AnyAsync(cancellationToken); - if (!isOwnerRoleHolder) - { - throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); - } - } - - private static string RoleToPrimaryRole(TenantRole role) - { - return role switch - { - TenantRole.Student => "student", - TenantRole.Teacher => "teacher", - TenantRole.Sales => "sales", - TenantRole.Agent => "agent", - TenantRole.TenantOperator => "tenant_operator", - TenantRole.TenantAdmin => "tenant_admin", - TenantRole.TenantOwner => "tenant_owner", - _ => "student" - }; - } - - private static string NormalizeRoleCode(string value) - { - var code = new string(value.Trim().ToLowerInvariant() - .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') - .ToArray()) - .Trim('-'); - while (code.Contains("--", StringComparison.Ordinal)) - { - code = code.Replace("--", "-", StringComparison.Ordinal); - } - - if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0])) - { - throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code"); - } - - return code; - } - - private static string NormalizeDomain(string value) - { - var host = Normalize(value)?.ToLowerInvariant() - .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required"); - if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal)) - { - throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host"); - } - - return host; - } - - private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second) - { - var result = new Dictionary(StringComparer.Ordinal); - if (first.ValueKind == JsonValueKind.Object) - { - foreach (var property in first.EnumerateObject()) - { - result[property.Name] = property.Value.Clone(); - } - } - - if (second.ValueKind == JsonValueKind.Object) - { - foreach (var property in second.EnumerateObject()) - { - result[property.Name] = property.Value.Clone(); - } - } - - return JsonSerializer.SerializeToElement(result); - } - - private static void AssertNoSecrets(JsonElement value, string code) - { - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return; - } - - if (value.ValueKind != JsonValueKind.Object) - { - throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}"); - } - - foreach (var property in value.EnumerateObject()) - { - var key = property.Name.ToLowerInvariant(); - if (key.Contains("secret", StringComparison.Ordinal) || - key.Contains("password", StringComparison.Ordinal) || - key.Contains("token", StringComparison.Ordinal) || - key.Contains("private", StringComparison.Ordinal) || - key is "appsecret" or "app_secret" or "accesskeysecret") - { - throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret"); - } - } - } - - private static bool IsPermissionKey(string key) - { - return key.Length <= 100 && - key.Contains(':', StringComparison.Ordinal) && - key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*'); - } - - private static bool IsAccessKey(string key) - { - return key.Length <= 100 && - key.Length > 0 && - char.IsAsciiLetter(key[0]) && - key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-'); - } - - private static TEnum ParseEnum(string? value, TEnum fallback, string code) - where TEnum : struct, Enum - { - return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum(value, code); - } - - private static TEnum ParseEnum(string value, string code) - where TEnum : struct, Enum - { - var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) - .Replace("-", string.Empty, StringComparison.Ordinal); - foreach (var candidate in Enum.GetValues()) - { - if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) - { - return candidate; - } - } - - throw new TenantAdminDirectException("Enum value was invalid.", code); - } } diff --git a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs index 13a4cbf..3383678 100644 --- a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs @@ -27,7 +27,7 @@ public sealed class AssetAccessEndpointTests PublicAsset(tenantId, assetId)); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); + using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -66,7 +66,7 @@ public sealed class AssetAccessEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); + using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); @@ -98,7 +98,7 @@ public sealed class AssetAccessEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var response = await client.GetAsync($"/api/assets/{assetId}/download"); + using var response = await client.GetAsync($"/api/student/assets/{assetId}/download"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -130,7 +130,7 @@ public sealed class AssetAccessEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var deniedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); + using var deniedResponse = await client.GetAsync($"/api/student/assets/{assetId}/download"); await factory.SeedAsync(new Entitlement { TenantId = tenantId, @@ -140,7 +140,7 @@ public sealed class AssetAccessEndpointTests Status = EntitlementStatus.Active, StartsAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); - using var grantedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); + using var grantedResponse = await client.GetAsync($"/api/student/assets/{assetId}/download"); Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, grantedResponse.StatusCode); @@ -170,7 +170,7 @@ public sealed class AssetAccessEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/assets/{assetId}/preview?tenantCode=master"); + using var response = await client.GetAsync($"/api/student/assets/{assetId}/preview?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -193,7 +193,7 @@ public sealed class AssetAccessEndpointTests await factory.SeedAsync(Tenant(tenantId, "scan-gate"), asset); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=scan-gate"); + using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=scan-gate"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/AssetEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs index 8c15b2d..fe01dfd 100644 --- a/Tiku.IntegrationTests/Api/AssetEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs @@ -56,7 +56,7 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf"); + using var response = await client.GetAsync($"/api/public/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -82,7 +82,7 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/content-assets?tenantCode=master&includeLocked=true"); + using var response = await client.GetAsync("/api/public/catalog/content-assets?tenantCode=master&includeLocked=true"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -114,8 +114,8 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var publicResponse = await client.GetAsync("/api/catalog/images?tenantCode=master&category=banner"); - using var lockedResponse = await client.GetAsync("/api/catalog/images?tenantCode=master&category=banner&includeLocked=true"); + using var publicResponse = await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner"); + using var lockedResponse = await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner&includeLocked=true"); Assert.Equal(["公开图"], (await ReadItemsAsync(publicResponse)).Select(item => item.GetProperty("title").GetString()!).ToArray()); var lockedTitles = (await ReadItemsAsync(lockedResponse)) @@ -149,7 +149,7 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/app-assets?tenantCode=master&assetKey=logo"); + using var response = await client.GetAsync("/api/public/catalog/app-assets?tenantCode=master&assetKey=logo"); var item = Assert.Single(await ReadItemsAsync(response)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -190,7 +190,7 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/question-videos?tenantCode=master&questionId={questionId}"); + using var response = await client.GetAsync($"/api/public/catalog/question-videos?tenantCode=master&questionId={questionId}"); var item = Assert.Single(await ReadItemsAsync(response)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs index f33bc97..0cfd49a 100644 --- a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -25,7 +25,7 @@ public sealed class AssetManagementEndpointTests using var client = factory.CreateClient(); using var response = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/sign", + "/api/tenant/content/assets/uploads/sign", new AssetUploadSignDto { FileName = "lesson.pdf", @@ -44,7 +44,7 @@ public sealed class AssetManagementEndpointTests await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/sign", + "/api/tenant/content/assets/uploads/sign", new AssetUploadSignDto { FileName = "lesson.pdf", @@ -64,7 +64,7 @@ public sealed class AssetManagementEndpointTests Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString()); Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal); - using var listResponse = await client.GetAsync("/api/tenant-content/assets?uploadStatus=pending"); + using var listResponse = await client.GetAsync("/api/tenant/content/assets?uploadStatus=pending"); var list = await ReadJsonAsync(listResponse); var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray()); Assert.Equal(assetId, listItem.GetProperty("id").GetGuid()); @@ -107,7 +107,7 @@ public sealed class AssetManagementEndpointTests await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/confirm", + "/api/tenant/content/assets/uploads/confirm", new AssetUploadConfirmDto { AssetId = assetId, @@ -152,32 +152,32 @@ public sealed class AssetManagementEndpointTests await LoginAsync(client, seed); using var firstConfirm = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/confirm", + "/api/tenant/content/assets/uploads/confirm", new AssetUploadConfirmDto { AssetId = firstAssetId }); Assert.Equal(HttpStatusCode.OK, firstConfirm.StatusCode); Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId)); storage.MetadataSizeBytes = 5; using var exhausted = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/confirm", + "/api/tenant/content/assets/uploads/confirm", new AssetUploadConfirmDto { AssetId = secondAssetId }); Assert.Equal(HttpStatusCode.Conflict, exhausted.StatusCode); var exhaustedBody = await ReadJsonAsync(exhausted); Assert.Equal("feature_quota_exhausted", exhaustedBody.RootElement.GetProperty("code").GetString()); Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId)); - using var archive = await client.DeleteAsync($"/api/tenant-content/assets/{firstAssetId}"); + using var archive = await client.DeleteAsync($"/api/tenant/content/assets/{firstAssetId}"); Assert.Equal(HttpStatusCode.OK, archive.StatusCode); Assert.Equal(0, await StorageUsageAsync(factory, seed.TenantId)); using var secondConfirm = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/confirm", + "/api/tenant/content/assets/uploads/confirm", new AssetUploadConfirmDto { AssetId = secondAssetId }); Assert.Equal(HttpStatusCode.OK, secondConfirm.StatusCode); Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId)); using var repeatedConfirm = await client.PostAsJsonAsync( - "/api/tenant-content/assets/uploads/confirm", + "/api/tenant/content/assets/uploads/confirm", new AssetUploadConfirmDto { AssetId = secondAssetId }); Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode); Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId)); @@ -240,8 +240,8 @@ public sealed class AssetManagementEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var listResponse = await client.GetAsync("/api/tenant-content/import-jobs?status=completedWithErrors"); - using var detailResponse = await client.GetAsync($"/api/tenant-content/import-jobs/{jobId}"); + using var listResponse = await client.GetAsync("/api/tenant/content/import-jobs?status=completedWithErrors"); + using var detailResponse = await client.GetAsync($"/api/tenant/content/import-jobs/{jobId}"); var list = await ReadJsonAsync(listResponse); var detail = await ReadJsonAsync(detailResponse); @@ -263,7 +263,7 @@ public sealed class AssetManagementEndpointTests await LoginAsync(client, seed); var upsertResponse = await client.PutAsJsonAsync( - "/api/tenant-content/assets", + "/api/tenant/content/assets", new UpsertAssetDto { Title = "管理侧资料", @@ -289,13 +289,13 @@ public sealed class AssetManagementEndpointTests }); var downloadResponse = await client.PostAsJsonAsync( - "/api/tenant-content/assets/sign-download", + "/api/tenant/content/assets/sign-download", new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 }); var previewResponse = await client.PostAsJsonAsync( - "/api/tenant-content/assets/sign-preview", + "/api/tenant/content/assets/sign-preview", new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 }); - var accessEventsResponse = await client.GetAsync($"/api/tenant-content/assets/access-events?assetId={assetId}"); - var scanEventsResponse = await client.GetAsync($"/api/tenant-content/assets/security-scan-events?assetId={assetId}"); + var accessEventsResponse = await client.GetAsync($"/api/tenant/content/assets/access-events?assetId={assetId}"); + var scanEventsResponse = await client.GetAsync($"/api/tenant/content/assets/security-scan-events?assetId={assetId}"); var accessEvents = await ReadJsonAsync(accessEventsResponse); var scanEvents = await ReadJsonAsync(scanEventsResponse); diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index e3e070d..3a5ed3c 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -32,7 +32,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var response = await client.PostAsJsonAsync( - "/api/auth/sms/send", + "/api/tenant/auth/sms/send", new SendSmsCodeDto { Realm = AuthRealm.Tenant, @@ -58,7 +58,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Remove("x-tenant-code"); var platformResponse = await client.PostAsJsonAsync( - "/api/auth/sms/send", + "/api/tenant/auth/sms/send", new SendSmsCodeDto { Realm = AuthRealm.Platform, @@ -95,12 +95,12 @@ public sealed class AuthEndpointTests using var client = factory.CreateClient(); var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone); - using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me"); + using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); jwtRequest.Headers.Host = "a.example.test"; jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken); var jwtResponse = await client.SendAsync(jwtRequest); - using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password"); + using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/login/password"); spoofRequest.Headers.Host = "a.example.test"; spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N")); spoofRequest.Content = JsonContent.Create(new PasswordLoginDto @@ -120,13 +120,13 @@ public sealed class AuthEndpointTests { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { - ["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth" + ["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/tenant/auth" }); using var client = factory.CreateClient(); var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}"; var requests = new[] { - new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password") + new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/login/password") { Content = JsonContent.Create(new PasswordLoginDto { @@ -135,11 +135,11 @@ public sealed class AuthEndpointTests Password = PasswordTestUserExtensions.TestPassword }) }, - new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh") + new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/refresh") { Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) }, - new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout") + new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/logout") { Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) } @@ -165,8 +165,8 @@ public sealed class AuthEndpointTests var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); client.UseAccessToken(tokens); - var meResponse = await client.GetAsync("/api/me"); - var tenantResponse = await client.GetAsync("/api/tenants/current"); + var meResponse = await client.GetAsync("/api/tenant/me"); + var tenantResponse = await client.GetAsync("/api/tenant/context/current"); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); @@ -184,7 +184,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( - "/api/auth/login/sms", + "/api/tenant/auth/login/sms", new SmsLoginDto { Realm = AuthRealm.Tenant, @@ -197,7 +197,7 @@ public sealed class AuthEndpointTests seed.TenantId, seed.Phone); client.UseAccessToken(tokens); - var meResponse = await client.GetAsync("/api/me"); + var meResponse = await client.GetAsync("/api/tenant/me"); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); @@ -212,12 +212,12 @@ public sealed class AuthEndpointTests var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); var logoutResponse = await client.PostAsJsonAsync( - "/api/auth/logout", + "/api/tenant/auth/logout", new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); client.UseAccessToken(tokens); - var meResponse = await client.GetAsync("/api/me"); + var meResponse = await client.GetAsync("/api/tenant/me"); var refreshResponse = await client.PostAsJsonAsync( - "/api/auth/refresh", + "/api/tenant/auth/refresh", new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); @@ -271,7 +271,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( - "/api/auth/oauth/wechat-miniapp", + "/api/tenant/auth/oauth/wechat-miniapp", new OAuthCodeDto { Realm = AuthRealm.Tenant, @@ -286,7 +286,7 @@ public sealed class AuthEndpointTests .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - var meResponse = await client.GetAsync("/api/me"); + var meResponse = await client.GetAsync("/api/tenant/me"); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -320,7 +320,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var response = await client.PostAsJsonAsync( - "/api/auth/oauth/wechat-miniapp", + "/api/tenant/auth/oauth/wechat-miniapp", new OAuthCodeDto { Realm = AuthRealm.Tenant, @@ -365,7 +365,7 @@ public sealed class AuthEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var response = await client.PostAsJsonAsync( - "/api/auth/oauth/wechat-miniapp", + "/api/tenant/auth/oauth/wechat-miniapp", new OAuthCodeDto { Realm = AuthRealm.Tenant, diff --git a/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs b/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs index 0cbf094..f40a4ea 100644 --- a/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs @@ -39,7 +39,7 @@ public sealed class AuthPasswordLifecycleTests Assert.Equal("password_change_required", login.RootElement.GetProperty("status").GetString()); var response = await client.PostAsJsonAsync( - "/api/auth/password/change-required", + "/api/tenant/auth/password/change-required", new RequiredPasswordChangeDto { ChallengeToken = login.RootElement.GetProperty("challengeToken").GetString()!, @@ -80,7 +80,7 @@ public sealed class AuthPasswordLifecycleTests (Guid TenantId, string Phone) seed) { var response = await client.PostAsJsonAsync( - "/api/auth/login/password", + "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, diff --git a/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs index f400912..eba3e27 100644 --- a/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs @@ -23,7 +23,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); var existing = await client.PostAsJsonAsync( - "/api/auth/password/reset/sms/send", + "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), @@ -31,7 +31,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests DeviceId = "known-device" }); var missing = await client.PostAsJsonAsync( - "/api/auth/password/reset/sms/send", + "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), @@ -56,7 +56,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); var send = await client.PostAsJsonAsync( - "/api/auth/password/reset/sms/send", + "/api/tenant/auth/password/reset/sms/send", new PasswordResetSmsSendDto { TenantCode = seed.TenantId.ToString("N"), @@ -73,11 +73,11 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Code = provider.Code!, NewPassword = "ResetPassword2026" }; - var reset = await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest); + var reset = await client.PostAsJsonAsync("/api/tenant/auth/password/reset", resetRequest); Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); client.UseAccessToken(oldTokens); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/tenant/me")).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode); @@ -86,7 +86,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests (await PostPasswordLoginAsync(client, seed, resetRequest.NewPassword)).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, - (await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest)).StatusCode); + (await client.PostAsJsonAsync("/api/tenant/auth/password/reset", resetRequest)).StatusCode); } [Fact] @@ -99,7 +99,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests client.UseAccessToken(oldTokens); var changed = await client.PostAsJsonAsync( - "/api/auth/password/change", + "/api/tenant/auth/password/change", new AuthenticatedPasswordChangeDto { CurrentPassword = PasswordTestUserExtensions.TestPassword, @@ -113,9 +113,9 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Assert.False(string.IsNullOrWhiteSpace(refreshToken)); client.UseAccessToken(oldTokens); - Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/tenant/me")).StatusCode); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/me")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/tenant/me")).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, (await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode); @@ -139,7 +139,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests secondClient.UseAccessToken(secondTokens); otherClient.UseAccessToken(otherTokens); - using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync()); + using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); var items = sessions.RootElement.EnumerateArray().ToArray(); Assert.Equal(2, items.Length); var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean()) @@ -149,14 +149,14 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Assert.Equal( HttpStatusCode.Conflict, - (await secondClient.DeleteAsync($"/api/me/sessions/{currentFamily}")).StatusCode); + (await secondClient.DeleteAsync($"/api/tenant/me/sessions/{currentFamily}")).StatusCode); Assert.Equal( HttpStatusCode.NotFound, - (await otherClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode); + (await otherClient.DeleteAsync($"/api/tenant/me/sessions/{otherOwnedFamily}")).StatusCode); Assert.Equal( HttpStatusCode.NoContent, - (await secondClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode); - using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync()); + (await secondClient.DeleteAsync($"/api/tenant/me/sessions/{otherOwnedFamily}")).StatusCode); + using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); Assert.Single(remaining.RootElement.EnumerateArray()); } @@ -173,14 +173,14 @@ public sealed class AuthRecoveryAndDeviceEndpointTests adminClient.UseAccessToken(await adminClient.LoginAsTenantAsync(admin.TenantId, admin.Phone)); var reset = await adminClient.PostAsJsonAsync( - $"/api/tenant-admin/members/{target.UserId}/password-reset", + $"/api/tenant/members/{target.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", Reason = "Account recovery verification" }); var crossTenant = await adminClient.PostAsJsonAsync( - $"/api/tenant-admin/members/{crossTenantTarget.UserId}/password-reset", + $"/api/tenant/members/{crossTenantTarget.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", @@ -190,7 +190,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); Assert.Equal(HttpStatusCode.NotFound, crossTenant.StatusCode); targetClient.UseAccessToken(targetTokens); - Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode); var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026"); Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode); Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal); @@ -210,7 +210,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests UserSeed seed, string password) => client.PostAsJsonAsync( - "/api/auth/login/password", + "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, diff --git a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs index 899cda3..e69c486 100644 --- a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs +++ b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs @@ -18,7 +18,7 @@ internal static class AuthenticationTestClientExtensions { SetTenantHeader(client, tenantId); var response = await client.PostAsJsonAsync( - "/api/auth/login/password", + "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, @@ -37,7 +37,7 @@ internal static class AuthenticationTestClientExtensions { client.DefaultRequestHeaders.Remove("x-tenant-code"); var response = await client.PostAsJsonAsync( - "/api/auth/login/password", + "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Platform, diff --git a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs index bb3ea5c..92dd77d 100644 --- a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs +++ b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs @@ -17,7 +17,7 @@ public sealed class AuthorizationManifestTests // Reviewed additions: platform approval, typed configuration, notification governance, // and operation-level authorization metadata. private const int ExpectedActionCount = 465; - private const string ExpectedSha256 = "06409c4dca7fc07001d518cf0c4728ff77f4fe94d4151bd63ae64f7f2eb1a2ab"; + private const string ExpectedSha256 = "d31e8f91cc261863272ceef6868db68c9a1288aaaef29332f4e1a5b11b617fcd"; [Fact] public void Controller_authorization_surface_matches_reviewed_manifest() @@ -42,7 +42,7 @@ public sealed class AuthorizationManifestTests { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - _ = await client.GetAsync("/api/health"); + _ = await client.GetAsync("/api/system/health"); var endpoints = factory.Services.GetRequiredService().Endpoints .Where(endpoint => endpoint.Metadata.GetMetadata() is not null) .ToArray(); @@ -104,7 +104,9 @@ public sealed class AuthorizationManifestTests private static string Describe(Type controller, MethodInfo action) { - var controllerRoute = controller.GetCustomAttribute()?.Template ?? string.Empty; + var controllerRoute = string.Join(',', controller.GetCustomAttributes() + .Select(attribute => attribute.Template ?? string.Empty) + .Order(StringComparer.Ordinal)); var http = action.GetCustomAttributes().ToArray(); var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal)); var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal)); diff --git a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs index 72afb70..4367c0b 100644 --- a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs +++ b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs @@ -70,13 +70,13 @@ public sealed class BackofficeUiBootstrapTests client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); commandRecorder.Reset(); - using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap"); + using var response = await client.GetAsync("/api/tenant/access/ui-bootstrap"); using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var firstRequestCommands = commandRecorder.Snapshot(); commandRecorder.Reset(); - using var repeatedResponse = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap"); + using var repeatedResponse = await client.GetAsync("/api/tenant/access/ui-bootstrap"); var repeatedRequestCommands = commandRecorder.Snapshot(); - using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap"); + using var roleManagementResponse = await client.GetAsync("/api/tenant/access/bootstrap"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode); @@ -143,7 +143,7 @@ public sealed class BackofficeUiBootstrapTests client.UseAccessToken(await client.LoginAsPlatformAsync(email)); commandRecorder.Reset(); - using var response = await client.GetAsync("/api/backoffice/platform/ui-bootstrap"); + using var response = await client.GetAsync("/api/platform/access/ui-bootstrap"); using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var commands = commandRecorder.Snapshot(); diff --git a/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs index 1eb1154..b9e5343 100644 --- a/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs +++ b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs @@ -36,7 +36,7 @@ public sealed class BrowserAuthenticationTests client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Add("Origin", "http://localhost"); - var response = await client.PostAsJsonAsync("/api/browser-auth/login/password", new PasswordLoginDto + var response = await client.PostAsJsonAsync("/api/tenant/auth/browser/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), @@ -81,7 +81,7 @@ public sealed class BrowserAuthenticationTests HandleCookies = false }); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password") + using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password") { Content = JsonContent.Create(new PasswordLoginDto { @@ -131,7 +131,7 @@ public sealed class BrowserAuthenticationTests HandleCookies = false }); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password") + using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password") { Content = JsonContent.Create(new PasswordLoginDto { @@ -145,11 +145,11 @@ public sealed class BrowserAuthenticationTests var loginResponse = await client.SendAsync(login); var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie); - using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/me"); + using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); crossSite.Headers.Add("Sec-Fetch-Site", "cross-site"); var rejected = await client.SendAsync(crossSite); - using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/me"); + using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); sameOrigin.Headers.Add("Origin", "http://localhost"); var accepted = await client.SendAsync(sameOrigin); @@ -164,7 +164,7 @@ public sealed class BrowserAuthenticationTests string origin, string? csrf) { - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/refresh"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/refresh"); request.Headers.Add("Cookie", cookieHeader); request.Headers.Add("Origin", origin); if (csrf is not null) diff --git a/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs index 0b57039..4d2d67c 100644 --- a/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs @@ -53,7 +53,7 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/regions?tenantCode=master"); + using var response = await client.GetAsync("/api/public/catalog/regions?tenantCode=master"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -109,7 +109,7 @@ public sealed class CatalogEndpointTests using var client = factory.CreateClient(); using var response = await client.GetAsync( - $"/api/catalog/subjects?tenantCode=master®ionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论"); + $"/api/public/catalog/subjects?tenantCode=master®ionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -144,7 +144,7 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/module-nodes?tenantCode=master&parentId=root"); + using var response = await client.GetAsync("/api/public/catalog/module-nodes?tenantCode=master&parentId=root"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -158,7 +158,7 @@ public sealed class CatalogEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/regions"); + using var response = await client.GetAsync("/api/public/catalog/regions"); var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -221,7 +221,7 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/banners?tenantCode=master®ionId={regionId}"); + using var response = await client.GetAsync($"/api/public/catalog/banners?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -282,8 +282,8 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var faqResponse = await client.GetAsync("/api/catalog/faqs?tenantCode=master"); - using var announcementResponse = await client.GetAsync("/api/catalog/announcements?tenantCode=master"); + using var faqResponse = await client.GetAsync("/api/public/catalog/faqs?tenantCode=master"); + using var announcementResponse = await client.GetAsync("/api/public/catalog/announcements?tenantCode=master"); var faqs = await ReadItemsAsync(faqResponse); var announcements = await ReadItemsAsync(announcementResponse); @@ -349,7 +349,7 @@ public sealed class CatalogEndpointTests using var client = factory.CreateClient(); using var response = await client.GetAsync( - $"/api/catalog/exam-dates?tenantCode=master®ionId={regionId}&schoolId={schoolId}"); + $"/api/public/catalog/exam-dates?tenantCode=master®ionId={regionId}&schoolId={schoolId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -409,7 +409,7 @@ public sealed class CatalogEndpointTests using var client = factory.CreateClient(); using var response = await client.GetAsync( - $"/api/catalog/products?tenantCode=master®ionId={regionId}&type=material&keyword=联考&limit=1"); + $"/api/public/catalog/products?tenantCode=master®ionId={regionId}&type=material&keyword=联考&limit=1"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -473,7 +473,7 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/svip-plans?tenantCode=master®ionId={regionId}"); + using var response = await client.GetAsync($"/api/public/catalog/svip-plans?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index be7c7e5..cdf9518 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -24,7 +24,7 @@ public sealed class CommerceEndpointTests await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/commerce/orders"); + var response = await client.GetAsync("/api/student/commerce/orders"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -57,7 +57,7 @@ public sealed class CommerceEndpointTests ])); var response = await client.PostAsJsonAsync( - "/api/commerce/orders", + "/api/student/commerce/orders", new CreateCommerceOrderDto { PlanId = planId, @@ -76,7 +76,7 @@ public sealed class CommerceEndpointTests await LoginAsync(client, seed); var createResponse = await client.PostAsJsonAsync( - "/api/commerce/orders", + "/api/student/commerce/orders", new CreateCommerceOrderDto { PlanId = seed.PlanId, @@ -85,9 +85,9 @@ public sealed class CommerceEndpointTests PayProvider = "manual" }); var created = await createResponse.Content.ReadFromJsonAsync(); - var listResponse = await client.GetAsync("/api/commerce/orders?limit=5"); + var listResponse = await client.GetAsync("/api/student/commerce/orders?limit=5"); var list = await listResponse.Content.ReadFromJsonAsync(); - var detailResponse = await client.GetAsync($"/api/commerce/orders/{created!.OrderNo}"); + var detailResponse = await client.GetAsync($"/api/student/commerce/orders/{created!.OrderNo}"); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); Assert.Equal(1998, created.AmountCents); @@ -107,7 +107,7 @@ public sealed class CommerceEndpointTests var order = await CreateOrderAsync(client, seed.PlanId); var paymentResponse = await client.PostAsJsonAsync( - "/api/commerce/payments", + "/api/student/commerce/payments", new CreateCommercePaymentDto { OrderNo = order.OrderNo, @@ -115,9 +115,9 @@ public sealed class CommerceEndpointTests Method = "manual" }); var payment = await paymentResponse.Content.ReadFromJsonAsync(); - var entitlementResponse = await client.GetAsync("/api/commerce/entitlements/current"); + var entitlementResponse = await client.GetAsync("/api/student/commerce/entitlements/current"); var entitlement = await entitlementResponse.Content.ReadFromJsonAsync(); - var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}"); + var orderResponse = await client.GetAsync($"/api/student/commerce/orders/{order.OrderNo}"); var paidOrder = await orderResponse.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode); @@ -143,8 +143,8 @@ public sealed class CommerceEndpointTests Method = "manual" }; - var first = await client.PostAsJsonAsync("/api/commerce/payments", request); - var second = await client.PostAsJsonAsync("/api/commerce/payments", request); + var first = await client.PostAsJsonAsync("/api/student/commerce/payments", request); + var second = await client.PostAsJsonAsync("/api/student/commerce/payments", request); var firstPayment = await first.Content.ReadFromJsonAsync(); var secondPayment = await second.Content.ReadFromJsonAsync(); @@ -171,13 +171,13 @@ public sealed class CommerceEndpointTests await LoginAsync(client, seed); var claimResponse = await client.PostAsJsonAsync( - "/api/commerce/coupons/claim", + "/api/student/commerce/coupons/claim", new ClaimCommerceCouponDto { CouponCode = "SAVE5" }); - var coupons = await (await client.GetAsync("/api/commerce/coupons")) + var coupons = await (await client.GetAsync("/api/student/commerce/coupons")) .Content .ReadFromJsonAsync(); var check = await (await client.PostAsJsonAsync( - "/api/commerce/coupons/check", + "/api/student/commerce/coupons/check", new CheckCommerceCouponDto { CouponCode = "SAVE5", @@ -212,7 +212,7 @@ public sealed class CommerceEndpointTests await LoginAsync(client, seed); var response = await client.PostAsJsonAsync( - "/api/commerce/orders", + "/api/student/commerce/orders", new CreateCommerceOrderDto { PlanId = seed.PlanId, @@ -220,10 +220,10 @@ public sealed class CommerceEndpointTests CouponCode = "FREE" }); var order = await response.Content.ReadFromJsonAsync(); - var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current")) + var entitlement = await (await client.GetAsync("/api/student/commerce/entitlements/current")) .Content .ReadFromJsonAsync(); - var coupons = await (await client.GetAsync("/api/commerce/coupons?status=used")) + var coupons = await (await client.GetAsync("/api/student/commerce/coupons?status=used")) .Content .ReadFromJsonAsync(); @@ -248,7 +248,7 @@ public sealed class CommerceEndpointTests Provider = "manual", Method = "manual" }; - await client.PostAsJsonAsync("/api/commerce/payments", request); + await client.PostAsJsonAsync("/api/student/commerce/payments", request); client.DefaultRequestHeaders.Authorization = null; var payload = new @@ -261,13 +261,13 @@ public sealed class CommerceEndpointTests signatureValid = true }; var firstNotify = await client.PostAsJsonAsync( - $"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", + $"/api/student/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", payload); var secondNotify = await client.PostAsJsonAsync( - $"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", + $"/api/student/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", payload); await LoginAsync(client, seed); - var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current")) + var entitlement = await (await client.GetAsync("/api/student/commerce/entitlements/current")) .Content .ReadFromJsonAsync(); using var scope = factory.CreateSystemScope(); @@ -291,7 +291,7 @@ public sealed class CommerceEndpointTests client.DefaultRequestHeaders.Authorization = null; var notifyResponse = await client.PostAsJsonAsync( - $"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", + $"/api/student/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}", new { eventId = "notify-invalid", @@ -302,7 +302,7 @@ public sealed class CommerceEndpointTests signatureValid = false }); await LoginAsync(client, seed); - var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}"); + var orderResponse = await client.GetAsync($"/api/student/commerce/orders/{order.OrderNo}"); var currentOrder = await orderResponse.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.BadRequest, notifyResponse.StatusCode); @@ -312,7 +312,7 @@ public sealed class CommerceEndpointTests private static async Task CreateOrderAsync(HttpClient client, Guid planId) { var response = await client.PostAsJsonAsync( - "/api/commerce/orders", + "/api/student/commerce/orders", new CreateCommerceOrderDto { PlanId = planId, diff --git a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs index 6747625..6d34331 100644 --- a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs @@ -20,7 +20,7 @@ public sealed class CommissionEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/commission/settings"); + var response = await client.GetAsync("/api/tenant/commission/settings"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -34,12 +34,12 @@ public sealed class CommissionEndpointTests await LoginAsync(client, seed.Admin); var settings = await client.PutAsJsonAsync( - "/api/commission/settings", + "/api/tenant/commission/settings", new UpdateCommissionSettingsDto { DefaultRate = 0.2m, MinSettlementCents = 1, SettlementCycle = "monthly" }); - var sources = await client.GetAsync($"/api/commission/orders?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); - var summary = await client.GetAsync($"/api/commission/summary?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); + var sources = await client.GetAsync($"/api/tenant/commission/orders?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); + var summary = await client.GetAsync($"/api/tenant/commission/summary?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); var generate = await client.PostAsJsonAsync( - "/api/commission/settlements/generate", + "/api/tenant/commission/settlements/generate", new GenerateCommissionSettlementDto { ReferrerUserId = seed.Referrer.UserId, @@ -49,7 +49,7 @@ public sealed class CommissionEndpointTests }); var settlement = await generate.Content.ReadFromJsonAsync(); var duplicate = await client.PostAsJsonAsync( - "/api/commission/settlements/generate", + "/api/tenant/commission/settlements/generate", new GenerateCommissionSettlementDto { ReferrerUserId = seed.Referrer.UserId, @@ -57,7 +57,7 @@ public sealed class CommissionEndpointTests EndDate = "2026-07-31" }); var status = await client.PostAsJsonAsync( - "/api/commission/settlements/status", + "/api/tenant/commission/settlements/status", new UpdateCommissionSettlementStatusDto { SettlementId = settlement!.Id, @@ -66,7 +66,7 @@ public sealed class CommissionEndpointTests PaymentAccount = "****1234" }); var proof = await client.PostAsJsonAsync( - "/api/commission/settlements/proofs", + "/api/tenant/commission/settlements/proofs", new CreateCommissionProofDto { SettlementId = settlement.Id, @@ -75,9 +75,9 @@ public sealed class CommissionEndpointTests }); var proofItem = await proof.Content.ReadFromJsonAsync(); var proofStatus = await client.PostAsJsonAsync( - "/api/commission/settlements/proofs/status", + "/api/tenant/commission/settlements/proofs/status", new UpdateCommissionProofStatusDto { ProofId = proofItem!.Id, Status = "approved" }); - var export = await client.GetAsync($"/api/commission/settlements/export?settlementId={settlement.Id}&format=csv"); + var export = await client.GetAsync($"/api/tenant/commission/settlements/export?settlementId={settlement.Id}&format=csv"); var exportItem = await export.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, settings.StatusCode); diff --git a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs index 0c39d0b..844f8bf 100644 --- a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs @@ -21,7 +21,7 @@ public sealed class ContentManagementEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/tenant-content/entries"); + using var response = await client.GetAsync("/api/tenant/content/entries"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -35,7 +35,7 @@ public sealed class ContentManagementEndpointTests await LoginAsync(client, seed); using var entryResponse = await client.PostAsJsonAsync( - "/api/tenant-content/entries", + "/api/tenant/content/entries", new UpsertContentEntryDto { EntryKey = "exam-practice", @@ -48,7 +48,7 @@ public sealed class ContentManagementEndpointTests var entryId = entryJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); using var nodeResponse = await client.PostAsJsonAsync( - "/api/tenant-content/nodes", + "/api/tenant/content/nodes", new UpsertContentNodeDto { EntryId = entryId, @@ -58,7 +58,7 @@ public sealed class ContentManagementEndpointTests IsLeaf = true }); var nodeJson = await ReadJsonAsync(nodeResponse); - using var listResponse = await client.GetAsync($"/api/tenant-content/nodes?entryId={entryId}&parentId=root"); + using var listResponse = await client.GetAsync($"/api/tenant/content/nodes?entryId={entryId}&parentId=root"); var listJson = await ReadJsonAsync(listResponse); Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); @@ -88,7 +88,7 @@ public sealed class ContentManagementEndpointTests var entryId = await CreateEntryAsync(client); var collectionResponse = await client.PostAsJsonAsync( - "/api/tenant-content/question-collections", + "/api/tenant/content/question-collections", new UpsertQuestionCollectionDto { EntryId = entryId, @@ -102,7 +102,7 @@ public sealed class ContentManagementEndpointTests var collectionId = collectionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var replaceResponse = await client.PostAsJsonAsync( - "/api/tenant-content/question-collections/items/replace", + "/api/tenant/content/question-collections/items/replace", new ReplaceCollectionItemsDto { CollectionId = collectionId, @@ -121,7 +121,7 @@ public sealed class ContentManagementEndpointTests var replaceJson = await ReadJsonAsync(replaceResponse); var blueprintResponse = await client.PostAsJsonAsync( - "/api/tenant-content/practice-blueprints", + "/api/tenant/content/practice-blueprints", new UpsertPracticeBlueprintDto { EntryId = entryId, @@ -155,8 +155,8 @@ public sealed class ContentManagementEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var mappingResponse = await client.GetAsync("/api/tenant-content/imports/field-mapping?importType=questions"); - using var templateResponse = await client.GetAsync("/api/tenant-content/imports/templates?importType=questions&format=csv"); + using var mappingResponse = await client.GetAsync("/api/tenant/content/imports/field-mapping?importType=questions"); + using var templateResponse = await client.GetAsync("/api/tenant/content/imports/templates?importType=questions&format=csv"); var mapping = await ReadJsonAsync(mappingResponse); var template = await ReadJsonAsync(templateResponse); @@ -213,7 +213,7 @@ public sealed class ContentManagementEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var selfResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true"); + using var selfResponse = await client.GetAsync("/api/tenant/content/entries?includeInactive=true"); using var selfJson = await ReadJsonAsync(selfResponse); await SetDataScopeAsync(factory, seed.TenantId, new @@ -222,10 +222,10 @@ public sealed class ContentManagementEndpointTests regionIds = new[] { allowedRegionId }, includesSelf = false }); - using var restrictedResponse = await client.GetAsync("/api/tenant-content/entries?includeInactive=true"); + using var restrictedResponse = await client.GetAsync("/api/tenant/content/entries?includeInactive=true"); using var restrictedJson = await ReadJsonAsync(restrictedResponse); using var deniedUpdate = await client.PostAsJsonAsync( - "/api/tenant-content/entries", + "/api/tenant/content/entries", new UpsertContentEntryDto { Id = outsideEntry.Id, @@ -244,7 +244,7 @@ public sealed class ContentManagementEndpointTests private static async Task CreateEntryAsync(HttpClient client) { using var response = await client.PostAsJsonAsync( - "/api/tenant-content/entries", + "/api/tenant/content/entries", new UpsertContentEntryDto { EntryKey = Guid.NewGuid().ToString("N"), diff --git a/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs index 6ff02aa..5e8cc81 100644 --- a/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs @@ -51,7 +51,7 @@ public sealed class ContentNavigationEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/content-entries?tenantCode=master®ionId={regionId}"); + using var response = await client.GetAsync($"/api/public/catalog/content-entries?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -97,8 +97,8 @@ public sealed class ContentNavigationEndpointTests }); using var client = factory.CreateClient(); - using var missingEntryResponse = await client.GetAsync("/api/catalog/content-nodes?tenantCode=master"); - using var response = await client.GetAsync($"/api/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root"); + using var missingEntryResponse = await client.GetAsync("/api/public/catalog/content-nodes?tenantCode=master"); + using var response = await client.GetAsync($"/api/public/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.BadRequest, missingEntryResponse.StatusCode); @@ -164,9 +164,9 @@ public sealed class ContentNavigationEndpointTests using var client = factory.CreateClient(); using var collectionsResponse = await client.GetAsync( - $"/api/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter"); + $"/api/public/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter"); using var blueprintsResponse = await client.GetAsync( - $"/api/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential"); + $"/api/public/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential"); var collections = await ReadItemsAsync(collectionsResponse); var blueprints = await ReadItemsAsync(blueprintsResponse); @@ -250,7 +250,7 @@ public sealed class ContentNavigationEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}"); + using var response = await client.GetAsync($"/api/public/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs index 991be90..87a97dd 100644 --- a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs @@ -21,7 +21,7 @@ public sealed class CrmEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/crm/config"); + var response = await client.GetAsync("/api/tenant/crm/config"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -35,7 +35,7 @@ public sealed class CrmEndpointTests await LoginAsync(client, seed); var response = await client.PutAsJsonAsync( - "/api/crm/config", + "/api/tenant/crm/config", new UpsertCrmConfigDto { Enabled = true, @@ -95,11 +95,11 @@ public sealed class CrmEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var queue = await client.GetAsync("/api/crm/queue?status=failed"); - var deadLetters = await client.GetAsync("/api/crm/dead-letters"); - var logs = await client.GetAsync($"/api/crm/queue/logs?queueId={queueId}"); + var queue = await client.GetAsync("/api/tenant/crm/queue?status=failed"); + var deadLetters = await client.GetAsync("/api/tenant/crm/dead-letters"); + var logs = await client.GetAsync($"/api/tenant/crm/queue/logs?queueId={queueId}"); var retry = await client.PostAsJsonAsync( - "/api/crm/queue/action", + "/api/tenant/crm/queue/action", new CrmQueueActionDto { QueueId = queueId, Action = "retry", Note = "again" }); var queueBody = await queue.Content.ReadAsStringAsync(); var logsBody = await logs.Content.ReadAsStringAsync(); diff --git a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs index 07ceb5a..c27fb66 100644 --- a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs +++ b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs @@ -80,7 +80,7 @@ public sealed class CurrentQuotaEnforcementTests client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); Task CreateAsync(string content) => client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { Type = "choice", @@ -97,7 +97,7 @@ public sealed class CurrentQuotaEnforcementTests var questionId = createdJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var archive = await client.PatchAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { QuestionId = questionId, @@ -125,19 +125,19 @@ public sealed class CurrentQuotaEnforcementTests client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); var firstTeacher = await client.PutAsJsonAsync( - "/api/tenant-admin/members", + "/api/tenant/members", Member("teacher", "13910000001", "Teacher One")); var secondTeacher = await client.PutAsJsonAsync( - "/api/tenant-admin/members", + "/api/tenant/members", Member("teacher", "13910000002", "Teacher Two")); Assert.Equal(HttpStatusCode.OK, firstTeacher.StatusCode); Assert.Equal(HttpStatusCode.Conflict, secondTeacher.StatusCode); var firstStudent = await client.PutAsJsonAsync( - "/api/tenant-admin/students", + "/api/tenant/students", Student("13920000001", "Student One")); var blockedStudent = await client.PutAsJsonAsync( - "/api/tenant-admin/students", + "/api/tenant/students", Student("13920000002", "Student Two")); Assert.Equal(HttpStatusCode.OK, firstStudent.StatusCode); Assert.Equal(HttpStatusCode.Conflict, blockedStudent.StatusCode); @@ -145,7 +145,7 @@ public sealed class CurrentQuotaEnforcementTests var studentId = studentJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid(); var disableStudent = await client.PostAsJsonAsync( - "/api/tenant-admin/students/status", + "/api/tenant/students/status", new UpdateTenantAdminStudentStatusDto { UserId = studentId, @@ -153,7 +153,7 @@ public sealed class CurrentQuotaEnforcementTests Reason = "quota release test" }); var replacementStudent = await client.PutAsJsonAsync( - "/api/tenant-admin/students", + "/api/tenant/students", Student("13920000002", "Student Two")); Assert.Equal(HttpStatusCode.OK, disableStudent.StatusCode); @@ -188,13 +188,13 @@ public sealed class CurrentQuotaEnforcementTests client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); var teacher = await client.PutAsJsonAsync( - "/api/tenant-admin/members", + "/api/tenant/members", Member("teacher", "13930000001", "Unlimited Teacher")); var student = await client.PutAsJsonAsync( - "/api/tenant-admin/students", + "/api/tenant/students", Student("13930000002", "Unlimited Student")); var question = await client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { Type = "choice", diff --git a/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs index 1ccb9f3..d067412 100644 --- a/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs +++ b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs @@ -76,8 +76,8 @@ public sealed class DatabasePermissionServiceAuthorizationTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); - using var commission = await client.GetAsync("/api/commission/settings"); - using var referral = await client.GetAsync("/api/referral/stats"); + using var commission = await client.GetAsync("/api/tenant/commission/settings"); + using var referral = await client.GetAsync("/api/tenant/referral/stats"); Assert.Equal(HttpStatusCode.OK, commission.StatusCode); Assert.Equal(HttpStatusCode.OK, referral.StatusCode); diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs index dec9344..07d5ceb 100644 --- a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -42,7 +42,7 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { PrimaryCollectionId = collectionId, @@ -73,10 +73,10 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); using var published = await client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { Type = "choice", Content = "缺少答案", Status = "Published" }); using var draft = await client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { Type = "choice", Content = "草稿题", Status = "Draft" }); Assert.Equal(HttpStatusCode.BadRequest, published.StatusCode); @@ -93,39 +93,39 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); var unitResponse = await client.PutAsJsonAsync( - "/api/tenant-content/vocabulary-units", + "/api/tenant/content/vocabulary-units", new DirectVocabularyUnitDto { Name = "Unit 1", WordCount = 1 }); var unitJson = await ReadJsonAsync(unitResponse); var unitId = unitJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var wordResponse = await client.PutAsJsonAsync( - "/api/tenant-content/vocabulary-words", + "/api/tenant/content/vocabulary-words", new DirectVocabularyWordDto { UnitId = unitId, Word = "scale", Meaning = "规模" }); var subjectResponse = await client.PutAsJsonAsync( - "/api/tenant-content/handbook-subjects", + "/api/tenant/content/handbook-subjects", new DirectHandbookSubjectDto { Name = "文化常识", Type = "Common" }); var subjectJson = await ReadJsonAsync(subjectResponse); var subjectId = subjectJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var chapterResponse = await client.PutAsJsonAsync( - "/api/tenant-content/handbook-chapters", + "/api/tenant/content/handbook-chapters", new DirectHandbookChapterDto { SubjectId = subjectId, Name = "第一章" }); var chapterJson = await ReadJsonAsync(chapterResponse); var chapterId = chapterJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var entryResponse = await client.PutAsJsonAsync( - "/api/tenant-content/handbook-entries", + "/api/tenant/content/handbook-entries", new DirectHandbookEntryDto { ChapterId = chapterId, Title = "知识点", Content = "正文" }); var videoResponse = await client.PutAsJsonAsync( - "/api/tenant-content/videos", + "/api/tenant/content/videos", new DirectVideoDto { Title = "解析视频", VideoUrl = "https://example.test/video.mp4" }); var bannerResponse = await client.PutAsJsonAsync( - "/api/tenant-content/operations/banners", + "/api/tenant/content/operations/banners", new DirectOperationContentDto { Title = "开屏", Content = "欢迎", IsActive = true }); - using var bannerListResponse = await client.GetAsync("/api/tenant-content/operations/banners"); + using var bannerListResponse = await client.GetAsync("/api/tenant/content/operations/banners"); var bannerListJson = await ReadJsonAsync(bannerListResponse); Assert.Equal(HttpStatusCode.OK, unitResponse.StatusCode); @@ -147,23 +147,23 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); var questionResponse = await client.PostAsJsonAsync( - "/api/tenant-content/questions", + "/api/tenant/content/questions", new DirectQuestionWriteDto { Type = "choice", Content = "题目", CorrectOptionIndex = 0 }); var questionJson = await ReadJsonAsync(questionResponse); var questionId = questionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var videoResponse = await client.PutAsJsonAsync( - "/api/tenant-content/videos", + "/api/tenant/content/videos", new DirectVideoDto { Title = "视频" }); var videoJson = await ReadJsonAsync(videoResponse); var videoId = videoJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var bindResponse = await client.PostAsJsonAsync( - "/api/tenant-content/question-videos", + "/api/tenant/content/question-videos", new DirectQuestionVideoDto { QuestionId = questionId, VideoId = videoId }); var previewResponse = await client.PostAsJsonAsync( - "/api/tenant-content/imports/preview/questions", + "/api/tenant/content/imports/preview/questions", new DirectImportDto { Items = @@ -175,7 +175,7 @@ public sealed class DirectContentEndpointTests var previewJobId = previewJson.RootElement.GetProperty("job").GetProperty("id").GetGuid(); var executeResponse = await client.PostAsJsonAsync( - "/api/tenant-content/imports/questions", + "/api/tenant/content/imports/questions", new DirectImportDto { Items = @@ -187,7 +187,7 @@ public sealed class DirectContentEndpointTests var executeJobId = executeJson.RootElement.GetProperty("job").GetProperty("id").GetGuid(); var postCheckResponse = await client.PostAsJsonAsync( - "/api/tenant-content/imports/post-check", + "/api/tenant/content/imports/post-check", new DirectImportJobDto { JobId = executeJobId }); Assert.Equal(HttpStatusCode.OK, bindResponse.StatusCode); @@ -212,7 +212,7 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); var queueResponse = await client.PostAsJsonAsync( - "/api/tenant-content/imports/questions", + "/api/tenant/content/imports/questions", new DirectImportDto { Async = true, @@ -230,7 +230,7 @@ public sealed class DirectContentEndpointTests var storedJob = dbContext.BackgroundJobs.Single(item => item.Id == queuedJob!.Id); var importJobId = storedJob.Result.GetProperty("importJobId").GetGuid(); - var detailResponse = await client.GetAsync($"/api/tenant-content/imports/detail?jobId={importJobId}"); + var detailResponse = await client.GetAsync($"/api/tenant/content/imports/detail?jobId={importJobId}"); var detail = await ReadJsonAsync(detailResponse); Assert.Equal(HttpStatusCode.Accepted, queueResponse.StatusCode); @@ -257,7 +257,7 @@ public sealed class DirectContentEndpointTests await LoginAsync(client, seed); var fieldResponse = await client.PutAsJsonAsync( - "/api/tenant-content/scoreline/fields", + "/api/tenant/content/scoreline/fields", new DirectScorelineFieldDto { RegionId = regionId, @@ -268,7 +268,7 @@ public sealed class DirectContentEndpointTests IsTrend = true }); var recordResponse = await client.PutAsJsonAsync( - "/api/tenant-content/scoreline/records", + "/api/tenant/content/scoreline/records", new DirectScorelineRecordDto { RegionId = regionId, @@ -280,8 +280,8 @@ public sealed class DirectContentEndpointTests FieldValues = JsonSerializer.SerializeToElement(new { cultureScore = 420, rank = "A" }) }); - var fieldsResponse = await client.GetAsync($"/api/tenant-content/scoreline/fields?regionId={regionId}"); - var recordsResponse = await client.GetAsync($"/api/tenant-content/scoreline/records?regionId={regionId}&year=2026"); + var fieldsResponse = await client.GetAsync($"/api/tenant/content/scoreline/fields?regionId={regionId}"); + var recordsResponse = await client.GetAsync($"/api/tenant/content/scoreline/records?regionId={regionId}&year=2026"); var fieldsJson = await ReadJsonAsync(fieldsResponse); var recordsJson = await ReadJsonAsync(recordsResponse); @@ -324,10 +324,10 @@ public sealed class DirectContentEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var listResponse = await client.GetAsync("/api/tenant-content/scoreline/schools"); + using var listResponse = await client.GetAsync("/api/tenant/content/scoreline/schools"); using var list = await ReadJsonAsync(listResponse); using var deniedUpdate = await client.PutAsJsonAsync( - "/api/tenant-content/scoreline/schools", + "/api/tenant/content/scoreline/schools", new DirectSchoolDto { Id = outsideSchool.Id, diff --git a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs index 85b3b18..9787b00 100644 --- a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs +++ b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Tiku.Api.Middleware; -using Tiku.Infrastructure.Backoffice; +using Tiku.Application.Backoffice; namespace Tiku.IntegrationTests.Api; diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs index b1a4913..39a6264 100644 --- a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -23,7 +23,7 @@ public sealed class LearningEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/learning/favorites/questions"); + using var response = await client.GetAsync("/api/student/learning/favorites/questions"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -39,7 +39,7 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); using var response = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -49,14 +49,14 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [1] }); using var submitResponse = await client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, ExpectedSessionVersion = 2, IdempotencyKey = "wrong-submit-1" }); - using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions"); + using var wrongResponse = await client.GetAsync("/api/student/learning/wrong-questions"); var answer = await ReadJsonAsync(response); var wrongItems = await ReadItemsAsync(wrongResponse); @@ -163,7 +163,7 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); var createResponse = await client.PostAsJsonAsync( - "/api/learning/practice-sessions", + "/api/student/learning/practice-sessions", new CreatePracticeSessionDto { Mode = "chapter", @@ -173,7 +173,7 @@ public sealed class LearningEndpointTests }); var created = await ReadJsonAsync(createResponse); var practiceSessionId = created.RootElement.GetProperty("id").GetGuid(); - var detailResponse = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); + var detailResponse = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); var detail = await ReadJsonAsync(detailResponse); var detailQuestions = detail.RootElement.GetProperty("questions").EnumerateArray().ToArray(); var firstSessionQuestionId = detailQuestions.Single(item => @@ -182,7 +182,7 @@ public sealed class LearningEndpointTests item.GetProperty("questionId").GetGuid() == secondQuestionId).GetProperty("sessionQuestionId").GetGuid(); var firstAnswerResponse = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = firstSessionQuestionId, @@ -192,7 +192,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [0] }); var secondAnswerResponse = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = secondSessionQuestionId, @@ -202,7 +202,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [1] }); var submitResponse = await client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId, @@ -210,9 +210,9 @@ public sealed class LearningEndpointTests IdempotencyKey = "practice-submit" }); var report = await ReadJsonAsync(submitResponse); - var reportResponse = await client.GetAsync($"/api/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); - var reportsResponse = await client.GetAsync("/api/learning/practice-reports"); - var historyResponse = await client.GetAsync("/api/learning/practice-sessions/history?status=finished"); + var reportResponse = await client.GetAsync($"/api/student/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); + var reportsResponse = await client.GetAsync("/api/student/learning/practice-reports"); + var historyResponse = await client.GetAsync("/api/student/learning/practice-sessions/history?status=finished"); var reports = await ReadItemsAsync(reportsResponse); var history = await ReadItemsAsync(historyResponse); @@ -252,14 +252,14 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); var addResponse = await client.PostAsJsonAsync( - "/api/learning/favorites/questions", + "/api/student/learning/favorites/questions", new QuestionActionDto { QuestionId = questionId }); - var listResponse = await client.GetAsync("/api/learning/favorites/questions"); + var listResponse = await client.GetAsync("/api/student/learning/favorites/questions"); var itemsAfterAdd = await ReadItemsAsync(listResponse); var removeResponse = await client.PostAsJsonAsync( - "/api/learning/favorites/questions", + "/api/student/learning/favorites/questions", new QuestionActionDto { QuestionId = questionId, Favorite = false }); - var emptyResponse = await client.GetAsync("/api/learning/favorites/questions"); + var emptyResponse = await client.GetAsync("/api/student/learning/favorites/questions"); var itemsAfterRemove = await ReadItemsAsync(emptyResponse); Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode); @@ -288,14 +288,14 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); using var updateResponse = await client.PostAsJsonAsync( - "/api/learning/vocabulary/progress", + "/api/student/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, Status = "learning", CorrectDelta = 2 }); - using var listResponse = await client.GetAsync("/api/learning/vocabulary/progress?status=learning"); + using var listResponse = await client.GetAsync("/api/student/learning/vocabulary/progress?status=learning"); var progress = await ReadJsonAsync(updateResponse); var items = await ReadItemsAsync(listResponse); @@ -350,12 +350,12 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); await client.PostAsJsonAsync( - "/api/learning/vocabulary/favorites", + "/api/student/learning/vocabulary/favorites", new FavoriteWordDto { WordId = wordId, Note = "重点" }); await client.PostAsJsonAsync( - "/api/learning/vocabulary/favorites", + "/api/student/learning/vocabulary/favorites", new FavoriteWordDto { WordId = otherWordId }); - using var response = await client.GetAsync($"/api/learning/vocabulary/favorites?unitId={unitId}"); + using var response = await client.GetAsync($"/api/student/learning/vocabulary/favorites?unitId={unitId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -384,7 +384,7 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -394,7 +394,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [1] }); await client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, @@ -402,7 +402,7 @@ public sealed class LearningEndpointTests IdempotencyKey = "stats-wrong-submit" }); await client.PostAsJsonAsync( - "/api/learning/vocabulary/progress", + "/api/student/learning/vocabulary/progress", new WordProgressDto { WordId = wordId, @@ -411,15 +411,15 @@ public sealed class LearningEndpointTests NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); - var statsResponse = await client.GetAsync("/api/learning/stats"); - var trendResponse = await client.GetAsync("/api/learning/trend?limit=7"); - var leaderboardResponse = await client.GetAsync("/api/learning/leaderboard?limit=10"); - var wrongPlanResponse = await client.GetAsync("/api/learning/wrong-questions/review-plan"); - var wordPlanResponse = await client.GetAsync("/api/learning/vocabulary/review-plan"); + var statsResponse = await client.GetAsync("/api/student/learning/stats"); + var trendResponse = await client.GetAsync("/api/student/learning/trend?limit=7"); + var leaderboardResponse = await client.GetAsync("/api/student/learning/leaderboard?limit=10"); + var wrongPlanResponse = await client.GetAsync("/api/student/learning/wrong-questions/review-plan"); + var wordPlanResponse = await client.GetAsync("/api/student/learning/vocabulary/review-plan"); var reviewResponse = await client.PostAsJsonAsync( - "/api/learning/vocabulary/review", + "/api/student/learning/vocabulary/review", new WordReviewDto { WordId = wordId, Result = "correct" }); - var wordStatsResponse = await client.GetAsync("/api/learning/vocabulary/stats"); + var wordStatsResponse = await client.GetAsync("/api/student/learning/vocabulary/stats"); var stats = await ReadJsonAsync(statsResponse); var trend = await ReadItemsAsync(trendResponse); @@ -457,10 +457,10 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [1] }; - var first = await client.PostAsJsonAsync("/api/learning/answers", firstRequest); - var replay = await client.PostAsJsonAsync("/api/learning/answers", firstRequest); + var first = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest); + var replay = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest); var conflict = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -470,7 +470,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [0] }); var revision = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -480,7 +480,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [0] }); var stale = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -519,7 +519,7 @@ public sealed class LearningEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var before = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); + var before = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); using (var scope = factory.CreateSystemScope("Mutate source question version after session creation")) { var db = scope.ServiceProvider.GetRequiredService(); @@ -529,7 +529,7 @@ public sealed class LearningEndpointTests version.CorrectOptionIndex = 1; await db.SaveChangesAsync(); } - var after = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); + var after = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); var beforeQuestion = (await ReadJsonAsync(before)).RootElement.GetProperty("questions")[0]; var afterQuestion = (await ReadJsonAsync(after)).RootElement.GetProperty("questions")[0]; @@ -549,7 +549,7 @@ public sealed class LearningEndpointTests await LoginAsync(client, seed); var answer = await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -559,7 +559,7 @@ public sealed class LearningEndpointTests AnswerText = "student response" }); var submit = await client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, @@ -589,7 +589,7 @@ public sealed class LearningEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -601,7 +601,7 @@ public sealed class LearningEndpointTests var submissions = await Task.WhenAll( client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, @@ -609,7 +609,7 @@ public sealed class LearningEndpointTests IdempotencyKey = "concurrent-submit-a" }), client.PostAsJsonAsync( - "/api/learning/practice-sessions/submit", + "/api/student/learning/practice-sessions/submit", new SubmitPracticeSessionDto { PracticeSessionId = answerable.SessionId, @@ -635,7 +635,7 @@ public sealed class LearningEndpointTests var responses = await Task.WhenAll( client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, @@ -645,7 +645,7 @@ public sealed class LearningEndpointTests SelectedOptionIndices = [0] }), client.PostAsJsonAsync( - "/api/learning/answers", + "/api/student/learning/answers", new SubmitAnswerDto { SessionQuestionId = answerable.SessionQuestionId, diff --git a/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs b/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs index 7cfea09..596ea9c 100644 --- a/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs +++ b/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs @@ -18,7 +18,7 @@ public sealed class MonolithBackgroundProcessingTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/health/ready"); + var response = await client.GetAsync("/api/system/health/ready"); using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs index 13b4aed..1cc8aca 100644 --- a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs +++ b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs @@ -15,8 +15,8 @@ public sealed class OpenApiDocumentationTests var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/auth/login/password", out _)); - Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/catalog/regions", out _)); + Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/tenant/auth/login/password", out _)); + Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/public/catalog/regions", out _)); } [Fact] @@ -28,11 +28,37 @@ public sealed class OpenApiDocumentationTests using var response = await client.GetAsync("/openapi/v1.json"); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var operation = document.RootElement.GetProperty("paths") - .GetProperty("/api/platform-admin/approvals/{requestId}/approve") + .GetProperty("/api/platform/approvals/{requestId}/approve") .GetProperty("post"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("platform:approval:decide", operation.GetProperty("x-tiku-required-permission").GetString()); Assert.Equal("high", operation.GetProperty("x-tiku-risk-level").GetString()); } + + [Fact] + public async Task Openapi_uses_role_scoped_routes_without_legacy_prefixes() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/openapi/v1.json"); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var paths = document.RootElement.GetProperty("paths").EnumerateObject() + .Select(item => item.Name) + .ToArray(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(paths, path => path.StartsWith("/api/platform/", StringComparison.Ordinal)); + Assert.Contains(paths, path => path.StartsWith("/api/tenant/", StringComparison.Ordinal)); + Assert.Contains(paths, path => path.StartsWith("/api/student/", StringComparison.Ordinal)); + Assert.Contains(paths, path => path.StartsWith("/api/public/", StringComparison.Ordinal)); + Assert.Contains(paths, path => path.StartsWith("/api/system/", StringComparison.Ordinal)); + Assert.DoesNotContain(paths, path => + path.StartsWith("/api/platform-admin", StringComparison.Ordinal) || + path.StartsWith("/api/backoffice/", StringComparison.Ordinal) || + path.StartsWith("/api/tenant-admin", StringComparison.Ordinal) || + path.StartsWith("/api/tenant-content", StringComparison.Ordinal) || + path.StartsWith("/api/tenant-commerce", StringComparison.Ordinal)); + } } diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs index 9f446ef..2c8b9a6 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -34,7 +34,7 @@ public sealed class PlatformAdminEndpointTests adminClient.UseAccessToken(await adminClient.LoginAsPlatformAsync(administrator.Email)); var reset = await adminClient.PostAsJsonAsync( - $"/api/platform-admin/staff/{target.UserId}/password-reset", + $"/api/platform/staff/{target.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", @@ -43,10 +43,10 @@ public sealed class PlatformAdminEndpointTests Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); targetClient.UseAccessToken(targetTokens); - Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode); targetClient.DefaultRequestHeaders.Authorization = null; var login = await targetClient.PostAsJsonAsync( - "/api/auth/login/password", + "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Platform, @@ -88,36 +88,36 @@ public sealed class PlatformAdminEndpointTests client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); string[] endpoints = [ - "/api/backoffice/platform/bootstrap", - "/api/platform-admin/overview", - "/api/platform-admin/domains?limit=200", - "/api/platform-admin/saas/catalog", - "/api/platform-admin/tenants?limit=200", - "/api/platform-admin/saas/subscriptions?limit=200", - "/api/platform-admin/saas/orders?limit=200", - "/api/platform-admin/saas/refunds?limit=200", - "/api/platform-admin/saas/invoices?limit=200", - "/api/platform-admin/saas/payments?limit=200", - "/api/platform-admin/saas/usage?limit=200", - "/api/platform-admin/saas/invoices/reminders?limit=100", - "/api/platform-admin/question-banks?status=all", - "/api/platform-admin/staff?limit=200", - "/api/platform-admin/saas/dunning/channels?limit=100", - "/api/platform-admin/saas/dunning/events?limit=100", - "/api/platform-admin/audit-logs?limit=200", - "/api/platform-admin/audit-alerts?limit=100", - "/api/platform-admin/tenant-capabilities/crm/configs?limit=200", - "/api/platform-admin/tenant-capabilities/crm/leads?limit=200", - "/api/platform-admin/tenant-capabilities/crm/logs?limit=200", - "/api/platform-admin/tenant-capabilities/sms/channels?limit=200", - "/api/platform-admin/tenant-capabilities/sms/templates?limit=200", - "/api/platform-admin/tenant-capabilities/sms/logs?limit=200", - "/api/platform-admin/payment-settings/apps?limit=200", - "/api/platform-admin/payment-settings/channels?limit=200", - "/api/platform-admin/payment-settings/rebates/summary", - "/api/platform-admin/tenant-capabilities/payments/apps?limit=200", - "/api/platform-admin/payment-settings/events?limit=100", - "/api/platform-admin/tenant-capabilities/payments/events?limit=100" + "/api/platform/access/bootstrap", + "/api/platform/overview", + "/api/platform/domains?limit=200", + "/api/platform/saas/catalog", + "/api/platform/tenants?limit=200", + "/api/platform/saas/subscriptions?limit=200", + "/api/platform/saas/orders?limit=200", + "/api/platform/saas/refunds?limit=200", + "/api/platform/saas/invoices?limit=200", + "/api/platform/saas/payments?limit=200", + "/api/platform/saas/usage?limit=200", + "/api/platform/saas/invoices/reminders?limit=100", + "/api/platform/question-banks?status=all", + "/api/platform/staff?limit=200", + "/api/platform/saas/dunning/channels?limit=100", + "/api/platform/saas/dunning/events?limit=100", + "/api/platform/audit-logs?limit=200", + "/api/platform/audit-alerts?limit=100", + "/api/platform/tenant-capabilities/crm/configs?limit=200", + "/api/platform/tenant-capabilities/crm/leads?limit=200", + "/api/platform/tenant-capabilities/crm/logs?limit=200", + "/api/platform/tenant-capabilities/sms/channels?limit=200", + "/api/platform/tenant-capabilities/sms/templates?limit=200", + "/api/platform/tenant-capabilities/sms/logs?limit=200", + "/api/platform/payment-settings/apps?limit=200", + "/api/platform/payment-settings/channels?limit=200", + "/api/platform/payment-settings/rebates/summary", + "/api/platform/tenant-capabilities/payments/apps?limit=200", + "/api/platform/payment-settings/events?limit=100", + "/api/platform/tenant-capabilities/payments/events?limit=100" ]; var responses = await Task.WhenAll(endpoints.Select(async endpoint => @@ -173,10 +173,10 @@ public sealed class PlatformAdminEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); - var overview = await client.GetAsync("/api/platform-admin/overview"); - var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a"); + var overview = await client.GetAsync("/api/platform/overview"); + var tenants = await client.GetAsync("/api/platform/tenants?search=six-a"); var feature = await client.PutAsJsonAsync( - "/api/platform-admin/saas/features", + "/api/platform/saas/features", new UpsertSaasFeatureDto( null, SaasFeatureCatalog.Exam, @@ -188,7 +188,7 @@ public sealed class PlatformAdminEndpointTests SaasFeatureStatus.Active, 30)); var offering = await client.PutAsJsonAsync( - "/api/platform-admin/saas/offerings", + "/api/platform/saas/offerings", new UpsertSaasOfferingDto( null, $"standard-{Guid.NewGuid():N}", @@ -212,17 +212,17 @@ public sealed class PlatformAdminEndpointTests [SaasFeatureCatalog.Exam], new Dictionary(), JsonDefaults.Object()); - var version = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest); + var version = await client.PutAsJsonAsync("/api/platform/saas/offering-versions", versionRequest); Assert.Equal(HttpStatusCode.OK, version.StatusCode); var versionJson = await JsonDocument.ParseAsync(await version.Content.ReadAsStreamAsync()); var versionId = versionJson.RootElement.GetProperty("id").GetGuid(); - var published = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null); + var published = await client.PostAsync($"/api/platform/saas/offering-versions/{versionId}/publish", null); var immutableUpdate = await client.PutAsJsonAsync( - "/api/platform-admin/saas/offering-versions", + "/api/platform/saas/offering-versions", versionRequest with { Id = versionId, AmountCents = 50_000 }); - var catalog = await client.GetAsync("/api/platform-admin/saas/catalog"); - var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null); - using var suspendRequest = new HttpRequestMessage(HttpMethod.Patch, "/api/platform-admin/tenants/status") + var catalog = await client.GetAsync("/api/platform/saas/catalog"); + var recheck = await client.PostAsync($"/api/platform/domains/{domainId}/recheck", null); + using var suspendRequest = new HttpRequestMessage(HttpMethod.Patch, "/api/platform/tenants/status") { Content = JsonContent.Create(new UpdatePlatformTenantStatusDto { @@ -234,7 +234,7 @@ public sealed class PlatformAdminEndpointTests suspendRequest.Headers.Add("Idempotency-Key", $"suspend-{tenantId:N}"); var suspended = await client.SendAsync(suspendRequest); - using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); + using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/public/runtime/bootstrap"); runtimeRequest.Headers.Host = "six-a.example.test"; var runtimeAfterSuspend = await client.SendAsync(runtimeRequest); @@ -299,7 +299,7 @@ public sealed class PlatformAdminEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); - var response = await client.GetAsync("/api/platform-admin/saas/catalog"); + var response = await client.GetAsync("/api/platform/saas/catalog"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -324,7 +324,7 @@ public sealed class PlatformAdminEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); - var crm = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto + var crm = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto { TenantId = tenantA, Enabled = true, @@ -334,8 +334,8 @@ public sealed class PlatformAdminEndpointTests AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }), AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 }) }); - var retry = await client.PostAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); - var smsChannel = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto + var retry = await client.PostAsJsonAsync("/api/platform/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); + var smsChannel = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto { TenantId = tenantA, Provider = "aliyun", @@ -348,7 +348,7 @@ public sealed class PlatformAdminEndpointTests }); var smsJson = JsonDocument.Parse(await smsChannel.Content.ReadAsStringAsync()); var channelId = smsJson.RootElement.GetProperty("id").GetGuid(); - var template = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto + var template = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto { TenantId = tenantA, ChannelId = channelId, @@ -359,14 +359,14 @@ public sealed class PlatformAdminEndpointTests Status = SmsTemplateStatus.Active, Content = "验证码 ${code}" }); - var paymentApp = await client.PutAsJsonAsync("/api/platform-admin/payment-settings/apps", new UpsertPlatformPaymentAppDto + var paymentApp = await client.PutAsJsonAsync("/api/platform/payment-settings/apps", new UpsertPlatformPaymentAppDto { AppCode = "platform_collect_test", AppName = "平台收款测试", Status = PlatformPaymentAppStatus.Active, SettlementMode = "PlatformCollect" }); - var tenantPayment = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto + var tenantPayment = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto { TenantId = tenantA, Provider = "manual", @@ -375,12 +375,12 @@ public sealed class PlatformAdminEndpointTests SecretRef = "tenant_payment:manual:redacted" }); var reads = await Task.WhenAll( - client.GetAsync("/api/platform-admin/tenant-capabilities/crm/configs"), - client.GetAsync("/api/platform-admin/tenant-capabilities/crm/leads"), - client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"), - client.GetAsync("/api/platform-admin/tenant-capabilities/sms/templates"), - client.GetAsync("/api/platform-admin/payment-settings/apps"), - client.GetAsync("/api/platform-admin/tenant-capabilities/payments/apps")); + client.GetAsync("/api/platform/tenant-capabilities/crm/configs"), + client.GetAsync("/api/platform/tenant-capabilities/crm/leads"), + client.GetAsync("/api/platform/tenant-capabilities/sms/channels"), + client.GetAsync("/api/platform/tenant-capabilities/sms/templates"), + client.GetAsync("/api/platform/payment-settings/apps"), + client.GetAsync("/api/platform/tenant-capabilities/payments/apps")); Assert.Equal(HttpStatusCode.OK, crm.StatusCode); Assert.Equal(HttpStatusCode.OK, retry.StatusCode); @@ -417,8 +417,8 @@ public sealed class PlatformAdminEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); - var read = await client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"); - var write = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto + var read = await client.GetAsync("/api/platform/tenant-capabilities/sms/channels"); + var write = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto { TenantId = tenantId, Provider = "aliyun", @@ -489,7 +489,7 @@ public sealed class PlatformAdminEndpointTests client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var upsertResponse = await client.PutAsJsonAsync( - "/api/platform-admin/saas/dunning/channels", + "/api/platform/saas/dunning/channels", new UpsertPlatformBillingDunningChannelDto { ChannelCode = "wecom-overdue", @@ -535,24 +535,24 @@ public sealed class PlatformAdminEndpointTests RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 }) }); - var channelsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/channels?search=wecom"); - var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/events?status=failed"); - var detailResponse = await client.GetAsync($"/api/platform-admin/saas/dunning/events/detail?eventId={eventId}"); + var channelsResponse = await client.GetAsync("/api/platform/saas/dunning/channels?search=wecom"); + var eventsResponse = await client.GetAsync("/api/platform/saas/dunning/events?status=failed"); + var detailResponse = await client.GetAsync($"/api/platform/saas/dunning/events/detail?eventId={eventId}"); var retryResponse = await client.PostAsJsonAsync( - "/api/platform-admin/saas/dunning/events/retry", + "/api/platform/saas/dunning/events/retry", new RetryPlatformBillingDunningEventDto { EventId = eventId, Reason = "manual retry" }); var acknowledgeResponse = await client.PostAsJsonAsync( - "/api/platform-admin/saas/dunning/events/acknowledge", + "/api/platform/saas/dunning/events/acknowledge", new ResolvePlatformBillingDunningEventDto { EventId = eventId, Reason = "delivery confirmed manually" }); var ignoreResponse = await client.PostAsJsonAsync( - "/api/platform-admin/saas/dunning/events/ignore", + "/api/platform/saas/dunning/events/ignore", new ResolvePlatformBillingDunningEventDto { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); var disableResponse = await client.PostAsJsonAsync( - "/api/platform-admin/saas/dunning/channels/disable", + "/api/platform/saas/dunning/channels/disable", new DisablePlatformBillingDunningChannelDto { ChannelId = channelId, @@ -651,8 +651,8 @@ public sealed class PlatformAdminEndpointTests }; var responses = await Task.WhenAll( - client.PostAsJsonAsync("/api/platform-admin/tenants", request), - client.PostAsJsonAsync("/api/platform-admin/tenants", request)); + client.PostAsJsonAsync("/api/platform/tenants", request), + client.PostAsJsonAsync("/api/platform/tenants", request)); var failedProvisioning = await Task.WhenAll(responses .Where(response => response.StatusCode != HttpStatusCode.OK) .Select(async response => $"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}")); @@ -681,13 +681,13 @@ public sealed class PlatformAdminEndpointTests CollectionMode = request.CollectionMode, RenewalLeadDays = request.RenewalLeadDays }; - var conflict = await client.PostAsJsonAsync("/api/platform-admin/tenants", conflictingRequest); + var conflict = await client.PostAsJsonAsync("/api/platform/tenants", conflictingRequest); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict)); var tenantId = tenantIds[0]; using (var pendingIssueRequest = new HttpRequestMessage( - HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links") + HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { @@ -716,7 +716,7 @@ public sealed class PlatformAdminEndpointTests using var browserClient = factory.CreateClient(new() { HandleCookies = false }); using (var setupRuntimeRequest = new HttpRequestMessage( - HttpMethod.Get, $"https://{primaryHost}/api/runtime/bootstrap")) + HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var setupRuntime = await browserClient.SendAsync(setupRuntimeRequest); using var setupPayload = await JsonDocument.ParseAsync(await setupRuntime.Content.ReadAsStreamAsync()); @@ -728,7 +728,7 @@ public sealed class PlatformAdminEndpointTests var issueRequests = issueKeys.Select(issueKey => { var issueRequest = new HttpRequestMessage( - HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links") + HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { @@ -753,7 +753,7 @@ public sealed class PlatformAdminEndpointTests var firstActivationToken = new Uri(activationUrl).Fragment["#token=".Length..]; using var replayIssueRequest = new HttpRequestMessage( - HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links") + HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Secure tenant handoff" }) }; @@ -765,7 +765,7 @@ public sealed class PlatformAdminEndpointTests Assert.Equal(JsonValueKind.Null, replayPayload.RootElement.GetProperty("activationUrl").ValueKind); using var replaceIssueRequest = new HttpRequestMessage( - HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links") + HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { @@ -782,7 +782,7 @@ public sealed class PlatformAdminEndpointTests var activationToken = new Uri(replacementUrl).Fragment["#token=".Length..]; using (var revokedRequest = new HttpRequestMessage( - HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete") + HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { @@ -799,7 +799,7 @@ public sealed class PlatformAdminEndpointTests } using var invalidPasswordRequest = new HttpRequestMessage( - HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete") + HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { @@ -813,7 +813,7 @@ public sealed class PlatformAdminEndpointTests Assert.Equal(HttpStatusCode.BadRequest, invalidPassword.StatusCode); using var activationRequest = new HttpRequestMessage( - HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete") + HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { @@ -830,7 +830,7 @@ public sealed class PlatformAdminEndpointTests accessCookie = accessCookie[..accessCookie.IndexOf(';')]; using (var readyRuntimeRequest = new HttpRequestMessage( - HttpMethod.Get, $"https://{primaryHost}/api/runtime/bootstrap")) + HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var readyRuntime = await browserClient.SendAsync(readyRuntimeRequest); using var readyPayload = await JsonDocument.ParseAsync(await readyRuntime.Content.ReadAsStreamAsync()); @@ -839,7 +839,7 @@ public sealed class PlatformAdminEndpointTests } using var bootstrapRequest = new HttpRequestMessage( - HttpMethod.Get, $"https://{primaryHost}/api/backoffice/tenant/ui-bootstrap"); + HttpMethod.Get, $"https://{primaryHost}/api/tenant/access/ui-bootstrap"); bootstrapRequest.Headers.Add("Cookie", accessCookie); bootstrapRequest.Headers.Add("Origin", $"https://{primaryHost}"); var bootstrap = await browserClient.SendAsync(bootstrapRequest); @@ -848,7 +848,7 @@ public sealed class PlatformAdminEndpointTests $"Expected UI bootstrap success, got {(int)bootstrap.StatusCode}: {await bootstrap.Content.ReadAsStringAsync()}"); using var consumedRequest = new HttpRequestMessage( - HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete") + HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { @@ -862,7 +862,7 @@ public sealed class PlatformAdminEndpointTests Assert.Equal(HttpStatusCode.Conflict, consumed.StatusCode); Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(consumed)); - var policy = await client.GetAsync($"/api/platform-admin/tenants/{tenantIds[0]}/billing-policy"); + var policy = await client.GetAsync($"/api/platform/tenants/{tenantIds[0]}/billing-policy"); Assert.Equal(HttpStatusCode.OK, policy.StatusCode); using var policyPayload = await JsonDocument.ParseAsync(await policy.Content.ReadAsStreamAsync()); Assert.Equal(21, policyPayload.RootElement.GetProperty("renewalLeadDays").GetInt32()); diff --git a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs index f47e073..6eb7bb4 100644 --- a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs @@ -48,7 +48,7 @@ public sealed class PlatformApprovalEndpointTests using var requesterClient = factory.CreateClient(); requesterClient.UseAccessToken(await requesterClient.LoginAsPlatformAsync(requester.Email)); - using var archive = new HttpRequestMessage(HttpMethod.Patch, "/api/platform-admin/tenants/status") + using var archive = new HttpRequestMessage(HttpMethod.Patch, "/api/platform/tenants/status") { Content = JsonContent.Create(new UpdatePlatformTenantStatusDto { TenantId = tenant.Id, Status = TenantStatus.Archived, Reason = "Close expired customer" }) }; @@ -58,12 +58,12 @@ public sealed class PlatformApprovalEndpointTests var body = await submission.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(body?.ApprovalRequest); - var selfApproval = await requesterClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Self approval")); + var selfApproval = await requesterClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Self approval")); Assert.Equal(HttpStatusCode.Conflict, selfApproval.StatusCode); using var approverClient = factory.CreateClient(); approverClient.UseAccessToken(await approverClient.LoginAsPlatformAsync(approver.Email)); - var approval = await approverClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Independent verification complete")); + var approval = await approverClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Independent verification complete")); Assert.Equal(HttpStatusCode.OK, approval.StatusCode); var approved = await approval.Content.ReadFromJsonAsync(JsonOptions); Assert.Equal(PlatformApprovalRequestStatus.Approved, approved?.Status); @@ -74,7 +74,7 @@ public sealed class PlatformApprovalEndpointTests Assert.Equal(1, await processor.ProcessApprovedAsync()); } - var replay = await approverClient.PostAsJsonAsync($"/api/platform-admin/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Replay")); + var replay = await approverClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Replay")); Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode); using var scope = factory.CreateSystemScope("Verify platform approval execution"); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs b/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs index c10f9d4..8e4b33d 100644 --- a/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs @@ -29,8 +29,8 @@ public sealed class PlatformBillingCallbackTests await PublishVersionAsync(factory, fixture.VersionId); using var client = factory.CreateClient(); - var first = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); - var repeated = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); + var first = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); + var repeated = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, repeated.StatusCode); @@ -65,7 +65,7 @@ public sealed class PlatformBillingCallbackTests using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( - "/api/platform-billing/callbacks/wechat_pay", + "/api/integrations/platform-billing/callbacks/wechat_pay", new { eventId = invalid.EventId }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -85,7 +85,7 @@ public sealed class PlatformBillingCallbackTests using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( - "/api/platform-billing/callbacks/alipay", + "/api/integrations/platform-billing/callbacks/alipay", new { eventId = mismatched.EventId }); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs index 7737775..1d648c5 100644 --- a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs @@ -44,27 +44,27 @@ public sealed class PlatformQuestionBankEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformUser.Email)); - var bankResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); + var bankResponse = await client.PutAsJsonAsync("/api/platform/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); Assert.True(bankResponse.StatusCode == HttpStatusCode.OK, await bankResponse.Content.ReadAsStringAsync()); var bank = await bankResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(bank); Assert.NotNull(bank.ContentEntryId); - var nodeResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand( + var nodeResponse = await client.PutAsJsonAsync("/api/platform/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand( null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, JsonDefaults.Object())); Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); var node = await nodeResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(node); var createQuestion = QuestionCommand(null, bank.Id, node.Id, "platform-question-1", "函数极限的定义是什么?"); - var questionResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/questions", createQuestion); + var questionResponse = await client.PutAsJsonAsync("/api/platform/question-banks/questions", createQuestion); Assert.Equal(HttpStatusCode.OK, questionResponse.StatusCode); var question = await questionResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(question); Assert.Equal(1, question.VersionNo); var updatedResponse = await client.PutAsJsonAsync( - "/api/platform-admin/question-banks/questions", + "/api/platform/question-banks/questions", createQuestion with { Id = question.Id, Content = "请说明函数极限的严格定义。" }); Assert.Equal(HttpStatusCode.OK, updatedResponse.StatusCode); var updated = await updatedResponse.Content.ReadFromJsonAsync(JsonOptions); @@ -82,7 +82,7 @@ public sealed class PlatformQuestionBankEndpointTests new { legacyId = "invalid-1", type = "choice", content = "" } })); var jobsBeforePreview = await CountImportJobsAsync(factory, platformTenantId); - var previewResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports/preview", import); + var previewResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports/preview", import); Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(preview); @@ -91,8 +91,8 @@ public sealed class PlatformQuestionBankEndpointTests Assert.Equal(1, preview.Detail.Job.ErrorCount); Assert.Equal(jobsBeforePreview, await CountImportJobsAsync(factory, platformTenantId)); - var firstImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); - var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); + var firstImportResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", import); + var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", import); Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, await firstImportResponse.Content.ReadAsStringAsync()); Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, await repeatedImportResponse.Content.ReadAsStringAsync()); var firstImport = await firstImportResponse.Content.ReadFromJsonAsync(JsonOptions); @@ -131,8 +131,8 @@ public sealed class PlatformQuestionBankEndpointTests } } })); - var structuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); - var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); + var structuredResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", structuredImport); + var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", structuredImport); Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, await structuredResponse.Content.ReadAsStringAsync()); Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, await repeatedStructuredResponse.Content.ReadAsStringAsync()); var structured = await structuredResponse.Content.ReadFromJsonAsync(JsonOptions); @@ -144,13 +144,13 @@ public sealed class PlatformQuestionBankEndpointTests Assert.Equal(0, repeatedStructured.CreatedNodeCount); Assert.Equal(1, repeatedStructured.SkippedQuestionCount); - var listedResponse = await client.GetAsync($"/api/platform-admin/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); + var listedResponse = await client.GetAsync($"/api/platform/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); Assert.Equal(HttpStatusCode.OK, listedResponse.StatusCode); var listed = await listedResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(listed); Assert.Equal(3, listed.Total); - var bankList = await client.GetFromJsonAsync("/api/platform-admin/question-banks?status=all", JsonOptions); + var bankList = await client.GetFromJsonAsync("/api/platform/question-banks?status=all", JsonOptions); Assert.NotNull(bankList); Assert.Contains(bankList, item => item.Id == bank.Id); Assert.DoesNotContain(bankList, item => item.Id == ordinaryBankId); @@ -175,11 +175,11 @@ public sealed class PlatformQuestionBankEndpointTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformWithoutPermission.Email)); - using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); + using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform/question-banks"); wrongHostRequest.Headers.Host = "tenant.example.test"; var wrongHost = await client.SendAsync(wrongHostRequest); - using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); + using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform/question-banks"); platformHostRequest.Headers.Host = "platform.example.test"; var missingPermission = await client.SendAsync(platformHostRequest); diff --git a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs index 05b52a9..10619c1 100644 --- a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs @@ -24,7 +24,7 @@ public sealed class PointsEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/points/summary"); + var response = await client.GetAsync("/api/student/points/summary"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -53,7 +53,7 @@ public sealed class PointsEndpointTests new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()) ])); - var response = await client.GetAsync("/api/points/summary"); + var response = await client.GetAsync("/api/student/points/summary"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -68,7 +68,7 @@ public sealed class PointsEndpointTests var sourceId = Guid.NewGuid(); var first = await client.PostAsJsonAsync( - "/api/points/tasks/claim", + "/api/student/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "daily_login", @@ -76,7 +76,7 @@ public sealed class PointsEndpointTests SourceId = sourceId }); var second = await client.PostAsJsonAsync( - "/api/points/tasks/claim", + "/api/student/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "daily_login", @@ -85,10 +85,10 @@ public sealed class PointsEndpointTests }); var firstClaim = await first.Content.ReadFromJsonAsync(); var secondClaim = await second.Content.ReadFromJsonAsync(); - var summary = await (await client.GetAsync("/api/points/summary")) + var summary = await (await client.GetAsync("/api/student/points/summary")) .Content .ReadFromJsonAsync(); - var tasks = await (await client.GetAsync("/api/points/tasks")) + var tasks = await (await client.GetAsync("/api/student/points/tasks")) .Content .ReadFromJsonAsync>(); @@ -109,7 +109,7 @@ public sealed class PointsEndpointTests await LoginAsync(client, seed); var response = await client.PostAsJsonAsync( - "/api/points/exchange-orders", + "/api/student/points/exchange-orders", new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); @@ -123,20 +123,20 @@ public sealed class PointsEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( - "/api/points/tasks/claim", + "/api/student/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "practice_reward", SourceType = "practice", SourceId = Guid.NewGuid() }); - var items = await (await client.GetAsync("/api/points/exchange-items")) + var items = await (await client.GetAsync("/api/student/points/exchange-items")) .Content .ReadFromJsonAsync>(); var response = await client.PostAsJsonAsync( - "/api/points/exchange-orders", + "/api/student/points/exchange-orders", new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); var order = await response.Content.ReadFromJsonAsync(); - var orders = await (await client.GetAsync("/api/points/exchange-orders")) + var orders = await (await client.GetAsync("/api/student/points/exchange-orders")) .Content .ReadFromJsonAsync>(); - var summary = await (await client.GetAsync("/api/points/summary")) + var summary = await (await client.GetAsync("/api/student/points/summary")) .Content .ReadFromJsonAsync(); diff --git a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs index f455c93..9ba7494 100644 --- a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs @@ -39,10 +39,10 @@ public sealed class ProfileEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var getResponse = await client.GetAsync("/api/profile/me"); + using var getResponse = await client.GetAsync("/api/student/profile/me"); var getJson = await ReadJsonAsync(getResponse); using var patchResponse = await client.PatchAsJsonAsync( - "/api/profile/me", + "/api/student/profile/me", new UpdateProfileDto { Name = "张三", @@ -100,7 +100,7 @@ public sealed class ProfileEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var response = await client.GetAsync("/api/profile/exam-countdowns"); + using var response = await client.GetAsync("/api/student/profile/exam-countdowns"); var json = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -115,7 +115,7 @@ public sealed class ProfileEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/profile/me"); + using var response = await client.GetAsync("/api/student/profile/me"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -161,15 +161,15 @@ public sealed class ProfileEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var notificationsResponse = await client.GetAsync("/api/profile/notifications"); + var notificationsResponse = await client.GetAsync("/api/student/profile/notifications"); var notificationsJson = await ReadJsonAsync(notificationsResponse); var statusResponse = await client.PostAsJsonAsync( - "/api/profile/notifications/status", + "/api/student/profile/notifications/status", new NotificationStatusDto { NotificationIds = [notificationId], Status = "read" }); - var badgesResponse = await client.GetAsync("/api/profile/badges?includeLocked=true"); + var badgesResponse = await client.GetAsync("/api/student/profile/badges?includeLocked=true"); var badgesJson = await ReadJsonAsync(badgesResponse); var feedbackResponse = await client.PostAsJsonAsync( - "/api/profile/feedbacks", + "/api/student/profile/feedbacks", new SubmitFeedbackDto { Type = "suggestion", @@ -178,7 +178,7 @@ public sealed class ProfileEndpointTests Priority = "normal" }); var feedbackJson = await ReadJsonAsync(feedbackResponse); - var feedbacksResponse = await client.GetAsync("/api/profile/feedbacks?type=suggestion"); + var feedbacksResponse = await client.GetAsync("/api/student/profile/feedbacks?type=suggestion"); var feedbacksJson = await ReadJsonAsync(feedbacksResponse); Assert.Equal(HttpStatusCode.OK, notificationsResponse.StatusCode); @@ -220,11 +220,11 @@ public sealed class ProfileEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var firstResponse = await client.PostAsync("/api/profile/check-in", null); + var firstResponse = await client.PostAsync("/api/student/profile/check-in", null); var first = await firstResponse.Content.ReadFromJsonAsync(JsonOptions); - var secondResponse = await client.PostAsync("/api/profile/check-in", null); + var secondResponse = await client.PostAsync("/api/student/profile/check-in", null); var second = await secondResponse.Content.ReadFromJsonAsync(JsonOptions); - var eventsResponse = await client.GetAsync("/api/profile/score-events?sourceType=daily_check_in"); + var eventsResponse = await client.GetAsync("/api/student/profile/score-events?sourceType=daily_check_in"); var events = await eventsResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); diff --git a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs index cb064fa..b0c66d3 100644 --- a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs @@ -43,7 +43,7 @@ public sealed class QuestionBankEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/catalog/question-banks?tenantCode=master"); + using var response = await client.GetAsync("/api/public/catalog/question-banks?tenantCode=master"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -115,7 +115,7 @@ public sealed class QuestionBankEndpointTests using var client = factory.CreateClient(); using var response = await client.GetAsync( - $"/api/catalog/questions?tenantCode=master&questionBankId={bankId}&subjectId={subjectId}&categoryId={categoryId}&collectionId={collectionId}&type=choice&keyword=关键词"); + $"/api/public/catalog/questions?tenantCode=master&questionBankId={bankId}&subjectId={subjectId}&categoryId={categoryId}&collectionId={collectionId}&type=choice&keyword=关键词"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -143,7 +143,7 @@ public sealed class QuestionBankEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/questions/{questionId}?tenantCode=master"); + using var response = await client.GetAsync($"/api/public/catalog/questions/{questionId}?tenantCode=master"); var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -184,7 +184,7 @@ public sealed class QuestionBankEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/questions/{questionId}/versions?tenantCode=master"); + using var response = await client.GetAsync($"/api/public/catalog/questions/{questionId}/versions?tenantCode=master"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs index 663784e..a2196ef 100644 --- a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -22,7 +22,7 @@ public sealed class ReferralEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto()); + var response = await client.PostAsJsonAsync("/api/student/referral/invite-code", new ReferralInviteDto()); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -35,8 +35,8 @@ public sealed class ReferralEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed.Referrer); - var first = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto { Channel = "h5" }); - var second = await client.PostAsJsonAsync("/api/referral/invite-code", new ReferralInviteDto()); + var first = await client.PostAsJsonAsync("/api/student/referral/invite-code", new ReferralInviteDto { Channel = "h5" }); + var second = await client.PostAsJsonAsync("/api/student/referral/invite-code", new ReferralInviteDto()); var firstItem = await first.Content.ReadFromJsonAsync(); var secondItem = await second.Content.ReadFromJsonAsync(); @@ -53,10 +53,10 @@ public sealed class ReferralEndpointTests using var client = factory.CreateClient(); var resolve = await client.PostAsJsonAsync( - "/api/referral/resolve", + "/api/student/referral/resolve", new ResolveReferralDto { TenantCode = seed.TenantCode, Code = seed.ReferralCode }); var track = await client.PostAsJsonAsync( - "/api/referral/track-event", + "/api/student/referral/track-event", new TrackReferralEventDto { TenantCode = seed.TenantCode, @@ -88,13 +88,13 @@ public sealed class ReferralEndpointTests await LoginAsync(client, seed.Student); var bind = await client.PostAsJsonAsync( - "/api/referral/bind", + "/api/student/referral/bind", new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" }); var repeatBind = await client.PostAsJsonAsync( - "/api/referral/bind", + "/api/student/referral/bind", new BindReferralDto { RefCode = seed.ReferralCode, Source = "miniapp" }); var qrcode = await client.PostAsJsonAsync( - "/api/referral/qrcode", + "/api/student/referral/qrcode", new ReferralQrcodeDto { Page = "pages/home/index", Provider = "wechat-miniapp" }); var bindItem = await bind.Content.ReadFromJsonAsync(); var repeatItem = await repeatBind.Content.ReadFromJsonAsync(); @@ -132,7 +132,7 @@ public sealed class ReferralEndpointTests using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( - "/api/referral/resolve", + "/api/student/referral/resolve", new ResolveReferralDto { Code = "ABC12345" }); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -146,7 +146,7 @@ public sealed class ReferralEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); - var response = await client.GetAsync("/api/referral/stats"); + var response = await client.GetAsync("/api/tenant/referral/stats"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -160,7 +160,7 @@ public sealed class ReferralEndpointTests await LoginAsync(client, seed.Admin); var manualBind = await client.PostAsJsonAsync( - "/api/referral/manual-bind", + "/api/tenant/referral/manual-bind", new ManualBindReferralDto { StudentUserId = seed.Student.UserId, @@ -169,7 +169,7 @@ public sealed class ReferralEndpointTests Force = true }); var team = await client.PutAsJsonAsync( - "/api/referral/team", + "/api/tenant/referral/team", new UpsertReferralTeamDto { MemberUserId = seed.Referrer.UserId, @@ -189,11 +189,11 @@ public sealed class ReferralEndpointTests PaidAt = DateTimeOffset.UtcNow }); - var stats = await client.GetAsync($"/api/referral/stats?referrerUserId={seed.Referrer.UserId}"); - var salesStats = await client.GetAsync("/api/referral/sales-stats"); - var conversion = await client.GetAsync("/api/referral/conversion-report?days=30"); - var clients = await client.GetAsync($"/api/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); - var teamList = await client.GetAsync($"/api/referral/team?leaderUserId={seed.Admin.UserId}"); + var stats = await client.GetAsync($"/api/tenant/referral/stats?referrerUserId={seed.Referrer.UserId}"); + var salesStats = await client.GetAsync("/api/tenant/referral/sales-stats"); + var conversion = await client.GetAsync("/api/tenant/referral/conversion-report?days=30"); + var clients = await client.GetAsync($"/api/tenant/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); + var teamList = await client.GetAsync($"/api/tenant/referral/team?leaderUserId={seed.Admin.UserId}"); var statsItem = await stats.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, manualBind.StatusCode); diff --git a/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs b/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs index 198065b..d0b3aea 100644 --- a/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs @@ -100,20 +100,20 @@ public sealed class SaasBillingLifecycleTests $"trial-{Guid.NewGuid():N}", "integration trial grant"); - var trial = await client.PostAsJsonAsync("/api/platform-admin/saas/subscriptions/trial", trialRequest); - var repeatedTrial = await client.PostAsJsonAsync("/api/platform-admin/saas/subscriptions/trial", trialRequest); + var trial = await client.PostAsJsonAsync("/api/platform/saas/subscriptions/trial", trialRequest); + var repeatedTrial = await client.PostAsJsonAsync("/api/platform/saas/subscriptions/trial", trialRequest); Assert.Equal(HttpStatusCode.OK, trial.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedTrial.StatusCode); var subscriptionId = await ReadGuidAsync(trial, "id"); Assert.Equal(subscriptionId, await ReadGuidAsync(repeatedTrial, "id")); var duplicateTrial = await client.PostAsJsonAsync( - "/api/platform-admin/saas/subscriptions/trial", + "/api/platform/saas/subscriptions/trial", trialRequest with { IdempotencyKey = $"trial-duplicate-{Guid.NewGuid():N}" }); Assert.Equal(HttpStatusCode.Conflict, duplicateTrial.StatusCode); Assert.Equal("tenant_saas_subscription_exists", await ReadCodeAsync(duplicateTrial)); var suspended = await client.PostAsJsonAsync( - $"/api/platform-admin/saas/subscriptions/{subscriptionId}/suspend", + $"/api/platform/saas/subscriptions/{subscriptionId}/suspend", new ChangePlatformSubscriptionDto("integration suspension")); Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); await AssertSubscriptionProjectionAsync( @@ -124,7 +124,7 @@ public sealed class SaasBillingLifecycleTests BillingStatus.Suspended); var resumed = await client.PostAsJsonAsync( - $"/api/platform-admin/saas/subscriptions/{subscriptionId}/resume", + $"/api/platform/saas/subscriptions/{subscriptionId}/resume", new ChangePlatformSubscriptionDto("integration resume")); Assert.Equal(HttpStatusCode.OK, resumed.StatusCode); await AssertSubscriptionProjectionAsync( @@ -143,7 +143,7 @@ public sealed class SaasBillingLifecycleTests .SingleAsync(); } var extended = await client.PostAsJsonAsync( - $"/api/platform-admin/saas/subscriptions/{subscriptionId}/extend", + $"/api/platform/saas/subscriptions/{subscriptionId}/extend", new ChangePlatformSubscriptionDto("integration extension", 10)); Assert.Equal(HttpStatusCode.OK, extended.StatusCode); using (var scope = factory.CreateSystemScope("Verify subscription extension")) @@ -156,7 +156,7 @@ public sealed class SaasBillingLifecycleTests } var cancelled = await client.PostAsJsonAsync( - $"/api/platform-admin/saas/subscriptions/{subscriptionId}/cancel", + $"/api/platform/saas/subscriptions/{subscriptionId}/cancel", new ChangePlatformSubscriptionDto("integration cancellation")); Assert.Equal(HttpStatusCode.OK, cancelled.StatusCode); await AssertSubscriptionProjectionAsync( @@ -165,7 +165,7 @@ public sealed class SaasBillingLifecycleTests subscriptionId, TenantSaasSubscriptionStatus.Cancelled, BillingStatus.Cancelled); - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/platform-admin/saas/metrics")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/platform/saas/metrics")).StatusCode); } [Fact] @@ -177,7 +177,7 @@ public sealed class SaasBillingLifecycleTests client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var feature = await client.PutAsJsonAsync( - "/api/platform-admin/saas/features", + "/api/platform/saas/features", new UpsertSaasFeatureDto( null, SaasFeatureCatalog.PrivateQuestionBank, @@ -189,7 +189,7 @@ public sealed class SaasBillingLifecycleTests SaasFeatureStatus.Active, 10)); var limit = await client.PutAsJsonAsync( - "/api/platform-admin/saas/feature-limits", + "/api/platform/saas/feature-limits", new UpsertSaasFeatureLimitDto( null, SaasQuotaMetricCatalog.PrivateQuestionCount, @@ -200,7 +200,7 @@ public sealed class SaasBillingLifecycleTests 80, true)); var offering = await client.PutAsJsonAsync( - "/api/platform-admin/saas/offerings", + "/api/platform/saas/offerings", new UpsertSaasOfferingDto( null, $"private-bank-{Guid.NewGuid():N}", @@ -228,13 +228,13 @@ public sealed class SaasBillingLifecycleTests [SaasQuotaMetricCatalog.PrivateQuestionCount] = 10_000 }, JsonDefaults.Object()); - var draft = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest); + var draft = await client.PutAsJsonAsync("/api/platform/saas/offering-versions", versionRequest); Assert.Equal(HttpStatusCode.OK, draft.StatusCode); var versionId = await ReadGuidAsync(draft, "id"); - var publish = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null); + var publish = await client.PostAsync($"/api/platform/saas/offering-versions/{versionId}/publish", null); var mutatePublished = await client.PutAsJsonAsync( - "/api/platform-admin/saas/offering-versions", + "/api/platform/saas/offering-versions", versionRequest with { Id = versionId, AmountCents = 19_000 }); Assert.Equal(HttpStatusCode.OK, publish.StatusCode); @@ -279,7 +279,7 @@ public sealed class SaasBillingLifecycleTests using var tenantClient = factory.CreateClient(); tenantClient.UseAccessToken(await tenantClient.LoginAsTenantAsync(tenantA.TenantId, tenantA.Phone)); - var catalogResponse = await tenantClient.GetAsync("/api/tenant-billing/catalog"); + var catalogResponse = await tenantClient.GetAsync("/api/tenant/billing/catalog"); var quoteIdempotencyKey = $"quote-{Guid.NewGuid():N}"; var quoteRequest = new CreatePlatformBillingQuoteDto( catalog.VersionId, @@ -287,10 +287,10 @@ public sealed class SaasBillingLifecycleTests PlatformBillingOrderPurpose.NewSubscription, quoteIdempotencyKey); var quoteResponse = await tenantClient.PostAsJsonAsync( - "/api/tenant-billing/quotes", + "/api/tenant/billing/quotes", quoteRequest); var repeatedQuoteResponse = await tenantClient.PostAsJsonAsync( - "/api/tenant-billing/quotes", + "/api/tenant/billing/quotes", quoteRequest); Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, quoteResponse.StatusCode); @@ -300,10 +300,10 @@ public sealed class SaasBillingLifecycleTests var idempotencyKey = $"order-{Guid.NewGuid():N}"; var orderResponse = await tenantClient.PostAsJsonAsync( - "/api/tenant-billing/orders", + "/api/tenant/billing/orders", new CreatePlatformBillingOrderDto(quoteId, idempotencyKey)); var repeatedOrder = await tenantClient.PostAsJsonAsync( - "/api/tenant-billing/orders", + "/api/tenant/billing/orders", new CreatePlatformBillingOrderDto(quoteId, idempotencyKey)); Assert.Equal(HttpStatusCode.OK, orderResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedOrder.StatusCode); @@ -311,7 +311,7 @@ public sealed class SaasBillingLifecycleTests Assert.Equal(orderNo, await ReadStringAsync(repeatedOrder, "orderNo")); var paymentResponse = await tenantClient.PostAsJsonAsync( - $"/api/tenant-billing/orders/{orderNo}/payments", + $"/api/tenant/billing/orders/{orderNo}/payments", new CreatePlatformBillingPaymentDto( "manual", "bank_transfer", @@ -334,23 +334,23 @@ public sealed class SaasBillingLifecycleTests "integration test receipt"); platformClient.DefaultRequestHeaders.Add("Idempotency-Key", confirmationIdempotencyKey); var confirm = await platformClient.PostAsJsonAsync( - "/api/platform-admin/saas/payments/manual/confirm", + "/api/platform/saas/payments/manual/confirm", confirmation); var repeatedConfirm = await platformClient.PostAsJsonAsync( - "/api/platform-admin/saas/payments/manual/confirm", + "/api/platform/saas/payments/manual/confirm", confirmation); platformClient.DefaultRequestHeaders.Remove("Idempotency-Key"); Assert.True(confirm.StatusCode == HttpStatusCode.OK, await confirm.Content.ReadAsStringAsync()); Assert.True(repeatedConfirm.StatusCode == HttpStatusCode.OK, await repeatedConfirm.Content.ReadAsStringAsync()); - var subscription = await tenantClient.GetAsync("/api/tenant-billing/subscription"); + var subscription = await tenantClient.GetAsync("/api/tenant/billing/subscription"); Assert.Equal(HttpStatusCode.OK, subscription.StatusCode); Assert.Equal("Active", await ReadStringAsync(subscription, "status")); var tenantB = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-b"); using var tenantBClient = factory.CreateClient(); tenantBClient.UseAccessToken(await tenantBClient.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone)); - var crossTenantOrder = await tenantBClient.GetAsync($"/api/tenant-billing/orders/{orderNo}"); + var crossTenantOrder = await tenantBClient.GetAsync($"/api/tenant/billing/orders/{orderNo}"); Assert.Equal(HttpStatusCode.NotFound, crossTenantOrder.StatusCode); var refundKey = $"refund-{Guid.NewGuid():N}"; @@ -360,14 +360,14 @@ public sealed class SaasBillingLifecycleTests "integration test partial refund", refundKey, PlatformBillingRefundSubscriptionEffect.KeepService); - var refundResponse = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest); - var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest); + var refundResponse = await platformClient.PostAsJsonAsync("/api/platform/saas/refunds", refundRequest); + var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform/saas/refunds", refundRequest); Assert.Equal(HttpStatusCode.OK, refundResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedRefund.StatusCode); var refundId = await ReadSubmissionResultGuidAsync(refundResponse, "id"); Assert.Equal(refundId, await ReadSubmissionResultGuidAsync(repeatedRefund, "id")); var approved = await platformClient.PostAsJsonAsync( - $"/api/platform-admin/saas/refunds/{refundId}/approve", + $"/api/platform/saas/refunds/{refundId}/approve", new ReviewPlatformRefundDto("integration test approval")); Assert.Equal(HttpStatusCode.OK, approved.StatusCode); using (var processorScope = factory.CreateSystemScope("Execute approved SaaS refund")) @@ -385,7 +385,7 @@ public sealed class SaasBillingLifecycleTests .SingleAsync()); } var excessiveRefund = await platformClient.PostAsJsonAsync( - "/api/platform-admin/saas/refunds", + "/api/platform/saas/refunds", refundRequest with { AmountCents = paymentAmountCents, @@ -395,7 +395,7 @@ public sealed class SaasBillingLifecycleTests Assert.Equal("platform_billing_refund_amount_invalid", await ReadCodeAsync(excessiveRefund)); var finalRefund = await platformClient.PostAsJsonAsync( - "/api/platform-admin/saas/refunds", + "/api/platform/saas/refunds", refundRequest with { AmountCents = paymentAmountCents - paymentAmountCents / 2, @@ -405,7 +405,7 @@ public sealed class SaasBillingLifecycleTests Assert.Equal(HttpStatusCode.OK, finalRefund.StatusCode); var finalRefundId = await ReadSubmissionResultGuidAsync(finalRefund, "id"); var finalApproved = await platformClient.PostAsJsonAsync( - $"/api/platform-admin/saas/refunds/{finalRefundId}/approve", + $"/api/platform/saas/refunds/{finalRefundId}/approve", new ReviewPlatformRefundDto("integration test final approval")); Assert.Equal(HttpStatusCode.OK, finalApproved.StatusCode); using (var processorScope = factory.CreateSystemScope("Execute remaining SaaS refund")) @@ -414,7 +414,7 @@ public sealed class SaasBillingLifecycleTests .ProcessDueAsync(); Assert.True(processed >= 1); } - var tenantRefunds = await tenantClient.GetAsync("/api/tenant-billing/refunds"); + var tenantRefunds = await tenantClient.GetAsync("/api/tenant/billing/refunds"); Assert.Equal(HttpStatusCode.OK, tenantRefunds.StatusCode); using var scope = factory.CreateSystemScope("Verify SaaS billing settlement idempotency"); @@ -450,7 +450,7 @@ public sealed class SaasBillingLifecycleTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone)); var roleResponse = await client.PostAsJsonAsync( - "/api/backoffice/tenant/roles", + "/api/tenant/access/roles", new UpsertBackofficeRoleDto { Code = $"content_editor_{Guid.NewGuid():N}", @@ -462,7 +462,7 @@ public sealed class SaasBillingLifecycleTests var roleId = await ReadGuidAsync(roleResponse, "id"); var bind = await client.PutAsJsonAsync( - $"/api/backoffice/tenant/roles/{roleId}/bindings", + $"/api/tenant/access/roles/{roleId}/bindings", new ReplaceRoleBindingsDto { PermissionCodes = [BackendPermissions.TenantContentManage], @@ -515,7 +515,7 @@ public sealed class SaasBillingLifecycleTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone)); var roleResponse = await client.PostAsJsonAsync( - "/api/backoffice/tenant/roles", + "/api/tenant/access/roles", new UpsertBackofficeRoleDto { Code = $"video_editor_{Guid.NewGuid():N}", @@ -527,14 +527,14 @@ public sealed class SaasBillingLifecycleTests var roleId = await ReadGuidAsync(roleResponse, "id"); var videoBinding = await client.PutAsJsonAsync( - $"/api/backoffice/tenant/roles/{roleId}/bindings", + $"/api/tenant/access/roles/{roleId}/bindings", new ReplaceRoleBindingsDto { PermissionCodes = [BackendPermissions.TenantVideoManage], MenuCodes = [] }); var questionBinding = await client.PutAsJsonAsync( - $"/api/backoffice/tenant/roles/{roleId}/bindings", + $"/api/tenant/access/roles/{roleId}/bindings", new ReplaceRoleBindingsDto { PermissionCodes = [BackendPermissions.TenantContentManage], @@ -675,7 +675,7 @@ public sealed class SaasBillingLifecycleTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); - var response = await client.GetAsync("/api/tenant-onboarding/status"); + var response = await client.GetAsync("/api/tenant/onboarding/status"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); diff --git a/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs b/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs index e0c2f6d..77a8fee 100644 --- a/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs @@ -66,7 +66,7 @@ public sealed class ScorelineEndpointTests using var client = factory.CreateClient(); using var response = await client.GetAsync( - $"/api/scoreline/records?tenantCode={tenantCode}®ionId={regionId}&min.cultureScore=400"); + $"/api/public/scoreline/records?tenantCode={tenantCode}®ionId={regionId}&min.cultureScore=400"); var json = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -76,7 +76,7 @@ public sealed class ScorelineEndpointTests Assert.Equal(420, item.GetProperty("fieldValues").GetProperty("cultureScore").GetInt32()); using var firstCursorResponse = await client.GetAsync( - $"/api/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1"); + $"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1"); var firstCursorJson = await ReadJsonAsync(firstCursorResponse); Assert.Equal(HttpStatusCode.OK, firstCursorResponse.StatusCode); Assert.True(firstCursorJson.RootElement.GetProperty("hasMore").GetBoolean()); @@ -86,7 +86,7 @@ public sealed class ScorelineEndpointTests Assert.False(string.IsNullOrWhiteSpace(nextCursor)); using var secondCursorResponse = await client.GetAsync( - $"/api/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1&cursor={Uri.EscapeDataString(nextCursor!)}"); + $"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1&cursor={Uri.EscapeDataString(nextCursor!)}"); var secondCursorJson = await ReadJsonAsync(secondCursorResponse); Assert.Equal(HttpStatusCode.OK, secondCursorResponse.StatusCode); Assert.False(secondCursorJson.RootElement.GetProperty("hasMore").GetBoolean()); @@ -95,7 +95,7 @@ public sealed class ScorelineEndpointTests Assert.Equal(2025, secondCursorItem.GetProperty("year").GetInt32()); using var invalidCursorResponse = await client.GetAsync( - $"/api/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&cursor=not-a-cursor"); + $"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&cursor=not-a-cursor"); var invalidCursorJson = await ReadJsonAsync(invalidCursorResponse); Assert.Equal(HttpStatusCode.BadRequest, invalidCursorResponse.StatusCode); Assert.Equal("scoreline_cursor_invalid", invalidCursorJson.RootElement.GetProperty("code").GetString()); @@ -107,7 +107,7 @@ public sealed class ScorelineEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/scoreline/fields"); + using var response = await client.GetAsync("/api/public/scoreline/fields"); var json = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index a0b6c44..7f0f945 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -14,7 +14,7 @@ public sealed class SecurityFoundationTests await using var factory = CreateFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/_security/authenticated"); + var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -39,7 +39,7 @@ public sealed class SecurityFoundationTests new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); - var response = await client.GetAsync("/api/_security/tenant-admin"); + var response = await client.GetAsync("/api/system/security/tenant-admin"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -64,7 +64,7 @@ public sealed class SecurityFoundationTests new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); - var response = await client.GetAsync("/api/_security/authenticated"); + var response = await client.GetAsync("/api/system/security/authenticated"); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -89,7 +89,7 @@ public sealed class SecurityFoundationTests new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ], includeStandardClaims: false)); - var response = await client.GetAsync("/api/_security/authenticated"); + var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -111,7 +111,7 @@ public sealed class SecurityFoundationTests new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ], keyId: "unknown-key")); - var response = await client.GetAsync("/api/_security/authenticated"); + var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -122,12 +122,12 @@ public sealed class SecurityFoundationTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var firstResponse = await client.GetAsync("/api/health"); + using var firstResponse = await client.GetAsync("/api/system/health"); HttpResponseMessage? rejectedResponse = null; for (var index = 0; index < 1200; index++) { rejectedResponse?.Dispose(); - rejectedResponse = await client.GetAsync("/api/health"); + rejectedResponse = await client.GetAsync("/api/system/health"); } using var secondResponse = rejectedResponse ?? throw new InvalidOperationException("Rate limit test did not send a second request."); diff --git a/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs index 12563a0..e55371c 100644 --- a/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs @@ -46,7 +46,7 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/vocabulary-units?tenantCode=master®ionId={regionId}"); + using var response = await client.GetAsync($"/api/public/catalog/vocabulary-units?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -92,7 +92,7 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃"); + using var response = await client.GetAsync($"/api/public/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -134,7 +134,7 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}"); + using var response = await client.GetAsync($"/api/public/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -169,8 +169,8 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var listResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}"); - using var detailResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true"); + using var listResponse = await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}"); + using var detailResponse = await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true"); var listItem = Assert.Single(await ReadItemsAsync(listResponse)); var detailItem = Assert.Single(await ReadItemsAsync(detailResponse)); diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index b933eda..8c3cebe 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -58,7 +58,7 @@ public sealed class TenantAdminDirectEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var response = await client.GetAsync("/api/tenant-admin/overview"); + var response = await client.GetAsync("/api/tenant/overview"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -131,18 +131,18 @@ public sealed class TenantAdminDirectEndpointTests ] }; - var previewResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import/preview", importRequest); - var importResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import", importRequest); + var previewResponse = await client.PostAsJsonAsync("/api/tenant/students/import/preview", importRequest); + var importResponse = await client.PostAsJsonAsync("/api/tenant/students/import", importRequest); var importJson = await ReadJsonAsync(importResponse); var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777"); var assignResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/students/bulk-assign-class", + "/api/tenant/students/bulk-assign-class", new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] }); var statusResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/students/bulk-status", + "/api/tenant/students/bulk-status", new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" }); var ruleResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/students/supervision/rules", + "/api/tenant/students/supervision/rules", new UpsertTenantSupervisionRuleDto { Code = "inactive", @@ -150,14 +150,14 @@ public sealed class TenantAdminDirectEndpointTests DaysWithoutCheckIn = 3, MaxQuestionsAnsweredToday = 0 }); - var rulesResponse = await client.GetAsync("/api/tenant-admin/students/supervision/rules"); - var previewRiskResponse = await client.GetAsync("/api/tenant-admin/students/supervision/preview"); + var rulesResponse = await client.GetAsync("/api/tenant/students/supervision/rules"); + var previewRiskResponse = await client.GetAsync("/api/tenant/students/supervision/preview"); var generateResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/students/supervision/generate", + "/api/tenant/students/supervision/generate", new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId }); - var followupReportResponse = await client.GetAsync("/api/tenant-admin/student-followups/report"); - var feedbackReportResponse = await client.GetAsync("/api/tenant-admin/feedbacks/report"); - var pointRiskReportResponse = await client.GetAsync("/api/tenant-admin/points/risk-report"); + var followupReportResponse = await client.GetAsync("/api/tenant/student-followups/report"); + var feedbackReportResponse = await client.GetAsync("/api/tenant/feedbacks/report"); + var pointRiskReportResponse = await client.GetAsync("/api/tenant/points/risk-report"); Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode); @@ -200,7 +200,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var classResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/classes", + "/api/tenant/classes", new UpsertTenantAdminClassDto { RegionId = regionId, @@ -212,7 +212,7 @@ public sealed class TenantAdminDirectEndpointTests var classId = classJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var memberResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/classes/members", + "/api/tenant/classes/members", new UpsertTenantAdminClassMemberDto { ClassId = classId, @@ -227,9 +227,9 @@ public sealed class TenantAdminDirectEndpointTests var memberJson = await ReadJsonAsync(memberResponse); var studentUserId = memberJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid(); - var listResponse = await client.GetAsync($"/api/tenant-admin/students?classId={classId}"); + var listResponse = await client.GetAsync($"/api/tenant/students?classId={classId}"); var listJson = await ReadJsonAsync(listResponse); - var classesResponse = await client.GetAsync("/api/tenant-admin/classes"); + var classesResponse = await client.GetAsync("/api/tenant/classes"); var classesJson = await ReadJsonAsync(classesResponse); Assert.Equal(HttpStatusCode.OK, classResponse.StatusCode); @@ -259,7 +259,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var noteResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/student-notes", + "/api/tenant/student-notes", new UpsertTenantAdminStudentNoteDto { StudentUserId = studentId, @@ -268,7 +268,7 @@ public sealed class TenantAdminDirectEndpointTests IsPinned = true }); var followupResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/student-followups", + "/api/tenant/student-followups", new UpsertTenantAdminStudentFollowupDto { StudentUserId = studentId, @@ -278,8 +278,8 @@ public sealed class TenantAdminDirectEndpointTests Priority = "high", Status = "in_progress" }); - var notesResponse = await client.GetAsync($"/api/tenant-admin/student-notes?studentUserId={studentId}"); - var followupsResponse = await client.GetAsync($"/api/tenant-admin/student-followups?studentUserId={studentId}"); + var notesResponse = await client.GetAsync($"/api/tenant/student-notes?studentUserId={studentId}"); + var followupsResponse = await client.GetAsync($"/api/tenant/student-followups?studentUserId={studentId}"); var notesJson = await ReadJsonAsync(notesResponse); var followupsJson = await ReadJsonAsync(followupsResponse); @@ -311,7 +311,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var statusResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/students/status", + "/api/tenant/students/status", new UpdateTenantAdminStudentStatusDto { UserId = studentId, @@ -333,7 +333,7 @@ public sealed class TenantAdminDirectEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var response = await client.GetAsync("/api/tenant-admin/students"); + using var response = await client.GetAsync("/api/tenant/students"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -355,7 +355,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var brandingResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/branding", + "/api/tenant/branding", new UpsertTenantBrandingDto { BrandName = "机构题库", @@ -363,24 +363,24 @@ public sealed class TenantAdminDirectEndpointTests ServiceWechat = "wechat" }); var settingsResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/settings", + "/api/tenant/settings", new UpsertTenantSettingsDto { FeatureFlags = JsonSerializer.SerializeToElement(new { catalog = true }), PublicConfig = JsonSerializer.SerializeToElement(new { icp = "蜀ICP备" }) }); var previewResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/theme/preview", + "/api/tenant/theme/preview", new PreviewTenantThemeDto { TemplateCode = "classic", Theme = JsonSerializer.SerializeToElement(new { color = "red" }) }); var publishResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/theme/publish", + "/api/tenant/theme/publish", new PublishTenantThemeDto { UseDraft = true }); var domainResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/domains", + "/api/tenant/domains", new CreateTenantDomainDto { Host = "learn.example.com", @@ -388,7 +388,7 @@ public sealed class TenantAdminDirectEndpointTests IsPrimary = true }); var authProviderResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/auth-providers", + "/api/tenant/auth-providers", new UpsertTenantIdentityProviderDto { Provider = "wechat_mp", @@ -397,7 +397,7 @@ public sealed class TenantAdminDirectEndpointTests ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-test" }) }); var secretSettingsResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/settings", + "/api/tenant/settings", new UpsertTenantSettingsDto { PublicConfig = JsonSerializer.SerializeToElement(new { appSecret = "nope" }) @@ -431,7 +431,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var memberResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/members", + "/api/tenant/members", new UpsertTenantAdminMemberDto { Role = "teacher", @@ -441,8 +441,8 @@ public sealed class TenantAdminDirectEndpointTests Name = "教师" } }); - var membersResponse = await client.GetAsync("/api/tenant-admin/members?role=teacher"); - var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.member"); + var membersResponse = await client.GetAsync("/api/tenant/members?role=teacher"); + var auditResponse = await client.GetAsync("/api/tenant/audit-logs?action=tenant.member"); var membersJson = await ReadJsonAsync(membersResponse); var auditJson = await ReadJsonAsync(auditResponse); @@ -477,7 +477,7 @@ public sealed class TenantAdminDirectEndpointTests await LoginAsync(client, seed); var badgeResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/badges", + "/api/tenant/badges", new UpsertTenantAdminBadgeDto { Name = "反馈达人", @@ -489,7 +489,7 @@ public sealed class TenantAdminDirectEndpointTests var badgeId = badgeJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var grantResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/badge-grants", + "/api/tenant/badge-grants", new GrantTenantAdminBadgeDto { UserId = studentId, @@ -497,7 +497,7 @@ public sealed class TenantAdminDirectEndpointTests Note = "感谢反馈" }); var notificationResponse = await client.PutAsJsonAsync( - "/api/tenant-admin/notifications", + "/api/tenant/notifications", new UpsertTenantAdminNotificationDto { UserId = studentId, @@ -507,9 +507,9 @@ public sealed class TenantAdminDirectEndpointTests Message = "记得复习错题", DedupeKey = "daily-review" }); - var notificationsListResponse = await client.GetAsync($"/api/tenant-admin/notifications?userId={studentId}"); + var notificationsListResponse = await client.GetAsync($"/api/tenant/notifications?userId={studentId}"); var feedbackUpdateResponse = await client.PostAsJsonAsync( - "/api/tenant-admin/feedbacks/status", + "/api/tenant/feedbacks/status", new UpdateTenantAdminFeedbackDto { FeedbackId = feedbackId, @@ -518,7 +518,7 @@ public sealed class TenantAdminDirectEndpointTests Resolution = "已安排补充解析", Note = "后台处理完成" }); - var feedbackListResponse = await client.GetAsync("/api/tenant-admin/feedbacks?status=resolved"); + var feedbackListResponse = await client.GetAsync("/api/tenant/feedbacks?status=resolved"); var notificationsJson = await ReadJsonAsync(notificationsListResponse); var feedbackJson = await ReadJsonAsync(feedbackListResponse); diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index c1669ec..36323c1 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -34,7 +34,7 @@ public sealed class TenantCommerceEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var response = await client.GetAsync("/api/tenant-commerce/orders"); + var response = await client.GetAsync("/api/tenant/commerce/orders"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -48,7 +48,7 @@ public sealed class TenantCommerceEndpointTests await LoginAsync(client, seed); var response = await client.PutAsJsonAsync( - "/api/tenant-commerce/payment-accounts", + "/api/tenant/commerce/payment-accounts", new UpsertPaymentAccountDto { Provider = "wechat_pay", @@ -72,7 +72,7 @@ public sealed class TenantCommerceEndpointTests await LoginAsync(client, seed); var secretResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/secrets", + "/api/tenant/commerce/secrets", new UpsertTenantSecretDto { Purpose = "payment", @@ -86,7 +86,7 @@ public sealed class TenantCommerceEndpointTests }) }); var accountResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/payment-accounts", + "/api/tenant/commerce/payment-accounts", new UpsertPaymentAccountDto { Provider = "wechat_pay", @@ -97,7 +97,7 @@ public sealed class TenantCommerceEndpointTests merchantId = "mch" }) }); - var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay"); + var accountsResponse = await client.GetAsync("/api/tenant/commerce/payment-accounts?provider=wechat_pay"); Assert.Equal(HttpStatusCode.OK, secretResponse.StatusCode); Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); @@ -130,18 +130,18 @@ public sealed class TenantCommerceEndpointTests await LoginAsync(client, seed); var batchResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/code-batches", + "/api/tenant/commerce/code-batches", new CreateCodeBatchDto { Name = "测试批次", TotalCount = 2, Days = 30 }); - var codesResponse = await client.GetAsync("/api/tenant-commerce/activation-codes?status=unused&limit=5"); + var codesResponse = await client.GetAsync("/api/tenant/commerce/activation-codes?status=unused&limit=5"); var codes = await codesResponse.Content.ReadFromJsonAsync(); var code = codes!.Items.First().Code; var redeemResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/activation-codes/redeem", + "/api/tenant/commerce/activation-codes/redeem", new RedeemActivationCodeDto { Code = code, @@ -253,7 +253,7 @@ public sealed class TenantCommerceEndpointTests await LoginAsync(client, seed); var taskResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/point-activity-tasks", + "/api/tenant/commerce/point-activity-tasks", new UpsertPointTaskDto { TaskKey = "admin_daily", @@ -262,9 +262,9 @@ public sealed class TenantCommerceEndpointTests Points = 10, MaxClaimsPerUser = 1 }); - var tasksResponse = await client.GetAsync("/api/tenant-commerce/point-activity-tasks"); + var tasksResponse = await client.GetAsync("/api/tenant/commerce/point-activity-tasks"); var exchangeItemResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/point-exchange-items", + "/api/tenant/commerce/point-exchange-items", new UpsertPointExchangeItemDto { ItemKey = "admin_svip_7d", @@ -274,9 +274,9 @@ public sealed class TenantCommerceEndpointTests Days = 7, Stock = 10 }); - var exchangeItemsResponse = await client.GetAsync("/api/tenant-commerce/point-exchange-items"); + var exchangeItemsResponse = await client.GetAsync("/api/tenant/commerce/point-exchange-items"); var couponResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/coupons", + "/api/tenant/commerce/coupons", new UpsertTenantCouponDto { Code = "ADMIN10", @@ -284,8 +284,8 @@ public sealed class TenantCommerceEndpointTests DiscountValue = 10, MaxUses = 100 }); - var couponsResponse = await client.GetAsync("/api/tenant-commerce/coupons"); - var reportResponse = await client.GetAsync("/api/tenant-commerce/coupons/report"); + var couponsResponse = await client.GetAsync("/api/tenant/commerce/coupons"); + var reportResponse = await client.GetAsync("/api/tenant/commerce/coupons/report"); Assert.Equal(HttpStatusCode.OK, taskResponse.StatusCode); Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); @@ -320,7 +320,7 @@ public sealed class TenantCommerceEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var selfOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); + var selfOrders = await client.GetFromJsonAsync("/api/tenant/commerce/orders"); await SetAdminDataScopeAsync(factory, seed.TenantId, new { @@ -328,12 +328,12 @@ public sealed class TenantCommerceEndpointTests regionIds = new[] { allowedRegionId }, includesSelf = false }); - var regionalOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); + var regionalOrders = await client.GetFromJsonAsync("/api/tenant/commerce/orders"); using var deniedRefund = await client.PostAsJsonAsync( - "/api/tenant-commerce/refunds", + "/api/tenant/commerce/refunds", new CreateRefundRequestDto { OrderId = outsideOrder.Id, AmountCents = 100 }); using var allowedRefund = await client.PostAsJsonAsync( - "/api/tenant-commerce/refunds", + "/api/tenant/commerce/refunds", new CreateRefundRequestDto { OrderId = regionalOrder.Id, AmountCents = 100 }); Assert.NotNull(selfOrders); @@ -386,18 +386,18 @@ public sealed class TenantCommerceEndpointTests Rows = rows }; - var previewResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/preview", request); + var previewResponse = await client.PostAsJsonAsync("/api/tenant/commerce/reconciliation/preview", request); var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions); - var importResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/import", request); + var importResponse = await client.PostAsJsonAsync("/api/tenant/commerce/reconciliation/import", request); var batch = await importResponse.Content.ReadFromJsonAsync(JsonOptions); - var itemsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/items?batchId={batch!.Id}"); + var itemsResponse = await client.GetAsync($"/api/tenant/commerce/reconciliation/items?batchId={batch!.Id}"); var items = await itemsResponse.Content.ReadFromJsonAsync(JsonOptions); - var issuesResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/issues"); + var issuesResponse = await client.GetAsync("/api/tenant/commerce/reconciliation/issues"); var issues = await issuesResponse.Content.ReadFromJsonAsync(JsonOptions); - var issueEventsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); - var anomalies = await client.GetFromJsonAsync("/api/tenant-commerce/reconciliation/anomalies", JsonOptions); + var issueEventsResponse = await client.GetAsync($"/api/tenant/commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); + var anomalies = await client.GetFromJsonAsync("/api/tenant/commerce/reconciliation/anomalies", JsonOptions); var accountResponse = await client.PutAsJsonAsync( - "/api/tenant-commerce/payment-accounts", + "/api/tenant/commerce/payment-accounts", new UpsertPaymentAccountDto { Provider = PaymentProviders.WechatPay, @@ -405,14 +405,14 @@ public sealed class TenantCommerceEndpointTests ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" }) }); var jobResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/reconciliation/provider-bills/request", + "/api/tenant/commerce/reconciliation/provider-bills/request", new RequestProviderBillJobDto { Provider = PaymentProviders.WechatPay, BillDate = request.BillDate, BillType = ReconciliationBillType.Combined }); - var jobsResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/provider-bills/jobs?limit=5"); + var jobsResponse = await client.GetAsync("/api/tenant/commerce/reconciliation/provider-bills/jobs?limit=5"); var jobs = await jobsResponse.Content.ReadFromJsonAsync>(JsonOptions); using var scope = factory.CreateSystemScope(); var jobService = scope.ServiceProvider.GetRequiredService(); @@ -501,8 +501,8 @@ public sealed class TenantCommerceEndpointTests Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" }) }; - var first = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); - var second = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + var first = await client.PostAsJsonAsync($"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + var second = await client.PostAsJsonAsync($"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); @@ -552,7 +552,7 @@ public sealed class TenantCommerceEndpointTests await LoginAsync(client, seed); var createResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/adjustment-vouchers", + "/api/tenant/commerce/adjustment-vouchers", new CreateAdjustmentVoucherDto { OrderId = order.Id, @@ -564,7 +564,7 @@ public sealed class TenantCommerceEndpointTests }); var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); var pendingResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/adjustment-vouchers/status", + "/api/tenant/commerce/adjustment-vouchers/status", new UpdateAdjustmentVoucherStatusDto { VoucherId = created!.Id, @@ -572,17 +572,17 @@ public sealed class TenantCommerceEndpointTests Note = "submit" }); var approvedResponse = await client.PostAsJsonAsync( - "/api/tenant-commerce/adjustment-vouchers/status", + "/api/tenant/commerce/adjustment-vouchers/status", new UpdateAdjustmentVoucherStatusDto { VoucherId = created.Id, Status = CommerceAdjustmentVoucherStatus.Approved, Note = "approved" }); - var detailResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/detail?voucherId={created.Id}"); - var eventsResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/events?voucherId={created.Id}"); - var listResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers?status=approved"); - var reportResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers/report"); + var detailResponse = await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/detail?voucherId={created.Id}"); + var eventsResponse = await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/events?voucherId={created.Id}"); + var listResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers?status=approved"); + var reportResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers/report"); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, pendingResponse.StatusCode); diff --git a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs index f47bd3e..520ee09 100644 --- a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs @@ -26,13 +26,13 @@ public sealed class TenantPublicEndpointTests }); using var client = factory.CreateClient(); - using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/public/runtime/bootstrap"); firstRequest.Headers.Host = "student.example.test"; var first = await client.SendAsync(firstRequest); var firstBody = await first.Content.ReadFromJsonAsync(); var firstEtag = first.Headers.ETag?.Tag; - using var notModifiedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); + using var notModifiedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/public/runtime/bootstrap"); notModifiedRequest.Headers.Host = "student.example.test"; notModifiedRequest.Headers.TryAddWithoutValidation("If-None-Match", firstEtag); var notModified = await client.SendAsync(notModifiedRequest); @@ -51,7 +51,7 @@ public sealed class TenantPublicEndpointTests await service.PublishAsync(tenantId, 1); } - using var publishedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); + using var publishedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/public/runtime/bootstrap"); publishedRequest.Headers.Host = "student.example.test"; var published = await client.SendAsync(publishedRequest); var publishedBody = await published.Content.ReadFromJsonAsync(); @@ -72,7 +72,7 @@ public sealed class TenantPublicEndpointTests await SeedTenantAsync(factory, tenantId); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/tenant/resolve?tenantCode=master"); + var response = await client.GetAsync("/api/public/tenant/resolve?tenantCode=master"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -90,7 +90,7 @@ public sealed class TenantPublicEndpointTests await SeedTenantAsync(factory, tenantId); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/tenant/resolve?host=student.example.test"); + var response = await client.GetAsync("/api/public/tenant/resolve?host=student.example.test"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -118,7 +118,7 @@ public sealed class TenantPublicEndpointTests }); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/tenant/current-public?tenantCode=master"); + var response = await client.GetAsync("/api/public/tenant/current-public?tenantCode=master"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -132,7 +132,7 @@ public sealed class TenantPublicEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/tenant/resolve?tenantCode=missing"); + var response = await client.GetAsync("/api/public/tenant/resolve?tenantCode=missing"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -143,7 +143,7 @@ public sealed class TenantPublicEndpointTests await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - var response = await client.GetAsync("/api/health"); + var response = await client.GetAsync("/api/system/health"); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -155,7 +155,7 @@ public sealed class TenantPublicEndpointTests { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Options, "/api/health"); + using var request = new HttpRequestMessage(HttpMethod.Options, "/api/system/health"); request.Headers.Add("Origin", "http://localhost:5173"); request.Headers.Add("Access-Control-Request-Method", "GET"); diff --git a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs index 8664bef..c06c05a 100644 --- a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs @@ -51,19 +51,19 @@ public sealed class VideoEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var search = await client.GetFromJsonAsync>("/api/videos/search?keyword=透视", JsonOptions); - var questionVideos = await client.GetFromJsonAsync>($"/api/questions/videos?questionId={questionId}", JsonOptions); + var search = await client.GetFromJsonAsync>("/api/student/videos/search?keyword=透视", JsonOptions); + var questionVideos = await client.GetFromJsonAsync>($"/api/student/questions/videos?questionId={questionId}", JsonOptions); var batch = await (await client.PostAsJsonAsync( - "/api/questions/videos/batch", + "/api/student/questions/videos/batch", new QuestionVideoQueryDto { QuestionIds = [questionId] })) .Content .ReadFromJsonAsync>(JsonOptions); var playResponse = await client.PostAsJsonAsync( - "/api/videos/play", + "/api/student/videos/play", new VideoPlayDto { VideoId = videoId, QuestionId = questionId }); var play = await playResponse.Content.ReadFromJsonAsync(JsonOptions); var progressResponse = await client.PostAsJsonAsync( - "/api/videos/progress", + "/api/student/videos/progress", new VideoProgressDto { VideoId = videoId, @@ -114,7 +114,7 @@ public sealed class VideoEndpointTests await LoginAsync(client, tenantB); var response = await client.PostAsJsonAsync( - "/api/videos/play", + "/api/student/videos/play", new VideoPlayDto { VideoId = videoId }); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index f8bb219..a437d47 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -2,6 +2,58 @@ namespace Tiku.IntegrationTests; public sealed class ArchitectureBoundaryTests { + [Fact] + public void Api_controllers_depend_on_application_contracts_not_infrastructure() + { + var root = FindRepositoryRoot(); + var violations = Directory.EnumerateFiles( + Path.Combine(root, "Tiku.Api", "Controllers"), + "*.cs", + SearchOption.AllDirectories) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => + candidate.line.Contains("using Tiku.Infrastructure", StringComparison.Ordinal) || + candidate.line.Contains("TikuDbContext", StringComparison.Ordinal)) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"API controllers crossed the Application boundary:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + + [Fact] + public void New_service_files_do_not_exceed_the_hard_size_limit() + { + var root = FindRepositoryRoot(); + var legacyFacades = new HashSet(StringComparer.Ordinal) + { + "TenantAdminDirectService.cs", + "DirectContentService.cs", + "LearningActivityService.cs", + "CommerceAdminService.cs", + "PlatformAdminService.cs", + "BackgroundJobService.cs" + }; + var violations = Directory.EnumerateFiles( + Path.Combine(root, "Tiku.Infrastructure"), + "*Service.cs", + SearchOption.AllDirectories) + .Where(path => !path.Contains( + $"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .Where(path => !legacyFacades.Contains(Path.GetFileName(path))) + .Select(path => new { path, Lines = File.ReadLines(path).Count() }) + .Where(item => item.Lines > 800) + .Select(item => $"{Path.GetRelativePath(root, item.path)} ({item.Lines} lines)") + .ToArray(); + + Assert.True( + violations.Length == 0, + $"Service files over the 800-line hard limit were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + [Fact] public void Production_authorization_does_not_depend_on_legacy_role_claims() { @@ -93,7 +145,9 @@ public sealed class ArchitectureBoundaryTests .Where(path => !path.Contains( $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Configurations{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) - .Where(path => !allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal)); + .Where(path => + !allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal) && + !Path.GetFileName(path).StartsWith("TikuDbContext.", StringComparison.Ordinal)); AssertNoForbiddenSymbols( root, diff --git a/Tiku.PlatformAdmin.Web/README.md b/Tiku.PlatformAdmin.Web/README.md index 5d137e1..ed66ac1 100644 --- a/Tiku.PlatformAdmin.Web/README.md +++ b/Tiku.PlatformAdmin.Web/README.md @@ -20,7 +20,7 @@ npm install OPENAPI_URL=http://localhost:5090/openapi/v1.json npm run generate:api ``` -生成结果包括完整 OpenAPI TypeScript 类型,以及全部 `/api/platform-admin/**`、`/api/backoffice/platform/**` 接口的页面操作元数据。`npm test` 会验证每个后端平台接口都已归入且只归入一个页面组。 +生成结果包括完整 OpenAPI TypeScript 类型,以及全部 `/api/platform/**`、`/api/platform/access/**` 接口的页面操作元数据。`npm test` 会验证每个后端平台接口都已归入且只归入一个页面组。 生产部署使用同源 `/api` 反向代理到 `Tiku.Api`,浏览器不持有跨域 API Base URL。 diff --git a/Tiku.PlatformAdmin.Web/scripts/generate-platform-operations.mjs b/Tiku.PlatformAdmin.Web/scripts/generate-platform-operations.mjs index e9820f3..018cf00 100644 --- a/Tiku.PlatformAdmin.Web/scripts/generate-platform-operations.mjs +++ b/Tiku.PlatformAdmin.Web/scripts/generate-platform-operations.mjs @@ -25,7 +25,8 @@ for (const [route, pathItem] of Object.entries(document.paths || {})) { for (const [method, operation] of Object.entries(pathItem || {})) { if (!methods.has(method)) continue; const tags = operation.tags || []; - const isPlatform = route.startsWith('/api/platform-admin/') || route === '/api/platform-admin' || route.startsWith('/api/backoffice/platform/'); + const isPlatform = (route.startsWith('/api/platform/') || route === '/api/platform') && + !route.startsWith('/api/platform/auth/'); if (!isPlatform) continue; const requestSchema = operation.requestBody?.content?.['application/json']?.schema || null; const responseEntry = Object.entries(operation.responses || {}).find(([status]) => /^2/.test(status)); diff --git a/Tiku.PlatformAdmin.Web/src/api/groups.test.ts b/Tiku.PlatformAdmin.Web/src/api/groups.test.ts index 8ef1262..b7ba64d 100644 --- a/Tiku.PlatformAdmin.Web/src/api/groups.test.ts +++ b/Tiku.PlatformAdmin.Web/src/api/groups.test.ts @@ -13,6 +13,6 @@ describe('平台 OpenAPI 页面覆盖', () => { it('契约只包含平台权限域接口', () => { expect(platformOperations.length).toBeGreaterThan(0); - expect(platformOperations.every((operation) => operation.path.startsWith('/api/platform-admin/') || operation.path.startsWith('/api/backoffice/platform/'))).toBe(true); + expect(platformOperations.every((operation) => operation.path.startsWith('/api/platform/') || operation.path.startsWith('/api/platform/access/'))).toBe(true); }); }); diff --git a/Tiku.PlatformAdmin.Web/src/api/groups.ts b/Tiku.PlatformAdmin.Web/src/api/groups.ts index dd78869..55acc82 100644 --- a/Tiku.PlatformAdmin.Web/src/api/groups.ts +++ b/Tiku.PlatformAdmin.Web/src/api/groups.ts @@ -20,18 +20,18 @@ export type PlatformGroupKey = (typeof platformGroups)[number]['key']; export function groupForOperation(operation: PlatformOperation): PlatformGroupKey { const route = operation.path; - if (route === '/api/platform-admin/overview') return 'overview'; - if (route.startsWith('/api/platform-admin/operations')) return 'operations'; - if (route.startsWith('/api/platform-admin/approvals')) return 'approvals'; - if (route.startsWith('/api/platform-admin/governance')) return 'governance'; - if (route.startsWith('/api/platform-admin/tenants') || route.startsWith('/api/platform-admin/domains')) return 'tenants'; - if (route.startsWith('/api/platform-admin/question-banks')) return 'question-banks'; - if (route.startsWith('/api/platform-admin/tenant-capabilities/crm')) return 'crm'; - if (route.startsWith('/api/platform-admin/tenant-capabilities/sms')) return 'sms'; - if (route.startsWith('/api/platform-admin/payment-settings') || route.startsWith('/api/platform-admin/tenant-capabilities/payments')) return 'payments'; - if (route.startsWith('/api/backoffice/platform') || route.startsWith('/api/platform-admin/staff')) return 'staff'; - if (route.startsWith('/api/platform-admin/audit-')) return 'audit'; - if (route.startsWith('/api/platform-admin/saas/features') || route.startsWith('/api/platform-admin/saas/feature-limits') || route.startsWith('/api/platform-admin/saas/offerings') || route.startsWith('/api/platform-admin/saas/offering-versions') || route.startsWith('/api/platform-admin/saas/catalog') || route.startsWith('/api/platform-admin/saas/tenant-feature-overrides')) return 'catalog'; - if (route.startsWith('/api/platform-admin/saas/')) return 'billing'; + if (route === '/api/platform/overview') return 'overview'; + if (route.startsWith('/api/platform/operations')) return 'operations'; + if (route.startsWith('/api/platform/approvals')) return 'approvals'; + if (route.startsWith('/api/platform/governance')) return 'governance'; + if (route.startsWith('/api/platform/tenants') || route.startsWith('/api/platform/domains')) return 'tenants'; + if (route.startsWith('/api/platform/question-banks')) return 'question-banks'; + if (route.startsWith('/api/platform/tenant-capabilities/crm')) return 'crm'; + if (route.startsWith('/api/platform/tenant-capabilities/sms')) return 'sms'; + if (route.startsWith('/api/platform/payment-settings') || route.startsWith('/api/platform/tenant-capabilities/payments')) return 'payments'; + if (route.startsWith('/api/platform/access') || route.startsWith('/api/platform/staff')) return 'staff'; + if (route.startsWith('/api/platform/audit-')) return 'audit'; + if (route.startsWith('/api/platform/saas/features') || route.startsWith('/api/platform/saas/feature-limits') || route.startsWith('/api/platform/saas/offerings') || route.startsWith('/api/platform/saas/offering-versions') || route.startsWith('/api/platform/saas/catalog') || route.startsWith('/api/platform/saas/tenant-feature-overrides')) return 'catalog'; + if (route.startsWith('/api/platform/saas/')) return 'billing'; throw new Error(`平台接口尚未归入页面:${operation.id}`); } diff --git a/Tiku.PlatformAdmin.Web/src/api/http.ts b/Tiku.PlatformAdmin.Web/src/api/http.ts index 9cc2566..a5f7e59 100644 --- a/Tiku.PlatformAdmin.Web/src/api/http.ts +++ b/Tiku.PlatformAdmin.Web/src/api/http.ts @@ -25,7 +25,7 @@ async function parseResponse(response: Response): Promise { async function refreshTokens(): Promise { const tokens = tokenStore.get(); if (!tokens?.refreshToken) return null; - const response = await fetch(`${apiBaseUrl}/api/auth/refresh`, { + const response = await fetch(`${apiBaseUrl}/api/platform/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: tokens.refreshToken }), @@ -105,7 +105,7 @@ export async function authRequest(path: string, body?: unknown): Promise { } export async function getCurrentUser(): Promise { - const response = await authorizedFetch('/api/me', { method: 'GET' }); + const response = await authorizedFetch('/api/tenant/me', { method: 'GET' }); const payload = await parseResponse(response); if (!response.ok) throw new ApiError('登录会话已失效', response.status, payload); return payload as T; diff --git a/Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts b/Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts index 2efe2ff..3391984 100644 --- a/Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts +++ b/Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts @@ -4,9 +4,9 @@ import type { PlatformOperation } from './types'; export const platformOperations = [ { - "id": "GET /api/backoffice/platform/bootstrap", + "id": "GET /api/platform/access/bootstrap", "method": "GET", - "path": "/api/backoffice/platform/bootstrap", + "path": "/api/platform/access/bootstrap", "tag": "平台端-后台权限", "summary": "查询平台角色管理初始化数据", "description": "", @@ -20,9 +20,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/backoffice/platform/roles", + "id": "POST /api/platform/access/roles", "method": "POST", - "path": "/api/backoffice/platform/roles", + "path": "/api/platform/access/roles", "tag": "平台端-后台权限", "summary": "创建或更新平台后台角色", "description": "", @@ -38,9 +38,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/backoffice/platform/roles/{roleId}/bindings", + "id": "PUT /api/platform/access/roles/{roleId}/bindings", "method": "PUT", - "path": "/api/backoffice/platform/roles/{roleId}/bindings", + "path": "/api/platform/access/roles/{roleId}/bindings", "tag": "平台端-后台权限", "summary": "替换平台后台角色权限绑定", "description": "", @@ -74,9 +74,9 @@ export const platformOperations = [ "approvalPolicyCode": "security.super-admin-grant" }, { - "id": "GET /api/backoffice/platform/ui-bootstrap", + "id": "GET /api/platform/access/ui-bootstrap", "method": "GET", - "path": "/api/backoffice/platform/ui-bootstrap", + "path": "/api/platform/access/ui-bootstrap", "tag": "平台端-后台权限", "summary": "查询平台后台菜单与权限", "description": "返回当前平台管理员可见的后台菜单、权限和模块启用状态。", @@ -90,9 +90,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/backoffice/platform/users/{userId}/roles", + "id": "PUT /api/platform/access/users/{userId}/roles", "method": "PUT", - "path": "/api/backoffice/platform/users/{userId}/roles", + "path": "/api/platform/access/users/{userId}/roles", "tag": "平台端-后台权限", "summary": "替换平台用户后台角色", "description": "", @@ -116,9 +116,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/approvals", + "id": "GET /api/platform/approvals", "method": "GET", - "path": "/api/platform-admin/approvals", + "path": "/api/platform/approvals", "tag": "平台端-审批中心", "summary": "查询平台审批任务", "description": "", @@ -156,9 +156,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/approvals/{requestId}", + "id": "GET /api/platform/approvals/{requestId}", "method": "GET", - "path": "/api/platform-admin/approvals/{requestId}", + "path": "/api/platform/approvals/{requestId}", "tag": "平台端-审批中心", "summary": "查询平台审批详情", "description": "", @@ -182,9 +182,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/approvals/{requestId}/approve", + "id": "POST /api/platform/approvals/{requestId}/approve", "method": "POST", - "path": "/api/platform-admin/approvals/{requestId}/approve", + "path": "/api/platform/approvals/{requestId}/approve", "tag": "平台端-审批中心", "summary": "批准并执行平台审批任务", "description": "", @@ -210,9 +210,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/approvals/{requestId}/cancel", + "id": "POST /api/platform/approvals/{requestId}/cancel", "method": "POST", - "path": "/api/platform-admin/approvals/{requestId}/cancel", + "path": "/api/platform/approvals/{requestId}/cancel", "tag": "平台端-审批中心", "summary": "撤销本人提交的平台审批任务", "description": "", @@ -238,9 +238,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/approvals/{requestId}/reject", + "id": "POST /api/platform/approvals/{requestId}/reject", "method": "POST", - "path": "/api/platform-admin/approvals/{requestId}/reject", + "path": "/api/platform/approvals/{requestId}/reject", "tag": "平台端-审批中心", "summary": "拒绝平台审批任务", "description": "", @@ -266,9 +266,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/approvals/policies", + "id": "GET /api/platform/approvals/policies", "method": "GET", - "path": "/api/platform-admin/approvals/policies", + "path": "/api/platform/approvals/policies", "tag": "平台端-审批中心", "summary": "查询平台审批策略", "description": "", @@ -285,9 +285,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/approvals/policies/{code}", + "id": "PUT /api/platform/approvals/policies/{code}", "method": "PUT", - "path": "/api/platform-admin/approvals/policies/{code}", + "path": "/api/platform/approvals/policies/{code}", "tag": "平台端-审批中心", "summary": "更新平台审批策略", "description": "", @@ -312,9 +312,9 @@ export const platformOperations = [ "approvalPolicyCode": "approval.policy-change" }, { - "id": "GET /api/platform-admin/audit-alerts", + "id": "GET /api/platform/audit-alerts", "method": "GET", - "path": "/api/platform-admin/audit-alerts", + "path": "/api/platform/audit-alerts", "tag": "平台端-平台管理", "summary": "查询平台审计告警", "description": "", @@ -364,9 +364,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/audit-alerts/status", + "id": "POST /api/platform/audit-alerts/status", "method": "POST", - "path": "/api/platform-admin/audit-alerts/status", + "path": "/api/platform/audit-alerts/status", "tag": "平台端-平台管理", "summary": "更新平台审计告警状态", "description": "", @@ -382,9 +382,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/audit-logs", + "id": "GET /api/platform/audit-logs", "method": "GET", - "path": "/api/platform-admin/audit-logs", + "path": "/api/platform/audit-logs", "tag": "平台端-平台管理", "summary": "查询平台审计日志", "description": "", @@ -434,9 +434,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/domains", + "id": "GET /api/platform/domains", "method": "GET", - "path": "/api/platform-admin/domains", + "path": "/api/platform/domains", "tag": "平台端-平台管理", "summary": "查询租户域名状态", "description": "", @@ -486,9 +486,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/domains/{domainId}/recheck", + "id": "POST /api/platform/domains/{domainId}/recheck", "method": "POST", - "path": "/api/platform-admin/domains/{domainId}/recheck", + "path": "/api/platform/domains/{domainId}/recheck", "tag": "平台端-平台管理", "summary": "重新触发租户域名 DNS/TLS 验证", "description": "", @@ -512,9 +512,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/governance/configuration/definitions", + "id": "GET /api/platform/governance/configuration/definitions", "method": "GET", - "path": "/api/platform-admin/governance/configuration/definitions", + "path": "/api/platform/governance/configuration/definitions", "tag": "平台端-治理配置", "summary": "查询类型化平台配置定义", "description": "", @@ -531,9 +531,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/governance/configuration/drafts", + "id": "POST /api/platform/governance/configuration/drafts", "method": "POST", - "path": "/api/platform-admin/governance/configuration/drafts", + "path": "/api/platform/governance/configuration/drafts", "tag": "平台端-治理配置", "summary": "保存平台配置草稿", "description": "", @@ -549,9 +549,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/governance/configuration/versions", + "id": "GET /api/platform/governance/configuration/versions", "method": "GET", - "path": "/api/platform-admin/governance/configuration/versions", + "path": "/api/platform/governance/configuration/versions", "tag": "平台端-治理配置", "summary": "查询平台配置版本", "description": "", @@ -583,9 +583,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/governance/configuration/versions/{versionId}/publish", + "id": "POST /api/platform/governance/configuration/versions/{versionId}/publish", "method": "POST", - "path": "/api/platform-admin/governance/configuration/versions/{versionId}/publish", + "path": "/api/platform/governance/configuration/versions/{versionId}/publish", "tag": "平台端-治理配置", "summary": "发布平台配置版本", "description": "", @@ -609,9 +609,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/governance/configuration/versions/{versionId}/rollback", + "id": "POST /api/platform/governance/configuration/versions/{versionId}/rollback", "method": "POST", - "path": "/api/platform-admin/governance/configuration/versions/{versionId}/rollback", + "path": "/api/platform/governance/configuration/versions/{versionId}/rollback", "tag": "平台端-治理配置", "summary": "回滚平台配置版本", "description": "", @@ -637,9 +637,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/governance/notifications/deliveries", + "id": "GET /api/platform/governance/notifications/deliveries", "method": "GET", - "path": "/api/platform-admin/governance/notifications/deliveries", + "path": "/api/platform/governance/notifications/deliveries", "tag": "平台端-治理配置", "summary": "分页查询平台通知投递", "description": "", @@ -694,9 +694,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/governance/notifications/deliveries/{deliveryId}/retry", + "id": "POST /api/platform/governance/notifications/deliveries/{deliveryId}/retry", "method": "POST", - "path": "/api/platform-admin/governance/notifications/deliveries/{deliveryId}/retry", + "path": "/api/platform/governance/notifications/deliveries/{deliveryId}/retry", "tag": "平台端-治理配置", "summary": "重试平台通知投递", "description": "", @@ -720,9 +720,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/governance/notifications/send", + "id": "POST /api/platform/governance/notifications/send", "method": "POST", - "path": "/api/platform-admin/governance/notifications/send", + "path": "/api/platform/governance/notifications/send", "tag": "平台端-治理配置", "summary": "按平台岗位发送通知", "description": "", @@ -741,9 +741,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/governance/notifications/templates", + "id": "GET /api/platform/governance/notifications/templates", "method": "GET", - "path": "/api/platform-admin/governance/notifications/templates", + "path": "/api/platform/governance/notifications/templates", "tag": "平台端-治理配置", "summary": "查询平台通知模板", "description": "", @@ -760,9 +760,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/governance/notifications/templates", + "id": "PUT /api/platform/governance/notifications/templates", "method": "PUT", - "path": "/api/platform-admin/governance/notifications/templates", + "path": "/api/platform/governance/notifications/templates", "tag": "平台端-治理配置", "summary": "新增或更新平台通知模板", "description": "", @@ -778,9 +778,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/governance-metrics", + "id": "GET /api/platform/operations/governance-metrics", "method": "GET", - "path": "/api/platform-admin/operations/governance-metrics", + "path": "/api/platform/operations/governance-metrics", "tag": "平台端-运维", "summary": "查询审批、配置与通知治理指标", "description": "", @@ -792,9 +792,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/health", + "id": "GET /api/platform/operations/health", "method": "GET", - "path": "/api/platform-admin/operations/health", + "path": "/api/platform/operations/health", "tag": "平台端-运维", "summary": "查询受保护的依赖深度健康状态", "description": "", @@ -806,9 +806,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/job-metrics", + "id": "GET /api/platform/operations/job-metrics", "method": "GET", - "path": "/api/platform-admin/operations/job-metrics", + "path": "/api/platform/operations/job-metrics", "tag": "平台端-运维", "summary": "查询后台任务队列指标", "description": "", @@ -820,9 +820,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/jobs", + "id": "GET /api/platform/operations/jobs", "method": "GET", - "path": "/api/platform-admin/operations/jobs", + "path": "/api/platform/operations/jobs", "tag": "平台端-运维", "summary": "查询全平台后台任务", "description": "", @@ -874,9 +874,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/jobs/{jobId}", + "id": "GET /api/platform/operations/jobs/{jobId}", "method": "GET", - "path": "/api/platform-admin/operations/jobs/{jobId}", + "path": "/api/platform/operations/jobs/{jobId}", "tag": "平台端-运维", "summary": "查询平台后台任务详情", "description": "", @@ -900,9 +900,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/operations/jobs/{jobId}/cancel", + "id": "POST /api/platform/operations/jobs/{jobId}/cancel", "method": "POST", - "path": "/api/platform-admin/operations/jobs/{jobId}/cancel", + "path": "/api/platform/operations/jobs/{jobId}/cancel", "tag": "平台端-运维", "summary": "取消平台后台任务", "description": "", @@ -928,9 +928,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/operations/jobs/{jobId}/retry", + "id": "POST /api/platform/operations/jobs/{jobId}/retry", "method": "POST", - "path": "/api/platform-admin/operations/jobs/{jobId}/retry", + "path": "/api/platform/operations/jobs/{jobId}/retry", "tag": "平台端-运维", "summary": "重试平台后台任务", "description": "", @@ -954,9 +954,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/operations/workers", + "id": "GET /api/platform/operations/workers", "method": "GET", - "path": "/api/platform-admin/operations/workers", + "path": "/api/platform/operations/workers", "tag": "平台端-运维", "summary": "查询 Worker 与周期循环状态", "description": "", @@ -968,9 +968,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/overview", + "id": "GET /api/platform/overview", "method": "GET", - "path": "/api/platform-admin/overview", + "path": "/api/platform/overview", "tag": "平台端-平台管理", "summary": "查询平台经营概览", "description": "", @@ -984,9 +984,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/payment-settings/apps", + "id": "GET /api/platform/payment-settings/apps", "method": "GET", - "path": "/api/platform-admin/payment-settings/apps", + "path": "/api/platform/payment-settings/apps", "tag": "平台端-支付设置", "summary": "查询平台支付应用", "description": "", @@ -1024,9 +1024,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/payment-settings/apps", + "id": "PUT /api/platform/payment-settings/apps", "method": "PUT", - "path": "/api/platform-admin/payment-settings/apps", + "path": "/api/platform/payment-settings/apps", "tag": "平台端-支付设置", "summary": "新增或更新平台支付应用", "description": "", @@ -1042,9 +1042,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/payment-settings/channels", + "id": "GET /api/platform/payment-settings/channels", "method": "GET", - "path": "/api/platform-admin/payment-settings/channels", + "path": "/api/platform/payment-settings/channels", "tag": "平台端-支付设置", "summary": "查询平台支付渠道", "description": "", @@ -1090,9 +1090,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/payment-settings/channels", + "id": "PUT /api/platform/payment-settings/channels", "method": "PUT", - "path": "/api/platform-admin/payment-settings/channels", + "path": "/api/platform/payment-settings/channels", "tag": "平台端-支付设置", "summary": "新增或更新平台支付渠道", "description": "", @@ -1117,9 +1117,9 @@ export const platformOperations = [ "approvalPolicyCode": "payment.channel-change" }, { - "id": "POST /api/platform-admin/payment-settings/channels/{id}/disable", + "id": "POST /api/platform/payment-settings/channels/{id}/disable", "method": "POST", - "path": "/api/platform-admin/payment-settings/channels/{id}/disable", + "path": "/api/platform/payment-settings/channels/{id}/disable", "tag": "平台端-支付设置", "summary": "禁用平台支付渠道", "description": "", @@ -1143,9 +1143,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/payment-settings/events", + "id": "GET /api/platform/payment-settings/events", "method": "GET", - "path": "/api/platform-admin/payment-settings/events", + "path": "/api/platform/payment-settings/events", "tag": "平台端-支付设置", "summary": "查询平台支付事件", "description": "", @@ -1183,9 +1183,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/payment-settings/rebates/summary", + "id": "GET /api/platform/payment-settings/rebates/summary", "method": "GET", - "path": "/api/platform-admin/payment-settings/rebates/summary", + "path": "/api/platform/payment-settings/rebates/summary", "tag": "平台端-支付设置", "summary": "查询平台返佣汇总", "description": "", @@ -1199,9 +1199,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/question-banks", + "id": "GET /api/platform/question-banks", "method": "GET", - "path": "/api/platform-admin/question-banks", + "path": "/api/platform/question-banks", "tag": "平台端-公共题库", "summary": "查询平台公共题库", "description": "", @@ -1233,9 +1233,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/question-banks", + "id": "PUT /api/platform/question-banks", "method": "PUT", - "path": "/api/platform-admin/question-banks", + "path": "/api/platform/question-banks", "tag": "平台端-公共题库", "summary": "新增或更新平台公共题库", "description": "", @@ -1251,9 +1251,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/{bankId}/archive", + "id": "POST /api/platform/question-banks/{bankId}/archive", "method": "POST", - "path": "/api/platform-admin/question-banks/{bankId}/archive", + "path": "/api/platform/question-banks/{bankId}/archive", "tag": "平台端-公共题库", "summary": "归档平台公共题库", "description": "", @@ -1277,9 +1277,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/question-banks/{bankId}/nodes", + "id": "GET /api/platform/question-banks/{bankId}/nodes", "method": "GET", - "path": "/api/platform-admin/question-banks/{bankId}/nodes", + "path": "/api/platform/question-banks/{bankId}/nodes", "tag": "平台端-公共题库", "summary": "查询公共题库内容结构", "description": "", @@ -1306,9 +1306,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/assets/upload-confirm", + "id": "POST /api/platform/question-banks/assets/upload-confirm", "method": "POST", - "path": "/api/platform-admin/question-banks/assets/upload-confirm", + "path": "/api/platform/question-banks/assets/upload-confirm", "tag": "平台端-公共题库", "summary": "确认公共题库图片上传结果", "description": "", @@ -1324,9 +1324,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/assets/upload-sign", + "id": "POST /api/platform/question-banks/assets/upload-sign", "method": "POST", - "path": "/api/platform-admin/question-banks/assets/upload-sign", + "path": "/api/platform/question-banks/assets/upload-sign", "tag": "平台端-公共题库", "summary": "签发公共题库图片上传地址", "description": "", @@ -1342,9 +1342,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/imports", + "id": "POST /api/platform/question-banks/imports", "method": "POST", - "path": "/api/platform-admin/question-banks/imports", + "path": "/api/platform/question-banks/imports", "tag": "平台端-公共题库", "summary": "执行公共题库 JSON 导入", "description": "", @@ -1360,9 +1360,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/question-banks/imports/{jobId}", + "id": "GET /api/platform/question-banks/imports/{jobId}", "method": "GET", - "path": "/api/platform-admin/question-banks/imports/{jobId}", + "path": "/api/platform/question-banks/imports/{jobId}", "tag": "平台端-公共题库", "summary": "查询公共题库导入结果", "description": "", @@ -1386,9 +1386,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/imports/preview", + "id": "POST /api/platform/question-banks/imports/preview", "method": "POST", - "path": "/api/platform-admin/question-banks/imports/preview", + "path": "/api/platform/question-banks/imports/preview", "tag": "平台端-公共题库", "summary": "预检公共题库 JSON 导入", "description": "", @@ -1404,9 +1404,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/question-banks/nodes", + "id": "PUT /api/platform/question-banks/nodes", "method": "PUT", - "path": "/api/platform-admin/question-banks/nodes", + "path": "/api/platform/question-banks/nodes", "tag": "平台端-公共题库", "summary": "新增或更新公共题库内容节点", "description": "", @@ -1422,9 +1422,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/nodes/{nodeId}/archive", + "id": "POST /api/platform/question-banks/nodes/{nodeId}/archive", "method": "POST", - "path": "/api/platform-admin/question-banks/nodes/{nodeId}/archive", + "path": "/api/platform/question-banks/nodes/{nodeId}/archive", "tag": "平台端-公共题库", "summary": "归档公共题库内容节点", "description": "", @@ -1448,9 +1448,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/nodes/batch", + "id": "POST /api/platform/question-banks/nodes/batch", "method": "POST", - "path": "/api/platform-admin/question-banks/nodes/batch", + "path": "/api/platform/question-banks/nodes/batch", "tag": "平台端-公共题库", "summary": "批量创建公共题库章节或试卷", "description": "", @@ -1469,9 +1469,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/question-banks/questions", + "id": "GET /api/platform/question-banks/questions", "method": "GET", - "path": "/api/platform-admin/question-banks/questions", + "path": "/api/platform/question-banks/questions", "tag": "平台端-公共题库", "summary": "分页查询公共题库题目", "description": "", @@ -1561,9 +1561,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/question-banks/questions", + "id": "PUT /api/platform/question-banks/questions", "method": "PUT", - "path": "/api/platform-admin/question-banks/questions", + "path": "/api/platform/question-banks/questions", "tag": "平台端-公共题库", "summary": "新增或更新公共题库题目并保留版本", "description": "", @@ -1579,9 +1579,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/question-banks/questions/archive", + "id": "POST /api/platform/question-banks/questions/archive", "method": "POST", - "path": "/api/platform-admin/question-banks/questions/archive", + "path": "/api/platform/question-banks/questions/archive", "tag": "平台端-公共题库", "summary": "批量归档公共题库题目", "description": "", @@ -1602,9 +1602,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/catalog", + "id": "GET /api/platform/saas/catalog", "method": "GET", - "path": "/api/platform-admin/saas/catalog", + "path": "/api/platform/saas/catalog", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 商品目录", "description": "", @@ -1618,9 +1618,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/dunning/channels", + "id": "GET /api/platform/saas/dunning/channels", "method": "GET", - "path": "/api/platform-admin/saas/dunning/channels", + "path": "/api/platform/saas/dunning/channels", "tag": "平台端-平台管理", "summary": "查询平台催缴通知渠道", "description": "", @@ -1670,9 +1670,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/dunning/channels", + "id": "PUT /api/platform/saas/dunning/channels", "method": "PUT", - "path": "/api/platform-admin/saas/dunning/channels", + "path": "/api/platform/saas/dunning/channels", "tag": "平台端-平台管理", "summary": "创建或更新平台催缴通知渠道", "description": "", @@ -1688,9 +1688,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/dunning/channels/disable", + "id": "POST /api/platform/saas/dunning/channels/disable", "method": "POST", - "path": "/api/platform-admin/saas/dunning/channels/disable", + "path": "/api/platform/saas/dunning/channels/disable", "tag": "平台端-平台管理", "summary": "禁用平台催缴通知渠道", "description": "", @@ -1706,9 +1706,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/dunning/events", + "id": "GET /api/platform/saas/dunning/events", "method": "GET", - "path": "/api/platform-admin/saas/dunning/events", + "path": "/api/platform/saas/dunning/events", "tag": "平台端-平台管理", "summary": "查询平台催缴通知事件", "description": "", @@ -1758,9 +1758,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/dunning/events/acknowledge", + "id": "POST /api/platform/saas/dunning/events/acknowledge", "method": "POST", - "path": "/api/platform-admin/saas/dunning/events/acknowledge", + "path": "/api/platform/saas/dunning/events/acknowledge", "tag": "平台端-平台管理", "summary": "人工确认平台催缴通知事件", "description": "", @@ -1776,9 +1776,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/dunning/events/detail", + "id": "GET /api/platform/saas/dunning/events/detail", "method": "GET", - "path": "/api/platform-admin/saas/dunning/events/detail", + "path": "/api/platform/saas/dunning/events/detail", "tag": "平台端-平台管理", "summary": "查询平台催缴通知事件详情", "description": "", @@ -1801,9 +1801,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/dunning/events/ignore", + "id": "POST /api/platform/saas/dunning/events/ignore", "method": "POST", - "path": "/api/platform-admin/saas/dunning/events/ignore", + "path": "/api/platform/saas/dunning/events/ignore", "tag": "平台端-平台管理", "summary": "人工忽略平台催缴通知事件", "description": "", @@ -1819,9 +1819,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/dunning/events/retry", + "id": "POST /api/platform/saas/dunning/events/retry", "method": "POST", - "path": "/api/platform-admin/saas/dunning/events/retry", + "path": "/api/platform/saas/dunning/events/retry", "tag": "平台端-平台管理", "summary": "重新标记平台催缴通知事件待发送", "description": "", @@ -1837,9 +1837,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/feature-limits", + "id": "PUT /api/platform/saas/feature-limits", "method": "PUT", - "path": "/api/platform-admin/saas/feature-limits", + "path": "/api/platform/saas/feature-limits", "tag": "平台端-SaaS 套餐", "summary": "新增或更新 SaaS 功能限额", "description": "", @@ -1855,9 +1855,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/features", + "id": "PUT /api/platform/saas/features", "method": "PUT", - "path": "/api/platform-admin/saas/features", + "path": "/api/platform/saas/features", "tag": "平台端-SaaS 套餐", "summary": "新增或更新 SaaS 功能", "description": "", @@ -1873,9 +1873,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/invoices", + "id": "GET /api/platform/saas/invoices", "method": "GET", - "path": "/api/platform-admin/saas/invoices", + "path": "/api/platform/saas/invoices", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 发票", "description": "", @@ -1921,9 +1921,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/invoices/reminders", + "id": "GET /api/platform/saas/invoices/reminders", "method": "GET", - "path": "/api/platform-admin/saas/invoices/reminders", + "path": "/api/platform/saas/invoices/reminders", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 账单提醒", "description": "", @@ -1969,9 +1969,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/metrics", + "id": "GET /api/platform/saas/metrics", "method": "GET", - "path": "/api/platform-admin/saas/metrics", + "path": "/api/platform/saas/metrics", "tag": "平台端-SaaS 套餐", "summary": "查询 SaaS 商业经营指标", "description": "", @@ -1985,9 +1985,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/offering-versions", + "id": "PUT /api/platform/saas/offering-versions", "method": "PUT", - "path": "/api/platform-admin/saas/offering-versions", + "path": "/api/platform/saas/offering-versions", "tag": "平台端-SaaS 套餐", "summary": "新增或更新 SaaS 套餐版本草稿", "description": "", @@ -2003,9 +2003,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/offering-versions/{versionId}/clone", + "id": "POST /api/platform/saas/offering-versions/{versionId}/clone", "method": "POST", - "path": "/api/platform-admin/saas/offering-versions/{versionId}/clone", + "path": "/api/platform/saas/offering-versions/{versionId}/clone", "tag": "平台端-SaaS 套餐", "summary": "克隆 SaaS 套餐版本", "description": "", @@ -2029,9 +2029,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/offering-versions/{versionId}/publish", + "id": "POST /api/platform/saas/offering-versions/{versionId}/publish", "method": "POST", - "path": "/api/platform-admin/saas/offering-versions/{versionId}/publish", + "path": "/api/platform/saas/offering-versions/{versionId}/publish", "tag": "平台端-SaaS 套餐", "summary": "发布 SaaS 套餐版本", "description": "", @@ -2055,9 +2055,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/offering-versions/{versionId}/retire", + "id": "POST /api/platform/saas/offering-versions/{versionId}/retire", "method": "POST", - "path": "/api/platform-admin/saas/offering-versions/{versionId}/retire", + "path": "/api/platform/saas/offering-versions/{versionId}/retire", "tag": "平台端-SaaS 套餐", "summary": "下架 SaaS 套餐版本", "description": "", @@ -2081,9 +2081,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/offerings", + "id": "PUT /api/platform/saas/offerings", "method": "PUT", - "path": "/api/platform-admin/saas/offerings", + "path": "/api/platform/saas/offerings", "tag": "平台端-SaaS 套餐", "summary": "新增或更新 SaaS 套餐", "description": "", @@ -2099,9 +2099,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/orders", + "id": "GET /api/platform/saas/orders", "method": "GET", - "path": "/api/platform-admin/saas/orders", + "path": "/api/platform/saas/orders", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 订单", "description": "", @@ -2147,9 +2147,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/payments", + "id": "GET /api/platform/saas/payments", "method": "GET", - "path": "/api/platform-admin/saas/payments", + "path": "/api/platform/saas/payments", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 支付记录", "description": "", @@ -2195,9 +2195,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/payments/manual/confirm", + "id": "POST /api/platform/saas/payments/manual/confirm", "method": "POST", - "path": "/api/platform-admin/saas/payments/manual/confirm", + "path": "/api/platform/saas/payments/manual/confirm", "tag": "平台端-SaaS 套餐", "summary": "确认平台手工支付", "description": "", @@ -2222,9 +2222,9 @@ export const platformOperations = [ "approvalPolicyCode": "finance.adjustment" }, { - "id": "GET /api/platform-admin/saas/refunds", + "id": "GET /api/platform/saas/refunds", "method": "GET", - "path": "/api/platform-admin/saas/refunds", + "path": "/api/platform/saas/refunds", "tag": "平台端-SaaS 套餐", "summary": "查询平台 SaaS 退款记录", "description": "", @@ -2270,9 +2270,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/refunds", + "id": "POST /api/platform/saas/refunds", "method": "POST", - "path": "/api/platform-admin/saas/refunds", + "path": "/api/platform/saas/refunds", "tag": "平台端-SaaS 套餐", "summary": "申请平台 SaaS 退款", "description": "", @@ -2288,9 +2288,9 @@ export const platformOperations = [ "approvalPolicyCode": "finance.adjustment" }, { - "id": "POST /api/platform-admin/saas/refunds/{refundId}/approve", + "id": "POST /api/platform/saas/refunds/{refundId}/approve", "method": "POST", - "path": "/api/platform-admin/saas/refunds/{refundId}/approve", + "path": "/api/platform/saas/refunds/{refundId}/approve", "tag": "平台端-SaaS 套餐", "summary": "批准平台 SaaS 退款", "description": "", @@ -2316,9 +2316,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/refunds/{refundId}/reject", + "id": "POST /api/platform/saas/refunds/{refundId}/reject", "method": "POST", - "path": "/api/platform-admin/saas/refunds/{refundId}/reject", + "path": "/api/platform/saas/refunds/{refundId}/reject", "tag": "平台端-SaaS 套餐", "summary": "拒绝平台 SaaS 退款", "description": "", @@ -2344,9 +2344,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/refunds/{refundId}/retry", + "id": "POST /api/platform/saas/refunds/{refundId}/retry", "method": "POST", - "path": "/api/platform-admin/saas/refunds/{refundId}/retry", + "path": "/api/platform/saas/refunds/{refundId}/retry", "tag": "平台端-SaaS 套餐", "summary": "重试失败的平台 SaaS 退款", "description": "", @@ -2372,9 +2372,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/subscriptions", + "id": "GET /api/platform/saas/subscriptions", "method": "GET", - "path": "/api/platform-admin/saas/subscriptions", + "path": "/api/platform/saas/subscriptions", "tag": "平台端-SaaS 套餐", "summary": "查询租户 SaaS 订阅", "description": "", @@ -2420,9 +2420,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/subscriptions/{subscriptionId}/cancel", + "id": "POST /api/platform/saas/subscriptions/{subscriptionId}/cancel", "method": "POST", - "path": "/api/platform-admin/saas/subscriptions/{subscriptionId}/cancel", + "path": "/api/platform/saas/subscriptions/{subscriptionId}/cancel", "tag": "平台端-SaaS 套餐", "summary": "立即取消租户 SaaS 订阅", "description": "", @@ -2448,9 +2448,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/subscriptions/{subscriptionId}/extend", + "id": "POST /api/platform/saas/subscriptions/{subscriptionId}/extend", "method": "POST", - "path": "/api/platform-admin/saas/subscriptions/{subscriptionId}/extend", + "path": "/api/platform/saas/subscriptions/{subscriptionId}/extend", "tag": "平台端-SaaS 套餐", "summary": "延长租户 SaaS 订阅账期", "description": "", @@ -2476,9 +2476,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/subscriptions/{subscriptionId}/resume", + "id": "POST /api/platform/saas/subscriptions/{subscriptionId}/resume", "method": "POST", - "path": "/api/platform-admin/saas/subscriptions/{subscriptionId}/resume", + "path": "/api/platform/saas/subscriptions/{subscriptionId}/resume", "tag": "平台端-SaaS 套餐", "summary": "恢复租户 SaaS 订阅", "description": "", @@ -2504,9 +2504,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/subscriptions/{subscriptionId}/suspend", + "id": "POST /api/platform/saas/subscriptions/{subscriptionId}/suspend", "method": "POST", - "path": "/api/platform-admin/saas/subscriptions/{subscriptionId}/suspend", + "path": "/api/platform/saas/subscriptions/{subscriptionId}/suspend", "tag": "平台端-SaaS 套餐", "summary": "暂停租户 SaaS 订阅", "description": "", @@ -2532,9 +2532,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/saas/subscriptions/trial", + "id": "POST /api/platform/saas/subscriptions/trial", "method": "POST", - "path": "/api/platform-admin/saas/subscriptions/trial", + "path": "/api/platform/saas/subscriptions/trial", "tag": "平台端-SaaS 套餐", "summary": "为已有租户补录试用订阅", "description": "", @@ -2550,9 +2550,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/saas/tenant-feature-overrides", + "id": "PUT /api/platform/saas/tenant-feature-overrides", "method": "PUT", - "path": "/api/platform-admin/saas/tenant-feature-overrides", + "path": "/api/platform/saas/tenant-feature-overrides", "tag": "平台端-SaaS 套餐", "summary": "新增或更新租户功能覆盖规则", "description": "", @@ -2568,9 +2568,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/saas/usage", + "id": "GET /api/platform/saas/usage", "method": "GET", - "path": "/api/platform-admin/saas/usage", + "path": "/api/platform/saas/usage", "tag": "平台端-SaaS 套餐", "summary": "查询租户 SaaS 用量", "description": "", @@ -2609,9 +2609,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/staff", + "id": "GET /api/platform/staff", "method": "GET", - "path": "/api/platform-admin/staff", + "path": "/api/platform/staff", "tag": "平台端-平台管理", "summary": "查询平台员工列表", "description": "", @@ -2661,9 +2661,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/staff", + "id": "PUT /api/platform/staff", "method": "PUT", - "path": "/api/platform-admin/staff", + "path": "/api/platform/staff", "tag": "平台端-平台管理", "summary": "创建或更新平台员工", "description": "", @@ -2679,9 +2679,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/staff/{userId}/password-reset", + "id": "POST /api/platform/staff/{userId}/password-reset", "method": "POST", - "path": "/api/platform-admin/staff/{userId}/password-reset", + "path": "/api/platform/staff/{userId}/password-reset", "tag": "平台端-平台管理", "summary": "为平台员工设置一次性临时密码", "description": "", @@ -2705,9 +2705,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PATCH /api/platform-admin/staff/status", + "id": "PATCH /api/platform/staff/status", "method": "PATCH", - "path": "/api/platform-admin/staff/status", + "path": "/api/platform/staff/status", "tag": "平台端-平台管理", "summary": "启用或禁用平台员工", "description": "", @@ -2723,9 +2723,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/crm/configs", + "id": "GET /api/platform/tenant-capabilities/crm/configs", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/crm/configs", + "path": "/api/platform/tenant-capabilities/crm/configs", "tag": "平台端-租户 CRM 能力", "summary": "查询租户 CRM 配置", "description": "", @@ -2774,9 +2774,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenant-capabilities/crm/configs", + "id": "PUT /api/platform/tenant-capabilities/crm/configs", "method": "PUT", - "path": "/api/platform-admin/tenant-capabilities/crm/configs", + "path": "/api/platform/tenant-capabilities/crm/configs", "tag": "平台端-租户 CRM 能力", "summary": "新增或更新租户 CRM 配置", "description": "", @@ -2792,9 +2792,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/crm/leads", + "id": "GET /api/platform/tenant-capabilities/crm/leads", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/crm/leads", + "path": "/api/platform/tenant-capabilities/crm/leads", "tag": "平台端-租户 CRM 能力", "summary": "查询租户 CRM 线索队列", "description": "", @@ -2843,9 +2843,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenant-capabilities/crm/leads/retry", + "id": "POST /api/platform/tenant-capabilities/crm/leads/retry", "method": "POST", - "path": "/api/platform-admin/tenant-capabilities/crm/leads/retry", + "path": "/api/platform/tenant-capabilities/crm/leads/retry", "tag": "平台端-租户 CRM 能力", "summary": "重试租户 CRM 线索推送", "description": "", @@ -2861,9 +2861,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/crm/logs", + "id": "GET /api/platform/tenant-capabilities/crm/logs", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/crm/logs", + "path": "/api/platform/tenant-capabilities/crm/logs", "tag": "平台端-租户 CRM 能力", "summary": "查询租户 CRM 推送日志", "description": "", @@ -2918,9 +2918,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/payments/apps", + "id": "GET /api/platform/tenant-capabilities/payments/apps", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/payments/apps", + "path": "/api/platform/tenant-capabilities/payments/apps", "tag": "平台端-租户支付能力", "summary": "查询租户支付应用", "description": "", @@ -2969,9 +2969,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenant-capabilities/payments/apps", + "id": "PUT /api/platform/tenant-capabilities/payments/apps", "method": "PUT", - "path": "/api/platform-admin/tenant-capabilities/payments/apps", + "path": "/api/platform/tenant-capabilities/payments/apps", "tag": "平台端-租户支付能力", "summary": "新增或更新租户支付应用", "description": "", @@ -2987,9 +2987,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/payments/events", + "id": "GET /api/platform/tenant-capabilities/payments/events", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/payments/events", + "path": "/api/platform/tenant-capabilities/payments/events", "tag": "平台端-租户支付能力", "summary": "查询租户支付事件", "description": "", @@ -3038,9 +3038,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/sms/channels", + "id": "GET /api/platform/tenant-capabilities/sms/channels", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/sms/channels", + "path": "/api/platform/tenant-capabilities/sms/channels", "tag": "平台端-租户短信能力", "summary": "查询租户短信渠道", "description": "", @@ -3089,9 +3089,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenant-capabilities/sms/channels", + "id": "PUT /api/platform/tenant-capabilities/sms/channels", "method": "PUT", - "path": "/api/platform-admin/tenant-capabilities/sms/channels", + "path": "/api/platform/tenant-capabilities/sms/channels", "tag": "平台端-租户短信能力", "summary": "新增或更新租户短信渠道", "description": "", @@ -3107,9 +3107,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenant-capabilities/sms/channels/{id}/disable", + "id": "POST /api/platform/tenant-capabilities/sms/channels/{id}/disable", "method": "POST", - "path": "/api/platform-admin/tenant-capabilities/sms/channels/{id}/disable", + "path": "/api/platform/tenant-capabilities/sms/channels/{id}/disable", "tag": "平台端-租户短信能力", "summary": "禁用租户短信渠道", "description": "", @@ -3133,9 +3133,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/sms/logs", + "id": "GET /api/platform/tenant-capabilities/sms/logs", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/sms/logs", + "path": "/api/platform/tenant-capabilities/sms/logs", "tag": "平台端-租户短信能力", "summary": "查询租户短信发送日志", "description": "", @@ -3184,9 +3184,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenant-capabilities/sms/templates", + "id": "GET /api/platform/tenant-capabilities/sms/templates", "method": "GET", - "path": "/api/platform-admin/tenant-capabilities/sms/templates", + "path": "/api/platform/tenant-capabilities/sms/templates", "tag": "平台端-租户短信能力", "summary": "查询租户短信模板", "description": "", @@ -3235,9 +3235,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenant-capabilities/sms/templates", + "id": "PUT /api/platform/tenant-capabilities/sms/templates", "method": "PUT", - "path": "/api/platform-admin/tenant-capabilities/sms/templates", + "path": "/api/platform/tenant-capabilities/sms/templates", "tag": "平台端-租户短信能力", "summary": "新增或更新租户短信模板", "description": "", @@ -3253,9 +3253,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenant-capabilities/sms/templates/{id}/disable", + "id": "POST /api/platform/tenant-capabilities/sms/templates/{id}/disable", "method": "POST", - "path": "/api/platform-admin/tenant-capabilities/sms/templates/{id}/disable", + "path": "/api/platform/tenant-capabilities/sms/templates/{id}/disable", "tag": "平台端-租户短信能力", "summary": "禁用租户短信模板", "description": "", @@ -3279,9 +3279,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenant-capabilities/sms/templates/{id}/submit-review", + "id": "POST /api/platform/tenant-capabilities/sms/templates/{id}/submit-review", "method": "POST", - "path": "/api/platform-admin/tenant-capabilities/sms/templates/{id}/submit-review", + "path": "/api/platform/tenant-capabilities/sms/templates/{id}/submit-review", "tag": "平台端-租户短信能力", "summary": "提交租户短信模板审核", "description": "", @@ -3305,9 +3305,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants", + "id": "GET /api/platform/tenants", "method": "GET", - "path": "/api/platform-admin/tenants", + "path": "/api/platform/tenants", "tag": "平台端-平台管理", "summary": "查询平台租户列表", "description": "", @@ -3357,9 +3357,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants", + "id": "POST /api/platform/tenants", "method": "POST", - "path": "/api/platform-admin/tenants", + "path": "/api/platform/tenants", "tag": "平台端-平台管理", "summary": "创建平台租户", "description": "", @@ -3384,9 +3384,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants/{tenantId}/archive", + "id": "POST /api/platform/tenants/{tenantId}/archive", "method": "POST", - "path": "/api/platform-admin/tenants/{tenantId}/archive", + "path": "/api/platform/tenants/{tenantId}/archive", "tag": "平台端-租户生命周期", "summary": "逻辑归档租户", "description": "", @@ -3412,9 +3412,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants/{tenantId}/archive-preview", + "id": "GET /api/platform/tenants/{tenantId}/archive-preview", "method": "GET", - "path": "/api/platform-admin/tenants/{tenantId}/archive-preview", + "path": "/api/platform/tenants/{tenantId}/archive-preview", "tag": "平台端-租户生命周期", "summary": "预检租户归档条件", "description": "", @@ -3438,9 +3438,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants/{tenantId}/billing-policy", + "id": "GET /api/platform/tenants/{tenantId}/billing-policy", "method": "GET", - "path": "/api/platform-admin/tenants/{tenantId}/billing-policy", + "path": "/api/platform/tenants/{tenantId}/billing-policy", "tag": "平台端-平台管理", "summary": "查询租户收款策略", "description": "", @@ -3464,9 +3464,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenants/{tenantId}/billing-policy", + "id": "PUT /api/platform/tenants/{tenantId}/billing-policy", "method": "PUT", - "path": "/api/platform-admin/tenants/{tenantId}/billing-policy", + "path": "/api/platform/tenants/{tenantId}/billing-policy", "tag": "平台端-平台管理", "summary": "更新租户收款策略", "description": "", @@ -3492,9 +3492,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants/{tenantId}/exports", + "id": "POST /api/platform/tenants/{tenantId}/exports", "method": "POST", - "path": "/api/platform-admin/tenants/{tenantId}/exports", + "path": "/api/platform/tenants/{tenantId}/exports", "tag": "平台端-租户生命周期", "summary": "创建租户数据导出", "description": "", @@ -3518,9 +3518,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants/{tenantId}/exports/{operationId}", + "id": "GET /api/platform/tenants/{tenantId}/exports/{operationId}", "method": "GET", - "path": "/api/platform-admin/tenants/{tenantId}/exports/{operationId}", + "path": "/api/platform/tenants/{tenantId}/exports/{operationId}", "tag": "平台端-租户生命周期", "summary": "查询租户数据导出状态", "description": "", @@ -3553,9 +3553,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants/{tenantId}/exports/{operationId}/download", + "id": "GET /api/platform/tenants/{tenantId}/exports/{operationId}/download", "method": "GET", - "path": "/api/platform-admin/tenants/{tenantId}/exports/{operationId}/download", + "path": "/api/platform/tenants/{tenantId}/exports/{operationId}/download", "tag": "平台端-租户生命周期", "summary": "获取租户导出下载地址", "description": "", @@ -3588,9 +3588,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants/{tenantId}/owner-activation-links", + "id": "POST /api/platform/tenants/{tenantId}/owner-activation-links", "method": "POST", - "path": "/api/platform-admin/tenants/{tenantId}/owner-activation-links", + "path": "/api/platform/tenants/{tenantId}/owner-activation-links", "tag": "平台端-平台管理", "summary": "一次性领取租户 Owner 激活链接", "description": "仅在主域名和试用/订阅有效时签发;幂等重放不会再次返回明文链接。", @@ -3624,9 +3624,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants/{tenantId}/owner-transfer", + "id": "POST /api/platform/tenants/{tenantId}/owner-transfer", "method": "POST", - "path": "/api/platform-admin/tenants/{tenantId}/owner-transfer", + "path": "/api/platform/tenants/{tenantId}/owner-transfer", "tag": "平台端-租户生命周期", "summary": "转移租户所有者", "description": "", @@ -3652,9 +3652,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenants/{tenantId}/primary-domain", + "id": "PUT /api/platform/tenants/{tenantId}/primary-domain", "method": "PUT", - "path": "/api/platform-admin/tenants/{tenantId}/primary-domain", + "path": "/api/platform/tenants/{tenantId}/primary-domain", "tag": "平台端-平台管理", "summary": "更正租户主域名", "description": "", @@ -3680,9 +3680,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "POST /api/platform-admin/tenants/{tenantId}/restore", + "id": "POST /api/platform/tenants/{tenantId}/restore", "method": "POST", - "path": "/api/platform-admin/tenants/{tenantId}/restore", + "path": "/api/platform/tenants/{tenantId}/restore", "tag": "平台端-租户生命周期", "summary": "恢复租户到暂停状态", "description": "", @@ -3708,9 +3708,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PUT /api/platform-admin/tenants/billing-profile", + "id": "PUT /api/platform/tenants/billing-profile", "method": "PUT", - "path": "/api/platform-admin/tenants/billing-profile", + "path": "/api/platform/tenants/billing-profile", "tag": "平台端-平台管理", "summary": "保存租户账务与开票资料", "description": "", @@ -3726,9 +3726,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "GET /api/platform-admin/tenants/detail", + "id": "GET /api/platform/tenants/detail", "method": "GET", - "path": "/api/platform-admin/tenants/detail", + "path": "/api/platform/tenants/detail", "tag": "平台端-平台管理", "summary": "查询平台租户详情", "description": "", @@ -3751,9 +3751,9 @@ export const platformOperations = [ "approvalPolicyCode": null }, { - "id": "PATCH /api/platform-admin/tenants/status", + "id": "PATCH /api/platform/tenants/status", "method": "PATCH", - "path": "/api/platform-admin/tenants/status", + "path": "/api/platform/tenants/status", "tag": "平台端-平台管理", "summary": "更新租户业务状态", "description": "", diff --git a/Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts b/Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts index 4f9288c..f5eea8d 100644 --- a/Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts +++ b/Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts @@ -4,7 +4,7 @@ */ export interface paths { - "/api/assets/{assetId}/download": { + "/api/student/assets/{assetId}/download": { parameters: { query?: never; header?: never; @@ -92,7 +92,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/assets/{assetId}/preview": { + "/api/student/assets/{assetId}/preview": { parameters: { query?: never; header?: never; @@ -180,7 +180,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/activation/complete": { + "/api/platform/auth/activation/complete": { parameters: { query?: never; header?: never; @@ -243,7 +243,133 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/sms/send": { + "/api/tenant/auth/activation/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 完成租户负责人一次性激活 + * @description 使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CompleteOwnerActivationDto"]; + "text/json": components["schemas"]["CompleteOwnerActivationDto"]; + "application/*+json": components["schemas"]["CompleteOwnerActivationDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/activation/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 完成租户负责人一次性激活 + * @description 使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CompleteOwnerActivationDto"]; + "text/json": components["schemas"]["CompleteOwnerActivationDto"]; + "application/*+json": components["schemas"]["CompleteOwnerActivationDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/sms/send": { parameters: { query?: never; header?: never; @@ -306,7 +432,133 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/login/password": { + "/api/tenant/auth/sms/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 发送短信验证码 + * @description 发送登录用途短信验证码,并应用租户级短信限流。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendSmsCodeDto"]; + "text/json": components["schemas"]["SendSmsCodeDto"]; + "application/*+json": components["schemas"]["SendSmsCodeDto"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmsSendResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/sms/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 发送短信验证码 + * @description 发送登录用途短信验证码,并应用租户级短信限流。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendSmsCodeDto"]; + "text/json": components["schemas"]["SendSmsCodeDto"]; + "application/*+json": components["schemas"]["SendSmsCodeDto"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmsSendResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/login/password": { parameters: { query?: never; header?: never; @@ -360,7 +612,115 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/login/sms": { + "/api/tenant/auth/login/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 手机号密码登录 + * @description 使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordLoginDto"]; + "text/json": components["schemas"]["PasswordLoginDto"]; + "application/*+json": components["schemas"]["PasswordLoginDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/login/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 手机号密码登录 + * @description 使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordLoginDto"]; + "text/json": components["schemas"]["PasswordLoginDto"]; + "application/*+json": components["schemas"]["PasswordLoginDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/login/sms": { parameters: { query?: never; header?: never; @@ -414,7 +774,115 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/oauth/wechat": { + "/api/tenant/auth/login/sms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 短信验证码登录 + * @description 校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SmsLoginDto"]; + "text/json": components["schemas"]["SmsLoginDto"]; + "application/*+json": components["schemas"]["SmsLoginDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/login/sms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 短信验证码登录 + * @description 校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SmsLoginDto"]; + "text/json": components["schemas"]["SmsLoginDto"]; + "application/*+json": components["schemas"]["SmsLoginDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/oauth/wechat": { parameters: { query?: never; header?: never; @@ -477,7 +945,133 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/oauth/wechat-miniapp": { + "/api/tenant/auth/oauth/wechat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 微信网页 OAuth 登录 + * @description 使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OAuthCodeDto"]; + "text/json": components["schemas"]["OAuthCodeDto"]; + "application/*+json": components["schemas"]["OAuthCodeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/oauth/wechat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 微信网页 OAuth 登录 + * @description 使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OAuthCodeDto"]; + "text/json": components["schemas"]["OAuthCodeDto"]; + "application/*+json": components["schemas"]["OAuthCodeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/oauth/wechat-miniapp": { parameters: { query?: never; header?: never; @@ -540,7 +1134,133 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/refresh": { + "/api/tenant/auth/oauth/wechat-miniapp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 微信小程序登录 + * @description 使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OAuthCodeDto"]; + "text/json": components["schemas"]["OAuthCodeDto"]; + "application/*+json": components["schemas"]["OAuthCodeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/oauth/wechat-miniapp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 微信小程序登录 + * @description 使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OAuthCodeDto"]; + "text/json": components["schemas"]["OAuthCodeDto"]; + "application/*+json": components["schemas"]["OAuthCodeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/refresh": { parameters: { query?: never; header?: never; @@ -585,7 +1305,97 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/logout": { + "/api/tenant/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 刷新登录会话 + * @description 使用 refresh token 轮换数据库 session,并签发新的 access/refresh token。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshSessionDto"]; + "text/json": components["schemas"]["RefreshSessionDto"]; + "application/*+json": components["schemas"]["RefreshSessionDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthTokenPair"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 刷新登录会话 + * @description 使用 refresh token 轮换数据库 session,并签发新的 access/refresh token。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshSessionDto"]; + "text/json": components["schemas"]["RefreshSessionDto"]; + "application/*+json": components["schemas"]["RefreshSessionDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthTokenPair"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/logout": { parameters: { query?: never; header?: never; @@ -630,7 +1440,97 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/logout-all": { + "/api/tenant/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 退出登录 + * @description 撤销 refresh token 对应的数据库 session;session 校验开启时,旧 access token 也会被拒绝。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshSessionDto"]; + "text/json": components["schemas"]["RefreshSessionDto"]; + "application/*+json": components["schemas"]["RefreshSessionDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 退出登录 + * @description 撤销 refresh token 对应的数据库 session;session 校验开启时,旧 access token 也会被拒绝。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshSessionDto"]; + "text/json": components["schemas"]["RefreshSessionDto"]; + "application/*+json": components["schemas"]["RefreshSessionDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/logout-all": { parameters: { query?: never; header?: never; @@ -669,7 +1569,85 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/password/change-required": { + "/api/tenant/auth/logout-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 退出全部登录会话 + * @description 撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/logout-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 退出全部登录会话 + * @description 撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/password/change-required": { parameters: { query?: never; header?: never; @@ -714,7 +1692,97 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/password/reset/sms/send": { + "/api/tenant/auth/password/change-required": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 修改首次登录必改密码 + * @description 校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequiredPasswordChangeDto"]; + "text/json": components["schemas"]["RequiredPasswordChangeDto"]; + "application/*+json": components["schemas"]["RequiredPasswordChangeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/password/change-required": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 修改首次登录必改密码 + * @description 校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequiredPasswordChangeDto"]; + "text/json": components["schemas"]["RequiredPasswordChangeDto"]; + "application/*+json": components["schemas"]["RequiredPasswordChangeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/password/reset/sms/send": { parameters: { query?: never; header?: never; @@ -759,7 +1827,97 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/password/reset": { + "/api/tenant/auth/password/reset/sms/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 发送密码重置短信验证码 + * @description 仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetSmsSendDto"]; + "text/json": components["schemas"]["PasswordResetSmsSendDto"]; + "application/*+json": components["schemas"]["PasswordResetSmsSendDto"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmsSendResult"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/password/reset/sms/send": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 发送密码重置短信验证码 + * @description 仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetSmsSendDto"]; + "text/json": components["schemas"]["PasswordResetSmsSendDto"]; + "application/*+json": components["schemas"]["PasswordResetSmsSendDto"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmsSendResult"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/password/reset": { parameters: { query?: never; header?: never; @@ -801,7 +1959,91 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/password/change": { + "/api/tenant/auth/password/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 使用短信验证码重置密码 */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetDto"]; + "text/json": components["schemas"]["PasswordResetDto"]; + "application/*+json": components["schemas"]["PasswordResetDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/password/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 使用短信验证码重置密码 */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetDto"]; + "text/json": components["schemas"]["PasswordResetDto"]; + "application/*+json": components["schemas"]["PasswordResetDto"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/platform/auth/password/change": { parameters: { query?: never; header?: never; @@ -846,7 +2088,97 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/jobs": { + "/api/tenant/auth/password/change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 已登录用户修改密码 + * @description 修改成功后撤销旧会话并返回新的令牌对。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + "text/json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + "application/*+json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/student/auth/password/change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 已登录用户修改密码 + * @description 修改成功后撤销旧会话并返回新的令牌对。 + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + "text/json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + "application/*+json": components["schemas"]["AuthenticatedPasswordChangeDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticationResultDto"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tenant/access/jobs": { parameters: { query?: never; header?: never; @@ -915,7 +2247,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/jobs/{jobId}": { + "/api/tenant/access/jobs/{jobId}": { parameters: { query?: never; header?: never; @@ -955,7 +2287,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/jobs/{jobId}/cancel": { + "/api/tenant/access/jobs/{jobId}/cancel": { parameters: { query?: never; header?: never; @@ -1001,7 +2333,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/jobs/{jobId}/retry": { + "/api/tenant/access/jobs/{jobId}/retry": { parameters: { query?: never; header?: never; @@ -1041,7 +2373,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/activation/complete": { + "/api/tenant/auth/browser/activation/complete": { parameters: { query?: never; header?: never; @@ -1083,7 +2415,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/sms/send": { + "/api/tenant/auth/browser/sms/send": { parameters: { query?: never; header?: never; @@ -1125,7 +2457,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/login/password": { + "/api/tenant/auth/browser/login/password": { parameters: { query?: never; header?: never; @@ -1167,7 +2499,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/login/sms": { + "/api/tenant/auth/browser/login/sms": { parameters: { query?: never; header?: never; @@ -1209,7 +2541,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/oauth/wechat": { + "/api/tenant/auth/browser/oauth/wechat": { parameters: { query?: never; header?: never; @@ -1251,7 +2583,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/oauth/wechat-miniapp": { + "/api/tenant/auth/browser/oauth/wechat-miniapp": { parameters: { query?: never; header?: never; @@ -1293,7 +2625,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/refresh": { + "/api/tenant/auth/browser/refresh": { parameters: { query?: never; header?: never; @@ -1332,7 +2664,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/logout": { + "/api/tenant/auth/browser/logout": { parameters: { query?: never; header?: never; @@ -1371,7 +2703,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/logout-all": { + "/api/tenant/auth/browser/logout-all": { parameters: { query?: never; header?: never; @@ -1407,7 +2739,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/password/reset/sms/send": { + "/api/tenant/auth/browser/password/reset/sms/send": { parameters: { query?: never; header?: never; @@ -1449,7 +2781,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/password/reset": { + "/api/tenant/auth/browser/password/reset": { parameters: { query?: never; header?: never; @@ -1491,7 +2823,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/browser-auth/password/change": { + "/api/tenant/auth/browser/password/change": { parameters: { query?: never; header?: never; @@ -1533,7 +2865,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/regions": { + "/api/public/catalog/regions": { parameters: { query?: never; header?: never; @@ -1601,7 +2933,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/region-modules": { + "/api/public/catalog/region-modules": { parameters: { query?: never; header?: never; @@ -1669,7 +3001,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/module-nodes": { + "/api/public/catalog/module-nodes": { parameters: { query?: never; header?: never; @@ -1737,7 +3069,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/schools": { + "/api/public/catalog/schools": { parameters: { query?: never; header?: never; @@ -1805,7 +3137,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/majors": { + "/api/public/catalog/majors": { parameters: { query?: never; header?: never; @@ -1873,7 +3205,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/subjects": { + "/api/public/catalog/subjects": { parameters: { query?: never; header?: never; @@ -1941,7 +3273,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/categories": { + "/api/public/catalog/categories": { parameters: { query?: never; header?: never; @@ -2009,7 +3341,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/question-categories": { + "/api/public/catalog/question-categories": { parameters: { query?: never; header?: never; @@ -2077,7 +3409,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/content-entries": { + "/api/public/catalog/content-entries": { parameters: { query?: never; header?: never; @@ -2151,7 +3483,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/content-nodes": { + "/api/public/catalog/content-nodes": { parameters: { query?: never; header?: never; @@ -2234,7 +3566,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/question-collections": { + "/api/public/catalog/question-collections": { parameters: { query?: never; header?: never; @@ -2308,7 +3640,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/question-collections/questions": { + "/api/public/catalog/question-collections/questions": { parameters: { query?: never; header?: never; @@ -2391,7 +3723,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/practice-blueprints": { + "/api/public/catalog/practice-blueprints": { parameters: { query?: never; header?: never; @@ -2465,7 +3797,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/question-banks": { + "/api/public/catalog/question-banks": { parameters: { query?: never; header?: never; @@ -2539,7 +3871,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/questions": { + "/api/public/catalog/questions": { parameters: { query?: never; header?: never; @@ -2616,7 +3948,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/questions/{questionId}": { + "/api/public/catalog/questions/{questionId}": { parameters: { query?: never; header?: never; @@ -2692,7 +4024,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/questions/{questionId}/versions": { + "/api/public/catalog/questions/{questionId}/versions": { parameters: { query?: never; header?: never; @@ -2768,7 +4100,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/vocabulary-units": { + "/api/public/catalog/vocabulary-units": { parameters: { query?: never; header?: never; @@ -2834,7 +4166,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/vocabulary-words": { + "/api/public/catalog/vocabulary-words": { parameters: { query?: never; header?: never; @@ -2900,7 +4232,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/handbook-subjects": { + "/api/public/catalog/handbook-subjects": { parameters: { query?: never; header?: never; @@ -2966,7 +4298,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/handbook-chapters": { + "/api/public/catalog/handbook-chapters": { parameters: { query?: never; header?: never; @@ -3032,7 +4364,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/handbook-entries": { + "/api/public/catalog/handbook-entries": { parameters: { query?: never; header?: never; @@ -3098,7 +4430,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/content-assets": { + "/api/public/catalog/content-assets": { parameters: { query?: never; header?: never; @@ -3172,7 +4504,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/images": { + "/api/public/catalog/images": { parameters: { query?: never; header?: never; @@ -3246,7 +4578,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/app-assets": { + "/api/public/catalog/app-assets": { parameters: { query?: never; header?: never; @@ -3320,7 +4652,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/video-explanations": { + "/api/public/catalog/video-explanations": { parameters: { query?: never; header?: never; @@ -3394,7 +4726,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/question-videos": { + "/api/public/catalog/question-videos": { parameters: { query?: never; header?: never; @@ -3468,7 +4800,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/banners": { + "/api/public/catalog/banners": { parameters: { query?: never; header?: never; @@ -3536,7 +4868,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/faqs": { + "/api/public/catalog/faqs": { parameters: { query?: never; header?: never; @@ -3604,7 +4936,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/announcements": { + "/api/public/catalog/announcements": { parameters: { query?: never; header?: never; @@ -3672,7 +5004,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/exam-dates": { + "/api/public/catalog/exam-dates": { parameters: { query?: never; header?: never; @@ -3740,7 +5072,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/products": { + "/api/public/catalog/products": { parameters: { query?: never; header?: never; @@ -3808,7 +5140,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/catalog/svip-plans": { + "/api/public/catalog/svip-plans": { parameters: { query?: never; header?: never; @@ -3876,7 +5208,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/orders": { + "/api/student/commerce/orders": { parameters: { query?: never; header?: never; @@ -3943,7 +5275,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/orders/{orderNo}": { + "/api/student/commerce/orders/{orderNo}": { parameters: { query?: never; header?: never; @@ -3981,7 +5313,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/payments": { + "/api/student/commerce/payments": { parameters: { query?: never; header?: never; @@ -4023,7 +5355,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/entitlements/current": { + "/api/student/commerce/entitlements/current": { parameters: { query?: never; header?: never; @@ -4059,7 +5391,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/coupons/claim": { + "/api/student/commerce/coupons/claim": { parameters: { query?: never; header?: never; @@ -4101,7 +5433,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/coupons": { + "/api/student/commerce/coupons": { parameters: { query?: never; header?: never; @@ -4142,7 +5474,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/coupons/check": { + "/api/student/commerce/coupons/check": { parameters: { query?: never; header?: never; @@ -4184,7 +5516,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/payments/notify/wechat-pay": { + "/api/student/commerce/payments/notify/wechat-pay": { parameters: { query?: never; header?: never; @@ -4222,7 +5554,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/payments/notify/alipay": { + "/api/student/commerce/payments/notify/alipay": { parameters: { query?: never; header?: never; @@ -4260,7 +5592,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/refunds/notify/wechat_pay": { + "/api/student/commerce/refunds/notify/wechat_pay": { parameters: { query?: never; header?: never; @@ -4304,7 +5636,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commerce/refunds/notify/alipay": { + "/api/student/commerce/refunds/notify/alipay": { parameters: { query?: never; header?: never; @@ -4348,7 +5680,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settings": { + "/api/tenant/commission/settings": { parameters: { query?: never; header?: never; @@ -4410,7 +5742,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/member-rate": { + "/api/tenant/commission/member-rate": { parameters: { query?: never; header?: never; @@ -4452,7 +5784,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/summary": { + "/api/tenant/commission/summary": { parameters: { query?: never; header?: never; @@ -4497,7 +5829,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/orders": { + "/api/tenant/commission/orders": { parameters: { query?: never; header?: never; @@ -4542,7 +5874,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements": { + "/api/tenant/commission/settlements": { parameters: { query?: never; header?: never; @@ -4585,7 +5917,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements/export": { + "/api/tenant/commission/settlements/export": { parameters: { query?: never; header?: never; @@ -4626,7 +5958,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements/generate": { + "/api/tenant/commission/settlements/generate": { parameters: { query?: never; header?: never; @@ -4668,7 +6000,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements/status": { + "/api/tenant/commission/settlements/status": { parameters: { query?: never; header?: never; @@ -4710,7 +6042,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements/proofs": { + "/api/tenant/commission/settlements/proofs": { parameters: { query?: never; header?: never; @@ -4775,7 +6107,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/commission/settlements/proofs/status": { + "/api/tenant/commission/settlements/proofs/status": { parameters: { query?: never; header?: never; @@ -4817,7 +6149,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/crm/config": { + "/api/tenant/crm/config": { parameters: { query?: never; header?: never; @@ -4879,7 +6211,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/crm/queue": { + "/api/tenant/crm/queue": { parameters: { query?: never; header?: never; @@ -4924,7 +6256,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/crm/dead-letters": { + "/api/tenant/crm/dead-letters": { parameters: { query?: never; header?: never; @@ -4969,7 +6301,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/crm/queue/logs": { + "/api/tenant/crm/queue/logs": { parameters: { query?: never; header?: never; @@ -5010,7 +6342,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/crm/queue/action": { + "/api/tenant/crm/queue/action": { parameters: { query?: never; header?: never; @@ -5052,7 +6384,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/health": { + "/api/system/health": { parameters: { query?: never; header?: never; @@ -5091,7 +6423,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/health/ready": { + "/api/system/health/ready": { parameters: { query?: never; header?: never; @@ -5127,7 +6459,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/stats": { + "/api/student/learning/stats": { parameters: { query?: never; header?: never; @@ -5163,7 +6495,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/trend": { + "/api/student/learning/trend": { parameters: { query?: never; header?: never; @@ -5206,7 +6538,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/leaderboard": { + "/api/student/learning/leaderboard": { parameters: { query?: never; header?: never; @@ -5249,7 +6581,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-sessions": { + "/api/student/learning/practice-sessions": { parameters: { query?: never; header?: never; @@ -5300,7 +6632,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-sessions/detail": { + "/api/student/learning/practice-sessions/detail": { parameters: { query?: never; header?: never; @@ -5356,7 +6688,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-sessions/submit": { + "/api/student/learning/practice-sessions/submit": { parameters: { query?: never; header?: never; @@ -5407,7 +6739,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-sessions/report": { + "/api/student/learning/practice-sessions/report": { parameters: { query?: never; header?: never; @@ -5463,7 +6795,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-reports": { + "/api/student/learning/practice-reports": { parameters: { query?: never; header?: never; @@ -5510,7 +6842,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/practice-sessions/history": { + "/api/student/learning/practice-sessions/history": { parameters: { query?: never; header?: never; @@ -5557,7 +6889,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/answers": { + "/api/student/learning/answers": { parameters: { query?: never; header?: never; @@ -5626,7 +6958,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/favorites/questions": { + "/api/student/learning/favorites/questions": { parameters: { query?: never; header?: never; @@ -5704,7 +7036,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/wrong-questions": { + "/api/student/learning/wrong-questions": { parameters: { query?: never; header?: never; @@ -5747,7 +7079,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/wrong-questions/review-plan": { + "/api/student/learning/wrong-questions/review-plan": { parameters: { query?: never; header?: never; @@ -5790,7 +7122,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/wrong-questions/resolve": { + "/api/student/learning/wrong-questions/resolve": { parameters: { query?: never; header?: never; @@ -5841,7 +7173,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/vocabulary/progress": { + "/api/student/learning/vocabulary/progress": { parameters: { query?: never; header?: never; @@ -5919,7 +7251,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/vocabulary/review-plan": { + "/api/student/learning/vocabulary/review-plan": { parameters: { query?: never; header?: never; @@ -5962,7 +7294,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/vocabulary/review": { + "/api/student/learning/vocabulary/review": { parameters: { query?: never; header?: never; @@ -6013,7 +7345,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/vocabulary/stats": { + "/api/student/learning/vocabulary/stats": { parameters: { query?: never; header?: never; @@ -6056,7 +7388,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/learning/vocabulary/favorites": { + "/api/student/learning/vocabulary/favorites": { parameters: { query?: never; header?: never; @@ -6134,7 +7466,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/me": { + "/api/tenant/me": { parameters: { query?: never; header?: never; @@ -6175,7 +7507,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/me/sessions": { + "/api/tenant/me/sessions": { parameters: { query?: never; header?: never; @@ -6213,7 +7545,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/me/sessions/{sessionFamilyId}": { + "/api/tenant/me/sessions/{sessionFamilyId}": { parameters: { query?: never; header?: never; @@ -6249,7 +7581,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/overview": { + "/api/platform/overview": { parameters: { query?: never; header?: never; @@ -6285,7 +7617,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants": { + "/api/platform/tenants": { parameters: { query?: never; header?: never; @@ -6356,7 +7688,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/primary-domain": { + "/api/platform/tenants/{tenantId}/primary-domain": { parameters: { query?: never; header?: never; @@ -6400,7 +7732,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/owner-activation-links": { + "/api/platform/tenants/{tenantId}/owner-activation-links": { parameters: { query?: never; header?: never; @@ -6449,7 +7781,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/billing-policy": { + "/api/platform/tenants/{tenantId}/billing-policy": { parameters: { query?: never; header?: never; @@ -6515,7 +7847,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/detail": { + "/api/platform/tenants/detail": { parameters: { query?: never; header?: never; @@ -6553,7 +7885,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/status": { + "/api/platform/tenants/status": { parameters: { query?: never; header?: never; @@ -6606,7 +7938,7 @@ export interface paths { }; trace?: never; }; - "/api/platform-admin/tenants/billing-profile": { + "/api/platform/tenants/billing-profile": { parameters: { query?: never; header?: never; @@ -6648,7 +7980,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/domains": { + "/api/platform/domains": { parameters: { query?: never; header?: never; @@ -6691,7 +8023,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/domains/{domainId}/recheck": { + "/api/platform/domains/{domainId}/recheck": { parameters: { query?: never; header?: never; @@ -6729,7 +8061,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/staff": { + "/api/platform/staff": { parameters: { query?: never; header?: never; @@ -6798,7 +8130,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/staff/status": { + "/api/platform/staff/status": { parameters: { query?: never; header?: never; @@ -6840,7 +8172,7 @@ export interface paths { }; trace?: never; }; - "/api/platform-admin/staff/{userId}/password-reset": { + "/api/platform/staff/{userId}/password-reset": { parameters: { query?: never; header?: never; @@ -6884,7 +8216,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/audit-logs": { + "/api/platform/audit-logs": { parameters: { query?: never; header?: never; @@ -6927,7 +8259,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/audit-alerts": { + "/api/platform/audit-alerts": { parameters: { query?: never; header?: never; @@ -6970,7 +8302,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/audit-alerts/status": { + "/api/platform/audit-alerts/status": { parameters: { query?: never; header?: never; @@ -7012,7 +8344,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/channels": { + "/api/platform/saas/dunning/channels": { parameters: { query?: never; header?: never; @@ -7081,7 +8413,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/channels/disable": { + "/api/platform/saas/dunning/channels/disable": { parameters: { query?: never; header?: never; @@ -7123,7 +8455,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/events": { + "/api/platform/saas/dunning/events": { parameters: { query?: never; header?: never; @@ -7166,7 +8498,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/events/detail": { + "/api/platform/saas/dunning/events/detail": { parameters: { query?: never; header?: never; @@ -7204,7 +8536,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/events/retry": { + "/api/platform/saas/dunning/events/retry": { parameters: { query?: never; header?: never; @@ -7246,7 +8578,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/events/acknowledge": { + "/api/platform/saas/dunning/events/acknowledge": { parameters: { query?: never; header?: never; @@ -7288,7 +8620,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/dunning/events/ignore": { + "/api/platform/saas/dunning/events/ignore": { parameters: { query?: never; header?: never; @@ -7330,7 +8662,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals": { + "/api/platform/approvals": { parameters: { query?: never; header?: never; @@ -7371,7 +8703,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/{requestId}": { + "/api/platform/approvals/{requestId}": { parameters: { query?: never; header?: never; @@ -7411,7 +8743,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/policies": { + "/api/platform/approvals/policies": { parameters: { query?: never; header?: never; @@ -7449,7 +8781,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/policies/{code}": { + "/api/platform/approvals/policies/{code}": { parameters: { query?: never; header?: never; @@ -7495,7 +8827,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/{requestId}/approve": { + "/api/platform/approvals/{requestId}/approve": { parameters: { query?: never; header?: never; @@ -7541,7 +8873,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/{requestId}/reject": { + "/api/platform/approvals/{requestId}/reject": { parameters: { query?: never; header?: never; @@ -7587,7 +8919,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/approvals/{requestId}/cancel": { + "/api/platform/approvals/{requestId}/cancel": { parameters: { query?: never; header?: never; @@ -7633,7 +8965,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/platform/ui-bootstrap": { + "/api/platform/access/ui-bootstrap": { parameters: { query?: never; header?: never; @@ -7674,7 +9006,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/platform/bootstrap": { + "/api/platform/access/bootstrap": { parameters: { query?: never; header?: never; @@ -7712,7 +9044,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/platform/roles": { + "/api/platform/access/roles": { parameters: { query?: never; header?: never; @@ -7756,7 +9088,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/platform/roles/{roleId}/bindings": { + "/api/platform/access/roles/{roleId}/bindings": { parameters: { query?: never; header?: never; @@ -7815,7 +9147,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/platform/users/{userId}/roles": { + "/api/platform/access/users/{userId}/roles": { parameters: { query?: never; header?: never; @@ -7857,7 +9189,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-billing/callbacks/{provider}": { + "/api/integrations/platform-billing/callbacks/{provider}": { parameters: { query?: never; header?: never; @@ -7896,7 +9228,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/configuration/definitions": { + "/api/platform/governance/configuration/definitions": { parameters: { query?: never; header?: never; @@ -7934,7 +9266,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/configuration/versions": { + "/api/platform/governance/configuration/versions": { parameters: { query?: never; header?: never; @@ -7975,7 +9307,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/configuration/drafts": { + "/api/platform/governance/configuration/drafts": { parameters: { query?: never; header?: never; @@ -8019,7 +9351,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/configuration/versions/{versionId}/publish": { + "/api/platform/governance/configuration/versions/{versionId}/publish": { parameters: { query?: never; header?: never; @@ -8059,7 +9391,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/configuration/versions/{versionId}/rollback": { + "/api/platform/governance/configuration/versions/{versionId}/rollback": { parameters: { query?: never; header?: never; @@ -8105,7 +9437,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/notifications/templates": { + "/api/platform/governance/notifications/templates": { parameters: { query?: never; header?: never; @@ -8171,7 +9503,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/notifications/send": { + "/api/platform/governance/notifications/send": { parameters: { query?: never; header?: never; @@ -8215,7 +9547,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/notifications/deliveries": { + "/api/platform/governance/notifications/deliveries": { parameters: { query?: never; header?: never; @@ -8258,7 +9590,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/governance/notifications/deliveries/{deliveryId}/retry": { + "/api/platform/governance/notifications/deliveries/{deliveryId}/retry": { parameters: { query?: never; header?: never; @@ -8298,7 +9630,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/health": { + "/api/platform/operations/health": { parameters: { query?: never; header?: never; @@ -8332,7 +9664,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/workers": { + "/api/platform/operations/workers": { parameters: { query?: never; header?: never; @@ -8366,7 +9698,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/job-metrics": { + "/api/platform/operations/job-metrics": { parameters: { query?: never; header?: never; @@ -8400,7 +9732,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/governance-metrics": { + "/api/platform/operations/governance-metrics": { parameters: { query?: never; header?: never; @@ -8434,7 +9766,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/jobs": { + "/api/platform/operations/jobs": { parameters: { query?: never; header?: never; @@ -8477,7 +9809,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/jobs/{jobId}": { + "/api/platform/operations/jobs/{jobId}": { parameters: { query?: never; header?: never; @@ -8517,7 +9849,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/jobs/{jobId}/cancel": { + "/api/platform/operations/jobs/{jobId}/cancel": { parameters: { query?: never; header?: never; @@ -8563,7 +9895,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/operations/jobs/{jobId}/retry": { + "/api/platform/operations/jobs/{jobId}/retry": { parameters: { query?: never; header?: never; @@ -8603,7 +9935,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/payment-settings/apps": { + "/api/platform/payment-settings/apps": { parameters: { query?: never; header?: never; @@ -8668,7 +10000,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/payment-settings/channels": { + "/api/platform/payment-settings/channels": { parameters: { query?: never; header?: never; @@ -8745,7 +10077,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/payment-settings/channels/{id}/disable": { + "/api/platform/payment-settings/channels/{id}/disable": { parameters: { query?: never; header?: never; @@ -8783,7 +10115,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/payment-settings/events": { + "/api/platform/payment-settings/events": { parameters: { query?: never; header?: never; @@ -8822,7 +10154,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/payment-settings/rebates/summary": { + "/api/platform/payment-settings/rebates/summary": { parameters: { query?: never; header?: never; @@ -8858,7 +10190,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks": { + "/api/platform/question-banks": { parameters: { query?: never; header?: never; @@ -8923,7 +10255,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/{bankId}/archive": { + "/api/platform/question-banks/{bankId}/archive": { parameters: { query?: never; header?: never; @@ -8961,7 +10293,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/{bankId}/nodes": { + "/api/platform/question-banks/{bankId}/nodes": { parameters: { query?: never; header?: never; @@ -8999,7 +10331,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/nodes": { + "/api/platform/question-banks/nodes": { parameters: { query?: never; header?: never; @@ -9041,7 +10373,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/nodes/batch": { + "/api/platform/question-banks/nodes/batch": { parameters: { query?: never; header?: never; @@ -9083,7 +10415,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/nodes/{nodeId}/archive": { + "/api/platform/question-banks/nodes/{nodeId}/archive": { parameters: { query?: never; header?: never; @@ -9121,7 +10453,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/questions": { + "/api/platform/question-banks/questions": { parameters: { query?: never; header?: never; @@ -9192,7 +10524,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/questions/archive": { + "/api/platform/question-banks/questions/archive": { parameters: { query?: never; header?: never; @@ -9234,7 +10566,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/imports/preview": { + "/api/platform/question-banks/imports/preview": { parameters: { query?: never; header?: never; @@ -9276,7 +10608,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/imports": { + "/api/platform/question-banks/imports": { parameters: { query?: never; header?: never; @@ -9318,7 +10650,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/imports/{jobId}": { + "/api/platform/question-banks/imports/{jobId}": { parameters: { query?: never; header?: never; @@ -9356,7 +10688,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/assets/upload-sign": { + "/api/platform/question-banks/assets/upload-sign": { parameters: { query?: never; header?: never; @@ -9398,7 +10730,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/question-banks/assets/upload-confirm": { + "/api/platform/question-banks/assets/upload-confirm": { parameters: { query?: never; header?: never; @@ -9440,7 +10772,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/catalog": { + "/api/platform/saas/catalog": { parameters: { query?: never; header?: never; @@ -9478,7 +10810,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/features": { + "/api/platform/saas/features": { parameters: { query?: never; header?: never; @@ -9522,7 +10854,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/feature-limits": { + "/api/platform/saas/feature-limits": { parameters: { query?: never; header?: never; @@ -9566,7 +10898,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/offerings": { + "/api/platform/saas/offerings": { parameters: { query?: never; header?: never; @@ -9610,7 +10942,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/offering-versions": { + "/api/platform/saas/offering-versions": { parameters: { query?: never; header?: never; @@ -9654,7 +10986,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/offering-versions/{versionId}/publish": { + "/api/platform/saas/offering-versions/{versionId}/publish": { parameters: { query?: never; header?: never; @@ -9694,7 +11026,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/offering-versions/{versionId}/clone": { + "/api/platform/saas/offering-versions/{versionId}/clone": { parameters: { query?: never; header?: never; @@ -9734,7 +11066,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/offering-versions/{versionId}/retire": { + "/api/platform/saas/offering-versions/{versionId}/retire": { parameters: { query?: never; header?: never; @@ -9774,7 +11106,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/orders": { + "/api/platform/saas/orders": { parameters: { query?: never; header?: never; @@ -9816,7 +11148,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/payments": { + "/api/platform/saas/payments": { parameters: { query?: never; header?: never; @@ -9858,7 +11190,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/refunds": { + "/api/platform/saas/refunds": { parameters: { query?: never; header?: never; @@ -9939,7 +11271,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/invoices": { + "/api/platform/saas/invoices": { parameters: { query?: never; header?: never; @@ -9981,7 +11313,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/usage": { + "/api/platform/saas/usage": { parameters: { query?: never; header?: never; @@ -10022,7 +11354,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/invoices/reminders": { + "/api/platform/saas/invoices/reminders": { parameters: { query?: never; header?: never; @@ -10064,7 +11396,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions": { + "/api/platform/saas/subscriptions": { parameters: { query?: never; header?: never; @@ -10106,7 +11438,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/payments/manual/confirm": { + "/api/platform/saas/payments/manual/confirm": { parameters: { query?: never; header?: never; @@ -10163,7 +11495,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/tenant-feature-overrides": { + "/api/platform/saas/tenant-feature-overrides": { parameters: { query?: never; header?: never; @@ -10207,7 +11539,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/metrics": { + "/api/platform/saas/metrics": { parameters: { query?: never; header?: never; @@ -10245,7 +11577,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions/trial": { + "/api/platform/saas/subscriptions/trial": { parameters: { query?: never; header?: never; @@ -10289,7 +11621,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions/{subscriptionId}/suspend": { + "/api/platform/saas/subscriptions/{subscriptionId}/suspend": { parameters: { query?: never; header?: never; @@ -10335,7 +11667,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions/{subscriptionId}/resume": { + "/api/platform/saas/subscriptions/{subscriptionId}/resume": { parameters: { query?: never; header?: never; @@ -10381,7 +11713,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions/{subscriptionId}/cancel": { + "/api/platform/saas/subscriptions/{subscriptionId}/cancel": { parameters: { query?: never; header?: never; @@ -10427,7 +11759,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/subscriptions/{subscriptionId}/extend": { + "/api/platform/saas/subscriptions/{subscriptionId}/extend": { parameters: { query?: never; header?: never; @@ -10473,7 +11805,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/refunds/{refundId}/approve": { + "/api/platform/saas/refunds/{refundId}/approve": { parameters: { query?: never; header?: never; @@ -10519,7 +11851,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/refunds/{refundId}/reject": { + "/api/platform/saas/refunds/{refundId}/reject": { parameters: { query?: never; header?: never; @@ -10565,7 +11897,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/saas/refunds/{refundId}/retry": { + "/api/platform/saas/refunds/{refundId}/retry": { parameters: { query?: never; header?: never; @@ -10611,7 +11943,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/crm/configs": { + "/api/platform/tenant-capabilities/crm/configs": { parameters: { query?: never; header?: never; @@ -10680,7 +12012,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/crm/leads": { + "/api/platform/tenant-capabilities/crm/leads": { parameters: { query?: never; header?: never; @@ -10723,7 +12055,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/crm/leads/retry": { + "/api/platform/tenant-capabilities/crm/leads/retry": { parameters: { query?: never; header?: never; @@ -10765,7 +12097,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/crm/logs": { + "/api/platform/tenant-capabilities/crm/logs": { parameters: { query?: never; header?: never; @@ -10807,7 +12139,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/channels": { + "/api/platform/tenant-capabilities/sms/channels": { parameters: { query?: never; header?: never; @@ -10876,7 +12208,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/channels/{id}/disable": { + "/api/platform/tenant-capabilities/sms/channels/{id}/disable": { parameters: { query?: never; header?: never; @@ -10914,7 +12246,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/templates": { + "/api/platform/tenant-capabilities/sms/templates": { parameters: { query?: never; header?: never; @@ -10983,7 +12315,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/templates/{id}/submit-review": { + "/api/platform/tenant-capabilities/sms/templates/{id}/submit-review": { parameters: { query?: never; header?: never; @@ -11021,7 +12353,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/templates/{id}/disable": { + "/api/platform/tenant-capabilities/sms/templates/{id}/disable": { parameters: { query?: never; header?: never; @@ -11059,7 +12391,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/sms/logs": { + "/api/platform/tenant-capabilities/sms/logs": { parameters: { query?: never; header?: never; @@ -11102,7 +12434,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/payments/apps": { + "/api/platform/tenant-capabilities/payments/apps": { parameters: { query?: never; header?: never; @@ -11171,7 +12503,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenant-capabilities/payments/events": { + "/api/platform/tenant-capabilities/payments/events": { parameters: { query?: never; header?: never; @@ -11214,7 +12546,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/archive-preview": { + "/api/platform/tenants/{tenantId}/archive-preview": { parameters: { query?: never; header?: never; @@ -11254,7 +12586,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/exports": { + "/api/platform/tenants/{tenantId}/exports": { parameters: { query?: never; header?: never; @@ -11294,7 +12626,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/exports/{operationId}": { + "/api/platform/tenants/{tenantId}/exports/{operationId}": { parameters: { query?: never; header?: never; @@ -11335,7 +12667,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/exports/{operationId}/download": { + "/api/platform/tenants/{tenantId}/exports/{operationId}/download": { parameters: { query?: never; header?: never; @@ -11376,7 +12708,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/archive": { + "/api/platform/tenants/{tenantId}/archive": { parameters: { query?: never; header?: never; @@ -11422,7 +12754,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/restore": { + "/api/platform/tenants/{tenantId}/restore": { parameters: { query?: never; header?: never; @@ -11468,7 +12800,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/platform-admin/tenants/{tenantId}/owner-transfer": { + "/api/platform/tenants/{tenantId}/owner-transfer": { parameters: { query?: never; header?: never; @@ -11514,7 +12846,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/points/summary": { + "/api/student/points/summary": { parameters: { query?: never; header?: never; @@ -11550,7 +12882,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/points/tasks": { + "/api/student/points/tasks": { parameters: { query?: never; header?: never; @@ -11593,7 +12925,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/points/tasks/claim": { + "/api/student/points/tasks/claim": { parameters: { query?: never; header?: never; @@ -11635,7 +12967,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/points/exchange-items": { + "/api/student/points/exchange-items": { parameters: { query?: never; header?: never; @@ -11678,7 +13010,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/points/exchange-orders": { + "/api/student/points/exchange-orders": { parameters: { query?: never; header?: never; @@ -11747,7 +13079,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/me": { + "/api/student/profile/me": { parameters: { query?: never; header?: never; @@ -11828,7 +13160,7 @@ export interface paths { }; trace?: never; }; - "/api/profile/exam-countdowns": { + "/api/student/profile/exam-countdowns": { parameters: { query?: never; header?: never; @@ -11883,7 +13215,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/notifications": { + "/api/student/profile/notifications": { parameters: { query?: never; header?: never; @@ -11938,7 +13270,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/notifications/status": { + "/api/student/profile/notifications/status": { parameters: { query?: never; header?: never; @@ -11980,7 +13312,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/badges": { + "/api/student/profile/badges": { parameters: { query?: never; header?: never; @@ -12035,7 +13367,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/feedbacks": { + "/api/student/profile/feedbacks": { parameters: { query?: never; header?: never; @@ -12116,7 +13448,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/check-in": { + "/api/student/profile/check-in": { parameters: { query?: never; header?: never; @@ -12152,7 +13484,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/profile/score-events": { + "/api/student/profile/score-events": { parameters: { query?: never; header?: never; @@ -12207,7 +13539,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/questions/videos": { + "/api/student/questions/videos": { parameters: { query?: never; header?: never; @@ -12250,7 +13582,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/questions/videos/batch": { + "/api/student/questions/videos/batch": { parameters: { query?: never; header?: never; @@ -12292,7 +13624,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/invite-code": { + "/api/student/referral/invite-code": { parameters: { query?: never; header?: never; @@ -12334,7 +13666,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/resolve": { + "/api/student/referral/resolve": { parameters: { query?: never; header?: never; @@ -12385,7 +13717,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/track-event": { + "/api/student/referral/track-event": { parameters: { query?: never; header?: never; @@ -12436,7 +13768,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/bind": { + "/api/student/referral/bind": { parameters: { query?: never; header?: never; @@ -12478,7 +13810,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/qrcode": { + "/api/student/referral/qrcode": { parameters: { query?: never; header?: never; @@ -12520,7 +13852,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/stats": { + "/api/tenant/referral/stats": { parameters: { query?: never; header?: never; @@ -12561,7 +13893,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/sales-stats": { + "/api/tenant/referral/sales-stats": { parameters: { query?: never; header?: never; @@ -12602,7 +13934,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/conversion-report": { + "/api/tenant/referral/conversion-report": { parameters: { query?: never; header?: never; @@ -12649,7 +13981,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/sales-clients": { + "/api/tenant/referral/sales-clients": { parameters: { query?: never; header?: never; @@ -12690,7 +14022,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/manual-bind": { + "/api/tenant/referral/manual-bind": { parameters: { query?: never; header?: never; @@ -12732,7 +14064,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/referral/team": { + "/api/tenant/referral/team": { parameters: { query?: never; header?: never; @@ -12797,7 +14129,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/runtime/bootstrap": { + "/api/public/runtime/bootstrap": { parameters: { query?: never; header?: never; @@ -12851,7 +14183,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/scoreline/fields": { + "/api/public/scoreline/fields": { parameters: { query?: never; header?: never; @@ -12917,7 +14249,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/scoreline/records": { + "/api/public/scoreline/records": { parameters: { query?: never; header?: never; @@ -12983,7 +14315,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/scoreline/records/cursor": { + "/api/public/scoreline/records/cursor": { parameters: { query?: never; header?: never; @@ -13049,7 +14381,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/scoreline/trend": { + "/api/public/scoreline/trend": { parameters: { query?: never; header?: never; @@ -13115,7 +14447,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/scoreline/years": { + "/api/public/scoreline/years": { parameters: { query?: never; header?: never; @@ -13181,7 +14513,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/taxonomy/nodes": { + "/api/tenant/taxonomy/nodes": { parameters: { query?: never; header?: never; @@ -13243,7 +14575,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/overview": { + "/api/tenant/overview": { parameters: { query?: never; header?: never; @@ -13279,7 +14611,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/classes": { + "/api/tenant/classes": { parameters: { query?: never; header?: never; @@ -13350,7 +14682,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/classes/disable": { + "/api/tenant/classes/disable": { parameters: { query?: never; header?: never; @@ -13392,7 +14724,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/classes/members": { + "/api/tenant/classes/members": { parameters: { query?: never; header?: never; @@ -13463,7 +14795,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/classes/members/remove": { + "/api/tenant/classes/members/remove": { parameters: { query?: never; header?: never; @@ -13505,7 +14837,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students": { + "/api/tenant/students": { parameters: { query?: never; header?: never; @@ -13578,7 +14910,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/status": { + "/api/tenant/students/status": { parameters: { query?: never; header?: never; @@ -13620,7 +14952,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/import/preview": { + "/api/tenant/students/import/preview": { parameters: { query?: never; header?: never; @@ -13662,7 +14994,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/import": { + "/api/tenant/students/import": { parameters: { query?: never; header?: never; @@ -13704,7 +15036,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/bulk-assign-class": { + "/api/tenant/students/bulk-assign-class": { parameters: { query?: never; header?: never; @@ -13746,7 +15078,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/bulk-status": { + "/api/tenant/students/bulk-status": { parameters: { query?: never; header?: never; @@ -13788,7 +15120,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/supervision/rules": { + "/api/tenant/students/supervision/rules": { parameters: { query?: never; header?: never; @@ -13850,7 +15182,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/supervision/preview": { + "/api/tenant/students/supervision/preview": { parameters: { query?: never; header?: never; @@ -13886,7 +15218,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/students/supervision/generate": { + "/api/tenant/students/supervision/generate": { parameters: { query?: never; header?: never; @@ -13928,7 +15260,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/student-followups/report": { + "/api/tenant/student-followups/report": { parameters: { query?: never; header?: never; @@ -13964,7 +15296,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/feedbacks/report": { + "/api/tenant/feedbacks/report": { parameters: { query?: never; header?: never; @@ -14000,7 +15332,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/points/risk-report": { + "/api/tenant/points/risk-report": { parameters: { query?: never; header?: never; @@ -14036,7 +15368,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/student-notes": { + "/api/tenant/student-notes": { parameters: { query?: never; header?: never; @@ -14105,7 +15437,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/student-followups": { + "/api/tenant/student-followups": { parameters: { query?: never; header?: never; @@ -14174,7 +15506,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/members": { + "/api/tenant/members": { parameters: { query?: never; header?: never; @@ -14245,7 +15577,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/members/disable": { + "/api/tenant/members/disable": { parameters: { query?: never; header?: never; @@ -14287,7 +15619,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/members/{userId}/password-reset": { + "/api/tenant/members/{userId}/password-reset": { parameters: { query?: never; header?: never; @@ -14331,7 +15663,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/audit-logs": { + "/api/tenant/audit-logs": { parameters: { query?: never; header?: never; @@ -14376,7 +15708,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/branding": { + "/api/tenant/branding": { parameters: { query?: never; header?: never; @@ -14418,7 +15750,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/settings": { + "/api/tenant/settings": { parameters: { query?: never; header?: never; @@ -14460,7 +15792,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/theme-templates": { + "/api/tenant/theme-templates": { parameters: { query?: never; header?: never; @@ -14496,7 +15828,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/theme": { + "/api/tenant/theme": { parameters: { query?: never; header?: never; @@ -14532,7 +15864,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/theme/preview": { + "/api/tenant/theme/preview": { parameters: { query?: never; header?: never; @@ -14574,7 +15906,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/theme/publish": { + "/api/tenant/theme/publish": { parameters: { query?: never; header?: never; @@ -14616,7 +15948,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/domains": { + "/api/tenant/domains": { parameters: { query?: never; header?: never; @@ -14678,7 +16010,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/auth-providers": { + "/api/tenant/auth-providers": { parameters: { query?: never; header?: never; @@ -14740,7 +16072,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/badges": { + "/api/tenant/badges": { parameters: { query?: never; header?: never; @@ -14809,7 +16141,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/badge-grants": { + "/api/tenant/badge-grants": { parameters: { query?: never; header?: never; @@ -14878,7 +16210,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/notifications": { + "/api/tenant/notifications": { parameters: { query?: never; header?: never; @@ -14949,7 +16281,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/feedbacks": { + "/api/tenant/feedbacks": { parameters: { query?: never; header?: never; @@ -14996,7 +16328,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/feedbacks/status": { + "/api/tenant/feedbacks/status": { parameters: { query?: never; header?: never; @@ -15038,7 +16370,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/ui-bootstrap": { + "/api/tenant/access/ui-bootstrap": { parameters: { query?: never; header?: never; @@ -15079,7 +16411,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/bootstrap": { + "/api/tenant/access/bootstrap": { parameters: { query?: never; header?: never; @@ -15117,7 +16449,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/roles": { + "/api/tenant/access/roles": { parameters: { query?: never; header?: never; @@ -15161,7 +16493,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/roles/{roleId}/bindings": { + "/api/tenant/access/roles/{roleId}/bindings": { parameters: { query?: never; header?: never; @@ -15207,7 +16539,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/backoffice/tenant/users/{userId}/roles": { + "/api/tenant/access/users/{userId}/roles": { parameters: { query?: never; header?: never; @@ -15249,7 +16581,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/catalog": { + "/api/tenant/billing/catalog": { parameters: { query?: never; header?: never; @@ -15287,7 +16619,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/quotes": { + "/api/tenant/billing/quotes": { parameters: { query?: never; header?: never; @@ -15331,7 +16663,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/orders": { + "/api/tenant/billing/orders": { parameters: { query?: never; header?: never; @@ -15399,7 +16731,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/orders/{orderNo}/payments": { + "/api/tenant/billing/orders/{orderNo}/payments": { parameters: { query?: never; header?: never; @@ -15445,7 +16777,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/orders/{orderNo}/cancel": { + "/api/tenant/billing/orders/{orderNo}/cancel": { parameters: { query?: never; header?: never; @@ -15485,7 +16817,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/orders/{orderNo}": { + "/api/tenant/billing/orders/{orderNo}": { parameters: { query?: never; header?: never; @@ -15525,7 +16857,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/subscription": { + "/api/tenant/billing/subscription": { parameters: { query?: never; header?: never; @@ -15563,7 +16895,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/subscription/change": { + "/api/tenant/billing/subscription/change": { parameters: { query?: never; header?: never; @@ -15607,7 +16939,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/subscription/renew": { + "/api/tenant/billing/subscription/renew": { parameters: { query?: never; header?: never; @@ -15651,7 +16983,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/subscription/cancel": { + "/api/tenant/billing/subscription/cancel": { parameters: { query?: never; header?: never; @@ -15689,7 +17021,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/usage": { + "/api/tenant/billing/usage": { parameters: { query?: never; header?: never; @@ -15727,7 +17059,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/invoices": { + "/api/tenant/billing/invoices": { parameters: { query?: never; header?: never; @@ -15767,7 +17099,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/receivables": { + "/api/tenant/billing/receivables": { parameters: { query?: never; header?: never; @@ -15807,7 +17139,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-billing/refunds": { + "/api/tenant/billing/refunds": { parameters: { query?: never; header?: never; @@ -15847,7 +17179,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/payment-accounts": { + "/api/tenant/commerce/payment-accounts": { parameters: { query?: never; header?: never; @@ -15920,7 +17252,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/secrets": { + "/api/tenant/commerce/secrets": { parameters: { query?: never; header?: never; @@ -15962,7 +17294,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/orders": { + "/api/tenant/commerce/orders": { parameters: { query?: never; header?: never; @@ -16009,7 +17341,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/payments": { + "/api/tenant/commerce/payments": { parameters: { query?: never; header?: never; @@ -16056,7 +17388,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/code-batches": { + "/api/tenant/commerce/code-batches": { parameters: { query?: never; header?: never; @@ -16098,7 +17430,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/activation-codes": { + "/api/tenant/commerce/activation-codes": { parameters: { query?: never; header?: never; @@ -16145,7 +17477,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/activation-codes/redeem": { + "/api/tenant/commerce/activation-codes/redeem": { parameters: { query?: never; header?: never; @@ -16187,7 +17519,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/point-activity-tasks": { + "/api/tenant/commerce/point-activity-tasks": { parameters: { query?: never; header?: never; @@ -16260,7 +17592,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/point-activity-claims": { + "/api/tenant/commerce/point-activity-claims": { parameters: { query?: never; header?: never; @@ -16307,7 +17639,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/point-exchange-items": { + "/api/tenant/commerce/point-exchange-items": { parameters: { query?: never; header?: never; @@ -16380,7 +17712,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/point-exchange-orders": { + "/api/tenant/commerce/point-exchange-orders": { parameters: { query?: never; header?: never; @@ -16427,7 +17759,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/point-exchange-orders/status": { + "/api/tenant/commerce/point-exchange-orders/status": { parameters: { query?: never; header?: never; @@ -16469,7 +17801,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/coupons": { + "/api/tenant/commerce/coupons": { parameters: { query?: never; header?: never; @@ -16542,7 +17874,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/coupons/redemptions": { + "/api/tenant/commerce/coupons/redemptions": { parameters: { query?: never; header?: never; @@ -16589,7 +17921,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/coupons/report": { + "/api/tenant/commerce/coupons/report": { parameters: { query?: never; header?: never; @@ -16636,7 +17968,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/refunds": { + "/api/tenant/commerce/refunds": { parameters: { query?: never; header?: never; @@ -16709,7 +18041,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/refunds/status": { + "/api/tenant/commerce/refunds/status": { parameters: { query?: never; header?: never; @@ -16751,7 +18083,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/refunds/{refundRequestId}/events": { + "/api/tenant/commerce/refunds/{refundRequestId}/events": { parameters: { query?: never; header?: never; @@ -16789,7 +18121,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/batches": { + "/api/tenant/commerce/reconciliation/batches": { parameters: { query?: never; header?: never; @@ -16862,7 +18194,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/issues": { + "/api/tenant/commerce/reconciliation/issues": { parameters: { query?: never; header?: never; @@ -16909,7 +18241,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/items": { + "/api/tenant/commerce/reconciliation/items": { parameters: { query?: never; header?: never; @@ -16947,7 +18279,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/issues/events": { + "/api/tenant/commerce/reconciliation/issues/events": { parameters: { query?: never; header?: never; @@ -16985,7 +18317,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/issues/status": { + "/api/tenant/commerce/reconciliation/issues/status": { parameters: { query?: never; header?: never; @@ -17027,7 +18359,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/adjustment-vouchers": { + "/api/tenant/commerce/adjustment-vouchers": { parameters: { query?: never; header?: never; @@ -17100,7 +18432,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/adjustment-vouchers/detail": { + "/api/tenant/commerce/adjustment-vouchers/detail": { parameters: { query?: never; header?: never; @@ -17138,7 +18470,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/adjustment-vouchers/status": { + "/api/tenant/commerce/adjustment-vouchers/status": { parameters: { query?: never; header?: never; @@ -17180,7 +18512,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/adjustment-vouchers/events": { + "/api/tenant/commerce/adjustment-vouchers/events": { parameters: { query?: never; header?: never; @@ -17218,7 +18550,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/adjustment-vouchers/report": { + "/api/tenant/commerce/adjustment-vouchers/report": { parameters: { query?: never; header?: never; @@ -17254,7 +18586,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/anomalies": { + "/api/tenant/commerce/reconciliation/anomalies": { parameters: { query?: never; header?: never; @@ -17290,7 +18622,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/preview": { + "/api/tenant/commerce/reconciliation/preview": { parameters: { query?: never; header?: never; @@ -17332,7 +18664,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/import": { + "/api/tenant/commerce/reconciliation/import": { parameters: { query?: never; header?: never; @@ -17374,7 +18706,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/provider-bills/jobs": { + "/api/tenant/commerce/reconciliation/provider-bills/jobs": { parameters: { query?: never; header?: never; @@ -17421,7 +18753,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-commerce/reconciliation/provider-bills/request": { + "/api/tenant/commerce/reconciliation/provider-bills/request": { parameters: { query?: never; header?: never; @@ -17463,7 +18795,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/entries": { + "/api/tenant/content/entries": { parameters: { query?: never; header?: never; @@ -17550,7 +18882,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/nodes": { + "/api/tenant/content/nodes": { parameters: { query?: never; header?: never; @@ -17637,7 +18969,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/question-collections": { + "/api/tenant/content/question-collections": { parameters: { query?: never; header?: never; @@ -17724,7 +19056,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/question-collections/items/replace": { + "/api/tenant/content/question-collections/items/replace": { parameters: { query?: never; header?: never; @@ -17766,7 +19098,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/practice-blueprints": { + "/api/tenant/content/practice-blueprints": { parameters: { query?: never; header?: never; @@ -17853,7 +19185,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/field-mapping": { + "/api/tenant/content/imports/field-mapping": { parameters: { query?: never; header?: never; @@ -17894,7 +19226,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/templates": { + "/api/tenant/content/imports/templates": { parameters: { query?: never; header?: never; @@ -17935,7 +19267,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets": { + "/api/tenant/content/assets": { parameters: { query?: never; header?: never; @@ -18018,7 +19350,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/access-events": { + "/api/tenant/content/assets/access-events": { parameters: { query?: never; header?: never; @@ -18061,7 +19393,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/security-scan-events": { + "/api/tenant/content/assets/security-scan-events": { parameters: { query?: never; header?: never; @@ -18104,7 +19436,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/uploads/sign": { + "/api/tenant/content/assets/uploads/sign": { parameters: { query?: never; header?: never; @@ -18164,7 +19496,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/uploads/confirm": { + "/api/tenant/content/assets/uploads/confirm": { parameters: { query?: never; header?: never; @@ -18224,7 +19556,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/{assetId}": { + "/api/tenant/content/assets/{assetId}": { parameters: { query?: never; header?: never; @@ -18271,7 +19603,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/sign-download": { + "/api/tenant/content/assets/sign-download": { parameters: { query?: never; header?: never; @@ -18313,7 +19645,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/assets/sign-preview": { + "/api/tenant/content/assets/sign-preview": { parameters: { query?: never; header?: never; @@ -18355,7 +19687,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/import-jobs": { + "/api/tenant/content/import-jobs": { parameters: { query?: never; header?: never; @@ -18400,7 +19732,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/import-jobs/{jobId}": { + "/api/tenant/content/import-jobs/{jobId}": { parameters: { query?: never; header?: never; @@ -18447,7 +19779,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/questions": { + "/api/tenant/content/questions": { parameters: { query?: never; header?: never; @@ -18515,7 +19847,7 @@ export interface paths { }; trace?: never; }; - "/api/tenant-content/vocabulary-units": { + "/api/tenant/content/vocabulary-units": { parameters: { query?: never; header?: never; @@ -18606,7 +19938,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/vocabulary-words": { + "/api/tenant/content/vocabulary-words": { parameters: { query?: never; header?: never; @@ -18697,7 +20029,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/handbook-subjects": { + "/api/tenant/content/handbook-subjects": { parameters: { query?: never; header?: never; @@ -18788,7 +20120,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/handbook-chapters": { + "/api/tenant/content/handbook-chapters": { parameters: { query?: never; header?: never; @@ -18879,7 +20211,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/handbook-entries": { + "/api/tenant/content/handbook-entries": { parameters: { query?: never; header?: never; @@ -18970,7 +20302,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/schools": { + "/api/tenant/content/scoreline/schools": { parameters: { query?: never; header?: never; @@ -19061,7 +20393,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/majors": { + "/api/tenant/content/scoreline/majors": { parameters: { query?: never; header?: never; @@ -19152,7 +20484,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/fields": { + "/api/tenant/content/scoreline/fields": { parameters: { query?: never; header?: never; @@ -19243,7 +20575,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/records": { + "/api/tenant/content/scoreline/records": { parameters: { query?: never; header?: never; @@ -19334,7 +20666,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/years": { + "/api/tenant/content/scoreline/years": { parameters: { query?: never; header?: never; @@ -19399,7 +20731,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/scoreline/trend": { + "/api/tenant/content/scoreline/trend": { parameters: { query?: never; header?: never; @@ -19464,7 +20796,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/videos": { + "/api/tenant/content/videos": { parameters: { query?: never; header?: never; @@ -19555,7 +20887,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/question-videos": { + "/api/tenant/content/question-videos": { parameters: { query?: never; header?: never; @@ -19597,7 +20929,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/operations/{kind}": { + "/api/tenant/content/operations/{kind}": { parameters: { query?: never; header?: never; @@ -19692,7 +21024,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/preview/{importType}": { + "/api/tenant/content/imports/preview/{importType}": { parameters: { query?: never; header?: never; @@ -19736,7 +21068,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/{importType}": { + "/api/tenant/content/imports/{importType}": { parameters: { query?: never; header?: never; @@ -19789,7 +21121,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/detail": { + "/api/tenant/content/imports/detail": { parameters: { query?: never; header?: never; @@ -19828,7 +21160,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/issues": { + "/api/tenant/content/imports/issues": { parameters: { query?: never; header?: never; @@ -19867,7 +21199,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-content/imports/post-check": { + "/api/tenant/content/imports/post-check": { parameters: { query?: never; header?: never; @@ -19932,7 +21264,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/frontend-config": { + "/api/tenant/frontend-config": { parameters: { query?: never; header?: never; @@ -19968,7 +21300,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/frontend-config/draft": { + "/api/tenant/frontend-config/draft": { parameters: { query?: never; header?: never; @@ -20010,7 +21342,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-admin/frontend-config/publish": { + "/api/tenant/frontend-config/publish": { parameters: { query?: never; header?: never; @@ -20052,7 +21384,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant-onboarding/status": { + "/api/tenant/onboarding/status": { parameters: { query?: never; header?: never; @@ -20090,7 +21422,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant/resolve": { + "/api/public/tenant/resolve": { parameters: { query?: never; header?: never; @@ -20143,7 +21475,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenant/current-public": { + "/api/public/tenant/current-public": { parameters: { query?: never; header?: never; @@ -20196,7 +21528,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/tenants/current": { + "/api/tenant/context/current": { parameters: { query?: never; header?: never; @@ -20237,7 +21569,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/videos/search": { + "/api/student/videos/search": { parameters: { query?: never; header?: never; @@ -20280,7 +21612,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/videos/play": { + "/api/student/videos/play": { parameters: { query?: never; header?: never; @@ -20322,7 +21654,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/videos/progress": { + "/api/student/videos/progress": { parameters: { query?: never; header?: never; diff --git a/Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx b/Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx index 13720b7..bca19a8 100644 --- a/Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx +++ b/Tiku.PlatformAdmin.Web/src/auth/AuthProvider.tsx @@ -43,7 +43,7 @@ export function AuthProvider({ children }: PropsWithChildren) { try { const [currentUser, uiBootstrap] = await Promise.all([ getCurrentUser(), - platformRequest('GET', '/api/backoffice/platform/ui-bootstrap'), + platformRequest('GET', '/api/platform/access/ui-bootstrap'), ]); setUser(currentUser); setBootstrap(uiBootstrap); @@ -85,7 +85,7 @@ export function AuthProvider({ children }: PropsWithChildren) { }, challengeToken, async login(identifier, password) { - const result = await authRequest('/api/auth/login/password', { + const result = await authRequest('/api/platform/auth/login/password', { realm: platformRealm, tenantCode: null, identifier, @@ -96,7 +96,7 @@ export function AuthProvider({ children }: PropsWithChildren) { }, async completePasswordChange(password) { if (!challengeToken) throw new Error('密码变更挑战已失效,请重新登录'); - const result = await authRequest('/api/auth/password/change-required', { + const result = await authRequest('/api/platform/auth/password/change-required', { challengeToken, newPassword: password, }); @@ -105,7 +105,7 @@ export function AuthProvider({ children }: PropsWithChildren) { async logout() { const refreshToken = tokenStore.get()?.refreshToken; try { - if (refreshToken) await authRequest('/api/auth/logout', { refreshToken }); + if (refreshToken) await authRequest('/api/platform/auth/logout', { refreshToken }); } finally { tokenStore.set(null); setUser(null); diff --git a/Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx b/Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx index 1d1cea7..ee22c12 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/ApprovalCenterPage.tsx @@ -35,8 +35,8 @@ export function ApprovalCenterPage() { setLoading(true); try { const [requests, policyItems] = await Promise.all([ - platformRequest('GET', '/api/platform-admin/approvals', { query: { limit: 200 } }), - platformRequest('GET', '/api/platform-admin/approvals/policies'), + platformRequest('GET', '/api/platform/approvals', { query: { limit: 200 } }), + platformRequest('GET', '/api/platform/approvals/policies'), ]); setItems(requests); setPolicies(policyItems); @@ -60,7 +60,7 @@ export function ApprovalCenterPage() { onOk: async () => { setLoading(true); try { - const result = await platformRequest('POST', `/api/platform-admin/approvals/{requestId}/${action}`, { + const result = await platformRequest('POST', `/api/platform/approvals/{requestId}/${action}`, { path: { requestId: selected.id }, body: { reason }, }); setSelected(result); diff --git a/Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx b/Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx index d76a443..502dec2 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx @@ -22,26 +22,26 @@ const workbenches: Record operation.path.startsWith('/api/platform-admin/tenants') || operation.path === '/api/platform-admin/saas/subscriptions/trial', + preferredRead: 'GET /api/platform/tenants', + matches: (operation) => operation.path.startsWith('/api/platform/tenants') || operation.path === '/api/platform/saas/subscriptions/trial', }, receivables: { title: '订阅与应收工作台', description: '统一查看商业指标、订阅、订单和应收,并执行暂停、恢复、取消、延长及人工收款。', - preferredRead: 'GET /api/platform-admin/saas/metrics', - matches: (operation) => operation.path === '/api/platform-admin/saas/metrics' || operation.path.startsWith('/api/platform-admin/saas/subscriptions') || operation.path.startsWith('/api/platform-admin/saas/orders') || operation.path === '/api/platform-admin/saas/invoices' || operation.path.startsWith('/api/platform-admin/saas/payments'), + preferredRead: 'GET /api/platform/saas/metrics', + matches: (operation) => operation.path === '/api/platform/saas/metrics' || operation.path.startsWith('/api/platform/saas/subscriptions') || operation.path.startsWith('/api/platform/saas/orders') || operation.path === '/api/platform/saas/invoices' || operation.path.startsWith('/api/platform/saas/payments'), }, dunning: { title: '催缴工作台', description: '跟踪提醒、外部投递、失败原因和重试状态;高风险渠道变更和人工重试均需要二次确认。', - preferredRead: 'GET /api/platform-admin/saas/dunning/events', - matches: (operation) => operation.path.startsWith('/api/platform-admin/saas/dunning') || operation.path.startsWith('/api/platform-admin/saas/invoices/reminders'), + preferredRead: 'GET /api/platform/saas/dunning/events', + matches: (operation) => operation.path.startsWith('/api/platform/saas/dunning') || operation.path.startsWith('/api/platform/saas/invoices/reminders'), }, refunds: { title: '退款工作台', description: '按申请、审核、执行和失败重试处理 SaaS 退款;每次申请必须明确服务保持、期末取消或立即终止。', - preferredRead: 'GET /api/platform-admin/saas/refunds', - matches: (operation) => operation.path.startsWith('/api/platform-admin/saas/refunds'), + preferredRead: 'GET /api/platform/saas/refunds', + matches: (operation) => operation.path.startsWith('/api/platform/saas/refunds'), }, }; diff --git a/Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx b/Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx index 603ba69..685baf6 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/DashboardPage.tsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router'; import { apiRequest } from '../api/http'; import { platformOperations } from '../api/platform-operations.generated'; -const overviewOperation = platformOperations.find((operation) => operation.path === '/api/platform-admin/overview' && operation.method === 'GET'); +const overviewOperation = platformOperations.find((operation) => operation.path === '/api/platform/overview' && operation.method === 'GET'); const metricLabels: Record = { tenantCount: '租户总数', diff --git a/Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx b/Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx index 0caa983..83841f7 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/GovernanceCenterPage.tsx @@ -20,12 +20,12 @@ export function ConfigurationCenterPage() { const [form] = Form.useForm<{ environment: string; value?: string; secretRef?: string; reason: string }>(); const loadDefinitions = useCallback(async () => { - const values = await platformRequest('GET', '/api/platform-admin/governance/configuration/definitions'); + const values = await platformRequest('GET', '/api/platform/governance/configuration/definitions'); setDefinitions(values); setSelected((current) => current ?? values[0]); }, []); const loadVersions = useCallback(async (definition?: Definition) => { if (!definition) return setVersions([]); - setVersions(await platformRequest('GET', '/api/platform-admin/governance/configuration/versions', { query: { definitionCode: definition.code } })); + setVersions(await platformRequest('GET', '/api/platform/governance/configuration/versions', { query: { definitionCode: definition.code } })); }, []); useEffect(() => { setLoading(true); loadDefinitions().catch((error) => message.error(error instanceof Error ? error.message : '配置定义加载失败')).finally(() => setLoading(false)); }, [loadDefinitions, message]); useEffect(() => { void loadVersions(selected); }, [loadVersions, selected]); @@ -40,7 +40,7 @@ export function ConfigurationCenterPage() { } setLoading(true); try { - await platformRequest('POST', '/api/platform-admin/governance/configuration/drafts', { body: { definitionCode: selected.code, environment: values.environment, value: parsed, secretRef: values.secretRef, reason: values.reason } }); + await platformRequest('POST', '/api/platform/governance/configuration/drafts', { body: { definitionCode: selected.code, environment: values.environment, value: parsed, secretRef: values.secretRef, reason: values.reason } }); setOpen(false); form.resetFields(); message.success('配置草稿已保存'); await loadVersions(selected); } catch (error) { message.error(error instanceof Error ? error.message : '配置草稿保存失败'); } finally { setLoading(false); } @@ -50,7 +50,7 @@ export function ConfigurationCenterPage() { content: '变更会保留完整版本和平台审计记录。', onOk: async () => { const body = action === 'rollback' ? { reason: `回滚到 v${version.version}` } : undefined; - await platformRequest('POST', `/api/platform-admin/governance/configuration/versions/{versionId}/${action}`, { path: { versionId: version.id }, body }); + await platformRequest('POST', `/api/platform/governance/configuration/versions/{versionId}/${action}`, { path: { versionId: version.id }, body }); message.success(action === 'publish' ? '配置已发布' : '配置已回滚'); await loadVersions(selected); }, }); @@ -71,14 +71,14 @@ export function NotificationCenterPage() { const [sendForm] = Form.useForm(); const load = useCallback(async () => { const [templateItems, deliveryPage] = await Promise.all([ - platformRequest('GET', '/api/platform-admin/governance/notifications/templates'), - platformRequest>('GET', '/api/platform-admin/governance/notifications/deliveries', { query: { page: 1, pageSize: 100 } }), + platformRequest('GET', '/api/platform/governance/notifications/templates'), + platformRequest>('GET', '/api/platform/governance/notifications/deliveries', { query: { page: 1, pageSize: 100 } }), ]); setTemplates(templateItems); setDeliveries(deliveryPage.items); }, []); useEffect(() => { load().catch((error) => message.error(error instanceof Error ? error.message : '通知中心加载失败')); }, [load, message]); - const saveTemplate = async () => { const values = await templateForm.validateFields(); await platformRequest('PUT', '/api/platform-admin/governance/notifications/templates', { body: { ...values, variables: [] } }); setTemplateOpen(false); templateForm.resetFields(); message.success('模板已保存'); await load(); }; - const send = async () => { const values = await sendForm.validateFields(); let variables = {}; try { variables = JSON.parse(values.variables || '{}'); } catch { return message.error('变量必须是 JSON 对象'); } await platformRequest('POST', '/api/platform-admin/governance/notifications/send', { body: { templateId: values.templateId, roleCodes: values.roleCodes.split(',').map((item: string) => item.trim()).filter(Boolean), variables, idempotencyKey: crypto.randomUUID() } }); setSendOpen(false); sendForm.resetFields(); message.success('通知投递已创建'); await load(); }; - const retry = async (id: string) => { await platformRequest('POST', '/api/platform-admin/governance/notifications/deliveries/{deliveryId}/retry', { path: { deliveryId: id } }); message.success('已重新入队'); await load(); }; + const saveTemplate = async () => { const values = await templateForm.validateFields(); await platformRequest('PUT', '/api/platform/governance/notifications/templates', { body: { ...values, variables: [] } }); setTemplateOpen(false); templateForm.resetFields(); message.success('模板已保存'); await load(); }; + const send = async () => { const values = await sendForm.validateFields(); let variables = {}; try { variables = JSON.parse(values.variables || '{}'); } catch { return message.error('变量必须是 JSON 对象'); } await platformRequest('POST', '/api/platform/governance/notifications/send', { body: { templateId: values.templateId, roleCodes: values.roleCodes.split(',').map((item: string) => item.trim()).filter(Boolean), variables, idempotencyKey: crypto.randomUUID() } }); setSendOpen(false); sendForm.resetFields(); message.success('通知投递已创建'); await load(); }; + const retry = async (id: string) => { await platformRequest('POST', '/api/platform/governance/notifications/deliveries/{deliveryId}/retry', { path: { deliveryId: id } }); message.success('已重新入队'); await load(); }; return
平台治理通知中心按平台岗位发送站内、短信或邮件通知,并跟踪投递与失败重试。
{value ? '启用' : '停用'} }]} />
{statusName[String(value)] ?? String(value)} }, { title: '尝试', dataIndex: 'attempts' }, { title: '错误', dataIndex: 'lastError' }, { title: '操作', render: (_, row: Delivery) => ['0', '3', 'Pending', 'Failed'].includes(String(row.status)) ? : null }]} /> diff --git a/Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx b/Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx index 54b300f..2350530 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/QuestionBankPage.tsx @@ -135,7 +135,7 @@ export function QuestionBankPage() { const loadBanks = useCallback(async (status = bankStatus, keyword = bankKeyword) => { setLoading(true); try { - const result = await platformRequest('GET', '/api/platform-admin/question-banks', { query: { keyword, status } }); + const result = await platformRequest('GET', '/api/platform/question-banks', { query: { keyword, status } }); setBanks(result); setBankId((current) => result.some((item) => item.id === current) ? current : result[0]?.id || ''); } catch (error) { message.error(error instanceof Error ? error.message : '题库加载失败'); } @@ -145,7 +145,7 @@ export function QuestionBankPage() { const loadNodes = useCallback(async () => { if (!bankId) { setNodes([]); setNodeId(''); return; } try { - const result = await platformRequest('GET', '/api/platform-admin/question-banks/{bankId}/nodes', { path: { bankId } }); + const result = await platformRequest('GET', '/api/platform/question-banks/{bankId}/nodes', { path: { bankId } }); setNodes(result); setNodeId((current) => result.some((item) => item.id === current) ? current : ''); } catch (error) { message.error(error instanceof Error ? error.message : '目录加载失败'); } @@ -155,7 +155,7 @@ export function QuestionBankPage() { if (!bankId) { setQuestions([]); setTotal(0); return; } setLoading(true); try { - const result = await platformRequest('GET', '/api/platform-admin/question-banks/questions', { + const result = await platformRequest('GET', '/api/platform/question-banks/questions', { query: { questionBankId: bankId, contentNodeId: nodeId || undefined, ...filters, page, pageSize }, }); setQuestions(result.items); @@ -176,7 +176,7 @@ export function QuestionBankPage() { const saveBank = async () => { const values = await bankForm.validateFields(); try { - const saved = await platformRequest('PUT', '/api/platform-admin/question-banks', { body: { id: values.id || null, name: values.name, metadata: parseJson(values.metadata, {}) } }); + const saved = await platformRequest('PUT', '/api/platform/question-banks', { body: { id: values.id || null, name: values.name, metadata: parseJson(values.metadata, {}) } }); message.success('题库已保存'); setBankDrawer(undefined); await loadBanks(); setBankId(saved.id); } catch (error) { message.error(error instanceof Error ? error.message : '题库保存失败'); } }; @@ -193,7 +193,7 @@ export function QuestionBankPage() { const saveNode = async () => { const values = await nodeForm.validateFields(); try { - await platformRequest('PUT', '/api/platform-admin/question-banks/nodes', { body: { ...values, questionBankId: bankId, parentId: values.parentId || null, nodeKey: values.nodeKey || null, metadata: parseJson(values.metadata, {}) } }); + await platformRequest('PUT', '/api/platform/question-banks/nodes', { body: { ...values, questionBankId: bankId, parentId: values.parentId || null, nodeKey: values.nodeKey || null, metadata: parseJson(values.metadata, {}) } }); message.success('目录节点已保存'); setNodeDrawer(undefined); await loadNodes(); } catch (error) { message.error(error instanceof Error ? error.message : '目录保存失败'); } }; @@ -202,7 +202,7 @@ export function QuestionBankPage() { const names = String(values.names).split(/\n|,/).map((item) => item.trim()).filter(Boolean); if (!names.length) return message.error('请输入至少一个节点名称'); if (names.length > 100) return message.error('一次最多创建 100 个节点'); - await platformRequest('POST', '/api/platform-admin/question-banks/nodes/batch', { body: { questionBankId: bankId, parentId: values.parentId || null, nodeType: values.nodeType, names } }); + await platformRequest('POST', '/api/platform/question-banks/nodes/batch', { body: { questionBankId: bankId, parentId: values.parentId || null, nodeType: values.nodeType, names } }); message.success(`已创建 ${names.length} 个节点`); setBatchNodeOpen(false); batchForm.resetFields(); await loadNodes(); }; @@ -221,7 +221,7 @@ export function QuestionBankPage() { const saveQuestion = async () => { const values = await questionForm.validateFields(); try { - await platformRequest('PUT', '/api/platform-admin/question-banks/questions', { body: { + await platformRequest('PUT', '/api/platform/question-banks/questions', { body: { ...values, id: values.id || null, questionBankId: bankId, contentNodeId: questionDrawer?.contentNodeId || nodeId, legacyId: questionDrawer?.legacyId || null, options: parseJson(values.options, []), correctOptionIndices: parseJson(values.correctOptionIndices, []), tags: parseJson(values.tags, []), subQuestions: parseJson(values.subQuestions, []), examMarkers: {}, sourceHash: null, @@ -232,14 +232,14 @@ export function QuestionBankPage() { const archiveQuestions = () => modal.confirm({ title: `归档 ${selectedQuestionIds.length} 道题目`, content: '归档后不会再作为正常题目展示。', okButtonProps: { danger: true }, - onOk: async () => { await platformRequest('POST', '/api/platform-admin/question-banks/questions/archive', { body: { questionIds: selectedQuestionIds } }); message.success('题目已归档'); await loadQuestions(); }, + onOk: async () => { await platformRequest('POST', '/api/platform/question-banks/questions/archive', { body: { questionIds: selectedQuestionIds } }); message.success('题目已归档'); await loadQuestions(); }, }); const runImport = async (preview: boolean) => { const values = await importForm.validateFields(); try { const payload = parseJson(values.payload, []); - const result = await platformRequest('POST', preview ? '/api/platform-admin/question-banks/imports/preview' : '/api/platform-admin/question-banks/imports', { + const result = await platformRequest('POST', preview ? '/api/platform/question-banks/imports/preview' : '/api/platform/question-banks/imports', { body: { questionBankId: bankId, contentNodeId: values.format === 'simple' ? (values.contentNodeId || nodeId || null) : null, format: values.format, sourceName: values.sourceName || null, payload }, }); setImportResult(result); @@ -252,7 +252,7 @@ export function QuestionBankPage() { const blob = file as File; try { onProgress?.({ percent: 10 }); - const sign = await platformRequest('POST', '/api/platform-admin/question-banks/assets/upload-sign', { body: { + const sign = await platformRequest('POST', '/api/platform/question-banks/assets/upload-sign', { body: { contentNodeId: nodeId || null, fileName: blob.name, mimeType: blob.type || 'application/octet-stream', fileSizeBytes: blob.size, title: blob.name, category: 'question', assetType: 'image', visibility: 'public', isPublic: true, metadata: {}, } }); @@ -260,7 +260,7 @@ export function QuestionBankPage() { const uploaded = await fetch(sign.upload.url, { method: sign.upload.method || 'PUT', headers, body: blob }); if (!uploaded.ok) throw new Error(`文件上传失败(HTTP ${uploaded.status})`); onProgress?.({ percent: 80 }); - const confirmed = await platformRequest('POST', '/api/platform-admin/question-banks/assets/upload-confirm', { body: { assetId: sign.item.id, mimeType: blob.type, fileSizeBytes: blob.size } }); + const confirmed = await platformRequest('POST', '/api/platform/question-banks/assets/upload-confirm', { body: { assetId: sign.item.id, mimeType: blob.type, fileSizeBytes: blob.size } }); questionForm.setFieldValue('mediaUrl', confirmed.item.cdnUrl || sign.upload.url.split('?')[0]); onProgress?.({ percent: 100 }); onSuccess?.(confirmed); message.success('资源上传成功'); } catch (error) { onError?.(error as Error); message.error(error instanceof Error ? error.message : '资源上传失败'); } @@ -294,7 +294,7 @@ export function QuestionBankPage() {
内容目录 { await platformRequest('POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive', { path: { nodeId } }); message.success('节点已归档'); await loadNodes(); }}> { await platformRequest('POST', '/api/platform/question-banks/nodes/{nodeId}/archive', { path: { nodeId } }); message.success('节点已归档'); await loadNodes(); }}>
@@ -306,7 +306,7 @@ export function QuestionBankPage() { setBankDrawer(undefined)} extra={}> -
{ parseJson(value, {}); } }]}>{bankDrawer?.id && { await platformRequest('POST', '/api/platform-admin/question-banks/{bankId}/archive', { path: { bankId: bankDrawer.id } }); setBankDrawer(undefined); await loadBanks(); }}>} +
{ parseJson(value, {}); } }]}>{bankDrawer?.id && { await platformRequest('POST', '/api/platform/question-banks/{bankId}/archive', { path: { bankId: bankDrawer.id } }); setBankDrawer(undefined); await loadBanks(); }}>}
setNodeDrawer(undefined)} extra={}>
diff --git a/Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx b/Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx index 85918ee..fec6c5a 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx +++ b/Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx @@ -85,7 +85,7 @@ export function TenantOnboardingWorkbench() { const [domainForm] = Form.useForm<{ host: string; reason: string }>(); const loadTenants = useCallback(async () => { - const result = await platformRequest('GET', '/api/platform-admin/tenants', { + const result = await platformRequest('GET', '/api/platform/tenants', { query: { Limit: 200 }, }); setTenants(result.items); @@ -97,7 +97,7 @@ export function TenantOnboardingWorkbench() { try { const result = await platformRequest( 'GET', - '/api/platform-admin/tenants/detail', + '/api/platform/tenants/detail', { query: { tenantId } }, ); setDetail(result); @@ -148,7 +148,7 @@ export function TenantOnboardingWorkbench() { trialDays: values.trialDays, defaultPaymentProvider: 'manual', }; - const result = await platformRequest('POST', '/api/platform-admin/tenants', { + const result = await platformRequest('POST', '/api/platform/tenants', { headers: { 'Idempotency-Key': crypto.randomUUID() }, body, }); @@ -172,7 +172,7 @@ export function TenantOnboardingWorkbench() { try { const result = await platformRequest( 'POST', - '/api/platform-admin/tenants/{tenantId}/owner-activation-links', + '/api/platform/tenants/{tenantId}/owner-activation-links', { path: { tenantId: selectedTenantId }, headers: { 'Idempotency-Key': crypto.randomUUID() }, @@ -200,7 +200,7 @@ export function TenantOnboardingWorkbench() { const values = await domainForm.validateFields(); setLoading(true); try { - await platformRequest('PUT', '/api/platform-admin/tenants/{tenantId}/primary-domain', { + await platformRequest('PUT', '/api/platform/tenants/{tenantId}/primary-domain', { path: { tenantId: selectedTenantId }, body: values, }); diff --git a/Tiku.PlatformAdmin.Web/src/pages/business-definitions.test.ts b/Tiku.PlatformAdmin.Web/src/pages/business-definitions.test.ts index 8e98d58..30ecef8 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/business-definitions.test.ts +++ b/Tiku.PlatformAdmin.Web/src/pages/business-definitions.test.ts @@ -4,7 +4,7 @@ import { businessPageForOperation } from './business-definitions'; describe('平台业务页面覆盖', () => { it('除经营概览外,每个 OpenAPI 平台接口只属于一个业务页面', () => { - const operations = platformOperations.filter((operation) => operation.path !== '/api/platform-admin/overview'); + const operations = platformOperations.filter((operation) => operation.path !== '/api/platform/overview'); expect(operations.map((operation) => [operation.id, businessPageForOperation(operation).length])).toEqual( operations.map((operation) => [operation.id, 1]), ); diff --git a/Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts b/Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts index b061215..b2ffa4a 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts +++ b/Tiku.PlatformAdmin.Web/src/pages/business-definitions.ts @@ -11,21 +11,21 @@ export interface BusinessPageDefinition { const starts = (operation: PlatformOperation, prefix: string) => operation.path.startsWith(prefix); export const businessPages: readonly BusinessPageDefinition[] = [ - { key: 'tenants', eyebrow: '租户管理', title: '租户列表与生命周期', description: '管理租户主体、域名、业务状态、账务状态与开票资料。', matches: (op) => starts(op, '/api/platform-admin/tenants') || starts(op, '/api/platform-admin/domains') }, - { key: 'subscriptions', eyebrow: 'SaaS 账务', title: '套餐与订阅', description: '维护 SaaS 功能、限额、商品、版本与租户订阅。', matches: (op) => starts(op, '/api/platform-admin/saas/catalog') || starts(op, '/api/platform-admin/saas/features') || starts(op, '/api/platform-admin/saas/feature-limits') || starts(op, '/api/platform-admin/saas/offerings') || starts(op, '/api/platform-admin/saas/offering-versions') || starts(op, '/api/platform-admin/saas/tenant-feature-overrides') || starts(op, '/api/platform-admin/saas/subscriptions') }, - { key: 'billing', eyebrow: 'SaaS 账务', title: '服务费账单与交易', description: '集中查看平台订单、退款和服务费账单。', matches: (op) => starts(op, '/api/platform-admin/saas/orders') || starts(op, '/api/platform-admin/saas/refunds') || op.path === '/api/platform-admin/saas/invoices' || op.path === '/api/platform-admin/saas/metrics' }, - { key: 'usage', eyebrow: 'SaaS 账务', title: '用量与超额计费', description: '按租户和账期查看席位、题量、存储等 SaaS 用量。', matches: (op) => starts(op, '/api/platform-admin/saas/usage') }, - { key: 'dunning', eyebrow: 'SaaS 账务', title: '收款、逾期与催缴', description: '处理人工收款、账单提醒、催缴渠道与失败事件重试。', matches: (op) => starts(op, '/api/platform-admin/saas/payments') || starts(op, '/api/platform-admin/saas/dunning') || starts(op, '/api/platform-admin/saas/invoices/reminders') }, - { key: 'question-banks', eyebrow: '平台资产', title: '公共题库工作台', description: '维护题库、内容结构、题目版本、批量导入与资源上传。', matches: (op) => starts(op, '/api/platform-admin/question-banks') }, - { key: 'crm', eyebrow: '客户服务', title: 'CRM 接入与线索队列', description: '管理租户 CRM 配置、线索推送队列、失败重试与投递日志。', matches: (op) => starts(op, '/api/platform-admin/tenant-capabilities/crm') }, - { key: 'sms', eyebrow: '消息服务', title: '短信渠道、模板与发送日志', description: '管理租户短信渠道和模板审核,并检索短信发送记录。', matches: (op) => starts(op, '/api/platform-admin/tenant-capabilities/sms') }, - { key: 'payments', eyebrow: '支付服务', title: '支付应用、渠道与事件', description: '管理平台和租户支付应用、支付渠道、回调事件及返佣汇总。', matches: (op) => starts(op, '/api/platform-admin/payment-settings') || starts(op, '/api/platform-admin/tenant-capabilities/payments') }, - { key: 'staff', eyebrow: '安全治理', title: '平台员工与权限', description: '管理平台员工、角色、权限绑定与账号状态。', matches: (op) => starts(op, '/api/platform-admin/staff') || starts(op, '/api/backoffice/platform') }, - { key: 'audit', eyebrow: '安全治理', title: '平台审计日志', description: '检索跨租户敏感操作、账务变更与权限事件。', matches: (op) => starts(op, '/api/platform-admin/audit-logs') }, - { key: 'alerts', eyebrow: '安全治理', title: '审计告警与处理', description: '按开放、确认、解决或忽略状态处理平台安全告警。', matches: (op) => starts(op, '/api/platform-admin/audit-alerts') }, - { key: 'operations', eyebrow: '运行治理', title: '任务与 Worker 运行中心', description: '查看依赖健康、Worker 心跳、后台任务积压,并处理失败任务。', matches: (op) => starts(op, '/api/platform-admin/operations') }, - { key: 'approvals', eyebrow: '安全治理', title: '平台审批中心', description: '处理分级四眼审批任务并维护版本化审批策略。', matches: (op) => starts(op, '/api/platform-admin/approvals') }, - { key: 'governance', eyebrow: '平台治理', title: '配置与通知中心', description: '发布类型化配置并管理岗位通知模板和投递记录。', matches: (op) => starts(op, '/api/platform-admin/governance') }, + { key: 'tenants', eyebrow: '租户管理', title: '租户列表与生命周期', description: '管理租户主体、域名、业务状态、账务状态与开票资料。', matches: (op) => starts(op, '/api/platform/tenants') || starts(op, '/api/platform/domains') }, + { key: 'subscriptions', eyebrow: 'SaaS 账务', title: '套餐与订阅', description: '维护 SaaS 功能、限额、商品、版本与租户订阅。', matches: (op) => starts(op, '/api/platform/saas/catalog') || starts(op, '/api/platform/saas/features') || starts(op, '/api/platform/saas/feature-limits') || starts(op, '/api/platform/saas/offerings') || starts(op, '/api/platform/saas/offering-versions') || starts(op, '/api/platform/saas/tenant-feature-overrides') || starts(op, '/api/platform/saas/subscriptions') }, + { key: 'billing', eyebrow: 'SaaS 账务', title: '服务费账单与交易', description: '集中查看平台订单、退款和服务费账单。', matches: (op) => starts(op, '/api/platform/saas/orders') || starts(op, '/api/platform/saas/refunds') || op.path === '/api/platform/saas/invoices' || op.path === '/api/platform/saas/metrics' }, + { key: 'usage', eyebrow: 'SaaS 账务', title: '用量与超额计费', description: '按租户和账期查看席位、题量、存储等 SaaS 用量。', matches: (op) => starts(op, '/api/platform/saas/usage') }, + { key: 'dunning', eyebrow: 'SaaS 账务', title: '收款、逾期与催缴', description: '处理人工收款、账单提醒、催缴渠道与失败事件重试。', matches: (op) => starts(op, '/api/platform/saas/payments') || starts(op, '/api/platform/saas/dunning') || starts(op, '/api/platform/saas/invoices/reminders') }, + { key: 'question-banks', eyebrow: '平台资产', title: '公共题库工作台', description: '维护题库、内容结构、题目版本、批量导入与资源上传。', matches: (op) => starts(op, '/api/platform/question-banks') }, + { key: 'crm', eyebrow: '客户服务', title: 'CRM 接入与线索队列', description: '管理租户 CRM 配置、线索推送队列、失败重试与投递日志。', matches: (op) => starts(op, '/api/platform/tenant-capabilities/crm') }, + { key: 'sms', eyebrow: '消息服务', title: '短信渠道、模板与发送日志', description: '管理租户短信渠道和模板审核,并检索短信发送记录。', matches: (op) => starts(op, '/api/platform/tenant-capabilities/sms') }, + { key: 'payments', eyebrow: '支付服务', title: '支付应用、渠道与事件', description: '管理平台和租户支付应用、支付渠道、回调事件及返佣汇总。', matches: (op) => starts(op, '/api/platform/payment-settings') || starts(op, '/api/platform/tenant-capabilities/payments') }, + { key: 'staff', eyebrow: '安全治理', title: '平台员工与权限', description: '管理平台员工、角色、权限绑定与账号状态。', matches: (op) => starts(op, '/api/platform/staff') || starts(op, '/api/platform/access') }, + { key: 'audit', eyebrow: '安全治理', title: '平台审计日志', description: '检索跨租户敏感操作、账务变更与权限事件。', matches: (op) => starts(op, '/api/platform/audit-logs') }, + { key: 'alerts', eyebrow: '安全治理', title: '审计告警与处理', description: '按开放、确认、解决或忽略状态处理平台安全告警。', matches: (op) => starts(op, '/api/platform/audit-alerts') }, + { key: 'operations', eyebrow: '运行治理', title: '任务与 Worker 运行中心', description: '查看依赖健康、Worker 心跳、后台任务积压,并处理失败任务。', matches: (op) => starts(op, '/api/platform/operations') }, + { key: 'approvals', eyebrow: '安全治理', title: '平台审批中心', description: '处理分级四眼审批任务并维护版本化审批策略。', matches: (op) => starts(op, '/api/platform/approvals') }, + { key: 'governance', eyebrow: '平台治理', title: '配置与通知中心', description: '发布类型化配置并管理岗位通知模板和投递记录。', matches: (op) => starts(op, '/api/platform/governance') }, ]; export function businessPageForOperation(operation: PlatformOperation) { diff --git a/Tiku.PlatformAdmin.Web/src/pages/question-bank-contract.test.ts b/Tiku.PlatformAdmin.Web/src/pages/question-bank-contract.test.ts index e886b53..2da2005 100644 --- a/Tiku.PlatformAdmin.Web/src/pages/question-bank-contract.test.ts +++ b/Tiku.PlatformAdmin.Web/src/pages/question-bank-contract.test.ts @@ -2,21 +2,21 @@ import { describe, expect, it } from 'vitest'; import { platformOperations } from '../api/platform-operations.generated'; const expected = [ - ['GET', '/api/platform-admin/question-banks'], - ['PUT', '/api/platform-admin/question-banks'], - ['POST', '/api/platform-admin/question-banks/{bankId}/archive'], - ['GET', '/api/platform-admin/question-banks/{bankId}/nodes'], - ['PUT', '/api/platform-admin/question-banks/nodes'], - ['POST', '/api/platform-admin/question-banks/nodes/batch'], - ['POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive'], - ['GET', '/api/platform-admin/question-banks/questions'], - ['PUT', '/api/platform-admin/question-banks/questions'], - ['POST', '/api/platform-admin/question-banks/questions/archive'], - ['POST', '/api/platform-admin/question-banks/imports/preview'], - ['POST', '/api/platform-admin/question-banks/imports'], - ['GET', '/api/platform-admin/question-banks/imports/{jobId}'], - ['POST', '/api/platform-admin/question-banks/assets/upload-sign'], - ['POST', '/api/platform-admin/question-banks/assets/upload-confirm'], + ['GET', '/api/platform/question-banks'], + ['PUT', '/api/platform/question-banks'], + ['POST', '/api/platform/question-banks/{bankId}/archive'], + ['GET', '/api/platform/question-banks/{bankId}/nodes'], + ['PUT', '/api/platform/question-banks/nodes'], + ['POST', '/api/platform/question-banks/nodes/batch'], + ['POST', '/api/platform/question-banks/nodes/{nodeId}/archive'], + ['GET', '/api/platform/question-banks/questions'], + ['PUT', '/api/platform/question-banks/questions'], + ['POST', '/api/platform/question-banks/questions/archive'], + ['POST', '/api/platform/question-banks/imports/preview'], + ['POST', '/api/platform/question-banks/imports'], + ['GET', '/api/platform/question-banks/imports/{jobId}'], + ['POST', '/api/platform/question-banks/assets/upload-sign'], + ['POST', '/api/platform/question-banks/assets/upload-confirm'], ] as const; describe('公共题库页面接口契约', () => { diff --git a/Tiku.Worker/WorkerServices.cs b/Tiku.Worker/WorkerServices.cs index dc6aa14..17358e1 100644 --- a/Tiku.Worker/WorkerServices.cs +++ b/Tiku.Worker/WorkerServices.cs @@ -259,7 +259,7 @@ internal sealed class BackgroundJobsWorker( { await using var scope = scopeFactory.CreateAsyncScope(); InitializeSystem(scope.ServiceProvider, "Background job lease worker"); - return await scope.ServiceProvider.GetRequiredService() + return await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken); } } diff --git a/docs/README.md b/docs/README.md index 5d8eb6e..6eea606 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ | 首次拉取代码,启动本地开发环境 | [本地开发快速上手](quickstart.md) | | 从空库验收租户创建、Owner 激活和站点发布 | [空数据库到租户建站验收](tenant-provisioning.md) | | 理解项目分层、运行时和业务边界 | [系统架构与业务边界](architecture/overview.md) | +| 按模块协作、拆分 Service 或调整依赖 | [模块边界与所有权](architecture/module-boundaries.md) | | 修改认证、权限或租户数据 | [认证、授权与租户隔离](architecture/security-and-tenancy.md) | | 查看 Tiku/RuoYi 双线取舍和公平基准边界 | [双线能力矩阵](architecture/dual-line-capability-matrix.md) | | 部署 API/Worker、配置依赖或排查任务 | [配置与后台任务](operations.md) | diff --git a/docs/architecture/module-boundaries.md b/docs/architecture/module-boundaries.md new file mode 100644 index 0000000..576a4e6 --- /dev/null +++ b/docs/architecture/module-boundaries.md @@ -0,0 +1,40 @@ +# 模块边界与所有权 + +后端保持模块化单体、统一 PostgreSQL、独立 API/Worker。目录是团队所有权边界,跨模块调用必须经过 `Tiku.Application` 合同。 + +## 依赖方向 + +```text +Platform Core: Tenancy / Auth / Security +Catalog -> QuestionBanks -> Content / Assets -> Learning +Commerce -> Points / Growth +TenantAdmin / PlatformAdmin -> Application contracts or dedicated read models +Jobs -> queue / processor / operations + module job handlers +``` + +- API Controller 不得引用 `Tiku.Infrastructure` 或 `TikuDbContext`。 +- 一个模块不得引用另一个模块的 Infrastructure 类型。 +- 跨模块查询使用 Application 查询合同;跨模块写入调用目标模块命令合同。 +- `TikuDbContext` 仍是统一事务入口,但 DbSet 按模块拆在 `Persistence/Modules`。 +- EF Migration 与模型快照串行合并,由 CODEOWNERS 默认负责人复核。 + +## 子模块目录 + +- TenantAdmin:Dashboard、Classes、Students、Supervision、StudentEngagement、MembersAndAccess、SiteSettings、DomainsAndEngagement。 +- Content:Questions、Vocabulary、Handbook、EducationCatalog、Scorelines、Videos、OperationContent、Imports。 +- Learning:Analytics、Answering、QuestionReview、WordLearning、PracticeSessions、Foundation。 +- Commerce:Orders、Payments、Coupons、Refunds、Reconciliation、Adjustments、Points。 +- PlatformAdmin:Dashboard、TenantProvisioning、TenantDomains、StaffAndAccess、AuditAndAlerts、Dunning、Operations。 + +Service 文件超过 500 行应在评审中说明原因,超过 800 行由架构测试阻止新增。现有聚合接口应逐步由子模块接口替代,不允许把新能力继续追加到聚合 Service。 + +## API audience + +- `/api/platform/*`:平台控制面。 +- `/api/tenant/*`:租户管理后台。 +- `/api/student/*`:学生业务。 +- `/api/public/*`:匿名公开读取。 +- `/api/system/*`:健康与内部诊断。 +- `/api/integrations/*`:外部系统回调。 + +旧的 platform-admin、backoffice、tenant-admin、tenant-content 和 tenant-commerce 路由不再提供兼容入口。后端、OpenAPI 客户端和两个前端必须作为一个发布单元回滚或上线。 diff --git a/docs/architecture/security-and-tenancy.md b/docs/architecture/security-and-tenancy.md index 3e4b3ff..4708908 100644 --- a/docs/architecture/security-and-tenancy.md +++ b/docs/architecture/security-and-tenancy.md @@ -6,8 +6,8 @@ API 支持两组认证接口: -- `/api/auth/**` 返回 access token 与 refresh token,适合 Bearer 客户端。 -- `/api/browser-auth/**` 把 token 写入 HttpOnly Cookie,适合同源浏览器客户端。 +- `/api/tenant/auth/**` 返回 access token 与 refresh token,适合 Bearer 客户端。 +- `/api/tenant/auth/browser/**` 把 token 写入 HttpOnly Cookie,适合同源浏览器客户端。 当前登录方式: diff --git a/docs/operations.md b/docs/operations.md index cd9febb..02c0620 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -186,11 +186,11 @@ Worker 使用 ClamAV `INSTREAM` 协议,不在本地落盘待扫描对象。启 ## 健康检查与观测 -- `GET /api/health`:轻量 liveness,只说明 API 进程可响应。 -- `GET /api/health/ready`:检查 PostgreSQL 和已配置 Redis;依赖未就绪时返回 503,匿名响应只包含总体状态和检查时间。 -- `GET /api/platform-admin/operations/health`:需要 `platform:operations:view`,返回 PostgreSQL、Redis、Worker heartbeat、ClamAV 和对象存储配置状态。 -- `GET /api/platform-admin/operations/workers`:查询 Worker 心跳、周期循环和 stale 状态。 -- `GET /api/platform-admin/operations/job-metrics`:查询队列状态、最老 Pending 任务与过期租约。 +- `GET /api/system/health`:轻量 liveness,只说明 API 进程可响应。 +- `GET /api/system/health/ready`:检查 PostgreSQL 和已配置 Redis;依赖未就绪时返回 503,匿名响应只包含总体状态和检查时间。 +- `GET /api/platform/operations/health`:需要 `platform:operations:view`,返回 PostgreSQL、Redis、Worker heartbeat、ClamAV 和对象存储配置状态。 +- `GET /api/platform/operations/workers`:查询 Worker 心跳、周期循环和 stale 状态。 +- `GET /api/platform/operations/job-metrics`:查询队列状态、最老 Pending 任务与过期租约。 - 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client 和数据库观测数据。 - Serilog 输出结构化请求日志;数据库性能拦截器记录慢查询指标。 diff --git a/docs/quickstart.md b/docs/quickstart.md index 9afabe9..5966bec 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -84,8 +84,8 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.Api --launch-profil - API: - Scalar: - OpenAPI: -- Liveness: -- Readiness: +- Liveness: +- Readiness: 后台循环不在 API 内运行。需要处理域名、订阅、任务队列、授权缓存失效或商业账务时,另开终端启动 Worker: @@ -127,7 +127,7 @@ PostgreSQL 特有的 Migration、事务、约束和租户隔离行为必须由 ### Readiness 返回 503 -`/api/health/ready` 会检查 PostgreSQL 和已配置的 Redis。先验证数据库连接;配置 Redis 后还需确认 Redis 可访问。匿名响应不会暴露依赖详情。 +`/api/system/health/ready` 会检查 PostgreSQL 和已配置的 Redis。先验证数据库连接;配置 Redis 后还需确认 Redis 可访问。匿名响应不会暴露依赖详情。 ### API 启动了但后台任务不执行 diff --git a/docs/tenant-provisioning.md b/docs/tenant-provisioning.md index 3123f02..7fa02d5 100644 --- a/docs/tenant-provisioning.md +++ b/docs/tenant-provisioning.md @@ -91,7 +91,7 @@ API 在 Development 会同时拉起平台管理端 Vite 服务: - API: - Scalar: - OpenAPI: -- Readiness: +- Readiness: 不要使用 `http://localhost:5090/platform-admin/` 作为开发入口;该路径受 API 授权保护,未登录访问返回 401。 diff --git a/tools/performance/README.md b/tools/performance/README.md index 2fc5aed..8f73292 100644 --- a/tools/performance/README.md +++ b/tools/performance/README.md @@ -6,9 +6,9 @@ | 模式 | 请求 | 依赖含义 | | --- | --- | --- | -| `hot` | `GET /api/catalog/regions` | 租户解析 + 热输出缓存,代表高缓存命中读流量 | +| `hot` | `GET /api/public/catalog/regions` | 租户解析 + 热输出缓存,代表高缓存命中读流量 | | `cold` | 同一目录接口,每次附加唯一查询参数 | 绕过输出缓存复用,持续执行租户解析与 PostgreSQL 目录查询 | -| `ready` | `GET /api/health/ready` | 每次检查 PostgreSQL 与 Redis 就绪状态;后台任务和商业状态由独立运行指标观察 | +| `ready` | `GET /api/system/health/ready` | 每次检查 PostgreSQL 与 Redis 就绪状态;后台任务和商业状态由独立运行指标观察 | | `mixed` | 70% hot、20% cold、10% ready | 默认的读多型业务流量 | 运行器会先分别执行 PostgreSQL 查询和 Redis `PING`,再要求 API readiness 返回 `status=ready`;任一依赖未接通时测试会直接失败,不会给出误导性的容量数字。公开 readiness 响应不暴露内部依赖明细。 @@ -54,7 +54,7 @@ TIKU_MODE=ready TIKU_RATE=100 TIKU_DURATION=30s tools/performance/run-local.sh ```bash BENCH_TARGET_NAME=tiku BENCH_SCENARIO_NAME=admin-page \ -BENCH_BASE_URL=http://127.0.0.1:5091 BENCH_PATH=/api/platform-admin/tenants \ +BENCH_BASE_URL=http://127.0.0.1:5091 BENCH_PATH=/api/platform/tenants \ BENCH_AUTHORIZATION='Bearer <临时令牌>' BENCH_RATE=100 BENCH_DURATION=30s \ tools/performance/run-three.sh ```