diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index c2bd263..f7c47b4 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -24,10 +24,13 @@ internal static class ApiPresentationExtensions services.AddProblemDetails(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs b/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs index 4d5fe11..d1ea338 100644 --- a/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs +++ b/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs @@ -1,44 +1,17 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.PlatformAdmin; -using Tiku.Application.Security; -using Tiku.Domain.Platform; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Configuration; internal sealed class TenantProvisioningStartupValidator( - ITenantExecutionScope tenantExecutionScope, + ITenantProvisioningReadinessProbe readinessProbe, IOptions options, ILogger logger) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { var offeringCode = options.Value.DefaultBaseOfferingCode.Trim().ToLowerInvariant(); - var available = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - null, - SystemScopeCallerType.Platform, - nameof(TenantProvisioningStartupValidator), - "Validate the production default tenant offering", - Guid.NewGuid().ToString("N"), - true), - async (services, token) => - { - var dbContext = services.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - return await ( - from offering in dbContext.SaasOfferings.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() - on offering.Id equals version.OfferingId - where offering.Code == offeringCode && - offering.Type == SaasOfferingType.BasePlan && - offering.Status == SaasOfferingStatus.Active && - version.Status == SaasOfferingVersionStatus.Published && - (version.EffectiveAt == null || version.EffectiveAt <= now) - select version.Id).AnyAsync(token); - }, - cancellationToken); + var available = await readinessProbe.IsPublishedBaseOfferingAvailableAsync(offeringCode, cancellationToken); if (!available) throw new InvalidOperationException( @@ -51,4 +24,4 @@ internal sealed class TenantProvisioningStartupValidator( { return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs index 0d6122a..5f891df 100644 --- a/Tiku.Api/Contracts/LearningDtos.cs +++ b/Tiku.Api/Contracts/LearningDtos.cs @@ -172,7 +172,7 @@ public sealed class SubmitPracticeSessionDto [Required] public Guid PracticeSessionId { get; set; } - [Required] [Range(1, long.MaxValue)] public long ExpectedSessionVersion { get; set; } + [Required][Range(1, long.MaxValue)] public long ExpectedSessionVersion { get; set; } [Required] [StringLength(200, MinimumLength = 1)] diff --git a/Tiku.Api/Contracts/PlatformAdminDtos.cs b/Tiku.Api/Contracts/PlatformAdminDtos.cs index edf5f25..ea089b5 100644 --- a/Tiku.Api/Contracts/PlatformAdminDtos.cs +++ b/Tiku.Api/Contracts/PlatformAdminDtos.cs @@ -170,7 +170,7 @@ public sealed class UpsertTenantBillingPolicyDto { public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online; - [Required] [StringLength(50)] public string DefaultPaymentProvider { get; set; } = "manual"; + [Required][StringLength(50)] public string DefaultPaymentProvider { get; set; } = "manual"; public bool AutoGenerateRenewal { get; set; } = true; diff --git a/Tiku.Api/Contracts/PlatformApprovalDtos.cs b/Tiku.Api/Contracts/PlatformApprovalDtos.cs index ecdf42e..d49b6da 100644 --- a/Tiku.Api/Contracts/PlatformApprovalDtos.cs +++ b/Tiku.Api/Contracts/PlatformApprovalDtos.cs @@ -5,7 +5,7 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; -public sealed record PlatformApprovalDecisionDto([Required] [MaxLength(1000)] string Reason); +public sealed record PlatformApprovalDecisionDto([Required][MaxLength(1000)] string Reason); public sealed record UpdatePlatformApprovalPolicyDto( bool Enabled, diff --git a/Tiku.Api/Contracts/PlatformGovernanceDtos.cs b/Tiku.Api/Contracts/PlatformGovernanceDtos.cs index e47183c..3a9b5d1 100644 --- a/Tiku.Api/Contracts/PlatformGovernanceDtos.cs +++ b/Tiku.Api/Contracts/PlatformGovernanceDtos.cs @@ -7,11 +7,11 @@ using Tiku.Domain.Platform; namespace Tiku.Api.Contracts; public sealed record SavePlatformConfigurationDraftDto( - [Required] [MaxLength(120)] string DefinitionCode, - [Required] [MaxLength(80)] string Environment, + [Required][MaxLength(120)] string DefinitionCode, + [Required][MaxLength(80)] string Environment, JsonElement? Value, [MaxLength(300)] string? SecretRef, - [Required] [MaxLength(1000)] string Reason) + [Required][MaxLength(1000)] string Reason) { public SavePlatformConfigurationDraftCommand ToCommand() { @@ -19,15 +19,15 @@ public sealed record SavePlatformConfigurationDraftDto( } } -public sealed record PlatformRollbackDto([Required] [MaxLength(1000)] string Reason); +public sealed record PlatformRollbackDto([Required][MaxLength(1000)] string Reason); public sealed record UpsertPlatformNotificationTemplateDto( Guid? Id, - [Required] [MaxLength(120)] string Code, - [Required] [MaxLength(200)] string Name, + [Required][MaxLength(120)] string Code, + [Required][MaxLength(200)] string Name, PlatformNotificationChannel Channel, - [Required] [MaxLength(500)] string SubjectTemplate, - [Required] [MaxLength(8000)] string BodyTemplate, + [Required][MaxLength(500)] string SubjectTemplate, + [Required][MaxLength(8000)] string BodyTemplate, bool Enabled, JsonElement? Variables) { @@ -43,7 +43,7 @@ public sealed record SendPlatformNotificationDto( Guid TemplateId, [MinLength(1)] IReadOnlyCollection RoleCodes, IReadOnlyDictionary? Variables, - [Required] [MaxLength(200)] string IdempotencyKey) + [Required][MaxLength(200)] string IdempotencyKey) { public SendPlatformNotificationCommand ToCommand() { diff --git a/Tiku.Api/Contracts/SaasBillingDtos.cs b/Tiku.Api/Contracts/SaasBillingDtos.cs index f6f89d7..c2fd0c3 100644 --- a/Tiku.Api/Contracts/SaasBillingDtos.cs +++ b/Tiku.Api/Contracts/SaasBillingDtos.cs @@ -10,12 +10,12 @@ namespace Tiku.Api.Contracts; /// public sealed record UpsertSaasFeatureDto( Guid? Id, - [Required] [MaxLength(120)] string Code, - [Required] [MaxLength(200)] string Name, - [Required] [MaxLength(100)] string Category, + [Required][MaxLength(120)] string Code, + [Required][MaxLength(200)] string Name, + [Required][MaxLength(100)] string Category, [MaxLength(1000)] string? Description, [Range(0, int.MaxValue)] int ReferencePriceCents, - [Required] [MaxLength(10)] string Currency, + [Required][MaxLength(10)] string Currency, SaasFeatureStatus Status, int SortOrder) { @@ -31,8 +31,8 @@ public sealed record UpsertSaasFeatureDto( /// public sealed record UpsertSaasOfferingDto( Guid? Id, - [Required] [MaxLength(120)] string Code, - [Required] [MaxLength(200)] string Name, + [Required][MaxLength(120)] string Code, + [Required][MaxLength(200)] string Name, SaasOfferingType Type, SaasOfferingStatus Status, [MaxLength(1000)] string? Description, @@ -49,10 +49,10 @@ public sealed record UpsertSaasOfferingDto( /// public sealed record UpsertSaasFeatureLimitDto( Guid? Id, - [Required] [MaxLength(120)] string MetricCode, - [Required] [MaxLength(120)] string FeatureCode, - [Required] [MaxLength(200)] string Name, - [Required] [MaxLength(50)] string Unit, + [Required][MaxLength(120)] string MetricCode, + [Required][MaxLength(120)] string FeatureCode, + [Required][MaxLength(200)] string Name, + [Required][MaxLength(50)] string Unit, SaasFeatureLimitKind Kind, [Range(1, 100)] int WarningPercent, bool IsHardLimit) @@ -73,7 +73,7 @@ public sealed record UpsertSaasOfferingVersionDto( PlatformBillingCycle BillingCycle, [Range(0, int.MaxValue)] int OriginalAmountCents, [Range(0, int.MaxValue)] int AmountCents, - [Required] [MaxLength(10)] string Currency, + [Required][MaxLength(10)] string Currency, DateTimeOffset? EffectiveAt, IReadOnlyCollection? FeatureCodes, IReadOnlyDictionary? Limits, @@ -94,7 +94,7 @@ public sealed record CreatePlatformBillingQuoteDto( Guid BaseOfferingVersionId, IReadOnlyCollection? AddOnOfferingVersionIds, PlatformBillingOrderPurpose Purpose, - [Required] [MaxLength(200)] string IdempotencyKey) + [Required][MaxLength(200)] string IdempotencyKey) { public CreatePlatformBillingQuoteCommand ToCommand() { @@ -106,7 +106,7 @@ public sealed record CreatePlatformBillingQuoteDto( /// /// 创建平台账务订单请求 DTO。 /// -public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required] [MaxLength(200)] string IdempotencyKey) +public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required][MaxLength(200)] string IdempotencyKey) { public CreatePlatformBillingOrderCommand ToCommand() { @@ -118,9 +118,9 @@ public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required] [Max /// 创建平台账务支付请求 DTO。 /// public sealed record CreatePlatformBillingPaymentDto( - [Required] [MaxLength(50)] string Provider, - [Required] [MaxLength(50)] string Method, - [Required] [MaxLength(200)] string IdempotencyKey, + [Required][MaxLength(50)] string Provider, + [Required][MaxLength(50)] string Method, + [Required][MaxLength(200)] string IdempotencyKey, string? OpenId, string? ReturnUrl, string? QuitUrl) @@ -138,7 +138,7 @@ public sealed record CreatePlatformBillingPaymentDto( public sealed record ChangeTenantSubscriptionDto( Guid BaseOfferingVersionId, IReadOnlyCollection? AddOnOfferingVersionIds, - [Required] [MaxLength(200)] string IdempotencyKey) + [Required][MaxLength(200)] string IdempotencyKey) { public ChangeTenantSubscriptionCommand ToCommand() { @@ -149,7 +149,7 @@ public sealed record ChangeTenantSubscriptionDto( /// /// Idempotent租户账务请求 DTO。 /// -public sealed record IdempotentTenantBillingDto([Required] [MaxLength(200)] string IdempotencyKey); +public sealed record IdempotentTenantBillingDto([Required][MaxLength(200)] string IdempotencyKey); /// /// 确认人工平台支付请求 DTO。 @@ -158,7 +158,7 @@ public sealed record ConfirmManualPlatformPaymentDto( Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, - [Required] [MaxLength(1000)] string Reason) + [Required][MaxLength(1000)] string Reason) { public ConfirmManualPaymentCommand ToCommand() { @@ -171,10 +171,10 @@ public sealed record ConfirmManualPlatformPaymentDto( /// public sealed record UpsertTenantFeatureOverrideDto( Guid TenantId, - [Required] [MaxLength(120)] string FeatureCode, + [Required][MaxLength(120)] string FeatureCode, TenantFeatureOverrideMode Mode, DateTimeOffset? ExpiresAt, - [Required] [MaxLength(1000)] string Reason) + [Required][MaxLength(1000)] string Reason) { public UpsertTenantFeatureOverrideCommand ToCommand() { @@ -187,8 +187,8 @@ public sealed record GrantTenantTrialDto( Guid TenantId, Guid BaseOfferingVersionId, [Range(1, 365)] int TrialDays, - [Required] [MaxLength(200)] string IdempotencyKey, - [Required] [MaxLength(1000)] string Reason) + [Required][MaxLength(200)] string IdempotencyKey, + [Required][MaxLength(1000)] string Reason) { public GrantTenantTrialCommand ToCommand() { @@ -198,7 +198,7 @@ public sealed record GrantTenantTrialDto( /// 平台修改订阅状态。 public sealed record ChangePlatformSubscriptionDto( - [Required] [MaxLength(1000)] string Reason, + [Required][MaxLength(1000)] string Reason, [Range(1, 3650)] int? ExtendDays = null) { public ChangePlatformSubscriptionCommand ToCommand(Guid subscriptionId) @@ -211,8 +211,8 @@ public sealed record ChangePlatformSubscriptionDto( public sealed record RequestPlatformRefundDto( Guid PaymentId, [Range(1, int.MaxValue)] int AmountCents, - [Required] [MaxLength(1000)] string Reason, - [Required] [MaxLength(200)] string IdempotencyKey, + [Required][MaxLength(1000)] string Reason, + [Required][MaxLength(200)] string IdempotencyKey, PlatformBillingRefundSubscriptionEffect SubscriptionEffect) { public RequestPlatformRefundCommand ToCommand() @@ -227,7 +227,7 @@ public sealed record RequestPlatformRefundDto( } /// 审核或重试 SaaS 退款。 -public sealed record ReviewPlatformRefundDto([Required] [MaxLength(1000)] string Reason) +public sealed record ReviewPlatformRefundDto([Required][MaxLength(1000)] string Reason) { public ReviewPlatformRefundCommand ToCommand(Guid refundId) { diff --git a/Tiku.Api/Controllers/AuthCapabilitySet.cs b/Tiku.Api/Controllers/AuthCapabilitySet.cs new file mode 100644 index 0000000..b48dd4f --- /dev/null +++ b/Tiku.Api/Controllers/AuthCapabilitySet.cs @@ -0,0 +1,17 @@ +using Tiku.Application.Auth; + +namespace Tiku.Api.Controllers; + +public sealed class AuthCapabilitySet( + IPasswordLoginService passwordLogin, + ISmsLoginService smsLogin, + IWechatLoginService wechatLogin, + IAuthSessionService sessions, + IPasswordLifecycleService passwordLifecycle) +{ + internal IPasswordLoginService PasswordLogin { get; } = passwordLogin; + internal ISmsLoginService SmsLogin { get; } = smsLogin; + internal IWechatLoginService WechatLogin { get; } = wechatLogin; + internal IAuthSessionService Sessions { get; } = sessions; + internal IPasswordLifecycleService PasswordLifecycle { get; } = passwordLifecycle; +} diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index 966dee5..3a03414 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -18,7 +18,7 @@ namespace Tiku.Api.Controllers; [Route("api/student/auth")] [Produces("application/json")] public sealed class AuthController( - IAuthService authService, + AuthCapabilitySet authCapabilities, IOwnerActivationService ownerActivationService, ISmsVerificationService smsVerificationService, AuthRequestContextResolver requestContextResolver, @@ -87,7 +87,7 @@ public sealed class AuthController( requestContextResolver.EnsureRouteRealm(realm, Request); var identifier = request.Identifier ?? request.Phone; if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required."); - var result = await authService.LoginWithPasswordAsync( + var result = await authCapabilities.PasswordLogin.LoginWithPasswordAsync( new PasswordLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, @@ -114,7 +114,7 @@ public sealed class AuthController( { var realm = request.Realm!.Value; requestContextResolver.EnsureRouteRealm(realm, Request); - var result = await authService.LoginWithSmsAsync( + var result = await authCapabilities.SmsLogin.LoginWithSmsAsync( new SmsLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, @@ -141,7 +141,7 @@ public sealed class AuthController( { var realm = request.Realm!.Value; requestContextResolver.EnsureRouteRealm(realm, Request); - var result = await authService.LoginWithWechatWebAsync( + var result = await authCapabilities.WechatLogin.LoginWithWechatWebAsync( new WechatLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, @@ -167,7 +167,7 @@ public sealed class AuthController( { var realm = request.Realm!.Value; requestContextResolver.EnsureRouteRealm(realm, Request); - var result = await authService.LoginWithWechatMiniAppAsync( + var result = await authCapabilities.WechatLogin.LoginWithWechatMiniAppAsync( new WechatLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, @@ -189,7 +189,7 @@ public sealed class AuthController( CancellationToken cancellationToken) { requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request); - var result = await authService.RefreshAsync( + var result = await authCapabilities.Sessions.RefreshAsync( new RefreshSessionRequest( request.RefreshToken, GetIpAddress(), @@ -209,7 +209,7 @@ public sealed class AuthController( CancellationToken cancellationToken) { requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request); - await authService.LogoutAsync( + await authCapabilities.Sessions.LogoutAsync( new LogoutSessionRequest(request.RefreshToken), cancellationToken); @@ -225,7 +225,7 @@ public sealed class AuthController( { if (currentUser.UserId is not { } userId) return Unauthorized(); - await authService.LogoutAllAsync(userId, cancellationToken); + await authCapabilities.Sessions.LogoutAllAsync(userId, cancellationToken); return NoContent(); } @@ -239,7 +239,7 @@ public sealed class AuthController( CancellationToken cancellationToken) { requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request); - var result = await authService.ChangeRequiredPasswordAsync( + var result = await authCapabilities.PasswordLifecycle.ChangeRequiredPasswordAsync( new PasswordChangeChallengeRequest( request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); @@ -259,7 +259,7 @@ public sealed class AuthController( var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, Request, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); - var result = await authService.RequestPasswordResetAsync( + var result = await authCapabilities.PasswordLifecycle.RequestPasswordResetAsync( new PasswordResetCodeRequest( tenantId, request.Phone, @@ -282,7 +282,7 @@ public sealed class AuthController( var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, Request, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); - await authService.ResetPasswordAsync( + await authCapabilities.PasswordLifecycle.ResetPasswordAsync( new PasswordResetRequest( tenantId, request.Phone, @@ -305,7 +305,7 @@ public sealed class AuthController( { if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); - var result = await authService.ChangePasswordAsync( + var result = await authCapabilities.PasswordLifecycle.ChangePasswordAsync( new AuthenticatedPasswordChangeRequest( userId, sessionId, diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs index 9554bcb..9b249c4 100644 --- a/Tiku.Api/Controllers/BrowserAuthController.cs +++ b/Tiku.Api/Controllers/BrowserAuthController.cs @@ -18,7 +18,7 @@ namespace Tiku.Api.Controllers; [Route("api/tenant/auth/browser")] [Produces("application/json")] public sealed class BrowserAuthController( - IAuthService authService, + AuthCapabilitySet authCapabilities, IOwnerActivationService ownerActivationService, ISmsVerificationService smsVerificationService, ITenantContext tenantContext, @@ -84,7 +84,7 @@ public sealed class BrowserAuthController( EnsureTenantRealm(realm); var identifier = request.Identifier ?? request.Phone; if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required."); - var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest( + var result = await authCapabilities.PasswordLogin.LoginWithPasswordAsync(new PasswordLoginRequest( realm, await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), identifier, @@ -105,7 +105,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var realm = request.Realm!.Value; EnsureTenantRealm(realm); - var result = await authService.LoginWithSmsAsync(new SmsLoginRequest( + var result = await authCapabilities.SmsLogin.LoginWithSmsAsync(new SmsLoginRequest( realm, await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Phone, @@ -125,7 +125,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var realm = request.Realm!.Value; EnsureTenantRealm(realm); - var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest( + var result = await authCapabilities.WechatLogin.LoginWithWechatWebAsync(new WechatLoginRequest( realm, await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Code, @@ -144,7 +144,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var realm = request.Realm!.Value; EnsureTenantRealm(realm); - var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest( + var result = await authCapabilities.WechatLogin.LoginWithWechatMiniAppAsync(new WechatLoginRequest( realm, await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), request.Code, @@ -161,7 +161,7 @@ public sealed class BrowserAuthController( { var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie]; if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized(); - var tokens = await authService.RefreshAsync(new RefreshSessionRequest( + var tokens = await authCapabilities.Sessions.RefreshAsync(new RefreshSessionRequest( refreshToken, HttpContext.Connection.RemoteIpAddress?.ToString(), Request.Headers.UserAgent.ToString()), cancellationToken); @@ -177,7 +177,7 @@ public sealed class BrowserAuthController( { var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie]; if (!string.IsNullOrWhiteSpace(refreshToken)) - await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken); + await authCapabilities.Sessions.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken); ClearCookies(); return NoContent(); } @@ -188,7 +188,7 @@ public sealed class BrowserAuthController( public async Task LogoutAll(CancellationToken cancellationToken) { if (currentUser.UserId is not { } userId) return Unauthorized(); - await authService.LogoutAllAsync(userId, cancellationToken); + await authCapabilities.Sessions.LogoutAllAsync(userId, cancellationToken); ClearCookies(); return NoContent(); } @@ -204,7 +204,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); - var result = await authService.RequestPasswordResetAsync( + var result = await authCapabilities.PasswordLifecycle.RequestPasswordResetAsync( new PasswordResetCodeRequest( tenantId, request.Phone, @@ -227,7 +227,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); - await authService.ResetPasswordAsync( + await authCapabilities.PasswordLifecycle.ResetPasswordAsync( new PasswordResetRequest( tenantId, request.Phone, @@ -251,7 +251,7 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); - var result = await authService.ChangePasswordAsync( + var result = await authCapabilities.PasswordLifecycle.ChangePasswordAsync( new AuthenticatedPasswordChangeRequest( userId, sessionId, diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index 50788d8..297915a 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -7,7 +7,6 @@ using Tiku.Api.Contracts; using Tiku.Api.Security; using Tiku.Application.Commerce; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Domain.Commerce; namespace Tiku.Api.Controllers; @@ -19,12 +18,13 @@ namespace Tiku.Api.Controllers; [Produces("application/json")] [Route("api/student/commerce")] public sealed class CommerceController( - ICommerceService commerceService, + ICommerceOrderService commerceOrderService, + ICommercePaymentService commercePaymentService, + ICommerceEntitlementService commerceEntitlementService, + ICommerceCouponService commerceCouponService, + ICommercePaymentNotificationService commercePaymentNotificationService, IRefundAdministrationService refundAdministrationService, - ICurrentUser currentUser, - ITenantContext currentTenant, - ITenantContextInitializer tenantInitializer, - ITenantDirectory tenantDirectory) : ControllerBase + CommerceRequestContextResolver requestContextResolver) : ControllerBase { [HttpPost("orders")] [EndpointSummary("创建学生端订单")] @@ -33,8 +33,8 @@ public sealed class CommerceController( CreateCommerceOrderDto request, CancellationToken cancellationToken) { - return Ok(await commerceService.CreateOrderAsync( - ResolveActor(), + return Ok(await commerceOrderService.CreateOrderAsync( + requestContextResolver.ResolveActor(), request.ToCommand(), cancellationToken)); } @@ -46,8 +46,8 @@ public sealed class CommerceController( [FromQuery] CommerceOrderQueryDto query, CancellationToken cancellationToken) { - return Ok(await commerceService.GetOrdersAsync( - ResolveActor(), + return Ok(await commerceOrderService.GetOrdersAsync( + requestContextResolver.ResolveActor(), new CommerceOrderQuery(query.Limit, query.Status), cancellationToken)); } @@ -59,8 +59,8 @@ public sealed class CommerceController( string orderNo, CancellationToken cancellationToken) { - return Ok(await commerceService.GetOrderAsync( - ResolveActor(), + return Ok(await commerceOrderService.GetOrderAsync( + requestContextResolver.ResolveActor(), orderNo, cancellationToken)); } @@ -72,8 +72,8 @@ public sealed class CommerceController( CreateCommercePaymentDto request, CancellationToken cancellationToken) { - return Ok(await commerceService.CreatePaymentAsync( - ResolveActor(), + return Ok(await commercePaymentService.CreatePaymentAsync( + requestContextResolver.ResolveActor(), request.ToCommand(), cancellationToken)); } @@ -84,8 +84,8 @@ public sealed class CommerceController( public async Task> CurrentEntitlement( CancellationToken cancellationToken) { - return Ok(await commerceService.GetCurrentEntitlementAsync( - ResolveActor(), + return Ok(await commerceEntitlementService.GetCurrentEntitlementAsync( + requestContextResolver.ResolveActor(), cancellationToken)); } @@ -96,8 +96,8 @@ public sealed class CommerceController( ClaimCommerceCouponDto request, CancellationToken cancellationToken) { - return Ok(await commerceService.ClaimCouponAsync( - ResolveActor(), + return Ok(await commerceCouponService.ClaimCouponAsync( + requestContextResolver.ResolveActor(), request.ToCommand(), cancellationToken)); } @@ -109,8 +109,8 @@ public sealed class CommerceController( [FromQuery] CommerceCouponQueryDto query, CancellationToken cancellationToken) { - return Ok(await commerceService.GetCouponsAsync( - ResolveActor(), + return Ok(await commerceCouponService.GetCouponsAsync( + requestContextResolver.ResolveActor(), query.ToQuery(), cancellationToken)); } @@ -122,8 +122,8 @@ public sealed class CommerceController( CheckCommerceCouponDto request, CancellationToken cancellationToken) { - return Ok(await commerceService.CheckCouponAsync( - ResolveActor(), + return Ok(await commerceCouponService.CheckCouponAsync( + requestContextResolver.ResolveActor(), request.ToCommand(), cancellationToken)); } @@ -137,7 +137,7 @@ public sealed class CommerceController( CancellationToken cancellationToken) { return Ok(await ProcessNotificationAsync( - await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken), PaymentProviders.WechatPay, cancellationToken)); } @@ -151,7 +151,7 @@ public sealed class CommerceController( CancellationToken cancellationToken) { return Ok(await ProcessNotificationAsync( - await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken), PaymentProviders.Alipay, cancellationToken)); } @@ -166,7 +166,7 @@ public sealed class CommerceController( CancellationToken cancellationToken) { return Ok(await refundAdministrationService.ProcessRefundNotificationAsync( - await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken), request.ToCommand(PaymentProviders.WechatPay), cancellationToken)); } @@ -181,34 +181,11 @@ public sealed class CommerceController( CancellationToken cancellationToken) { return Ok(await refundAdministrationService.ProcessRefundNotificationAsync( - await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken), request.ToCommand(PaymentProviders.Alipay), cancellationToken)); } - private async Task ResolveNotificationTenantAsync( - string? tenantCode, - CancellationToken cancellationToken) - { - if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; - - if (string.IsNullOrWhiteSpace(tenantCode)) - throw new CommerceException("Tenant code is required for payment notification.", "tenant_required"); - - var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken) - ?? throw new CommerceException("Tenant was not found.", "tenant_not_found"); - tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); - return tenant.TenantId; - } - - private CommerceActor ResolveActor() - { - if (currentTenant.TenantId is null || currentUser.UserId is null) - throw new CommerceException("Current commerce actor was not resolved.", "commerce_access_denied"); - - return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value); - } - private async Task ProcessNotificationAsync( Guid tenantId, string provider, @@ -224,7 +201,7 @@ public sealed class CommerceController( pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase); - return await commerceService.ProcessPaymentNotificationAsync( + return await commercePaymentNotificationService.ProcessPaymentNotificationAsync( tenantId, provider, headers, diff --git a/Tiku.Api/Controllers/CommerceRequestContextResolver.cs b/Tiku.Api/Controllers/CommerceRequestContextResolver.cs new file mode 100644 index 0000000..7562587 --- /dev/null +++ b/Tiku.Api/Controllers/CommerceRequestContextResolver.cs @@ -0,0 +1,37 @@ +using Tiku.Application.Commerce; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; +using Tiku.Domain.Tenancy; + +namespace Tiku.Api.Controllers; + +public sealed class CommerceRequestContextResolver( + ICurrentUser currentUser, + ITenantContext currentTenant, + ITenantContextInitializer tenantInitializer, + ITenantDirectory tenantDirectory) +{ + internal CommerceActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + throw new CommerceException("Current commerce actor was not resolved.", "commerce_access_denied"); + + return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } + + internal async Task ResolveNotificationTenantAsync( + string? tenantCode, + CancellationToken cancellationToken) + { + if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; + + if (string.IsNullOrWhiteSpace(tenantCode)) + throw new CommerceException("Tenant code is required for payment notification.", "tenant_required"); + + var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken) + ?? throw new CommerceException("Tenant was not found.", "tenant_not_found"); + tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); + return tenant.TenantId; + } +} diff --git a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs index 6c67a1d..8acf2d1 100644 --- a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs +++ b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs @@ -13,7 +13,11 @@ namespace Tiku.Api.Controllers; [Produces("application/json")] [Route("api/platform/question-banks")] public sealed class PlatformQuestionBanksController( - IPlatformQuestionBankService service, + IPlatformQuestionBankCatalogService catalogService, + IPlatformQuestionBankNodeService nodeService, + IPlatformQuestionAdministrationService questionService, + IPlatformQuestionImportService importService, + IPlatformQuestionAssetService assetService, ICurrentUser currentUser) : ControllerBase { [HttpGet] @@ -23,7 +27,7 @@ public sealed class PlatformQuestionBanksController( [FromQuery] string? status, CancellationToken cancellationToken) { - return Ok(await service.GetBanksAsync(ResolveActor(), + return Ok(await catalogService.GetBanksAsync(ResolveActor(), new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken)); } @@ -33,7 +37,7 @@ public sealed class PlatformQuestionBanksController( UpsertPlatformQuestionBankCommand request, CancellationToken cancellationToken) { - return Ok(await service.UpsertBankAsync(ResolveActor(), request, cancellationToken)); + return Ok(await catalogService.UpsertBankAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("{bankId:guid}/archive")] @@ -41,7 +45,7 @@ public sealed class PlatformQuestionBanksController( public async Task> ArchiveBank(Guid bankId, CancellationToken cancellationToken) { - return Ok(await service.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken)); + return Ok(await catalogService.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken)); } [HttpGet("{bankId:guid}/nodes")] @@ -49,7 +53,7 @@ public sealed class PlatformQuestionBanksController( public async Task>> GetNodes(Guid bankId, CancellationToken cancellationToken) { - return Ok(await service.GetNodesAsync(ResolveActor(), bankId, cancellationToken)); + return Ok(await nodeService.GetNodesAsync(ResolveActor(), bankId, cancellationToken)); } [HttpPut("nodes")] @@ -58,7 +62,7 @@ public sealed class PlatformQuestionBanksController( UpsertPlatformQuestionBankNodeCommand request, CancellationToken cancellationToken) { - return Ok(await service.UpsertNodeAsync(ResolveActor(), request, cancellationToken)); + return Ok(await nodeService.UpsertNodeAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("nodes/batch")] @@ -67,7 +71,7 @@ public sealed class PlatformQuestionBanksController( BatchCreatePlatformQuestionBankNodesCommand request, CancellationToken cancellationToken) { - return Ok(await service.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken)); + return Ok(await nodeService.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("nodes/{nodeId:guid}/archive")] @@ -75,7 +79,7 @@ public sealed class PlatformQuestionBanksController( public async Task> ArchiveNode(Guid nodeId, CancellationToken cancellationToken) { - return Ok(await service.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken)); + return Ok(await nodeService.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken)); } [HttpGet("questions")] @@ -91,7 +95,7 @@ public sealed class PlatformQuestionBanksController( [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) { - return Ok(await service.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter( + return Ok(await questionService.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter( questionBankId, contentNodeId, keyword, type, difficulty, status, page, pageSize), cancellationToken)); } @@ -101,7 +105,7 @@ public sealed class PlatformQuestionBanksController( UpsertPlatformQuestionCommand request, CancellationToken cancellationToken) { - return Ok(await service.UpsertQuestionAsync(ResolveActor(), request, cancellationToken)); + return Ok(await questionService.UpsertQuestionAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("questions/archive")] @@ -110,7 +114,7 @@ public sealed class PlatformQuestionBanksController( ArchivePlatformQuestionsCommand request, CancellationToken cancellationToken) { - return Ok(await service.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken)); + return Ok(await questionService.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("imports/preview")] @@ -119,7 +123,7 @@ public sealed class PlatformQuestionBanksController( PlatformQuestionImportCommand request, CancellationToken cancellationToken) { - return Ok(await service.PreviewImportAsync(ResolveActor(), request, cancellationToken)); + return Ok(await importService.PreviewImportAsync(ResolveActor(), request, cancellationToken)); } [HttpPost("imports")] @@ -128,14 +132,14 @@ public sealed class PlatformQuestionBanksController( PlatformQuestionImportCommand request, CancellationToken cancellationToken) { - return Ok(await service.ExecuteImportAsync(ResolveActor(), request, cancellationToken)); + return Ok(await importService.ExecuteImportAsync(ResolveActor(), request, cancellationToken)); } [HttpGet("imports/{jobId:guid}")] [EndpointSummary("查询公共题库导入结果")] public async Task> GetImport(Guid jobId, CancellationToken cancellationToken) { - return Ok(await service.GetImportAsync(ResolveActor(), jobId, cancellationToken)); + return Ok(await importService.GetImportAsync(ResolveActor(), jobId, cancellationToken)); } [HttpPost("assets/upload-sign")] @@ -144,7 +148,7 @@ public sealed class PlatformQuestionBanksController( AssetUploadSignDto request, CancellationToken cancellationToken) { - return Ok(await service.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await assetService.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } [HttpPost("assets/upload-confirm")] @@ -153,7 +157,7 @@ public sealed class PlatformQuestionBanksController( AssetUploadConfirmDto request, CancellationToken cancellationToken) { - return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), + return Ok(await assetService.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } @@ -163,4 +167,4 @@ public sealed class PlatformQuestionBanksController( ? new PlatformAdminActor(userId) : throw new PlatformAdminException("无法识别当前平台员工。", "platform_access_denied"); } -} \ No newline at end of file +} diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs index 71e9c8a..7ad55d3 100644 --- a/Tiku.Api/Controllers/ReferralController.cs +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -14,7 +14,9 @@ namespace Tiku.Api.Controllers; [Produces("application/json")] [Route("api/student/referral")] public sealed class ReferralController( - IReferralService referralService, + IStudentReferralService studentReferralService, + IReferralAdministrationService referralAdministrationService, + IReferralAnalyticsService referralAnalyticsService, ICurrentUser currentUser, ITenantContext currentTenant, ITenantContextInitializer tenantInitializer, @@ -28,7 +30,7 @@ public sealed class ReferralController( ReferralInviteDto request, CancellationToken cancellationToken) { - return Ok(await referralService.GetOrCreateInviteCodeAsync( + return Ok(await studentReferralService.GetOrCreateInviteCodeAsync( ResolveUserActor(), request.ToCommand(), cancellationToken)); @@ -43,7 +45,7 @@ public sealed class ReferralController( ResolveReferralDto request, CancellationToken cancellationToken) { - return Ok(await referralService.ResolveAsync( + return Ok(await studentReferralService.ResolveAsync( new ReferralActor(await ResolveTenantIdAsync(request.TenantCode, cancellationToken), currentUser.UserId), request.ToCommand(), cancellationToken)); @@ -58,7 +60,7 @@ public sealed class ReferralController( TrackReferralEventDto request, CancellationToken cancellationToken) { - return Ok(await referralService.TrackEventAsync( + return Ok(await studentReferralService.TrackEventAsync( new ReferralActor(await ResolveTenantIdAsync(request.TenantCode, cancellationToken), currentUser.UserId), request.ToCommand(), HttpContext.Connection.RemoteIpAddress?.ToString(), @@ -74,7 +76,7 @@ public sealed class ReferralController( BindReferralDto request, CancellationToken cancellationToken) { - return Ok(await referralService.BindAsync( + return Ok(await studentReferralService.BindAsync( ResolveUserActor(), request.ToCommand(), cancellationToken)); @@ -88,7 +90,7 @@ public sealed class ReferralController( ReferralQrcodeDto request, CancellationToken cancellationToken) { - return Ok(await referralService.GetOrCreateQrcodeAsync( + return Ok(await studentReferralService.GetOrCreateQrcodeAsync( ResolveUserActor(), request.ToCommand(), cancellationToken)); @@ -103,7 +105,7 @@ public sealed class ReferralController( [FromQuery] ReferralStatsQueryDto query, CancellationToken cancellationToken) { - return Ok(await referralService.GetStatsAsync( + return Ok(await referralAnalyticsService.GetStatsAsync( ResolveAdminActor(), query.ToQuery(), cancellationToken)); @@ -118,7 +120,7 @@ public sealed class ReferralController( [FromQuery] ReferralStatsQueryDto query, CancellationToken cancellationToken) { - return Ok(await referralService.GetSalesStatsAsync( + return Ok(await referralAnalyticsService.GetSalesStatsAsync( ResolveAdminActor(), query.ToQuery(), cancellationToken)); @@ -133,7 +135,7 @@ public sealed class ReferralController( [FromQuery] ReferralConversionQueryDto query, CancellationToken cancellationToken) { - return Ok(await referralService.GetConversionReportAsync( + return Ok(await referralAnalyticsService.GetConversionReportAsync( ResolveAdminActor(), query.ToQuery(), cancellationToken)); @@ -148,7 +150,7 @@ public sealed class ReferralController( [FromQuery] ReferralStatsQueryDto query, CancellationToken cancellationToken) { - return Ok(await referralService.GetClientsAsync( + return Ok(await referralAnalyticsService.GetClientsAsync( ResolveAdminActor(), query.ToQuery(), cancellationToken)); @@ -163,7 +165,7 @@ public sealed class ReferralController( ManualBindReferralDto request, CancellationToken cancellationToken) { - return Ok(await referralService.ManualBindAsync( + return Ok(await referralAdministrationService.ManualBindAsync( ResolveAdminActor(), request.ToCommand(), cancellationToken)); @@ -178,7 +180,7 @@ public sealed class ReferralController( [FromQuery] ReferralTeamQueryDto query, CancellationToken cancellationToken) { - return Ok(await referralService.GetTeamAsync( + return Ok(await referralAdministrationService.GetTeamAsync( ResolveAdminActor(), query.ToQuery(), cancellationToken)); @@ -193,7 +195,7 @@ public sealed class ReferralController( UpsertReferralTeamDto request, CancellationToken cancellationToken) { - return Ok(await referralService.UpsertTeamAsync( + return Ok(await referralAdministrationService.UpsertTeamAsync( ResolveAdminActor(), request.ToCommand(), cancellationToken)); @@ -227,4 +229,4 @@ public sealed class ReferralController( tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); return tenant.TenantId; } -} \ No newline at end of file +} diff --git a/Tiku.Api/Controllers/TenantContentCapabilitySet.cs b/Tiku.Api/Controllers/TenantContentCapabilitySet.cs new file mode 100644 index 0000000..36989c7 --- /dev/null +++ b/Tiku.Api/Controllers/TenantContentCapabilitySet.cs @@ -0,0 +1,26 @@ +using Tiku.Application.Assets; +using Tiku.Application.Content; + +namespace Tiku.Api.Controllers; + +public sealed class TenantContentCapabilitySet( + IContentEntryManagementService contentEntries, + IContentNodeManagementService contentNodes, + IQuestionCollectionManagementService questionCollections, + IPracticeBlueprintManagementService practiceBlueprints, + IAssetCatalogManagementService assetCatalog, + IAssetUploadManagementService assetUploads, + IAssetLifecycleManagementService assetLifecycle, + IAssetAuditQueryService assetAudit, + IAssetImportJobQueryService assetImports) +{ + internal IContentEntryManagementService ContentEntries { get; } = contentEntries; + internal IContentNodeManagementService ContentNodes { get; } = contentNodes; + internal IQuestionCollectionManagementService QuestionCollections { get; } = questionCollections; + internal IPracticeBlueprintManagementService PracticeBlueprints { get; } = practiceBlueprints; + internal IAssetCatalogManagementService AssetCatalog { get; } = assetCatalog; + internal IAssetUploadManagementService AssetUploads { get; } = assetUploads; + internal IAssetLifecycleManagementService AssetLifecycle { get; } = assetLifecycle; + internal IAssetAuditQueryService AssetAudit { get; } = assetAudit; + internal IAssetImportJobQueryService AssetImports { get; } = assetImports; +} diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index 8094b4b..3deadfb 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -14,8 +14,7 @@ namespace Tiku.Api.Controllers; [Produces("application/json")] [Route("api/tenant/content")] public sealed class TenantContentController( - IAssetManagementService assetManagementService, - IContentManagementService contentManagementService, + TenantContentCapabilitySet capabilities, ICurrentUser currentUser, ITenantContext currentTenant) : ControllerBase { @@ -26,7 +25,7 @@ public sealed class TenantContentController( [FromQuery] ContentManagementQueryDto query, CancellationToken cancellationToken) { - return Ok(await contentManagementService.GetEntriesAsync( + return Ok(await capabilities.ContentEntries.GetEntriesAsync( ResolveContentActor(), query.ToFilter(), cancellationToken)); @@ -39,7 +38,7 @@ public sealed class TenantContentController( UpsertContentEntryDto request, CancellationToken cancellationToken) { - return Ok(await contentManagementService.UpsertEntryAsync( + return Ok(await capabilities.ContentEntries.UpsertEntryAsync( ResolveContentActor(), request.ToCommand(), cancellationToken)); @@ -52,7 +51,7 @@ public sealed class TenantContentController( [FromQuery] ContentManagementQueryDto query, CancellationToken cancellationToken) { - return Ok(await contentManagementService.GetNodesAsync( + return Ok(await capabilities.ContentNodes.GetNodesAsync( ResolveContentActor(), query.ToFilter(), cancellationToken)); @@ -65,7 +64,7 @@ public sealed class TenantContentController( UpsertContentNodeDto request, CancellationToken cancellationToken) { - return Ok(await contentManagementService.UpsertNodeAsync( + return Ok(await capabilities.ContentNodes.UpsertNodeAsync( ResolveContentActor(), request.ToCommand(), cancellationToken)); @@ -78,7 +77,7 @@ public sealed class TenantContentController( [FromQuery] ContentManagementQueryDto query, CancellationToken cancellationToken) { - return Ok(await contentManagementService.GetCollectionsAsync( + return Ok(await capabilities.QuestionCollections.GetCollectionsAsync( ResolveContentActor(), query.ToFilter(), cancellationToken)); @@ -91,7 +90,7 @@ public sealed class TenantContentController( UpsertQuestionCollectionDto request, CancellationToken cancellationToken) { - return Ok(await contentManagementService.UpsertCollectionAsync( + return Ok(await capabilities.QuestionCollections.UpsertCollectionAsync( ResolveContentActor(), request.ToCommand(), cancellationToken)); @@ -104,7 +103,7 @@ public sealed class TenantContentController( ReplaceCollectionItemsDto request, CancellationToken cancellationToken) { - return Ok(await contentManagementService.ReplaceCollectionItemsAsync( + return Ok(await capabilities.QuestionCollections.ReplaceCollectionItemsAsync( ResolveContentActor(), request.ToCommand(), cancellationToken)); @@ -117,7 +116,7 @@ public sealed class TenantContentController( [FromQuery] ContentManagementQueryDto query, CancellationToken cancellationToken) { - return Ok(await contentManagementService.GetPracticeBlueprintsAsync( + return Ok(await capabilities.PracticeBlueprints.GetPracticeBlueprintsAsync( ResolveContentActor(), query.ToFilter(), cancellationToken)); @@ -130,7 +129,7 @@ public sealed class TenantContentController( UpsertPracticeBlueprintDto request, CancellationToken cancellationToken) { - return Ok(await contentManagementService.UpsertPracticeBlueprintAsync( + return Ok(await capabilities.PracticeBlueprints.UpsertPracticeBlueprintAsync( ResolveContentActor(), request.ToCommand(), cancellationToken)); @@ -142,7 +141,7 @@ public sealed class TenantContentController( public ActionResult GetImportFieldMapping([FromQuery] ImportTemplateQueryDto query) { _ = ResolveContentActor(); - return Ok(contentManagementService.GetImportFieldMapping(query.ImportType)); + return Ok(capabilities.PracticeBlueprints.GetImportFieldMapping(query.ImportType)); } [HttpGet("imports/templates")] @@ -151,7 +150,7 @@ public sealed class TenantContentController( public ActionResult GetImportTemplate([FromQuery] ImportTemplateQueryDto query) { _ = ResolveContentActor(); - return Ok(contentManagementService.GetImportTemplate(query.ImportType, query.Format)); + return Ok(capabilities.PracticeBlueprints.GetImportTemplate(query.ImportType, query.Format)); } [HttpGet("assets")] @@ -161,7 +160,7 @@ public sealed class TenantContentController( [FromQuery] AssetManagementQueryDto query, CancellationToken cancellationToken) { - return Ok(await assetManagementService.GetAssetsAsync( + return Ok(await capabilities.AssetCatalog.GetAssetsAsync( ResolveActor(), query.ToFilter(), cancellationToken)); @@ -174,7 +173,7 @@ public sealed class TenantContentController( [FromQuery] AssetEventQueryDto query, CancellationToken cancellationToken) { - return Ok(await assetManagementService.GetAccessEventsAsync( + return Ok(await capabilities.AssetAudit.GetAccessEventsAsync( ResolveActor(), query.ToFilter(), cancellationToken)); @@ -187,7 +186,7 @@ public sealed class TenantContentController( [FromQuery] AssetEventQueryDto query, CancellationToken cancellationToken) { - return Ok(await assetManagementService.GetSecurityScanEventsAsync( + return Ok(await capabilities.AssetAudit.GetSecurityScanEventsAsync( ResolveActor(), query.ToFilter(), cancellationToken)); @@ -200,7 +199,7 @@ public sealed class TenantContentController( UpsertAssetDto request, CancellationToken cancellationToken) { - return Ok(await assetManagementService.UpsertAssetAsync( + return Ok(await capabilities.AssetCatalog.UpsertAssetAsync( ResolveActor(), request.ToCommand(), cancellationToken)); @@ -215,7 +214,7 @@ public sealed class TenantContentController( AssetUploadSignDto request, CancellationToken cancellationToken) { - return Ok(await assetManagementService.SignUploadAsync( + return Ok(await capabilities.AssetUploads.SignUploadAsync( ResolveActor(), request.ToCommand(), cancellationToken)); @@ -230,7 +229,7 @@ public sealed class TenantContentController( AssetUploadConfirmDto request, CancellationToken cancellationToken) { - return Ok(await assetManagementService.ConfirmUploadAsync( + return Ok(await capabilities.AssetUploads.ConfirmUploadAsync( ResolveActor(), request.ToCommand(), cancellationToken)); @@ -244,7 +243,7 @@ public sealed class TenantContentController( Guid assetId, CancellationToken cancellationToken) { - return Ok(await assetManagementService.ArchiveAssetAsync( + return Ok(await capabilities.AssetLifecycle.ArchiveAssetAsync( ResolveActor(), assetId, cancellationToken)); @@ -257,7 +256,7 @@ public sealed class TenantContentController( AssetAccessSignDto request, CancellationToken cancellationToken) { - return Ok(await assetManagementService.SignDownloadAsync( + return Ok(await capabilities.AssetLifecycle.SignDownloadAsync( ResolveActor(), request.ToCommand(), cancellationToken)); @@ -270,7 +269,7 @@ public sealed class TenantContentController( AssetAccessSignDto request, CancellationToken cancellationToken) { - return Ok(await assetManagementService.SignPreviewAsync( + return Ok(await capabilities.AssetLifecycle.SignPreviewAsync( ResolveActor(), request.ToCommand(), cancellationToken)); @@ -283,7 +282,7 @@ public sealed class TenantContentController( [FromQuery] ImportJobQueryDto query, CancellationToken cancellationToken) { - return Ok(await assetManagementService.GetImportJobsAsync( + return Ok(await capabilities.AssetImports.GetImportJobsAsync( ResolveActor(), query.ToFilter(), cancellationToken)); @@ -297,7 +296,7 @@ public sealed class TenantContentController( Guid jobId, CancellationToken cancellationToken) { - return Ok(await assetManagementService.GetImportJobAsync( + return Ok(await capabilities.AssetImports.GetImportJobAsync( ResolveActor(), jobId, cancellationToken)); @@ -320,4 +319,4 @@ public sealed class TenantContentController( return new ContentManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} \ No newline at end of file +} diff --git a/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs index df7cee9..996c42b 100644 --- a/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs +++ b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs @@ -1,9 +1,8 @@ using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; +using Tiku.Application.Backoffice; using Tiku.Application.Security; -using Tiku.Domain.Operations; -using Tiku.Infrastructure.Persistence; namespace Tiku.Api.Security; @@ -23,30 +22,27 @@ internal sealed class AuditingAuthorizationMiddlewareResultHandler( try { await using var scope = scopeFactory.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.AuditLogs.Add(new AuditLog - { - TenantId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) + var auditService = scope.ServiceProvider.GetRequiredService(); + await auditService.WriteAsync(new BackofficeOperationAuditCommand( + Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null, - ActorUserId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) + Guid.TryParse(context.User.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId) ? userId : null, - Action = "authorization.access_denied", - TargetType = "http_endpoint", - TargetId = context.Request.Path, - IpAddress = context.Connection.RemoteIpAddress?.ToString(), - UserAgent = context.Request.Headers.UserAgent.ToString(), - Details = JsonSerializer.SerializeToElement(new + "authorization.access_denied", + "http_endpoint", + context.Request.Path, + JsonSerializer.SerializeToElement(new { context.Request.Method, Path = context.Request.Path.Value, Realm = context.User.FindFirst(TikuClaimTypes.Realm)?.Value, Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message) .ToArray() - }) - }); - await dbContext.SaveChangesAsync(context.RequestAborted); + }), + context.Connection.RemoteIpAddress?.ToString(), + context.Request.Headers.UserAgent.ToString()), context.RequestAborted); } catch (Exception exception) { @@ -55,4 +51,4 @@ internal sealed class AuditingAuthorizationMiddlewareResultHandler( await fallback.HandleAsync(next, context, policy, authorizeResult); } -} \ No newline at end of file +} diff --git a/Tiku.Api/Security/EndpointAuthorizationMetadata.cs b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs index 400ebc9..7907326 100644 --- a/Tiku.Api/Security/EndpointAuthorizationMetadata.cs +++ b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs @@ -19,61 +19,61 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) - foreach (var action in controller.Actions) - { - var anonymous = controller.Attributes.OfType().Any() || - action.Attributes.OfType().Any(); - if (anonymous) continue; + foreach (var action in controller.Actions) + { + var anonymous = controller.Attributes.OfType().Any() || + action.Attributes.OfType().Any(); + if (anonymous) continue; - var policies = controller.Attributes.OfType() - .Concat(action.Attributes.OfType()) - .Select(attribute => attribute.Policy) - .Where(policy => !string.IsNullOrWhiteSpace(policy)) - .Cast() - .ToArray(); - var permission = policies.LastOrDefault(policy => - BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy)); - var realm = (permission is not null && BackendPermissions.Platform.Contains(permission)) || - policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal)) - ? "platform" - : (permission is not null && BackendPermissions.Tenant.Contains(permission)) || - policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal)) - ? "tenant" - : "authenticated"; - var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission); - var requiredFeatures = controller.Attributes.OfType() - .Concat(action.Attributes.OfType()) - .Select(attribute => attribute.FeatureCode) - .Concat(module is not null && - PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) && - moduleFeature is not null - ? [moduleFeature] - : []) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .Concat(action.Attributes.OfType() - .Select(attribute => $"route:{attribute.RouteValueName}")) - .ToArray(); - var httpMethods = action.Attributes.OfType() - .SelectMany(attribute => attribute.HttpMethods) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var operation = httpMethods.All(IsSafeMethod) - ? CapabilityOperation.Read - : CapabilityOperation.Write; - var route = $"{controller.ControllerName}.{action.ActionName}"; - var metadata = new EndpointAuthorizationMetadata( - realm, - module, - requiredFeatures, - permission, - operation, - policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) || - policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) || - policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal), - $"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}"); - foreach (var selector in action.Selectors) selector.EndpointMetadata.Add(metadata); - } + var policies = controller.Attributes.OfType() + .Concat(action.Attributes.OfType()) + .Select(attribute => attribute.Policy) + .Where(policy => !string.IsNullOrWhiteSpace(policy)) + .Cast() + .ToArray(); + var permission = policies.LastOrDefault(policy => + BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy)); + var realm = (permission is not null && BackendPermissions.Platform.Contains(permission)) || + policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal)) + ? "platform" + : (permission is not null && BackendPermissions.Tenant.Contains(permission)) || + policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal)) + ? "tenant" + : "authenticated"; + var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission); + var requiredFeatures = controller.Attributes.OfType() + .Concat(action.Attributes.OfType()) + .Select(attribute => attribute.FeatureCode) + .Concat(module is not null && + PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) && + moduleFeature is not null + ? [moduleFeature] + : []) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Concat(action.Attributes.OfType() + .Select(attribute => $"route:{attribute.RouteValueName}")) + .ToArray(); + var httpMethods = action.Attributes.OfType() + .SelectMany(attribute => attribute.HttpMethods) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var operation = httpMethods.All(IsSafeMethod) + ? CapabilityOperation.Read + : CapabilityOperation.Write; + var route = $"{controller.ControllerName}.{action.ActionName}"; + var metadata = new EndpointAuthorizationMetadata( + realm, + module, + requiredFeatures, + permission, + operation, + policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) || + policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) || + policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal), + $"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}"); + foreach (var selector in action.Selectors) selector.EndpointMetadata.Add(metadata); + } } private static bool IsSafeMethod(string method) diff --git a/Tiku.Application/Assets/IAssetManagementService.cs b/Tiku.Application/Assets/IAssetManagementService.cs index 5552872..90beecc 100644 --- a/Tiku.Application/Assets/IAssetManagementService.cs +++ b/Tiku.Application/Assets/IAssetManagementService.cs @@ -3,7 +3,7 @@ using Tiku.Application.Content; namespace Tiku.Application.Assets; -public interface IAssetManagementService +public interface IAssetCatalogManagementService { Task> GetAssetsAsync( AssetManagementActor actor, @@ -15,6 +15,10 @@ public interface IAssetManagementService UpsertAssetCommand command, CancellationToken cancellationToken = default); +} + +public interface IAssetUploadManagementService +{ Task SignUploadAsync( AssetManagementActor actor, AssetUploadSignCommand command, @@ -25,6 +29,10 @@ public interface IAssetManagementService AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); +} + +public interface IAssetLifecycleManagementService +{ Task> ArchiveAssetAsync( AssetManagementActor actor, Guid assetId, @@ -40,6 +48,10 @@ public interface IAssetManagementService AssetAccessSignCommand command, CancellationToken cancellationToken = default); +} + +public interface IAssetAuditQueryService +{ Task> GetAccessEventsAsync( AssetManagementActor actor, AssetEventFilter filter, @@ -50,6 +62,10 @@ public interface IAssetManagementService AssetEventFilter filter, CancellationToken cancellationToken = default); +} + +public interface IAssetImportJobQueryService +{ Task> GetImportJobsAsync( AssetManagementActor actor, ImportJobFilter filter, @@ -59,4 +75,4 @@ public interface IAssetManagementService AssetManagementActor actor, Guid jobId, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs index 0914df7..8e787fa 100644 --- a/Tiku.Application/Auth/IAuthService.cs +++ b/Tiku.Application/Auth/IAuthService.cs @@ -1,15 +1,23 @@ namespace Tiku.Application.Auth; -public interface IAuthService +public interface IPasswordLoginService { Task LoginWithPasswordAsync( PasswordLoginRequest request, CancellationToken cancellationToken = default); +} + +public interface ISmsLoginService +{ Task LoginWithSmsAsync( SmsLoginRequest request, CancellationToken cancellationToken = default); +} + +public interface IWechatLoginService +{ Task LoginWithWechatWebAsync( WechatLoginRequest request, CancellationToken cancellationToken = default); @@ -18,6 +26,10 @@ public interface IAuthService WechatLoginRequest request, CancellationToken cancellationToken = default); +} + +public interface IAuthSessionService +{ Task RefreshAsync( RefreshSessionRequest request, CancellationToken cancellationToken = default); @@ -28,6 +40,10 @@ public interface IAuthService Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default); +} + +public interface IPasswordLifecycleService +{ Task ChangeRequiredPasswordAsync( PasswordChangeChallengeRequest request, CancellationToken cancellationToken = default); @@ -43,4 +59,4 @@ public interface IAuthService Task ChangePasswordAsync( AuthenticatedPasswordChangeRequest request, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Tiku.Application/Commerce/CommerceModels.cs b/Tiku.Application/Commerce/CommerceModels.cs index 9593caf..b6448a7 100644 --- a/Tiku.Application/Commerce/CommerceModels.cs +++ b/Tiku.Application/Commerce/CommerceModels.cs @@ -112,7 +112,7 @@ public sealed record PaymentNotificationProcessResult( string Status, bool Idempotent); -public interface ICommerceService +public interface ICommerceOrderService { Task CreateOrderAsync( CommerceActor actor, @@ -129,15 +129,27 @@ public interface ICommerceService string orderNo, CancellationToken cancellationToken = default); +} + +public interface ICommercePaymentService +{ Task CreatePaymentAsync( CommerceActor actor, CreateCommercePaymentCommand command, CancellationToken cancellationToken = default); +} + +public interface ICommerceEntitlementService +{ Task GetCurrentEntitlementAsync( CommerceActor actor, CancellationToken cancellationToken = default); +} + +public interface ICommerceCouponService +{ Task ClaimCouponAsync( CommerceActor actor, ClaimCommerceCouponCommand command, @@ -153,6 +165,10 @@ public interface ICommerceService CheckCommerceCouponCommand command, CancellationToken cancellationToken = default); +} + +public interface ICommercePaymentNotificationService +{ Task ProcessPaymentNotificationAsync( Guid tenantId, string provider, @@ -165,4 +181,4 @@ public interface ICommerceService public sealed class CommerceException(string message, string code) : Exception(message) { public string Code { get; } = code; -} \ No newline at end of file +} diff --git a/Tiku.Application/Content/IContentManagementService.cs b/Tiku.Application/Content/IContentManagementService.cs index 0da729d..545fc0d 100644 --- a/Tiku.Application/Content/IContentManagementService.cs +++ b/Tiku.Application/Content/IContentManagementService.cs @@ -2,7 +2,7 @@ using Tiku.Application.Catalog; namespace Tiku.Application.Content; -public interface IContentManagementService +public interface IContentEntryManagementService { Task> GetEntriesAsync( ContentManagementActor actor, @@ -13,7 +13,10 @@ public interface IContentManagementService ContentManagementActor actor, UpsertContentEntryCommand command, CancellationToken cancellationToken = default); +} +public interface IContentNodeManagementService +{ Task> GetNodesAsync( ContentManagementActor actor, ContentManagementFilter filter, @@ -23,7 +26,10 @@ public interface IContentManagementService ContentManagementActor actor, UpsertContentNodeCommand command, CancellationToken cancellationToken = default); +} +public interface IQuestionCollectionManagementService +{ Task> GetCollectionsAsync( ContentManagementActor actor, ContentManagementFilter filter, @@ -38,7 +44,10 @@ public interface IContentManagementService ContentManagementActor actor, ReplaceCollectionItemsCommand command, CancellationToken cancellationToken = default); +} +public interface IPracticeBlueprintManagementService +{ Task> GetPracticeBlueprintsAsync( ContentManagementActor actor, ContentManagementFilter filter, @@ -52,4 +61,4 @@ public interface IContentManagementService ImportFieldMappingItem GetImportFieldMapping(string importType); ImportTemplateItem GetImportTemplate(string importType, string? format); -} \ No newline at end of file +} diff --git a/Tiku.Application/Growth/ReferralModels.cs b/Tiku.Application/Growth/ReferralModels.cs index a0afb0c..6a7c8fb 100644 --- a/Tiku.Application/Growth/ReferralModels.cs +++ b/Tiku.Application/Growth/ReferralModels.cs @@ -141,7 +141,7 @@ public sealed record ReferralTeamItem( string Status, JsonElement Metadata); -public interface IReferralService +public interface IStudentReferralService { Task GetOrCreateInviteCodeAsync( ReferralActor actor, @@ -170,6 +170,10 @@ public interface IReferralService ReferralQrcodeCommand command, CancellationToken cancellationToken = default); +} + +public interface IReferralAnalyticsService +{ Task GetStatsAsync( ReferralAdminActor actor, ReferralStatsQuery query, @@ -190,6 +194,10 @@ public interface IReferralService ReferralStatsQuery query, CancellationToken cancellationToken = default); +} + +public interface IReferralAdministrationService +{ Task ManualBindAsync( ReferralAdminActor actor, ManualBindReferralCommand command, @@ -216,4 +224,4 @@ public interface IReferralQrcodeGenerator public sealed class ReferralException(string message, string code) : Exception(message) { public string Code { get; } = code; -} \ No newline at end of file +} diff --git a/Tiku.Application/PlatformAdmin/ITenantProvisioningReadinessProbe.cs b/Tiku.Application/PlatformAdmin/ITenantProvisioningReadinessProbe.cs new file mode 100644 index 0000000..7ebf53a --- /dev/null +++ b/Tiku.Application/PlatformAdmin/ITenantProvisioningReadinessProbe.cs @@ -0,0 +1,8 @@ +namespace Tiku.Application.PlatformAdmin; + +public interface ITenantProvisioningReadinessProbe +{ + Task IsPublishedBaseOfferingAvailableAsync( + string offeringCode, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs index 711c726..2440f05 100644 --- a/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs @@ -135,7 +135,7 @@ public sealed record PlatformQuestionImportResult( int UpdatedQuestionCount, int SkippedQuestionCount); -public interface IPlatformQuestionBankService +public interface IPlatformQuestionBankCatalogService { Task> GetBanksAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); @@ -146,6 +146,10 @@ public interface IPlatformQuestionBankService Task ArchiveBankAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); +} + +public interface IPlatformQuestionBankNodeService +{ Task> GetNodesAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); @@ -158,6 +162,10 @@ public interface IPlatformQuestionBankService Task ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId, CancellationToken cancellationToken = default); +} + +public interface IPlatformQuestionAdministrationService +{ Task GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); @@ -167,6 +175,10 @@ public interface IPlatformQuestionBankService Task ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, CancellationToken cancellationToken = default); +} + +public interface IPlatformQuestionImportService +{ Task PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); @@ -176,9 +188,13 @@ public interface IPlatformQuestionBankService Task GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default); +} + +public interface IPlatformQuestionAssetService +{ Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default); Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/AssetAccessService.cs b/Tiku.Infrastructure/Assets/AssetAccessService.cs index 8517a20..de77648 100644 --- a/Tiku.Infrastructure/Assets/AssetAccessService.cs +++ b/Tiku.Infrastructure/Assets/AssetAccessService.cs @@ -10,7 +10,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; public sealed class AssetAccessService( - TikuDbContext dbContext, + IContentAssetPersistence contentAssetPersistence, + ICommercePersistence commercePersistence, + IIdentityPersistence identityPersistence, IObjectStorageService objectStorageService) : IAssetAccessService { private static readonly TimeSpan DefaultDownloadTtl = TimeSpan.FromMinutes(15); @@ -36,7 +38,7 @@ public sealed class AssetAccessService( AssetAccessDisposition disposition, CancellationToken cancellationToken) { - var asset = await dbContext.ContentAssets + var asset = await contentAssetPersistence.ContentAssets .SingleOrDefaultAsync( item => item.TenantId == request.TenantId && @@ -71,7 +73,7 @@ public sealed class AssetAccessService( if (accessType == AssetAccessType.Download) asset.DownloadCount++; - dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent( + contentAssetPersistence.ContentAssetAccessEvents.Add(CreateAccessEvent( request, asset, accessType, @@ -80,7 +82,7 @@ public sealed class AssetAccessService( signedUrl, null, access)); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new AssetAccessResultModel( ToSummary(asset), @@ -90,7 +92,7 @@ public sealed class AssetAccessService( } catch (Exception exception) when (ShouldAuditDenied(exception)) { - dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent( + contentAssetPersistence.ContentAssetAccessEvents.Add(CreateAccessEvent( request, asset, accessType, @@ -99,7 +101,7 @@ public sealed class AssetAccessService( null, DenyCode(exception), null)); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw; } } @@ -117,7 +119,7 @@ public sealed class AssetAccessService( if (!request.UserId.HasValue) throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED"); - var isMember = await dbContext.TenantMemberships + var isMember = await identityPersistence.TenantMemberships .AnyAsync( membership => membership.TenantId == request.TenantId && @@ -144,7 +146,7 @@ public sealed class AssetAccessService( CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; - return dbContext.Entitlements.AnyAsync( + return commercePersistence.Entitlements.AnyAsync( entitlement => entitlement.TenantId == request.TenantId && entitlement.UserId == request.UserId!.Value && diff --git a/Tiku.Infrastructure/Assets/AssetManagementDependencies.cs b/Tiku.Infrastructure/Assets/AssetManagementDependencies.cs new file mode 100644 index 0000000..1eb4644 --- /dev/null +++ b/Tiku.Infrastructure/Assets/AssetManagementDependencies.cs @@ -0,0 +1,30 @@ +using Tiku.Application.Assets; +using Tiku.Application.Jobs; +using Tiku.Application.Security; +using Tiku.Application.Storage; +using Tiku.Application.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Assets; + +internal sealed record AssetManagementDependencies( + IContentAssetPersistence ContentAssetPersistence, + IQuestionBankPersistence QuestionBankPersistence, + IObjectStorageService ObjectStorageService, + ITenantExternalProviderConfigService ProviderConfigService, + IFeatureAccessService FeatureAccessService, + IBackgroundJobQueue BackgroundJobService); + +internal abstract partial class AssetManagementServiceBase(AssetManagementDependencies dependencies) +{ + protected IContentAssetPersistence contentAssetPersistence { get; } = dependencies.ContentAssetPersistence; + protected IQuestionBankPersistence questionBankPersistence { get; } = dependencies.QuestionBankPersistence; + protected IObjectStorageService objectStorageService { get; } = dependencies.ObjectStorageService; + protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService; + protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService; + protected IBackgroundJobQueue backgroundJobService { get; } = dependencies.BackgroundJobService; + protected const int DefaultLimit = 100; + protected const int MaxLimit = 500; + protected static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15); + protected static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1); +} diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs deleted file mode 100644 index 5b6c094..0000000 --- a/Tiku.Infrastructure/Assets/AssetManagementService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Tiku.Application.Assets; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Assets; - -public sealed partial class AssetManagementService( - TikuDbContext dbContext, - IObjectStorageService objectStorageService, - ITenantExternalProviderConfigService providerConfigService, - IFeatureAccessService featureAccessService, - 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); -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/AssetQueryService.cs b/Tiku.Infrastructure/Assets/AssetQueryService.cs index e0863c2..dd662bd 100644 --- a/Tiku.Infrastructure/Assets/AssetQueryService.cs +++ b/Tiku.Infrastructure/Assets/AssetQueryService.cs @@ -6,7 +6,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; -public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryService +public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAssetQueryService { private const int DefaultLimit = 100; private const int MaxLimit = 500; diff --git a/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs b/Tiku.Infrastructure/Assets/Audit/AssetAuditQueryService.cs similarity index 88% rename from Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs rename to Tiku.Infrastructure/Assets/Audit/AssetAuditQueryService.cs index db83e53..e62f8a4 100644 --- a/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs +++ b/Tiku.Infrastructure/Assets/Audit/AssetAuditQueryService.cs @@ -4,14 +4,15 @@ using Tiku.Application.Catalog; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal sealed class AssetAuditQueryService(AssetManagementDependencies dependencies) + : AssetManagementServiceBase(dependencies), IAssetAuditQueryService { public async Task> GetAccessEventsAsync( AssetManagementActor actor, AssetEventFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ContentAssetAccessEvents.AsNoTracking() + var query = contentAssetPersistence.ContentAssetAccessEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value); @@ -47,7 +48,7 @@ public sealed partial class AssetManagementService AssetEventFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking() + var query = contentAssetPersistence.ContentAssetSecurityScanEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value); @@ -66,4 +67,4 @@ public sealed partial class AssetManagementService .ToArrayAsync(cancellationToken); return new CatalogList(items); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs b/Tiku.Infrastructure/Assets/Catalog/AssetCatalogManagementService.cs similarity index 96% rename from Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs rename to Tiku.Infrastructure/Assets/Catalog/AssetCatalogManagementService.cs index ee7f414..e98f6b1 100644 --- a/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs +++ b/Tiku.Infrastructure/Assets/Catalog/AssetCatalogManagementService.cs @@ -7,14 +7,15 @@ using Tiku.Domain.Content; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal sealed class AssetCatalogManagementService(AssetManagementDependencies dependencies) + : AssetManagementServiceBase(dependencies), IAssetCatalogManagementService { public async Task> GetAssetsAsync( AssetManagementActor actor, AssetManagementFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ContentAssets + var query = contentAssetPersistence.ContentAssets .AsNoTracking() .Where(asset => asset.TenantId == actor.TenantId); @@ -115,4 +116,4 @@ public sealed partial class AssetManagementService cancellationToken); return new ContentManagementResult(ToItem(asset)); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs b/Tiku.Infrastructure/Assets/Foundation/AssetManagementFoundation.cs similarity index 84% rename from Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs rename to Tiku.Infrastructure/Assets/Foundation/AssetManagementFoundation.cs index 80fa908..dddb3dd 100644 --- a/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs +++ b/Tiku.Infrastructure/Assets/Foundation/AssetManagementFoundation.cs @@ -10,9 +10,9 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal abstract partial class AssetManagementServiceBase { - private async Task ResolveUploadAssetAsync( + protected async Task ResolveUploadAssetAsync( AssetManagementActor actor, AssetUploadSignCommand command, string provider, @@ -24,7 +24,7 @@ public sealed partial class AssetManagementService ContentAsset? asset = null; if (command.AssetId.HasValue) { - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, cancellationToken); @@ -41,7 +41,7 @@ public sealed partial class AssetManagementService Source = "manual", Status = ContentStatus.Active }; - dbContext.ContentAssets.Add(asset); + contentAssetPersistence.ContentAssets.Add(asset); } asset.RegionId = command.RegionId; @@ -64,7 +64,7 @@ public sealed partial class AssetManagementService return asset; } - private async Task ResolveManagementAssetAsync( + protected async Task ResolveManagementAssetAsync( AssetManagementActor actor, UpsertAssetCommand command, CancellationToken cancellationToken) @@ -72,7 +72,7 @@ public sealed partial class AssetManagementService ContentAsset? asset = null; if (command.AssetId.HasValue) { - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + asset = await contentAssetPersistence.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"); @@ -80,7 +80,7 @@ public sealed partial class AssetManagementService else if (!string.IsNullOrWhiteSpace(command.LegacyId)) { var legacyId = command.LegacyId.Trim(); - asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.LegacyId == legacyId, cancellationToken); } @@ -96,18 +96,18 @@ public sealed partial class AssetManagementService Source = "manual", Status = ContentStatus.Active }; - dbContext.ContentAssets.Add(asset); + contentAssetPersistence.ContentAssets.Add(asset); return asset; } - private async Task SignAssetAccessAsync( + protected async Task SignAssetAccessAsync( AssetManagementActor actor, AssetAccessSignCommand command, AssetAccessType accessType, string disposition, CancellationToken cancellationToken) { - var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + var asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, cancellationToken); @@ -132,7 +132,7 @@ public sealed partial class AssetManagementService asset.FileName, disposition), cancellationToken); - dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent + contentAssetPersistence.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent { TenantId = actor.TenantId, AssetId = asset.Id, @@ -153,11 +153,11 @@ public sealed partial class AssetManagementService url.ObjectKey }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new AssetManagementSignedAccessResult(ToItem(asset), url); } - private static ContentAssetManagementItem ToItem(ContentAsset asset) + protected static ContentAssetManagementItem ToItem(ContentAsset asset) { return new ContentAssetManagementItem( asset.Id, @@ -194,7 +194,7 @@ public sealed partial class AssetManagementService asset.UpdatedAt); } - private async Task SaveWithStorageQuotaAdjustmentAsync( + protected async Task SaveWithStorageQuotaAdjustmentAsync( Guid tenantId, long byteDelta, CancellationToken cancellationToken) @@ -213,7 +213,7 @@ public sealed partial class AssetManagementService try { - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); } catch { @@ -228,7 +228,7 @@ public sealed partial class AssetManagementService return; } - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); if (byteDelta < 0) await featureAccessService.ReleaseQuotaAsync( tenantId, @@ -237,14 +237,14 @@ public sealed partial class AssetManagementService CancellationToken.None); } - private static long AccountedStorageBytes(ContentAsset asset) + protected static long AccountedStorageBytes(ContentAsset asset) { return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0 ? asset.VerifiedSizeBytes.Value : 0; } - private static ContentImportJobItem ToJobItem(ContentImportJob job) + protected static ContentImportJobItem ToJobItem(ContentImportJob job) { return new ContentImportJobItem( job.Id, @@ -274,13 +274,13 @@ public sealed partial class AssetManagementService job.UpdatedAt); } - private static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName) + protected 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) + protected static string SanitizeFileName(string fileName) { var trimmed = Path.GetFileName(fileName.Trim()); return string.Join( @@ -289,17 +289,17 @@ public sealed partial class AssetManagementService StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } - private static string? NormalizeOptional(string? value) + protected static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private static string? NormalizeChecksum(string? checksum) + protected static string? NormalizeChecksum(string? checksum) { return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant(); } - private static TimeSpan ResolveUploadTtl(int? expiresInSeconds) + protected static TimeSpan ResolveUploadTtl(int? expiresInSeconds) { if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) return DefaultUploadTtl; @@ -307,14 +307,14 @@ public sealed partial class AssetManagementService return requested <= MaxUploadTtl ? requested : MaxUploadTtl; } - private static int ResolveLimit(int? limit) + protected 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) + protected static ContentAssetType ResolveAssetType(string? value, string mimeType) { if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, true, out var parsed)) @@ -332,7 +332,7 @@ public sealed partial class AssetManagementService return ContentAssetType.Document; } - private static ContentVisibility ResolveVisibility(string? value, bool isPublic) + protected static ContentVisibility ResolveVisibility(string? value, bool isPublic) { if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, true, out var parsed)) @@ -341,7 +341,7 @@ public sealed partial class AssetManagementService return isPublic ? ContentVisibility.Public : ContentVisibility.Members; } - private static TEnum ParseEnum(string? value, TEnum fallback) + protected static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) return fallback; @@ -351,7 +351,7 @@ public sealed partial class AssetManagementService : fallback; } - private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType) + protected static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType) { return assetType is ContentAssetType.Pdf or ContentAssetType.Image || mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) || @@ -360,7 +360,7 @@ public sealed partial class AssetManagementService : AssetPreviewStatus.None; } - private static AssetStorageProvider ToAssetStorageProvider(string provider) + protected static AssetStorageProvider ToAssetStorageProvider(string provider) { return provider switch { @@ -374,7 +374,7 @@ public sealed partial class AssetManagementService }; } - private static string ToObjectStorageProvider(AssetStorageProvider provider) + protected static string ToObjectStorageProvider(AssetStorageProvider provider) { return provider switch { @@ -387,7 +387,7 @@ public sealed partial class AssetManagementService }; } - private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync( + protected async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync( Guid tenantId, CancellationToken cancellationToken) { @@ -413,7 +413,7 @@ public sealed partial class AssetManagementService } } - private static string? GetJsonString(JsonElement element, params string[] keys) + protected static string? GetJsonString(JsonElement element, params string[] keys) { if (element.ValueKind != JsonValueKind.Object) return null; @@ -423,4 +423,4 @@ public sealed partial class AssetManagementService return null; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs b/Tiku.Infrastructure/Assets/Imports/AssetImportJobQueryService.cs similarity index 88% rename from Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs rename to Tiku.Infrastructure/Assets/Imports/AssetImportJobQueryService.cs index 889bf0f..4e3904a 100644 --- a/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs +++ b/Tiku.Infrastructure/Assets/Imports/AssetImportJobQueryService.cs @@ -5,14 +5,15 @@ using Tiku.Domain.Content; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal sealed class AssetImportJobQueryService(AssetManagementDependencies dependencies) + : AssetManagementServiceBase(dependencies), IAssetImportJobQueryService { public async Task> GetImportJobsAsync( AssetManagementActor actor, ImportJobFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ContentImportJobs + var query = questionBankPersistence.ContentImportJobs .AsNoTracking() .Where(job => job.TenantId == actor.TenantId); @@ -42,7 +43,7 @@ public sealed partial class AssetManagementService Guid jobId, CancellationToken cancellationToken = default) { - var job = await dbContext.ContentImportJobs + var job = await questionBankPersistence.ContentImportJobs .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == jobId) .Select(item => ToJobItem(item)) @@ -50,7 +51,7 @@ public sealed partial class AssetManagementService if (job is null) throw new AssetManagementException("Import job was not found.", "import_job_not_found"); - var items = await dbContext.ContentImportItems + var items = await questionBankPersistence.ContentImportItems .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) .OrderBy(item => item.RowNo) @@ -69,7 +70,7 @@ public sealed partial class AssetManagementService item.IssuesCount)) .ToArrayAsync(cancellationToken); - var issues = await dbContext.ContentImportIssues + var issues = await questionBankPersistence.ContentImportIssues .AsNoTracking() .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) .OrderBy(issue => issue.RowNo) @@ -89,4 +90,4 @@ public sealed partial class AssetManagementService return new ContentImportJobDetail(job, items, issues); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs b/Tiku.Infrastructure/Assets/Lifecycle/AssetLifecycleManagementService.cs similarity index 84% rename from Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs rename to Tiku.Infrastructure/Assets/Lifecycle/AssetLifecycleManagementService.cs index 37bd5cd..b5b90a2 100644 --- a/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs +++ b/Tiku.Infrastructure/Assets/Lifecycle/AssetLifecycleManagementService.cs @@ -6,14 +6,15 @@ using Tiku.Domain.Content; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal sealed class AssetLifecycleManagementService(AssetManagementDependencies dependencies) + : AssetManagementServiceBase(dependencies), IAssetLifecycleManagementService { public async Task> ArchiveAssetAsync( AssetManagementActor actor, Guid assetId, CancellationToken cancellationToken = default) { - var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + var asset = await contentAssetPersistence.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"); @@ -24,7 +25,7 @@ public sealed partial class AssetManagementService var accountedBytes = AccountedStorageBytes(asset); asset.Status = ContentStatus.Archived; asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); if (accountedBytes > 0) await featureAccessService.ReleaseQuotaAsync( actor.TenantId, @@ -50,4 +51,4 @@ public sealed partial class AssetManagementService { return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/Security/AssetSecurityScanJobHandler.cs b/Tiku.Infrastructure/Assets/Security/AssetSecurityScanJobHandler.cs index 3354a4d..e41edd7 100644 --- a/Tiku.Infrastructure/Assets/Security/AssetSecurityScanJobHandler.cs +++ b/Tiku.Infrastructure/Assets/Security/AssetSecurityScanJobHandler.cs @@ -10,7 +10,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets.Security; internal sealed class AssetSecurityScanJobHandler( - TikuDbContext dbContext, + IContentAssetPersistence dbContext, IAssetSecurityScanner scanner, IObjectStorageService storage) : IBackgroundJobHandler { diff --git a/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs b/Tiku.Infrastructure/Assets/Uploads/AssetUploadManagementService.cs similarity index 93% rename from Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs rename to Tiku.Infrastructure/Assets/Uploads/AssetUploadManagementService.cs index 052b2eb..30153f4 100644 --- a/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs +++ b/Tiku.Infrastructure/Assets/Uploads/AssetUploadManagementService.cs @@ -8,7 +8,8 @@ using Tiku.Domain.Content; namespace Tiku.Infrastructure.Assets; -public sealed partial class AssetManagementService +internal sealed class AssetUploadManagementService(AssetManagementDependencies dependencies) + : AssetManagementServiceBase(dependencies), IAssetUploadManagementService { public async Task SignUploadAsync( AssetManagementActor actor, @@ -63,7 +64,7 @@ public sealed partial class AssetManagementService true), cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new AssetUploadSignResult(ToItem(asset), upload); } @@ -72,7 +73,7 @@ public sealed partial class AssetManagementService AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) { - var asset = await dbContext.ContentAssets + var asset = await contentAssetPersistence.ContentAssets .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && @@ -123,7 +124,7 @@ public sealed partial class AssetManagementService { asset.UploadStatus = AssetUploadStatus.Failed; asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing"); } @@ -132,7 +133,7 @@ public sealed partial class AssetManagementService { asset.UploadStatus = AssetUploadStatus.Failed; asset.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); throw new AssetManagementException( "Object storage did not return a verified asset size.", "asset_upload_size_unverified"); @@ -166,4 +167,4 @@ public sealed partial class AssetManagementService return new AssetUploadConfirmResult(ToItem(asset), metadata); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs index 349574e..7b8ce9a 100644 --- a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs +++ b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs @@ -9,7 +9,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; -public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlaybackService +public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPersistence, + IIdentityPersistence identityPersistence) : IVideoPlaybackService { public async Task> SearchAsync( VideoPlaybackActor actor, @@ -17,7 +18,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - var videos = dbContext.VideoExplanations.AsNoTracking() + var videos = contentAssetPersistence.VideoExplanations.AsNoTracking() .Where(video => video.TenantId == actor.TenantId && video.IsActive); if (query.SubjectId.HasValue) videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null); @@ -53,9 +54,9 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken); progress.PlayCount++; progress.LastPlayedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); - dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent + contentAssetPersistence.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent { TenantId = actor.TenantId, UserId = actor.UserId, @@ -70,7 +71,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba source = "student_video_play" }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new VideoPlaybackItem( video.Id, @@ -110,7 +111,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object(); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return ToProgressItem(progress); } @@ -120,7 +121,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - var questionVideos = dbContext.QuestionVideos.AsNoTracking() + var questionVideos = contentAssetPersistence.QuestionVideos.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.QuestionId.HasValue) questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value); @@ -129,7 +130,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value)); - var videos = dbContext.VideoExplanations.AsNoTracking() + var videos = contentAssetPersistence.VideoExplanations.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IsActive); var items = await questionVideos .OrderBy(item => item.SortOrder) @@ -163,7 +164,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba Guid? questionId, CancellationToken cancellationToken) { - var progress = await dbContext.VideoPlaybackProgress.SingleOrDefaultAsync( + var progress = await contentAssetPersistence.VideoPlaybackProgress.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -181,7 +182,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba LastPlayedAt = DateTimeOffset.UtcNow, Metadata = JsonDefaults.Object() }; - dbContext.VideoPlaybackProgress.Add(progress); + contentAssetPersistence.VideoPlaybackProgress.Add(progress); return progress; } @@ -190,7 +191,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba Guid videoId, CancellationToken cancellationToken) { - return await dbContext.VideoExplanations.SingleOrDefaultAsync( + return await contentAssetPersistence.VideoExplanations.SingleOrDefaultAsync( video => video.TenantId == tenantId && video.Id == videoId && video.IsActive, cancellationToken) ?? throw new VideoPlaybackException("Video was not found.", "video_not_found"); @@ -202,7 +203,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba Guid videoId, CancellationToken cancellationToken) { - var exists = await dbContext.QuestionVideos.AnyAsync( + var exists = await contentAssetPersistence.QuestionVideos.AnyAsync( item => item.TenantId == tenantId && item.QuestionId == questionId && @@ -213,7 +214,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken) { - var exists = await dbContext.TenantMemberships.AnyAsync( + var exists = await identityPersistence.TenantMemberships.AnyAsync( membership => membership.TenantId == actor.TenantId && membership.UserId == actor.UserId && diff --git a/Tiku.Infrastructure/Auth/AuthAdministrationService.cs b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs index 32b6443..f43ffb1 100644 --- a/Tiku.Infrastructure/Auth/AuthAdministrationService.cs +++ b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs @@ -10,7 +10,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; internal sealed class AuthAdministrationService( - TikuDbContext dbContext, + IIdentityPersistence identityPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, UserManager userManager, IAuthSessionStore sessionStore) : IAuthAdministrationService { @@ -22,11 +23,11 @@ internal sealed class AuthAdministrationService( throw new InvalidCredentialsException("password_reset_reason_required"); var permitted = request.TenantId is { } tenantId - ? await dbContext.TenantMemberships.AnyAsync( + ? await identityPersistence.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == request.TargetUserId && item.Status == MembershipStatus.Active, cancellationToken) - : await dbContext.PlatformBackendUserRoles.AnyAsync( + : await jobsOperationsPersistence.PlatformBackendUserRoles.AnyAsync( item => item.UserId == request.TargetUserId, cancellationToken); if (!permitted) throw new AuthSessionNotFoundException(); @@ -43,7 +44,7 @@ internal sealed class AuthAdministrationService( throw new InvalidOperationException("Unable to require a password change after the administrative reset."); await sessionStore.RevokeAllAsync(user.Id, "administrative_password_reset", cancellationToken); - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = request.TenantId, ActorUserId = request.ActorUserId, @@ -56,6 +57,6 @@ internal sealed class AuthAdministrationService( ForcePasswordChange = true }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); } } \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs deleted file mode 100644 index c9879a4..0000000 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Identity; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Auth; - -public sealed partial class AuthService( - TikuDbContext dbContext, - SignInManager signInManager, - UserManager userManager, - ISmsVerificationService smsVerificationService, - IAuthSessionStore sessionStore, - IWechatOAuthClient wechatOAuthClient, - ITenantExternalProviderConfigService providerConfigService, - IFeatureAccessService featureAccessService) : IAuthService -{ - private const string PasswordProvider = "password"; - private const string SmsProvider = "sms"; - private const string WechatWebProvider = "wechat_web"; - private const string WechatMiniAppProvider = "wechat_miniapp"; - private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"]; - - 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"]; -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AuthServiceDependencies.cs b/Tiku.Infrastructure/Auth/AuthServiceDependencies.cs new file mode 100644 index 0000000..4d10514 --- /dev/null +++ b/Tiku.Infrastructure/Auth/AuthServiceDependencies.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Identity; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; +using Tiku.Infrastructure.Persistence; +namespace Tiku.Infrastructure.Auth; + +internal sealed record AuthServiceDependencies( + IIdentityPersistence IdentityPersistence, + ITenancyPersistence TenancyPersistence, + IJobsOperationsPersistence JobsOperationsPersistence, + SignInManager SignInManager, + UserManager UserManager, + ISmsVerificationService SmsVerificationService, + IAuthSessionStore SessionStore, + IWechatOAuthClient WechatOAuthClient, + ITenantExternalProviderConfigService ProviderConfigService, + IFeatureAccessService FeatureAccessService); + +internal abstract partial class AuthServiceBase(AuthServiceDependencies dependencies) +{ + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected ITenancyPersistence tenancyPersistence { get; } = dependencies.TenancyPersistence; + protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence; + protected SignInManager signInManager { get; } = dependencies.SignInManager; + protected UserManager userManager { get; } = dependencies.UserManager; + protected ISmsVerificationService smsVerificationService { get; } = dependencies.SmsVerificationService; + protected IAuthSessionStore sessionStore { get; } = dependencies.SessionStore; + protected IWechatOAuthClient wechatOAuthClient { get; } = dependencies.WechatOAuthClient; + protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService; + protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService; + protected const string PasswordProvider = "password"; + protected const string SmsProvider = "sms"; + protected const string WechatWebProvider = "wechat_web"; + protected const string WechatMiniAppProvider = "wechat_miniapp"; + protected static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"]; + + protected static readonly string[] WechatMiniAppProviderAliases = + ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; + + protected static readonly string[] WechatIdentityProviders = + ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; +} diff --git a/Tiku.Infrastructure/Auth/AuthSessionStore.cs b/Tiku.Infrastructure/Auth/AuthSessionStore.cs index 344c7b7..8981c4c 100644 --- a/Tiku.Infrastructure/Auth/AuthSessionStore.cs +++ b/Tiku.Infrastructure/Auth/AuthSessionStore.cs @@ -18,7 +18,9 @@ using ZLinq; namespace Tiku.Infrastructure.Auth; public sealed class AuthSessionStore( - TikuDbContext dbContext, + IIdentityPersistence identityPersistence, + ITenancyPersistence tenancyPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, ITokenService tokenService, IOptions options, IAccessSecurityCache? configuredAccessSecurityCache = null, @@ -80,8 +82,8 @@ public sealed class AuthSessionStore( var session = CreateSession(request, Guid.NewGuid()); var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id); session.TokenHash = HashRefreshToken(refreshToken); - dbContext.AuthSessions.Add(session); - await dbContext.SaveChangesAsync(cancellationToken); + identityPersistence.AuthSessions.Add(session); + await identityPersistence.SaveChangesAsync(cancellationToken); return CreatePair(request, session, refreshToken); } @@ -96,8 +98,8 @@ public sealed class AuthSessionStore( var tokenHash = HashRefreshToken(refreshToken); var now = DateTimeOffset.UtcNow; AuthorizationCacheTelemetry.PostgresFallback(); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - var current = await dbContext.AuthSessions.SingleOrDefaultAsync( + await using var transaction = await identityPersistence.Database.BeginTransactionAsync(cancellationToken); + var current = await identityPersistence.AuthSessions.SingleOrDefaultAsync( item => item.Id == locator.SessionId && item.Realm == locator.Realm && item.TenantId == locator.TenantId && item.TokenHash == tokenHash, cancellationToken); @@ -110,7 +112,7 @@ public sealed class AuthSessionStore( throw new SessionRevokedException(); } - var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken); + var user = await identityPersistence.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken); if (user is null || user.Status != UserStatus.Active || !string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal)) { @@ -132,7 +134,7 @@ public sealed class AuthSessionStore( } var nextId = Guid.NewGuid(); - var updated = await dbContext.AuthSessions + var updated = await identityPersistence.AuthSessions .Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.RevokedAt, now) @@ -152,8 +154,8 @@ public sealed class AuthSessionStore( var next = CreateSession(request, nextId); var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id); next.TokenHash = HashRefreshToken(nextToken); - dbContext.AuthSessions.Add(next); - await dbContext.SaveChangesAsync(cancellationToken); + identityPersistence.AuthSessions.Add(next); + await identityPersistence.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken); return CreatePair(request, next, nextToken); @@ -199,8 +201,8 @@ public sealed class AuthSessionStore( try { state = await ( - from session in dbContext.AuthSessions.AsNoTracking() - join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id + from session in identityPersistence.AuthSessions.AsNoTracking() + join user in identityPersistence.Users.AsNoTracking() on session.UserId equals user.Id where session.Id == sessionId && session.UserId == userId && session.Realm == realm && @@ -211,32 +213,32 @@ public sealed class AuthSessionStore( session.SecurityStamp, realm != AuthRealm.Tenant || (tenantId != null && - dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) && - dbContext.TenantMemberships.Any(item => + tenancyPersistence.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) && + identityPersistence.TenantMemberships.Any(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active)), realm != AuthRealm.Platform || - (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 == BackendRoleStatus.Active && - (permission.Area == BackendPermissionArea.Platform || - permission.Area == BackendPermissionArea.Both) - select permission.Id).Any(), + (from userRole in jobsOperationsPersistence.PlatformBackendUserRoles + join role in jobsOperationsPersistence.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in jobsOperationsPersistence.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in jobsOperationsPersistence.BackendPermissions on binding.PermissionCode equals permission + .Code + where userRole.UserId == userId && + role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Platform || + permission.Area == BackendPermissionArea.Both) + select permission.Id).Any(), realm == AuthRealm.Tenant && tenantId != null - ? dbContext.Tenants.Where(item => item.Id == tenantId) + ? tenancyPersistence.Tenants.Where(item => item.Id == tenantId) .Select(item => (TenantStatus?)item.Status).FirstOrDefault() : null, realm == AuthRealm.Tenant && tenantId != null - ? dbContext.TenantMemberships + ? identityPersistence.TenantMemberships .Where(item => item.TenantId == tenantId && item.UserId == userId) .Select(item => (MembershipStatus?)item.Status).FirstOrDefault() : null, - dbContext.AuthorizationScopeVersions + jobsOperationsPersistence.AuthorizationScopeVersions .Where(item => item.Realm == realm && item.TenantId == tenantId) .Select(item => (long?)item.Version).FirstOrDefault() ?? 1L, session.ExpiresAt, @@ -297,7 +299,7 @@ public sealed class AuthSessionStore( Guid userId, CancellationToken cancellationToken = default) { - var session = await dbContext.AuthSessions.AsNoTracking() + var session = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.Id == sessionId && item.UserId == userId) .Select(item => new { item.Realm, item.TenantId }) .SingleOrDefaultAsync(cancellationToken); @@ -317,7 +319,7 @@ public sealed class AuthSessionStore( if (!TryParseRefreshToken(refreshToken, out var locator)) return; var hash = HashRefreshToken(refreshToken); - var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync( + var session = await identityPersistence.AuthSessions.AsNoTracking().SingleOrDefaultAsync( item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken); if (session is not null) await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken); @@ -326,16 +328,16 @@ public sealed class AuthSessionStore( public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var sessionIds = await dbContext.AuthSessions.AsNoTracking() + var sessionIds = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.UserId == userId && item.RevokedAt == null) .Select(item => item.Id).ToArrayAsync(cancellationToken); - var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null) + var count = await identityPersistence.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow) .SetProperty(item => item.RevokedReason, reason), cancellationToken); if (count > 0) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { ActorUserId = userId, Action = "auth.sessions.revoked_all", @@ -343,7 +345,7 @@ public sealed class AuthSessionStore( TargetId = userId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); await stateInvalidator.InvalidateUserAsync(userId, cancellationToken); @@ -359,11 +361,11 @@ public sealed class AuthSessionStore( { ValidateRealm(realm, tenantId); var now = DateTimeOffset.UtcNow; - var sessionIds = await dbContext.AuthSessions.AsNoTracking() + var sessionIds = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null) .Select(item => item.Id).ToArrayAsync(cancellationToken); - var count = await dbContext.AuthSessions + var count = await identityPersistence.AuthSessions .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null) .ExecuteUpdateAsync(setters => setters @@ -371,7 +373,7 @@ public sealed class AuthSessionStore( .SetProperty(item => item.RevokedReason, reason), cancellationToken); if (count > 0) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = userId, @@ -380,7 +382,7 @@ public sealed class AuthSessionStore( TargetId = userId.ToString(), Details = JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); } @@ -391,12 +393,12 @@ public sealed class AuthSessionStore( Guid currentSessionId, CancellationToken cancellationToken = default) { - var current = await dbContext.AuthSessions.AsNoTracking() + var current = await identityPersistence.AuthSessions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) ?? throw new SessionRevokedException(); var now = DateTimeOffset.UtcNow; - var sessions = await dbContext.AuthSessions.AsNoTracking() + var sessions = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); @@ -432,13 +434,13 @@ public sealed class AuthSessionStore( Guid sessionFamilyId, CancellationToken cancellationToken = default) { - var current = await dbContext.AuthSessions.AsNoTracking() + var current = await identityPersistence.AuthSessions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) ?? throw new SessionRevokedException(); if (current.TokenFamilyId == sessionFamilyId) throw new CurrentAuthSessionCannotBeRevokedException(); - var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync( + var owned = await identityPersistence.AuthSessions.AsNoTracking().AnyAsync( item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId && item.Realm == current.Realm && item.TenantId == current.TenantId, cancellationToken); @@ -523,9 +525,9 @@ public sealed class AuthSessionStore( if (realm == AuthRealm.Tenant && tenantId.HasValue) { var active = - await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, + await tenancyPersistence.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) && - await dbContext.TenantMemberships.AnyAsync( + await identityPersistence.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken); if (active) return; @@ -533,10 +535,10 @@ public sealed class AuthSessionStore( else if (realm == AuthRealm.Platform) { var active = 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 + from userRole in jobsOperationsPersistence.PlatformBackendUserRoles + join role in jobsOperationsPersistence.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in jobsOperationsPersistence.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in jobsOperationsPersistence.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.UserId == userId && role.Status == BackendRoleStatus.Active && (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both) @@ -550,20 +552,20 @@ public sealed class AuthSessionStore( private async Task RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken) { - var sessionIds = await dbContext.AuthSessions.AsNoTracking() + var sessionIds = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) .Select(item => item.Id).ToArrayAsync(cancellationToken); - var owner = await dbContext.AuthSessions.AsNoTracking() + var owner = await identityPersistence.AuthSessions.AsNoTracking() .Where(item => item.TokenFamilyId == familyId) .Select(item => new { item.UserId, item.TenantId }) .FirstOrDefaultAsync(cancellationToken); - var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) + var count = await identityPersistence.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.RevokedAt, now) .SetProperty(item => item.RevokedReason, reason), cancellationToken); if (count > 0 && owner is not null) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = owner.TenantId, ActorUserId = owner.UserId, @@ -572,7 +574,7 @@ public sealed class AuthSessionStore( TargetId = familyId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); } diff --git a/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs index ea45072..95e3452 100644 --- a/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs +++ b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs @@ -5,22 +5,23 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; -internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICurrentIdentityQueryService +internal sealed class CurrentIdentityQueryService(IIdentityPersistence identityPersistence, + ITenancyPersistence tenancyPersistence) : ICurrentIdentityQueryService { public async Task GetUserAsync( Guid userId, CancellationToken cancellationToken = default) { - var user = await dbContext.Users.AsNoTracking() + var user = await identityPersistence.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() + var memberships = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.UserId == userId && item.Status == MembershipStatus.Active) .Join( - dbContext.Tenants.AsNoTracking(), + tenancyPersistence.Tenants.AsNoTracking(), membership => membership.TenantId, tenant => tenant.Id, (membership, tenant) => new CurrentUserTenant( @@ -39,13 +40,13 @@ internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICu Guid tenantId, CancellationToken cancellationToken = default) { - return dbContext.TenantMemberships.AsNoTracking() + return identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.UserId == userId && item.TenantId == tenantId && item.Status == MembershipStatus.Active) .Join( - dbContext.Tenants.AsNoTracking(), + tenancyPersistence.Tenants.AsNoTracking(), membership => membership.TenantId, tenant => tenant.Id, (membership, tenant) => new CurrentTenantMembership( diff --git a/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs b/Tiku.Infrastructure/Auth/Foundation/AuthServiceFoundation.cs similarity index 80% rename from Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs rename to Tiku.Infrastructure/Auth/Foundation/AuthServiceFoundation.cs index 433fbc4..cec5fa9 100644 --- a/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs +++ b/Tiku.Infrastructure/Auth/Foundation/AuthServiceFoundation.cs @@ -12,35 +12,35 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal abstract partial class AuthServiceBase { - private async Task FindChallengeAsync( + protected async Task FindChallengeAsync( string token, AuthChallengePurpose purpose, CancellationToken cancellationToken) { var tokenHash = HashChallengeToken(token); var now = DateTimeOffset.UtcNow; - return await dbContext.AuthChallenges.SingleOrDefaultAsync( + return await identityPersistence.AuthChallenges.SingleOrDefaultAsync( item => item.TokenHash == tokenHash && item.Purpose == purpose && item.ConsumedAt == null && item.ExpiresAt > now && - dbContext.Users.Any(user => + identityPersistence.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) + protected async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; - var consumed = await dbContext.AuthChallenges + var consumed = await identityPersistence.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( + protected async Task CompleteSuccessfulLoginAsync( AuthRealm realm, Guid? tenantId, User user, @@ -63,7 +63,7 @@ public sealed partial class AuthService if (realm == AuthRealm.Tenant && tenantId.HasValue) { membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken); - tenant = await dbContext.Tenants.SingleOrDefaultAsync( + tenant = await tenancyPersistence.Tenants.SingleOrDefaultAsync( item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken); if (membership is null || tenant is null) { @@ -97,7 +97,7 @@ public sealed partial class AuthService identifier, ipAddress, userAgent, cancellationToken); } - private async Task IssueAuthenticatedResultAsync( + protected async Task IssueAuthenticatedResultAsync( User user, AuthRealm realm, Tenant? tenant, @@ -140,7 +140,7 @@ public sealed partial class AuthService new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens)); } - private async Task LoginWithWechatAsync( + protected async Task LoginWithWechatAsync( WechatLoginRequest request, string provider, IReadOnlyList providerAliases, @@ -176,8 +176,8 @@ public sealed partial class AuthService throw; } - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + await using var transaction = identityPersistence.Database.CurrentTransaction is null + ? await identityPersistence.Database.BeginTransactionAsync(cancellationToken) : null; var providerSubject = $"{config.AppId}:{identity.OpenId}"; var user = await UpsertWechatUserAsync( @@ -193,7 +193,7 @@ public sealed partial class AuthService // 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); + await identityPersistence.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return await CompleteSuccessfulLoginAsync( @@ -207,7 +207,7 @@ public sealed partial class AuthService cancellationToken); } - private async Task LoadWechatProviderOptionsAsync( + protected async Task LoadWechatProviderOptionsAsync( Guid tenantId, string provider, IReadOnlyList aliases, @@ -238,14 +238,14 @@ public sealed partial class AuthService return new WechatProviderOptions(appId, appSecret); } - private async Task UpsertWechatUserAsync( + protected async Task UpsertWechatUserAsync( string provider, string providerSubject, string appId, WechatIdentity wechatIdentity, CancellationToken cancellationToken) { - var existingIdentity = await dbContext.UserIdentities + var existingIdentity = await identityPersistence.UserIdentities .SingleOrDefaultAsync( identity => identity.Provider == provider && @@ -253,7 +253,7 @@ public sealed partial class AuthService cancellationToken); var user = existingIdentity is null ? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken) - : await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken); + : await identityPersistence.Users.FindAsync([existingIdentity.UserId], cancellationToken); if (user is null) { @@ -264,7 +264,7 @@ public sealed partial class AuthService PrimaryRole = "student", RawProfile = CreateWechatRawProfile(wechatIdentity) }; - dbContext.Users.Add(user); + identityPersistence.Users.Add(user); } else { @@ -280,7 +280,7 @@ public sealed partial class AuthService Provider = provider, ProviderSubject = providerSubject }; - dbContext.UserIdentities.Add(existingIdentity); + identityPersistence.UserIdentities.Add(existingIdentity); } existingIdentity.UserId = user.Id; @@ -290,13 +290,13 @@ public sealed partial class AuthService return user; } - private async Task FindUserByWechatUnionIdAsync( + protected async Task FindUserByWechatUnionIdAsync( string? unionId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(unionId)) return null; - var identity = await dbContext.UserIdentities + var identity = await identityPersistence.UserIdentities .Where(entity => entity.UnionId == unionId && WechatIdentityProviders.Contains(entity.Provider)) @@ -305,15 +305,15 @@ public sealed partial class AuthService return identity is null ? null - : await dbContext.Users.FindAsync([identity.UserId], cancellationToken); + : await identityPersistence.Users.FindAsync([identity.UserId], cancellationToken); } - private async Task EnsureTenantMembershipAsync( + protected async Task EnsureTenantMembershipAsync( Guid tenantId, Guid userId, CancellationToken cancellationToken) { - var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync( + var activeMembershipExists = await identityPersistence.TenantMemberships.AnyAsync( membership => membership.TenantId == tenantId && membership.UserId == userId && @@ -322,7 +322,7 @@ public sealed partial class AuthService if (activeMembershipExists) return; - var studentMembership = await dbContext.TenantMemberships + var studentMembership = await identityPersistence.TenantMemberships .FirstOrDefaultAsync( membership => membership.TenantId == tenantId && @@ -333,7 +333,7 @@ public sealed partial class AuthService // Invited and Disabled memberships require an explicit administrator action. throw new TenantAccessDeniedException(); - var policy = await dbContext.TenantAuthPolicies.AsNoTracking() + var policy = await tenancyPersistence.TenantAuthPolicies.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (policy is not null && !policy.AllowExternalStudentSelfRegistration) throw new TenantAccessDeniedException(); @@ -342,7 +342,7 @@ public sealed partial class AuthService SaasQuotaMetricCatalog.StudentCount, cancellationToken: cancellationToken); - dbContext.TenantMemberships.Add(new TenantMembership + identityPersistence.TenantMemberships.Add(new TenantMembership { TenantId = tenantId, UserId = userId, @@ -351,12 +351,12 @@ public sealed partial class AuthService }); } - private async Task FindActiveMembershipAsync( + protected async Task FindActiveMembershipAsync( Guid tenantId, Guid userId, CancellationToken cancellationToken) { - return await dbContext.TenantMemberships + return await identityPersistence.TenantMemberships .Where(entity => entity.TenantId == tenantId && entity.UserId == userId && @@ -365,7 +365,7 @@ public sealed partial class AuthService .FirstOrDefaultAsync(cancellationToken); } - private async Task CreateChallengeResultAsync( + protected async Task CreateChallengeResultAsync( User user, AuthRealm realm, Guid? tenantId, @@ -380,7 +380,7 @@ public sealed partial class AuthService 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 + identityPersistence.AuthChallenges.Add(new AuthChallenge { UserId = user.Id, Realm = realm, @@ -393,14 +393,14 @@ public sealed partial class AuthService IpAddress = ipAddress, UserAgent = userAgent }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.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( + protected async Task HasBackendPermissionsAsync( AuthRealm realm, Guid? tenantId, Guid userId, @@ -408,10 +408,10 @@ public sealed partial class AuthService { 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 + from userRole in jobsOperationsPersistence.PlatformBackendUserRoles + join role in jobsOperationsPersistence.PlatformBackendRoles on userRole.RoleId equals role.Id + join binding in jobsOperationsPersistence.PlatformBackendRolePermissions on role.Id equals binding.RoleId + join permission in jobsOperationsPersistence.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.UserId == userId && role.Status == BackendRoleStatus.Active && (permission.Area == BackendPermissionArea.Platform || @@ -421,10 +421,10 @@ public sealed partial class AuthService 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 + from userRole in jobsOperationsPersistence.TenantBackendUserRoles + join role in jobsOperationsPersistence.TenantBackendRoles on userRole.RoleId equals role.Id + join binding in jobsOperationsPersistence.TenantBackendRolePermissions on role.Id equals binding.RoleId + join permission in jobsOperationsPersistence.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.TenantId == tenantId.Value && userRole.UserId == userId && binding.TenantId == tenantId.Value && role.Status == BackendRoleStatus.Active && @@ -433,12 +433,12 @@ public sealed partial class AuthService select permission.Id).AnyAsync(cancellationToken); } - private static string HashChallengeToken(string token) + protected static string HashChallengeToken(string token) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); } - private async Task AddSecurityAuditAsync( + protected async Task AddSecurityAuditAsync( Guid userId, Guid? tenantId, string action, @@ -447,7 +447,7 @@ public sealed partial class AuthService string? userAgent, CancellationToken cancellationToken) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = userId, @@ -458,10 +458,10 @@ public sealed partial class AuthService IpAddress = ipAddress, UserAgent = userAgent }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); } - private async Task AddLoginEventAsync( + protected async Task AddLoginEventAsync( Guid? tenantId, Guid? userId, string provider, @@ -472,7 +472,7 @@ public sealed partial class AuthService string? userAgent, CancellationToken cancellationToken) { - dbContext.AuthLoginEvents.Add(new AuthLoginEvent + identityPersistence.AuthLoginEvents.Add(new AuthLoginEvent { TenantId = tenantId, UserId = userId, @@ -484,10 +484,10 @@ public sealed partial class AuthService UserAgent = userAgent }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); } - private static string? GetJsonString(JsonElement element, params string[] names) + protected static string? GetJsonString(JsonElement element, params string[] names) { if (element.ValueKind != JsonValueKind.Object) return null; @@ -500,7 +500,7 @@ public sealed partial class AuthService return null; } - private static JsonElement CreateWechatRawProfile(WechatIdentity identity) + protected static JsonElement CreateWechatRawProfile(WechatIdentity identity) { return JsonSerializer.SerializeToElement(new { @@ -510,4 +510,4 @@ public sealed partial class AuthService avatarUrl = identity.AvatarUrl }); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/OwnerActivationService.cs b/Tiku.Infrastructure/Auth/OwnerActivationService.cs index b4e67e8..08765bb 100644 --- a/Tiku.Infrastructure/Auth/OwnerActivationService.cs +++ b/Tiku.Infrastructure/Auth/OwnerActivationService.cs @@ -58,7 +58,7 @@ internal sealed class OwnerActivationService( true), async (services, token) => { - var dbContext = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); var grant = await dbContext.TenantOwnerActivationGrants .SingleOrDefaultAsync(value => value.Id == request.ActivationId, token) ?? throw Error("Owner activation was not found.", "owner_activation_invalid"); diff --git a/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs b/Tiku.Infrastructure/Auth/PasswordLifecycle/PasswordLifecycleService.cs similarity index 92% rename from Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs rename to Tiku.Infrastructure/Auth/PasswordLifecycle/PasswordLifecycleService.cs index 1a37fb9..9c1aac7 100644 --- a/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs +++ b/Tiku.Infrastructure/Auth/PasswordLifecycle/PasswordLifecycleService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal sealed class PasswordLifecycleService(AuthServiceDependencies dependencies) + : AuthServiceBase(dependencies), IPasswordLifecycleService { public async Task ChangeRequiredPasswordAsync( PasswordChangeChallengeRequest request, @@ -39,11 +40,11 @@ public sealed partial class AuthService CancellationToken cancellationToken = default) { var phone = SmsCodeHashing.NormalizePhone(request.Phone); - var userId = await dbContext.Users.AsNoTracking() + var userId = await identityPersistence.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( + var eligible = userId.HasValue && await identityPersistence.TenantMemberships.AsNoTracking().AnyAsync( membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value && membership.Status == MembershipStatus.Active, cancellationToken); @@ -65,10 +66,10 @@ public sealed partial class AuthService CancellationToken cancellationToken = default) { var phone = SmsCodeHashing.NormalizePhone(request.Phone); - var user = await dbContext.Users.SingleOrDefaultAsync( + var user = await identityPersistence.Users.SingleOrDefaultAsync( item => item.Phone == phone && item.Status == UserStatus.Active, cancellationToken); - if (user is null || !await dbContext.TenantMemberships.AnyAsync( + if (user is null || !await identityPersistence.TenantMemberships.AnyAsync( membership => membership.TenantId == request.TenantId && membership.UserId == user.Id && membership.Status == MembershipStatus.Active, cancellationToken)) @@ -142,4 +143,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs b/Tiku.Infrastructure/Auth/PasswordLogin/PasswordLoginService.cs similarity index 91% rename from Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs rename to Tiku.Infrastructure/Auth/PasswordLogin/PasswordLoginService.cs index 21eab1c..b16458d 100644 --- a/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs +++ b/Tiku.Infrastructure/Auth/PasswordLogin/PasswordLoginService.cs @@ -6,7 +6,8 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal sealed class PasswordLoginService(AuthServiceDependencies dependencies) + : AuthServiceBase(dependencies), IPasswordLoginService { public async Task LoginWithPasswordAsync( PasswordLoginRequest request, @@ -15,7 +16,7 @@ public sealed partial class AuthService var identifier = request.Phone.Trim(); var normalizedEmail = userManager.NormalizeEmail(identifier); var normalizedUserName = userManager.NormalizeName(identifier); - var user = await dbContext.Users + var user = await identityPersistence.Users .SingleOrDefaultAsync(entity => entity.Phone == identifier || entity.NormalizedEmail == normalizedEmail || @@ -56,4 +57,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs index 97edd7a..280631b 100644 --- a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs +++ b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs @@ -3,7 +3,10 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IIdentityProvider +internal sealed class SelfHostedIdentityProvider( + IPasswordLoginService passwordLoginService, + ISmsLoginService smsLoginService, + IWechatLoginService wechatLoginService) : IIdentityProvider { public async Task AuthenticateAsync( IdentityProviderRequest request, @@ -12,7 +15,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId var provider = request.Provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); var authenticated = provider switch { - "password" => await authService.LoginWithPasswordAsync( + "password" => await passwordLoginService.LoginWithPasswordAsync( new PasswordLoginRequest( AuthRealm.Tenant, request.TenantId, @@ -21,7 +24,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId request.IpAddress, request.UserAgent), cancellationToken), - "sms" => await authService.LoginWithSmsAsync( + "sms" => await smsLoginService.LoginWithSmsAsync( new SmsLoginRequest( AuthRealm.Tenant, request.TenantId, @@ -30,7 +33,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId request.IpAddress, request.UserAgent), cancellationToken), - "wechat_web" => await authService.LoginWithWechatWebAsync( + "wechat_web" => await wechatLoginService.LoginWithWechatWebAsync( new WechatLoginRequest( AuthRealm.Tenant, request.TenantId, @@ -38,7 +41,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId request.IpAddress, request.UserAgent), cancellationToken), - "wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync( + "wechat_miniapp" => await wechatLoginService.LoginWithWechatMiniAppAsync( new WechatLoginRequest( AuthRealm.Tenant, request.TenantId, @@ -58,4 +61,4 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId user.Email, user.Name); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs b/Tiku.Infrastructure/Auth/Sessions/AuthSessionService.cs similarity index 89% rename from Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs rename to Tiku.Infrastructure/Auth/Sessions/AuthSessionService.cs index bf78b66..e5e8009 100644 --- a/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs +++ b/Tiku.Infrastructure/Auth/Sessions/AuthSessionService.cs @@ -2,7 +2,8 @@ using Tiku.Application.Auth; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal sealed class AuthSessionService(AuthServiceDependencies dependencies) + : AuthServiceBase(dependencies), IAuthSessionService { public async Task RefreshAsync( RefreshSessionRequest request, @@ -28,4 +29,4 @@ public sealed partial class AuthService await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs b/Tiku.Infrastructure/Auth/SmsLogin/SmsLoginService.cs similarity index 91% rename from Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs rename to Tiku.Infrastructure/Auth/SmsLogin/SmsLoginService.cs index 767b17b..81bd9d7 100644 --- a/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs +++ b/Tiku.Infrastructure/Auth/SmsLogin/SmsLoginService.cs @@ -4,14 +4,15 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal sealed class SmsLoginService(AuthServiceDependencies dependencies) + : AuthServiceBase(dependencies), ISmsLoginService { public async Task LoginWithSmsAsync( SmsLoginRequest request, CancellationToken cancellationToken = default) { var phone = SmsCodeHashing.NormalizePhone(request.Phone); - var user = await dbContext.Users + var user = await identityPersistence.Users .SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken); try @@ -64,4 +65,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 5951cbf..8644e15 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -14,7 +14,7 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Auth; public sealed class SmsVerificationService( - TikuDbContext dbContext, + IIdentityPersistence dbContext, ISmsProvider smsProvider, IRedisSecurityStore redisSecurityStore, IFeatureAccessService featureAccessService, @@ -25,7 +25,7 @@ public sealed class SmsVerificationService( private readonly SmsSecurityOptions options = securityOptions.Value; public SmsVerificationService( - TikuDbContext dbContext, + IIdentityPersistence dbContext, ISmsProvider smsProvider, IFeatureAccessService featureAccessService, IOptions securityOptions) diff --git a/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs b/Tiku.Infrastructure/Auth/Wechat/WechatLoginService.cs similarity index 87% rename from Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs rename to Tiku.Infrastructure/Auth/Wechat/WechatLoginService.cs index 132a15e..4ebb510 100644 --- a/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs +++ b/Tiku.Infrastructure/Auth/Wechat/WechatLoginService.cs @@ -2,7 +2,8 @@ using Tiku.Application.Auth; namespace Tiku.Infrastructure.Auth; -public sealed partial class AuthService +internal sealed class WechatLoginService(AuthServiceDependencies dependencies) + : AuthServiceBase(dependencies), IWechatLoginService { public Task LoginWithWechatWebAsync( WechatLoginRequest request, @@ -27,4 +28,4 @@ public sealed partial class AuthService (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), cancellationToken); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index 3e4d807..a9a2e79 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -10,7 +10,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Backoffice; internal sealed class BackofficeService( - TikuDbContext dbContext, + IJobsOperationsPersistence jobsOperationsPersistence, + IIdentityPersistence identityPersistence, IOperationAuditService auditService, IFeatureAccessService featureAccessService, IAuthorizationStateInvalidator authorizationStateInvalidator) : IBackofficeService @@ -60,7 +61,7 @@ internal sealed class BackofficeService( { var tenantId = RequireTenantAdmin(actor); var roles = await LoadTenantRolesAsync(tenantId, cancellationToken); - var permissions = await dbContext.BackendPermissions.AsNoTracking() + var permissions = await jobsOperationsPersistence.BackendPermissions.AsNoTracking() .Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both) .OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); @@ -71,7 +72,7 @@ internal sealed class BackofficeService( cancellationToken); permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)) .ToArray(); - var menus = await dbContext.BackendMenus.AsNoTracking() + var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant) .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); @@ -90,11 +91,11 @@ internal sealed class BackofficeService( { RequirePlatformAdmin(actor); var roles = await LoadPlatformRolesAsync(cancellationToken); - var permissions = await dbContext.BackendPermissions.AsNoTracking() + var permissions = await jobsOperationsPersistence.BackendPermissions.AsNoTracking() .Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both) .OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); - var menus = await dbContext.BackendMenus.AsNoTracking() + var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform) .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); @@ -111,16 +112,16 @@ internal sealed class BackofficeService( { var tenantId = RequireTenantAdmin(actor); var role = command.Id.HasValue - ? await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + ? await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == command.Id.Value, cancellationToken) - : await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + : await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code), cancellationToken); if (role is null) { role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) }; - dbContext.TenantBackendRoles.Add(role); + jobsOperationsPersistence.TenantBackendRoles.Add(role); } else if (role.IsSystem) { @@ -131,7 +132,7 @@ internal sealed class BackofficeService( role.Status = command.Status; role.Description = command.Description?.Trim(); role.DataScope = command.DataScope ?? JsonDefaults.Object(); - await dbContext.SaveChangesAsync(cancellationToken); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken); @@ -145,14 +146,14 @@ internal sealed class BackofficeService( { RequirePlatformAdmin(actor); var role = command.Id.HasValue - ? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, + ? await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken) - : await dbContext.PlatformBackendRoles.SingleOrDefaultAsync( + : await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync( item => item.Code == NormalizeCode(command.Code), cancellationToken); if (role is null) { role = new PlatformBackendRole { Code = NormalizeCode(command.Code) }; - dbContext.PlatformBackendRoles.Add(role); + jobsOperationsPersistence.PlatformBackendRoles.Add(role); } else if (role.IsSystem) { @@ -162,7 +163,7 @@ internal sealed class BackofficeService( role.Name = command.Name.Trim(); role.Status = command.Status; role.Description = command.Description?.Trim(); - await dbContext.SaveChangesAsync(cancellationToken); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken); @@ -175,7 +176,7 @@ internal sealed class BackofficeService( CancellationToken cancellationToken = default) { var tenantId = RequireTenantAdmin(actor); - var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync( + var role = await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == command.RoleId, cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found"); if (role.IsSystem) @@ -195,7 +196,7 @@ internal sealed class BackofficeService( CancellationToken cancellationToken = default) { RequirePlatformAdmin(actor); - var role = await dbContext.PlatformBackendRoles.SingleOrDefaultAsync( + var role = await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync( item => item.Id == command.RoleId, cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found"); if (role.IsSystem) @@ -215,11 +216,11 @@ internal sealed class BackofficeService( { var tenantId = RequireTenantAdmin(actor); var roleIds = command.RoleIds.Distinct().ToArray(); - var ownerRoleId = await dbContext.TenantBackendRoles + var ownerRoleId = await jobsOperationsPersistence.TenantBackendRoles .Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem) .Select(item => (Guid?)item.Id) .SingleOrDefaultAsync(cancellationToken); - var isActiveOwner = await dbContext.TenantMemberships.AnyAsync( + var isActiveOwner = await identityPersistence.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == command.UserId && item.Role == TenantRole.TenantOwner && @@ -228,22 +229,22 @@ internal sealed class BackofficeService( if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value)) throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked"); - var count = await dbContext.TenantBackendRoles.CountAsync( + var count = await jobsOperationsPersistence.TenantBackendRoles.CountAsync( item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active, cancellationToken); if (count != roleIds.Length) throw new BackofficeException("One or more tenant roles were not found.", "role_not_found"); - await dbContext.TenantBackendUserRoles + await jobsOperationsPersistence.TenantBackendUserRoles .Where(item => item.TenantId == tenantId && item.UserId == command.UserId) .ExecuteDeleteAsync(cancellationToken); - dbContext.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole + jobsOperationsPersistence.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole { TenantId = tenantId, UserId = command.UserId, RoleId = roleId })); - await dbContext.SaveChangesAsync(cancellationToken); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); @@ -256,21 +257,21 @@ internal sealed class BackofficeService( { RequirePlatformAdmin(actor); var roleIds = command.RoleIds.Distinct().ToArray(); - var count = await dbContext.PlatformBackendRoles.CountAsync( + var count = await jobsOperationsPersistence.PlatformBackendRoles.CountAsync( item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active, cancellationToken); if (count != roleIds.Length) throw new BackofficeException("One or more platform roles were not found.", "role_not_found"); - await dbContext.PlatformBackendUserRoles + await jobsOperationsPersistence.PlatformBackendUserRoles .Where(item => item.UserId == command.UserId) .ExecuteDeleteAsync(cancellationToken); - dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole + jobsOperationsPersistence.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole { UserId = command.UserId, RoleId = roleId })); - await dbContext.SaveChangesAsync(cancellationToken); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); @@ -293,15 +294,15 @@ internal sealed class BackofficeService( "One or more permissions belong to a feature unavailable to this tenant.", "feature_not_available"); await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken); - await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId) + await jobsOperationsPersistence.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId) .ExecuteDeleteAsync(cancellationToken); - await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId) + await jobsOperationsPersistence.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId) .ExecuteDeleteAsync(cancellationToken); - dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => + jobsOperationsPersistence.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code })); - dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu - { TenantId = tenantId, RoleId = roleId, MenuCode = code })); - await dbContext.SaveChangesAsync(cancellationToken); + jobsOperationsPersistence.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu + { TenantId = tenantId, RoleId = roleId, MenuCode = code })); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); } private async Task ReplacePlatformBindingsCoreAsync( @@ -314,21 +315,21 @@ internal sealed class BackofficeService( var normalizedMenus = NormalizeCodes(menuCodes); await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken); await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken); - await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId) + await jobsOperationsPersistence.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId) .ExecuteDeleteAsync(cancellationToken); - await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId) + await jobsOperationsPersistence.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId) .ExecuteDeleteAsync(cancellationToken); - dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => + jobsOperationsPersistence.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code })); - dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu - { RoleId = roleId, MenuCode = code })); - await dbContext.SaveChangesAsync(cancellationToken); + jobsOperationsPersistence.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu + { RoleId = roleId, MenuCode = code })); + await jobsOperationsPersistence.SaveChangesAsync(cancellationToken); } private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) { - var count = await dbContext.BackendPermissions.CountAsync( + var count = await jobsOperationsPersistence.BackendPermissions.CountAsync( item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both), cancellationToken); if (count != codes.Length) @@ -341,7 +342,7 @@ internal sealed class BackofficeService( CancellationToken cancellationToken) { var codes = permissionCodes.ToArray(); - var menus = await dbContext.BackendMenus.AsNoTracking() + var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == area && (item.PermissionCode == null || codes.Contains(item.PermissionCode))) .OrderBy(item => item.SortOrder) @@ -367,7 +368,7 @@ internal sealed class BackofficeService( private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) { - var count = await dbContext.BackendMenus.CountAsync( + var count = await jobsOperationsPersistence.BackendMenus.CountAsync( item => codes.Contains(item.Code) && item.Area == area && item.IsActive, cancellationToken); if (count != codes.Length) throw new BackofficeException("One or more menus were not found.", "menu_not_found"); @@ -375,15 +376,15 @@ internal sealed class BackofficeService( private async Task LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken) { - var roles = await dbContext.TenantBackendRoles.AsNoTracking() + var roles = await jobsOperationsPersistence.TenantBackendRoles.AsNoTracking() .Where(item => item.TenantId == tenantId) .OrderBy(item => item.Code) .ToArrayAsync(cancellationToken); var roleIds = roles.Select(item => item.Id).ToArray(); - var permissions = await dbContext.TenantBackendRolePermissions.AsNoTracking() + var permissions = await jobsOperationsPersistence.TenantBackendRolePermissions.AsNoTracking() .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId)) .ToArrayAsync(cancellationToken); - var menus = await dbContext.TenantBackendRoleMenus.AsNoTracking() + var menus = await jobsOperationsPersistence.TenantBackendRoleMenus.AsNoTracking() .Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId)) .ToArrayAsync(cancellationToken); return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray(); @@ -391,14 +392,14 @@ internal sealed class BackofficeService( private async Task LoadPlatformRolesAsync(CancellationToken cancellationToken) { - var roles = await dbContext.PlatformBackendRoles.AsNoTracking() + var roles = await jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking() .OrderBy(item => item.Code) .ToArrayAsync(cancellationToken); var roleIds = roles.Select(item => item.Id).ToArray(); - var permissions = await dbContext.PlatformBackendRolePermissions.AsNoTracking() + var permissions = await jobsOperationsPersistence.PlatformBackendRolePermissions.AsNoTracking() .Where(item => roleIds.Contains(item.RoleId)) .ToArrayAsync(cancellationToken); - var menus = await dbContext.PlatformBackendRoleMenus.AsNoTracking() + var menus = await jobsOperationsPersistence.PlatformBackendRoleMenus.AsNoTracking() .Where(item => roleIds.Contains(item.RoleId)) .ToArrayAsync(cancellationToken); return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray(); diff --git a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs index 17cb6df..fceb100 100644 --- a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs +++ b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs @@ -4,7 +4,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Backoffice; -internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperationAuditService +internal sealed class OperationAuditService(IJobsOperationsPersistence dbContext) : IOperationAuditService { public async Task WriteAsync( BackofficeOperationAuditCommand command, diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs index 410d59c..0289e22 100644 --- a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs @@ -9,7 +9,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Bootstrap; -public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) +public sealed class BuiltinBackofficeCatalogSeeder(IBootstrapPersistence dbContext) { private static readonly BuiltinFeature[] Features = [ diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs index f566925..c913259 100644 --- a/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs @@ -6,7 +6,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Bootstrap; -public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext) +public sealed class BuiltinStarterOfferingSeeder(IPlatformControlPlanePersistence dbContext) { public const string OfferingCode = "starter"; public const int OfferingVersion = 1; diff --git a/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs index ef790ae..b6d742d 100644 --- a/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs @@ -22,12 +22,12 @@ public static class DevelopmentPlatformAdminSeeder public static void Configure(DbContextOptionsBuilder optionsBuilder) { optionsBuilder - .UseSeeding((context, _) => Seed((TikuDbContext)context)) + .UseSeeding((context, _) => Seed((IBootstrapPersistence)context)) .UseAsyncSeeding((context, _, cancellationToken) => - SeedAsync((TikuDbContext)context, cancellationToken)); + SeedAsync((IBootstrapPersistence)context, cancellationToken)); } - public static bool Seed(TikuDbContext dbContext) + public static bool Seed(IBootstrapPersistence dbContext) { ReloadPostgresTypes(dbContext); @@ -56,7 +56,7 @@ public static class DevelopmentPlatformAdminSeeder } public static async Task SeedAsync( - TikuDbContext dbContext, + IBootstrapPersistence dbContext, CancellationToken cancellationToken = default) { await ReloadPostgresTypesAsync(dbContext, cancellationToken); @@ -88,7 +88,7 @@ public static class DevelopmentPlatformAdminSeeder } private static void AddSeedGraph( - TikuDbContext dbContext, + IBootstrapPersistence dbContext, string temporaryPassword, IReadOnlyCollection permissionCodes, IReadOnlySet existingPermissionCodes) @@ -176,7 +176,7 @@ public static class DevelopmentPlatformAdminSeeder }); } - private static void EnsurePlatformPermissionCatalog(TikuDbContext dbContext) + private static void EnsurePlatformPermissionCatalog(IBootstrapPersistence dbContext) { var permissionCodes = BackendPermissions.Platform.ToArray(); var moduleCodes = permissionCodes @@ -230,7 +230,7 @@ public static class DevelopmentPlatformAdminSeeder })); } - private static void SeedDemoTenantCapabilities(TikuDbContext dbContext) + private static void SeedDemoTenantCapabilities(IBootstrapPersistence dbContext) { var tenantA = EnsureTenant(dbContext, "demo-crm-school", "演示题库机构 A"); var tenantB = EnsureTenant(dbContext, "demo-sms-campus", "演示题库机构 B"); @@ -241,7 +241,7 @@ public static class DevelopmentPlatformAdminSeeder EnsureTenantPaymentDemo(dbContext, tenantA); } - private static Tenant EnsureTenant(TikuDbContext dbContext, string slug, string name) + private static Tenant EnsureTenant(IBootstrapPersistence dbContext, string slug, string name) { var tenant = dbContext.Tenants.SingleOrDefault(value => value.Slug == slug); if (tenant is not null) return tenant; @@ -264,7 +264,7 @@ public static class DevelopmentPlatformAdminSeeder return tenant; } - private static void EnsureCrmDemo(TikuDbContext dbContext, Tenant tenant) + private static void EnsureCrmDemo(IBootstrapPersistence dbContext, Tenant tenant) { if (!dbContext.CrmConfigs.Any(value => value.TenantId == tenant.Id)) dbContext.CrmConfigs.Add(new CrmConfig @@ -301,7 +301,7 @@ public static class DevelopmentPlatformAdminSeeder }); } - private static void EnsureSmsDemo(TikuDbContext dbContext, Tenant tenant, string provider, string templateName) + private static void EnsureSmsDemo(IBootstrapPersistence dbContext, Tenant tenant, string provider, string templateName) { var scene = templateName.Contains("登录", StringComparison.Ordinal) ? "login" : "marketing"; var channel = dbContext.SmsChannels.SingleOrDefault(value => @@ -363,7 +363,7 @@ public static class DevelopmentPlatformAdminSeeder }); } - private static void EnsurePlatformPaymentDemo(TikuDbContext dbContext, Tenant tenant) + private static void EnsurePlatformPaymentDemo(IBootstrapPersistence dbContext, Tenant tenant) { var app = dbContext.PlatformPaymentApps.SingleOrDefault(value => value.AppCode == "platform_saas_collect"); if (app is null) @@ -444,7 +444,7 @@ public static class DevelopmentPlatformAdminSeeder } } - private static void EnsureTenantPaymentDemo(TikuDbContext dbContext, Tenant tenant) + private static void EnsureTenantPaymentDemo(IBootstrapPersistence dbContext, Tenant tenant) { if (!dbContext.TenantExternalProviders.Any(value => value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && @@ -467,13 +467,13 @@ public static class DevelopmentPlatformAdminSeeder return $"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a"; } - private static void ReloadPostgresTypes(TikuDbContext dbContext) + private static void ReloadPostgresTypes(IBootstrapPersistence dbContext) { if (dbContext.Database.IsNpgsql()) ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes(); } private static async Task ReloadPostgresTypesAsync( - TikuDbContext dbContext, + IBootstrapPersistence dbContext, CancellationToken cancellationToken) { if (dbContext.Database.IsNpgsql()) diff --git a/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs index da1121a..844f977 100644 --- a/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs +++ b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs @@ -19,7 +19,7 @@ public sealed record PlatformAdminBootstrapOptions( public sealed record PlatformAdminBootstrapResult(Guid UserId, Guid RoleId, string Email); public sealed class PlatformAdminBootstrapper( - TikuDbContext dbContext, + IBootstrapPersistence dbContext, UserManager userManager) { public const string SuperAdminRoleCode = "platform_super_admin"; diff --git a/Tiku.Infrastructure/Catalog/CatalogQueryService.cs b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs index a9406d1..caa9573 100644 --- a/Tiku.Infrastructure/Catalog/CatalogQueryService.cs +++ b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs @@ -6,7 +6,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Catalog; -public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQueryService +public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, + ILearningPersistence learningPersistence, + ICommercePersistence commercePersistence) : ICatalogQueryService { private const int DefaultLimit = 500; private const int MaxLimit = 2000; @@ -15,7 +18,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Regions + var query = catalogPersistence.Regions .AsNoTracking() .Where(region => region.TenantId == filter.TenantId && @@ -48,7 +51,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.RegionModules + var query = catalogPersistence.RegionModules .AsNoTracking() .Where(module => module.TenantId == filter.TenantId && @@ -85,7 +88,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ModuleNodes + var query = catalogPersistence.ModuleNodes .AsNoTracking() .Where(node => node.TenantId == filter.TenantId && @@ -129,7 +132,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Schools + var query = catalogPersistence.Schools .AsNoTracking() .Where(school => school.TenantId == filter.TenantId); @@ -160,7 +163,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Majors + var query = catalogPersistence.Majors .AsNoTracking() .Where(major => major.TenantId == filter.TenantId && @@ -195,7 +198,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Subjects + var query = catalogPersistence.Subjects .AsNoTracking() .Where(subject => subject.TenantId == filter.TenantId && @@ -242,7 +245,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Categories + var query = catalogPersistence.Categories .AsNoTracking() .Where(category => category.TenantId == filter.TenantId && @@ -280,7 +283,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Banners + var query = jobsOperationsPersistence.Banners .AsNoTracking() .Where(banner => banner.TenantId == filter.TenantId && @@ -323,7 +326,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Faqs + var query = jobsOperationsPersistence.Faqs .AsNoTracking() .Where(faq => faq.TenantId == filter.TenantId && @@ -360,7 +363,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Announcements + var query = jobsOperationsPersistence.Announcements .AsNoTracking() .Where(announcement => announcement.TenantId == filter.TenantId && @@ -394,7 +397,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.ExamDates + var query = learningPersistence.ExamDates .AsNoTracking() .Where(examDate => examDate.TenantId == filter.TenantId && @@ -468,7 +471,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.Products + var query = commercePersistence.Products .AsNoTracking() .Where(product => product.TenantId == filter.TenantId && @@ -513,7 +516,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery CatalogFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.SvipPlans + var query = commercePersistence.SvipPlans .AsNoTracking() .Where(plan => plan.TenantId == filter.TenantId && diff --git a/Tiku.Infrastructure/Catalog/TaxonomyService.cs b/Tiku.Infrastructure/Catalog/TaxonomyService.cs index 48d452b..bb81062 100644 --- a/Tiku.Infrastructure/Catalog/TaxonomyService.cs +++ b/Tiku.Infrastructure/Catalog/TaxonomyService.cs @@ -11,7 +11,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Catalog; public sealed class TaxonomyService( - TikuDbContext dbContext, + ICatalogPersistence catalogPersistence, IPublicQuestionAccessPolicy accessPolicy, ITenantExecutionScope tenantExecutionScope) : ITaxonomyService { @@ -35,14 +35,15 @@ public sealed class TaxonomyService( "List platform taxonomy with tenant extensions", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); + var systemCatalog = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); var platformTenantId = includePlatform - ? await systemDbContext.Tenants.AsNoTracking() + ? await systemTenancy.Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned) .Select(tenant => (Guid?)tenant.Id) .SingleOrDefaultAsync(token) : null; - return await systemDbContext.TaxonomyNodes.AsNoTracking() + return await systemCatalog.TaxonomyNodes.AsNoTracking() .Where(node => node.IsActive && (node.TenantId == tenantId || @@ -91,8 +92,9 @@ public sealed class TaxonomyService( "Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); - return await systemDbContext.TaxonomyNodes.AsNoTracking() + var systemCatalog = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); + return await systemCatalog.TaxonomyNodes.AsNoTracking() .Where(node => node.TenantId == parentOwnerTenantId && node.Id == command.ParentId.Value && @@ -119,8 +121,8 @@ public sealed class TaxonomyService( node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; - dbContext.TaxonomyNodes.Add(node); - await dbContext.SaveChangesAsync(cancellationToken); + catalogPersistence.TaxonomyNodes.Add(node); + await catalogPersistence.SaveChangesAsync(cancellationToken); return new TaxonomyNodeItem( node.Id, QuestionSource.Tenant, @@ -142,7 +144,7 @@ public sealed class TaxonomyService( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), "Resolve platform taxonomy owner", Guid.NewGuid().ToString("N")), - async (provider, token) => await provider.GetRequiredService() + async (provider, token) => await provider.GetRequiredService() .Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned) .Select(tenant => tenant.Id) @@ -151,4 +153,4 @@ public sealed class TaxonomyService( } private sealed record TaxonomyParent(string? Path, int Depth); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/ActivationCodes/ActivationCodeAdministrationService.cs b/Tiku.Infrastructure/Commerce/ActivationCodes/ActivationCodeAdministrationService.cs index 94bd892..0ff211d 100644 --- a/Tiku.Infrastructure/Commerce/ActivationCodes/ActivationCodeAdministrationService.cs +++ b/Tiku.Infrastructure/Commerce/ActivationCodes/ActivationCodeAdministrationService.cs @@ -24,7 +24,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration if (command.RegionId.HasValue) { - var regionExists = await dbContext.Regions + var regionExists = await catalogPersistence.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"); @@ -45,9 +45,9 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration IssuedAt = DateTimeOffset.UtcNow, Remark = command.Remark }; - dbContext.CodeBatches.Add(batch); + commercePersistence.CodeBatches.Add(batch); for (var index = 0; index < command.TotalCount; index++) - dbContext.ActivationCodes.Add(new ActivationCode + commercePersistence.ActivationCodes.Add(new ActivationCode { TenantId = actor.TenantId, BatchId = batch.Id, @@ -58,7 +58,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration Remark = batch.Remark }); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToCodeBatchItem(batch); } @@ -68,7 +68,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var codes = dbContext.ActivationCodes.AsNoTracking() + var codes = commercePersistence.ActivationCodes.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { @@ -89,7 +89,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var code = await dbContext.ActivationCodes + var code = await commercePersistence.ActivationCodes .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), @@ -97,7 +97,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration ?? 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 => + var userIsMember = await identityPersistence.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.Status == MembershipStatus.Active, @@ -109,7 +109,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration code.UsedBy = command.UserId; code.UsedRegionId = command.RegionId; code.UsedAt = DateTimeOffset.UtcNow; - dbContext.Entitlements.Add(new Entitlement + commercePersistence.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = command.UserId, @@ -122,7 +122,7 @@ internal sealed class ActivationCodeAdministrationService(CommerceAdministration Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToActivationCodeItem(code); } } diff --git a/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdjustmentService.cs b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdjustmentService.cs index 438cc5c..39cc180 100644 --- a/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdjustmentService.cs +++ b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdjustmentService.cs @@ -15,7 +15,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking() + var vouchers = commercePersistence.CommerceAdjustmentVouchers.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); @@ -33,7 +33,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - return await dbContext.CommerceAdjustmentVouchers.AsNoTracking() + return await commercePersistence.CommerceAdjustmentVouchers.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); @@ -46,17 +46,17 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci { await AssertAdminAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, + await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, + await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, + await AssertOptionalReferenceAsync(commercePersistence.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", + await AssertOptionalReferenceAsync(commercePersistence.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", + await AssertOptionalReferenceAsync(commercePersistence.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, + await AssertOptionalReferenceAsync(commercePersistence.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); var voucher = new CommerceAdjustmentVoucher { @@ -77,8 +77,8 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(), Metadata = JsonObjectOrDefault(command.Metadata) }; - dbContext.CommerceAdjustmentVouchers.Add(voucher); - dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + commercePersistence.CommerceAdjustmentVouchers.Add(voucher); + commercePersistence.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent { TenantId = actor.TenantId, VoucherId = voucher.Id, @@ -89,7 +89,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci }); await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return voucher; } @@ -99,7 +99,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync( + var voucher = await commercePersistence.CommerceAdjustmentVouchers.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); @@ -119,7 +119,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci voucher.ClosedAt ??= DateTimeOffset.UtcNow; } - dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + commercePersistence.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent { TenantId = actor.TenantId, VoucherId = voucher.Id, @@ -131,7 +131,7 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci }); 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return voucher; } @@ -141,12 +141,12 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync( + var exists = await commercePersistence.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() + var events = await commercePersistence.CommerceAdjustmentVoucherEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); @@ -159,24 +159,24 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci { await AssertAdminAsync(actor, cancellationToken); return new TenantAdjustmentReport( - await dbContext.CommerceAdjustmentVouchers.CountAsync( + await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync( + await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync( + await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync( + await commercePersistence.CommerceAdjustmentVouchers.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), - await dbContext.CommerceAdjustmentVouchers + await commercePersistence.CommerceAdjustmentVouchers .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) .SumAsync(item => item.AmountCents, cancellationToken), - await dbContext.CommerceAdjustmentVouchers + await commercePersistence.CommerceAdjustmentVouchers .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) @@ -188,23 +188,23 @@ internal sealed class CommerceAdjustmentService(CommerceAdministrationDependenci CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var openRefunds = await dbContext.CommerceRefundRequests.CountAsync( + var openRefunds = await commercePersistence.CommerceRefundRequests.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested, cancellationToken); - var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync( + var processingRefunds = await commercePersistence.CommerceRefundRequests.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, cancellationToken); - var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync( + var openIssues = await commercePersistence.CommerceReconciliationIssues.CountAsync( item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, cancellationToken); - var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync( + var failedBatches = await commercePersistence.CommerceReconciliationBatches.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, cancellationToken); - var pendingPayments = await dbContext.Payments.CountAsync( + var pendingPayments = await commercePersistence.Payments.CountAsync( item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending, cancellationToken); - var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync( + var mismatchCount = await commercePersistence.CommerceReconciliationItems.CountAsync( item => item.TenantId == actor.TenantId && (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch || item.MatchStatus == ReconciliationMatchStatus.StatusMismatch), diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs deleted file mode 100644 index dbcefe0..0000000 --- a/Tiku.Infrastructure/Commerce/CommerceService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tiku.Application.Commerce; -using Tiku.Domain.Commerce; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Commerce; - -public sealed partial class CommerceService( - TikuDbContext dbContext, - IPaymentProviderGateway paymentGateway) : ICommerceService -{ - private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents); -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/CommerceServiceDependencies.cs b/Tiku.Infrastructure/Commerce/CommerceServiceDependencies.cs new file mode 100644 index 0000000..4461bfc --- /dev/null +++ b/Tiku.Infrastructure/Commerce/CommerceServiceDependencies.cs @@ -0,0 +1,19 @@ +using Tiku.Application.Commerce; +using Tiku.Domain.Commerce; +using Tiku.Infrastructure.Persistence; +namespace Tiku.Infrastructure.Commerce; + +internal sealed record CommerceServiceDependencies( + ICommercePersistence CommercePersistence, + ICatalogPersistence CatalogPersistence, + IIdentityPersistence IdentityPersistence, + IPaymentProviderGateway PaymentGateway); + +internal abstract partial class CommerceServiceBase(CommerceServiceDependencies dependencies) +{ + protected ICommercePersistence commercePersistence { get; } = dependencies.CommercePersistence; + protected ICatalogPersistence catalogPersistence { get; } = dependencies.CatalogPersistence; + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected IPaymentProviderGateway paymentGateway { get; } = dependencies.PaymentGateway; + protected sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents); +} diff --git a/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs b/Tiku.Infrastructure/Commerce/Coupons/CommerceCouponService.cs similarity index 90% rename from Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs rename to Tiku.Infrastructure/Commerce/Coupons/CommerceCouponService.cs index a2fc2c3..db59619 100644 --- a/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs +++ b/Tiku.Infrastructure/Commerce/Coupons/CommerceCouponService.cs @@ -4,7 +4,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal sealed class CommerceCouponService(CommerceServiceDependencies dependencies) + : CommerceServiceBase(dependencies), ICommerceCouponService { public async Task ClaimCouponAsync( CommerceActor actor, @@ -15,7 +16,7 @@ public sealed partial class CommerceService var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken); ValidateCouponClaimable(coupon, null); - var existing = await dbContext.CouponRedemptions + var existing = await commercePersistence.CouponRedemptions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -40,8 +41,8 @@ public sealed partial class CommerceService ClaimedAt = DateTimeOffset.UtcNow }; coupon.UsedCount += 1; - dbContext.CouponRedemptions.Add(redemption); - await dbContext.SaveChangesAsync(cancellationToken); + commercePersistence.CouponRedemptions.Add(redemption); + await commercePersistence.SaveChangesAsync(cancellationToken); return ToCouponItem(coupon, redemption, null); } @@ -51,7 +52,7 @@ public sealed partial class CommerceService CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - var redemptions = dbContext.CouponRedemptions + var redemptions = commercePersistence.CouponRedemptions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -66,7 +67,7 @@ public sealed partial class CommerceService .Select(item => item.CouponId!.Value) .Distinct() .ToArray(); - var coupons = await dbContext.Coupons + var coupons = await commercePersistence.Coupons .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id)) .ToDictionaryAsync(item => item.Id, cancellationToken); @@ -88,7 +89,7 @@ public sealed partial class CommerceService if (command.Quantity is < 1 or > 99) throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); - var plan = await dbContext.SvipPlans + var plan = await commercePersistence.SvipPlans .AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && @@ -125,4 +126,4 @@ public sealed partial class CommerceService FormatCny(originalAmountCents)); } } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/Coupons/CouponAdministrationService.cs b/Tiku.Infrastructure/Commerce/Coupons/CouponAdministrationService.cs index cedb410..66dfda6 100644 --- a/Tiku.Infrastructure/Commerce/Coupons/CouponAdministrationService.cs +++ b/Tiku.Infrastructure/Commerce/Coupons/CouponAdministrationService.cs @@ -13,7 +13,7 @@ internal sealed class CouponAdministrationService(CommerceAdministrationDependen CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var coupons = dbContext.Coupons.AsNoTracking() + var coupons = commercePersistence.Coupons.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var items = await coupons .OrderByDescending(item => item.CreatedAt) @@ -29,16 +29,16 @@ internal sealed class CouponAdministrationService(CommerceAdministrationDependen { await AssertAdminAsync(actor, cancellationToken); var coupon = command.Id.HasValue - ? await dbContext.Coupons.SingleOrDefaultAsync( + ? await commercePersistence.Coupons.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, cancellationToken) - : await dbContext.Coupons.SingleOrDefaultAsync( + : await commercePersistence.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); + commercePersistence.Coupons.Add(coupon); } coupon.Code = command.Code.Trim(); @@ -51,7 +51,7 @@ internal sealed class CouponAdministrationService(CommerceAdministrationDependen coupon.Source = command.Source?.Trim(); coupon.Remark = command.Remark; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return coupon; } @@ -61,7 +61,7 @@ internal sealed class CouponAdministrationService(CommerceAdministrationDependen CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var redemptions = dbContext.CouponRedemptions.AsNoTracking() + var redemptions = commercePersistence.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); @@ -80,8 +80,8 @@ internal sealed class CouponAdministrationService(CommerceAdministrationDependen { await AssertAdminAsync(actor, cancellationToken); var couponCount = - await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); - var redemptions = dbContext.CouponRedemptions.AsNoTracking() + await commercePersistence.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); + var redemptions = commercePersistence.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var claimedCount = await redemptions.CountAsync(cancellationToken); var usedCount = diff --git a/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs b/Tiku.Infrastructure/Commerce/Entitlements/CommerceEntitlementService.cs similarity index 85% rename from Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs rename to Tiku.Infrastructure/Commerce/Entitlements/CommerceEntitlementService.cs index b1cdfe0..2f720c0 100644 --- a/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs +++ b/Tiku.Infrastructure/Commerce/Entitlements/CommerceEntitlementService.cs @@ -4,7 +4,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal sealed class CommerceEntitlementService(CommerceServiceDependencies dependencies) + : CommerceServiceBase(dependencies), ICommerceEntitlementService { public async Task GetCurrentEntitlementAsync( CommerceActor actor, @@ -12,7 +13,7 @@ public sealed partial class CommerceService { await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; - var entitlement = await dbContext.Entitlements + var entitlement = await commercePersistence.Entitlements .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -35,4 +36,4 @@ public sealed partial class CommerceService ? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays)) : null); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.Helpers.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.Helpers.cs index 8516263..99184c3 100644 --- a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.Helpers.cs +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.Helpers.cs @@ -177,7 +177,7 @@ internal abstract partial class CommerceAdministrationServiceBase protected async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) { - var order = await dbContext.Orders.SingleAsync( + var order = await commercePersistence.Orders.SingleAsync( item => item.TenantId == refund.TenantId && item.Id == refund.OrderId, cancellationToken); if (order.RefundedAmountCents < order.AmountCents) @@ -190,7 +190,7 @@ internal abstract partial class CommerceAdministrationServiceBase if (refund.PaymentId.HasValue) { - var payment = await dbContext.Payments.SingleOrDefaultAsync( + var payment = await commercePersistence.Payments.SingleOrDefaultAsync( item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value, cancellationToken); if (payment is not null) @@ -239,7 +239,7 @@ internal abstract partial class CommerceAdministrationServiceBase Guid actorUserId, object details) { - dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + commercePersistence.CommerceRefundEvents.Add(new CommerceRefundEvent { TenantId = refund.TenantId, RefundRequestId = refund.Id, @@ -260,7 +260,7 @@ internal abstract partial class CommerceAdministrationServiceBase CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.cs index 2dbbb13..99962ee 100644 --- a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.cs +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.cs @@ -7,7 +7,12 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; internal sealed record CommerceAdministrationDependencies( - TikuDbContext DbContext, + ICommercePersistence CommercePersistence, + IPointsPersistence PointsPersistence, + ICatalogPersistence CatalogPersistence, + IIdentityPersistence IdentityPersistence, + ITenancyPersistence TenancyPersistence, + IJobsOperationsPersistence JobsOperationsPersistence, ITenantSecretProtector TenantSecretProtector, ITenantExternalProviderConfigService ProviderConfigService, ICurrentAccessContext CurrentAccessContext, @@ -16,7 +21,13 @@ internal sealed record CommerceAdministrationDependencies( internal abstract partial class CommerceAdministrationServiceBase(CommerceAdministrationDependencies dependencies) { - protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected ICommercePersistence commercePersistence { get; } = dependencies.CommercePersistence; + protected IPointsPersistence pointsPersistence { get; } = dependencies.PointsPersistence; + protected ICatalogPersistence catalogPersistence { get; } = dependencies.CatalogPersistence; + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected ITenancyPersistence tenancyPersistence { get; } = dependencies.TenancyPersistence; + protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence; + protected IModulePersistence unitOfWork { get; } = dependencies.CommercePersistence; protected ITenantSecretProtector tenantSecretProtector { get; } = dependencies.TenantSecretProtector; protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService; protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext; diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceServiceFoundation.cs similarity index 85% rename from Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs rename to Tiku.Infrastructure/Commerce/Foundation/CommerceServiceFoundation.cs index 815cc68..eac0757 100644 --- a/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceServiceFoundation.cs @@ -8,9 +8,9 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal abstract partial class CommerceServiceBase { - private async Task MarkPaidAsync( + protected async Task MarkPaidAsync( CommerceActor actor, Order order, Payment payment, @@ -31,7 +31,7 @@ public sealed partial class CommerceService order.PaidAt = paidAt; var days = Math.Max(order.Days ?? 0, 0); - var current = await dbContext.Entitlements + var current = await commercePersistence.Entitlements .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -41,7 +41,7 @@ public sealed partial class CommerceService .FirstOrDefaultAsync(cancellationToken); if (current is null) { - dbContext.Entitlements.Add(new Entitlement + commercePersistence.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = actor.UserId, @@ -64,7 +64,7 @@ public sealed partial class CommerceService current.Metadata = rawPayload; } - dbContext.PaymentEvents.Add(new PaymentEvent + commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, PaymentId = payment.Id, @@ -77,7 +77,7 @@ public sealed partial class CommerceService }); } - private async Task ApplyCouponForOrderAsync( + protected async Task ApplyCouponForOrderAsync( CommerceActor actor, CreateCommerceOrderCommand command, SvipPlan plan, @@ -93,7 +93,7 @@ public sealed partial class CommerceService return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; } - private async Task ResolveCouponForCheckAsync( + protected async Task ResolveCouponForCheckAsync( CommerceActor actor, CheckCommerceCouponCommand command, SvipPlan plan, @@ -110,14 +110,14 @@ public sealed partial class CommerceService return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) }; } - private async Task ResolveOrClaimCouponByCodeAsync( + protected 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 + var existing = await commercePersistence.CouponRedemptions .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -141,17 +141,17 @@ public sealed partial class CommerceService ClaimedAt = DateTimeOffset.UtcNow }; coupon.UsedCount += 1; - dbContext.CouponRedemptions.Add(redemption); + commercePersistence.CouponRedemptions.Add(redemption); return new CouponApplication(coupon, redemption, 0); } - private async Task ResolveCouponByCodeForCheckAsync( + protected async Task ResolveCouponByCodeForCheckAsync( CommerceActor actor, string? couponCode, CancellationToken cancellationToken) { var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); - var redemption = await dbContext.CouponRedemptions + var redemption = await commercePersistence.CouponRedemptions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -177,12 +177,12 @@ public sealed partial class CommerceService 0); } - private async Task ResolveCouponByRedemptionAsync( + protected async Task ResolveCouponByRedemptionAsync( CommerceActor actor, Guid couponRedemptionId, CancellationToken cancellationToken) { - var redemption = await dbContext.CouponRedemptions + var redemption = await commercePersistence.CouponRedemptions .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -193,7 +193,7 @@ public sealed partial class CommerceService if (redemption.CouponId is null) throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid"); - var coupon = await dbContext.Coupons + var coupon = await commercePersistence.Coupons .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken) @@ -201,18 +201,18 @@ public sealed partial class CommerceService return new CouponApplication(coupon, redemption, 0); } - private async Task FindCouponByCodeAsync( + protected async Task FindCouponByCodeAsync( Guid tenantId, string? couponCode, CancellationToken cancellationToken) { var code = NormalizeRequired(couponCode, "coupon_code_required"); - return await dbContext.Coupons + return await commercePersistence.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) + protected static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption) { var now = DateTimeOffset.UtcNow; if ((coupon.ValidFrom is not null && coupon.ValidFrom > now) || @@ -223,7 +223,7 @@ public sealed partial class CommerceService throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); } - private static void ValidateCouponUsable( + protected static void ValidateCouponUsable( Coupon coupon, CouponRedemption redemption, SvipPlan plan, @@ -243,7 +243,7 @@ public sealed partial class CommerceService throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable"); } - private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents) + protected static int CalculateDiscountCents(Coupon coupon, int originalAmountCents) { var discount = coupon.DiscountType switch { @@ -256,16 +256,16 @@ public sealed partial class CommerceService return Math.Clamp(discount, 0, originalAmountCents); } - private static decimal PercentFactor(decimal value) + protected static decimal PercentFactor(decimal value) { if (value <= 0) return 0; return value <= 1 ? value : value / 100; } - private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) + protected async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) { - var exists = await dbContext.TenantMemberships.AnyAsync(item => + var exists = await identityPersistence.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active, @@ -273,13 +273,13 @@ public sealed partial class CommerceService if (!exists) throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); } - private async Task FindActorOrderAsync( + protected async Task FindActorOrderAsync( CommerceActor actor, string orderNo, CancellationToken cancellationToken) { var trimmed = orderNo.Trim(); - return await dbContext.Orders + return await commercePersistence.Orders .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -288,7 +288,7 @@ public sealed partial class CommerceService ?? throw new CommerceException("Order was not found.", "order_not_found"); } - private static CommerceOrderItem ToOrderItem(Order order) + protected static CommerceOrderItem ToOrderItem(Order order) { return new CommerceOrderItem( order.Id, @@ -309,7 +309,7 @@ public sealed partial class CommerceService order.RawPayload); } - private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload) + protected static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload) { return new CommercePaymentItem( payment.Id, @@ -326,7 +326,7 @@ public sealed partial class CommerceService payment.RawPayload); } - private static CommerceCouponItem ToCouponItem( + protected static CommerceCouponItem ToCouponItem( Coupon? coupon, CouponRedemption redemption, int? discountPreviewCents) @@ -348,28 +348,28 @@ public sealed partial class CommerceService redemption.UsedAt); } - private static OrderStatus ParseOrderStatus(string? status) + protected 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) + protected 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) + protected static string NormalizeEnum(string? value) { return string.Concat((value ?? string.Empty).Split( ['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); } - private static string NormalizeRequired(string? value, string code) + protected static string NormalizeRequired(string? value, string code) { var trimmed = value?.Trim(); return !string.IsNullOrWhiteSpace(trimmed) @@ -377,7 +377,7 @@ public sealed partial class CommerceService : throw new CommerceException("Required commerce value is missing.", code); } - private static string NormalizeProvider(string? provider) + protected static string NormalizeProvider(string? provider) { var normalized = (provider ?? PaymentProviders.Manual) .Trim() @@ -394,28 +394,28 @@ public sealed partial class CommerceService }; } - private static string NormalizeMethod(string? method) + protected static string NormalizeMethod(string? method) { var normalized = (method ?? "manual").Trim().ToLowerInvariant(); return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; } - private static bool IsPaid(string status) + protected static bool IsPaid(string status) { return string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); } - private static string GenerateOrderNo() + protected 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) + protected static string FormatCny(int cents) { return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceOrderAdministrationService.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceOrderAdministrationService.cs index e305ba9..4fd9fa9 100644 --- a/Tiku.Infrastructure/Commerce/Orders/CommerceOrderAdministrationService.cs +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceOrderAdministrationService.cs @@ -15,7 +15,7 @@ internal sealed class CommerceOrderAdministrationService(CommerceAdministrationD await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var orders = dbContext.Orders.AsNoTracking() + var orders = commercePersistence.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, @@ -39,13 +39,13 @@ internal sealed class CommerceOrderAdministrationService(CommerceAdministrationD await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var scopedOrders = dbContext.Orders.AsNoTracking() + var scopedOrders = commercePersistence.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() + var payments = from payment in commercePersistence.Payments.AsNoTracking() join order in scopedOrders on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } where payment.TenantId == actor.TenantId diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceOrderService.cs similarity index 89% rename from Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs rename to Tiku.Infrastructure/Commerce/Orders/CommerceOrderService.cs index 4ff1bc4..34bc847 100644 --- a/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceOrderService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal sealed class CommerceOrderService(CommerceServiceDependencies dependencies) + : CommerceServiceBase(dependencies), ICommerceOrderService { public async Task CreateOrderAsync( CommerceActor actor, @@ -16,7 +17,7 @@ public sealed partial class CommerceService throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); await AssertActiveMemberAsync(actor, cancellationToken); - var plan = await dbContext.SvipPlans + var plan = await commercePersistence.SvipPlans .AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && @@ -32,14 +33,14 @@ public sealed partial class CommerceService if (command.RegionId.HasValue) { - var regionExists = await dbContext.Regions + var regionExists = await catalogPersistence.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) + await using var transaction = commercePersistence.Database.IsRelational() + ? await commercePersistence.Database.BeginTransactionAsync(cancellationToken) : null; var originalAmountCents = checked(plan.PriceCents * command.Quantity); var coupon = await ApplyCouponForOrderAsync( @@ -81,8 +82,8 @@ public sealed partial class CommerceService couponRedemptionId = coupon?.Redemption.Id }) }; - dbContext.Orders.Add(order); - dbContext.OrderItems.Add(new OrderItem + commercePersistence.Orders.Add(order); + commercePersistence.OrderItems.Add(new OrderItem { TenantId = actor.TenantId, OrderId = order.Id, @@ -119,7 +120,7 @@ public sealed partial class CommerceService Status = PaymentStatus.Pending, AmountCents = 0 }; - dbContext.Payments.Add(payment); + commercePersistence.Payments.Add(payment); await MarkPaidAsync( actor, order, @@ -133,7 +134,7 @@ public sealed partial class CommerceService cancellationToken); } - await dbContext.SaveChangesAsync(cancellationToken); + await commercePersistence.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return ToOrderItem(order); @@ -145,7 +146,7 @@ public sealed partial class CommerceService CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - var orders = dbContext.Orders.AsNoTracking() + var orders = commercePersistence.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)); @@ -167,4 +168,4 @@ public sealed partial class CommerceService var order = await FindActorOrderAsync(actor, orderNo, cancellationToken); return ToOrderItem(order); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommercePaymentNotificationService.cs similarity index 85% rename from Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs rename to Tiku.Infrastructure/Commerce/PaymentCallbacks/CommercePaymentNotificationService.cs index 95e1da7..0b80763 100644 --- a/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs +++ b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommercePaymentNotificationService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal sealed class CommercePaymentNotificationService(CommerceServiceDependencies dependencies) + : CommerceServiceBase(dependencies), ICommercePaymentNotificationService { public async Task ProcessPaymentNotificationAsync( Guid tenantId, @@ -29,7 +30,7 @@ public sealed partial class CommerceService if (!notification.SignatureValid) throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid"); - var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync( + var alreadyProcessed = await commercePersistence.PaymentEvents.AnyAsync( item => item.Provider == normalizedProvider && item.EventId == notification.EventId && @@ -43,7 +44,7 @@ public sealed partial class CommerceService "processed", true); - var order = await dbContext.Orders + var order = await commercePersistence.Orders .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.OrderNo == notification.OrderNo, @@ -54,7 +55,7 @@ public sealed partial class CommerceService if (order.AmountCents != notification.AmountCents) { - dbContext.PaymentEvents.Add(new PaymentEvent + commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, Provider = normalizedProvider, @@ -64,11 +65,11 @@ public sealed partial class CommerceService Payload = notification.RawPayload, Error = "payment_amount_mismatch" }); - await dbContext.SaveChangesAsync(cancellationToken); + await commercePersistence.SaveChangesAsync(cancellationToken); throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch"); } - var payment = await dbContext.Payments + var payment = await commercePersistence.Payments .Where(item => item.TenantId == tenantId && item.OrderId == order.Id && @@ -86,7 +87,7 @@ public sealed partial class CommerceService Status = PaymentStatus.Pending, AmountCents = order.AmountCents }; - dbContext.Payments.Add(payment); + commercePersistence.Payments.Add(payment); } if (notification.Paid && order.Status == OrderStatus.Pending) @@ -102,7 +103,7 @@ public sealed partial class CommerceService notification.PaidAt, cancellationToken); else - dbContext.PaymentEvents.Add(new PaymentEvent + commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, PaymentId = payment.Id, @@ -114,7 +115,7 @@ public sealed partial class CommerceService ProcessedAt = DateTimeOffset.UtcNow }); - await dbContext.SaveChangesAsync(cancellationToken); + await commercePersistence.SaveChangesAsync(cancellationToken); return new PaymentNotificationProcessResult( normalizedProvider, notification.EventId, @@ -122,4 +123,4 @@ public sealed partial class CommerceService "processed", false); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/PaymentConfiguration/PaymentConfigurationService.cs b/Tiku.Infrastructure/Commerce/PaymentConfiguration/PaymentConfigurationService.cs index ea836d5..d0c6129 100644 --- a/Tiku.Infrastructure/Commerce/PaymentConfiguration/PaymentConfigurationService.cs +++ b/Tiku.Infrastructure/Commerce/PaymentConfiguration/PaymentConfigurationService.cs @@ -56,7 +56,7 @@ internal sealed class PaymentConfigurationService(CommerceAdministrationDependen ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}" : command.SecretRef.Trim(); var provider = NormalizeProvider(command.Provider); - var secret = await dbContext.TenantSecrets + var secret = await tenancyPersistence.TenantSecrets .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); if (secret is null) @@ -66,7 +66,7 @@ internal sealed class PaymentConfigurationService(CommerceAdministrationDependen TenantId = actor.TenantId, SecretRef = secretRef }; - dbContext.TenantSecrets.Add(secret); + tenancyPersistence.TenantSecrets.Add(secret); } else { @@ -88,7 +88,7 @@ internal sealed class PaymentConfigurationService(CommerceAdministrationDependen secret.EncryptionTag = protectedPayload.Tag; secret.ExpiresAt = command.ExpiresAt; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToSecretItem(secret); } } diff --git a/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs b/Tiku.Infrastructure/Commerce/Payments/CommercePaymentService.cs similarity index 88% rename from Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs rename to Tiku.Infrastructure/Commerce/Payments/CommercePaymentService.cs index 7ed985f..ec4b797 100644 --- a/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs +++ b/Tiku.Infrastructure/Commerce/Payments/CommercePaymentService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; -public sealed partial class CommerceService +internal sealed class CommercePaymentService(CommerceServiceDependencies dependencies) + : CommerceServiceBase(dependencies), ICommercePaymentService { public async Task CreatePaymentAsync( CommerceActor actor, @@ -19,7 +20,7 @@ public sealed partial class CommerceService var provider = NormalizeProvider(command.Provider); var method = NormalizeMethod(command.Method); - var payment = await dbContext.Payments + var payment = await commercePersistence.Payments .Where(item => item.TenantId == actor.TenantId && item.OrderId == order.Id && @@ -39,7 +40,7 @@ public sealed partial class CommerceService Status = PaymentStatus.Pending, AmountCents = order.AmountCents }; - dbContext.Payments.Add(payment); + commercePersistence.Payments.Add(payment); } var result = await paymentGateway.CreatePaymentAsync( @@ -74,7 +75,7 @@ public sealed partial class CommerceService null, cancellationToken); else - dbContext.PaymentEvents.Add(new PaymentEvent + commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, PaymentId = payment.Id, @@ -83,7 +84,7 @@ public sealed partial class CommerceService Payload = result.RawPayload }); - await dbContext.SaveChangesAsync(cancellationToken); + await commercePersistence.SaveChangesAsync(cancellationToken); return ToPaymentItem(payment, order.OrderNo, result.ClientPayload); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Commerce/Reconciliation/CommerceReconciliationJobHandler.cs b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceReconciliationJobHandler.cs index 8d3e977..5c57ac5 100644 --- a/Tiku.Infrastructure/Commerce/Reconciliation/CommerceReconciliationJobHandler.cs +++ b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceReconciliationJobHandler.cs @@ -8,7 +8,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce.Reconciliation; -internal sealed class CommerceReconciliationJobHandler(TikuDbContext dbContext) : IBackgroundJobHandler +internal sealed class CommerceReconciliationJobHandler(ICommercePersistence commercePersistence, + ITenancyPersistence tenancyPersistence) : IBackgroundJobHandler { public string JobType => "commerce_reconciliation"; @@ -17,7 +18,7 @@ internal sealed class CommerceReconciliationJobHandler(TikuDbContext dbContext) CancellationToken cancellationToken = default) { var provider = NormalizeProvider(BackgroundJobPayload.GetString(context.Payload, "provider")); - var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync( + var hasProviderConfig = await tenancyPersistence.TenantExternalProviders.AnyAsync( item => item.TenantId == context.TenantId && item.Capability == TenantExternalProviderCapability.Payment && @@ -32,7 +33,7 @@ internal sealed class CommerceReconciliationJobHandler(TikuDbContext dbContext) DateOnly.FromDateTime(DateTime.UtcNow.Date); var billType = BackgroundJobPayload.GetEnum(context.Payload, "billType", ReconciliationBillType.Combined); var sourceHash = $"background-job:{context.JobId:N}"; - var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync( + var batch = await commercePersistence.CommerceReconciliationBatches.SingleOrDefaultAsync( item => item.TenantId == context.TenantId && item.Provider == provider && @@ -58,8 +59,8 @@ internal sealed class CommerceReconciliationJobHandler(TikuDbContext dbContext) "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." }) }; - dbContext.CommerceReconciliationBatches.Add(batch); - await dbContext.SaveChangesAsync(cancellationToken); + commercePersistence.CommerceReconciliationBatches.Add(batch); + await commercePersistence.SaveChangesAsync(cancellationToken); } return new BackgroundJobHandlerResult(JsonSerializer.SerializeToElement(new diff --git a/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.Operations.cs b/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.Operations.cs index ba78293..6760e27 100644 --- a/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.Operations.cs +++ b/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.Operations.cs @@ -14,13 +14,13 @@ internal sealed partial class ReconciliationAdministrationService CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync( + var batchExists = await commercePersistence.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() + var items = await commercePersistence.CommerceReconciliationItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) .OrderBy(item => item.RowNo) .Take(500) @@ -34,13 +34,13 @@ internal sealed partial class ReconciliationAdministrationService CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync( + var issueExists = await commercePersistence.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() + var events = await commercePersistence.CommerceReconciliationIssueEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); @@ -89,16 +89,16 @@ internal sealed partial class ReconciliationAdministrationService preview.InvalidCount }) }; - dbContext.CommerceReconciliationBatches.Add(batch); + commercePersistence.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); + commercePersistence.CommerceReconciliationItems.Add(item); if (item.MatchStatus != ReconciliationMatchStatus.Matched) - dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue + commercePersistence.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue { TenantId = actor.TenantId, BatchId = batch.Id, @@ -129,7 +129,7 @@ internal sealed partial class ReconciliationAdministrationService await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return batch; } @@ -154,7 +154,7 @@ internal sealed partial class ReconciliationAdministrationService 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return job; } diff --git a/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.cs b/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.cs index 709bed5..ad882be 100644 --- a/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.cs +++ b/Tiku.Infrastructure/Commerce/Reconciliation/ReconciliationAdministrationService.cs @@ -14,7 +14,7 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var batches = dbContext.CommerceReconciliationBatches.AsNoTracking() + var batches = commercePersistence.CommerceReconciliationBatches.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Provider)) { @@ -50,10 +50,10 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini Status = ReconciliationBatchStatus.Pending, Metadata = JsonObjectOrDefault(command.Metadata) }; - dbContext.CommerceReconciliationBatches.Add(batch); + commercePersistence.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); + await unitOfWork.SaveChangesAsync(cancellationToken); return batch; } @@ -63,7 +63,7 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var issues = dbContext.CommerceReconciliationIssues.AsNoTracking() + var issues = commercePersistence.CommerceReconciliationIssues.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Provider)) { @@ -86,7 +86,7 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync( + var issue = await commercePersistence.CommerceReconciliationIssues.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.IssueId, cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", @@ -102,7 +102,7 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini issue.ResolvedAt = DateTimeOffset.UtcNow; } - dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent + commercePersistence.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent { TenantId = actor.TenantId, IssueId = issue.Id, @@ -115,7 +115,7 @@ internal sealed partial class ReconciliationAdministrationService(CommerceAdmini }); 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return issue; } } diff --git a/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.Notifications.cs b/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.Notifications.cs index 6a31ca0..e0972fb 100644 --- a/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.Notifications.cs +++ b/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.Notifications.cs @@ -14,13 +14,13 @@ internal sealed partial class RefundAdministrationService CancellationToken cancellationToken = default) { var provider = NormalizeProvider(command.Provider); - var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( + var refund = await commercePersistence.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( + var duplicate = await commercePersistence.PaymentEvents.AnyAsync( item => item.TenantId == tenantId && item.Provider == provider && item.EventType == "refund" && @@ -28,7 +28,7 @@ internal sealed partial class RefundAdministrationService cancellationToken); if (duplicate) return refund; - dbContext.PaymentEvents.Add(new PaymentEvent + commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, Provider = provider, @@ -55,7 +55,7 @@ internal sealed partial class RefundAdministrationService refund.FailedAt = DateTimeOffset.UtcNow; } - dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + commercePersistence.CommerceRefundEvents.Add(new CommerceRefundEvent { TenantId = tenantId, RefundRequestId = refund.Id, @@ -66,7 +66,7 @@ internal sealed partial class RefundAdministrationService }); } - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return refund; } } diff --git a/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.cs b/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.cs index e4d10e7..eeb0b85 100644 --- a/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.cs +++ b/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.cs @@ -17,13 +17,13 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var refunds = dbContext.CommerceRefundRequests.AsNoTracking() + var refunds = commercePersistence.CommerceRefundRequests.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => + item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && @@ -46,7 +46,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var order = await dbContext.Orders + var order = await commercePersistence.Orders .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) .ApplyDataScope( scope, @@ -62,7 +62,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration if (command.PaymentId.HasValue) { - var paymentExists = await dbContext.Payments.AnyAsync( + var paymentExists = await commercePersistence.Payments.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, cancellationToken); @@ -83,12 +83,12 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration EntitlementAction = command.EntitlementAction, Metadata = JsonObjectOrDefault(command.Metadata) }; - dbContext.CommerceRefundRequests.Add(refund); + commercePersistence.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); + await unitOfWork.SaveChangesAsync(cancellationToken); return refund; } @@ -100,14 +100,14 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var refund = await dbContext.CommerceRefundRequests + var refund = await commercePersistence.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) .ApplyDataScope( scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => + item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && @@ -149,7 +149,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return refund; } @@ -161,13 +161,13 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var refundExists = await dbContext.CommerceRefundRequests + var refundExists = await commercePersistence.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) .ApplyDataScope( scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => + item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && @@ -175,7 +175,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration .AnyAsync(cancellationToken); if (!refundExists) throw new CommerceException("Refund request was not found.", "refund_not_found"); - var items = await dbContext.CommerceRefundEvents.AsNoTracking() + var items = await commercePersistence.CommerceRefundEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); diff --git a/Tiku.Infrastructure/Commerce/TenantSecretService.cs b/Tiku.Infrastructure/Commerce/TenantSecretService.cs index 8a9daef..5e87440 100644 --- a/Tiku.Infrastructure/Commerce/TenantSecretService.cs +++ b/Tiku.Infrastructure/Commerce/TenantSecretService.cs @@ -7,7 +7,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; internal sealed class TenantSecretService( - TikuDbContext dbContext, + ITenancyPersistence dbContext, ITenantSecretProtector tenantSecretProtector) : ITenantSecretService { public async Task GetActiveSecretPayloadAsync( diff --git a/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs b/Tiku.Infrastructure/Content/Collections/QuestionCollectionManagementService.cs similarity index 90% rename from Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs rename to Tiku.Infrastructure/Content/Collections/QuestionCollectionManagementService.cs index 161a888..8882cc1 100644 --- a/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs +++ b/Tiku.Infrastructure/Content/Collections/QuestionCollectionManagementService.cs @@ -8,7 +8,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class ContentManagementService +internal sealed class QuestionCollectionManagementService(ContentManagementDependencies dependencies) + : ContentManagementServiceBase(dependencies), IQuestionCollectionManagementService { public async Task> GetCollectionsAsync( ContentManagementActor actor, @@ -17,7 +18,7 @@ public sealed partial class ContentManagementService { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.QuestionCollections + var query = questionBankPersistence.QuestionCollections .AsNoTracking() .Where(collection => collection.TenantId == actor.TenantId) .ApplyDataScope( @@ -69,7 +70,7 @@ public sealed partial class ContentManagementService cancellationToken); var collection = await ResolveEntityByIdOrLegacyAsync( - dbContext.QuestionCollections, + questionBankPersistence.QuestionCollections, actor.TenantId, command.Id, command.LegacyId, @@ -113,9 +114,9 @@ public sealed partial class ContentManagementService collection.Metadata = JsonObjectOrDefault(command.Metadata); collection.UpdatedBy = actor.UserId; - if (isNew) dbContext.QuestionCollections.Add(collection); + if (isNew) questionBankPersistence.QuestionCollections.Add(collection); - await dbContext.SaveChangesAsync(cancellationToken); + await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToCollectionItem(collection)); } @@ -126,7 +127,7 @@ public sealed partial class ContentManagementService { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var collection = await dbContext.QuestionCollections + var collection = await questionBankPersistence.QuestionCollections .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId) .ApplyDataScope( scope, @@ -148,10 +149,10 @@ public sealed partial class ContentManagementService resolvedQuestions.Add((question, reference)); } - var oldItems = await dbContext.QuestionCollectionItems + var oldItems = await questionBankPersistence.QuestionCollectionItems .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId) .ToArrayAsync(cancellationToken); - dbContext.QuestionCollectionItems.RemoveRange(oldItems); + questionBankPersistence.QuestionCollectionItems.RemoveRange(oldItems); var items = resolvedQuestions .Select((resolved, index) => new QuestionCollectionItem @@ -169,14 +170,14 @@ public sealed partial class ContentManagementService }) .ToArray(); - dbContext.QuestionCollectionItems.AddRange(items); + questionBankPersistence.QuestionCollectionItems.AddRange(items); collection.QuestionCount = items.Length; collection.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); + await questionBankPersistence.SaveChangesAsync(cancellationToken); return new CollectionItemsReplaceResult( command.CollectionId, collection.QuestionCount, items.Select(ToCollectionItemItem).ToArray()); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/ContentManagementDependencies.cs b/Tiku.Infrastructure/Content/ContentManagementDependencies.cs new file mode 100644 index 0000000..2139302 --- /dev/null +++ b/Tiku.Infrastructure/Content/ContentManagementDependencies.cs @@ -0,0 +1,20 @@ +using Tiku.Application.Content; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Content; + +internal sealed record ContentManagementDependencies( + IQuestionBankPersistence QuestionBankPersistence, + IQuestionReferenceService questionReferenceService, + ICurrentAccessContext currentAccessContext); + +internal abstract partial class ContentManagementServiceBase(ContentManagementDependencies dependencies) +{ + protected IQuestionBankPersistence questionBankPersistence { get; } = dependencies.QuestionBankPersistence; + protected IQuestionReferenceService questionReferenceService { get; } = dependencies.questionReferenceService; + protected ICurrentAccessContext currentAccessContext { get; } = dependencies.currentAccessContext; + protected const int DefaultLimit = 100; + protected const int MaxLimit = 1000; +} diff --git a/Tiku.Infrastructure/Content/ContentManagementService.cs b/Tiku.Infrastructure/Content/ContentManagementService.cs deleted file mode 100644 index b28c430..0000000 --- a/Tiku.Infrastructure/Content/ContentManagementService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Content; - -public sealed partial class ContentManagementService( - TikuDbContext dbContext, - IQuestionReferenceService questionReferenceService, - ICurrentAccessContext currentAccessContext) : IContentManagementService -{ - private const int DefaultLimit = 100; - private const int MaxLimit = 1000; -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs index c077674..28a5939 100644 --- a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs +++ b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs @@ -8,7 +8,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Content; -public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : IContentNavigationQueryService +public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbContext) : IContentNavigationQueryService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; diff --git a/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs b/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs index 7c42e52..418ab8f 100644 --- a/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs +++ b/Tiku.Infrastructure/Content/EducationCatalog/EducationCatalogManagementService.cs @@ -16,7 +16,7 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.Schools.AsNoTracking() + var query = catalogPersistence.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); @@ -41,7 +41,7 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe 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, + var item = await ResolveByIdOrLegacyAsync(catalogPersistence.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found"); @@ -51,9 +51,9 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe item.Name = command.Name.Trim(); item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.Schools.Add(item); + if (isNew) catalogPersistence.Schools.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -64,7 +64,7 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.Majors.AsNoTracking() + var query = catalogPersistence.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); @@ -97,7 +97,7 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe 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, + var item = await ResolveByIdOrLegacyAsync(catalogPersistence.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found"); @@ -110,9 +110,9 @@ internal sealed class EducationCatalogManagementService(DirectContentServiceDepe item.StudyTips = Normalize(command.StudyTips); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) dbContext.Majors.Add(item); + if (isNew) catalogPersistence.Majors.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } } diff --git a/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs b/Tiku.Infrastructure/Content/Entries/ContentEntryManagementService.cs similarity index 91% rename from Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs rename to Tiku.Infrastructure/Content/Entries/ContentEntryManagementService.cs index 35dc00e..ff55314 100644 --- a/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs +++ b/Tiku.Infrastructure/Content/Entries/ContentEntryManagementService.cs @@ -6,7 +6,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class ContentManagementService +internal sealed class ContentEntryManagementService(ContentManagementDependencies dependencies) + : ContentManagementServiceBase(dependencies), IContentEntryManagementService { public async Task> GetEntriesAsync( ContentManagementActor actor, @@ -15,7 +16,7 @@ public sealed partial class ContentManagementService { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ContentEntries + var query = questionBankPersistence.ContentEntries .AsNoTracking() .Where(entry => entry.TenantId == actor.TenantId) .ApplyDataScope( @@ -62,7 +63,7 @@ public sealed partial class ContentManagementService Normalize(command.Id?.ToString("N")) ?? Guid.NewGuid().ToString("N"); var entry = await ResolveEntityAsync( - dbContext.ContentEntries, + questionBankPersistence.ContentEntries, actor.TenantId, command.Id, item => item.EntryKey == entryKey, @@ -100,9 +101,9 @@ public sealed partial class ContentManagementService entry.IsActive = command.IsActive ?? true; entry.UpdatedBy = actor.UserId; - if (isNew) dbContext.ContentEntries.Add(entry); + if (isNew) questionBankPersistence.ContentEntries.Add(entry); - await dbContext.SaveChangesAsync(cancellationToken); + await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToEntryItem(entry)); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs b/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs index 4d16cb9..09408f8 100644 --- a/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs +++ b/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs @@ -7,7 +7,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Content.Exports; -internal sealed class ContentExportJobHandler(TikuDbContext dbContext) : IBackgroundJobHandler +internal sealed class ContentExportJobHandler(IContentAssetPersistence contentAssetPersistence, + IQuestionBankPersistence questionBankPersistence, + ITenantAdministrationPersistence tenantAdministrationPersistence) : IBackgroundJobHandler { public string JobType => "content_export"; @@ -17,7 +19,7 @@ internal sealed class ContentExportJobHandler(TikuDbContext dbContext) : IBackgr { var exportType = BackgroundJobPayload.GetString(context.Payload, "exportType") ?? "summary"; var assetKey = $"background-jobs/{context.JobId:N}/content-export.json"; - var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + var asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == context.TenantId && item.AssetKey == assetKey, cancellationToken); if (asset is null) @@ -32,15 +34,15 @@ internal sealed class ContentExportJobHandler(TikuDbContext dbContext) : IBackgr SecurityScanStatus = AssetSecurityScanStatus.NotRequired, Source = "background_job" }; - dbContext.ContentAssets.Add(asset); + contentAssetPersistence.ContentAssets.Add(asset); } var questionBankCount = - await dbContext.QuestionBanks.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); + await questionBankPersistence.QuestionBanks.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); var questionCount = - await dbContext.Questions.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); + await questionBankPersistence.Questions.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); var studentCount = - await dbContext.StudentProfiles.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); + await tenantAdministrationPersistence.StudentProfiles.CountAsync(item => item.TenantId == context.TenantId, cancellationToken); asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; asset.Title = "Content export manifest"; asset.Description = $"Generated content export manifest for {exportType}."; @@ -55,7 +57,7 @@ internal sealed class ContentExportJobHandler(TikuDbContext dbContext) : IBackgr studentCount, payload = context.Payload }); - await dbContext.SaveChangesAsync(cancellationToken); + await contentAssetPersistence.SaveChangesAsync(cancellationToken); return new BackgroundJobHandlerResult( JsonSerializer.SerializeToElement(new { diff --git a/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs b/Tiku.Infrastructure/Content/Foundation/ContentManagementFoundation.cs similarity index 89% rename from Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs rename to Tiku.Infrastructure/Content/Foundation/ContentManagementFoundation.cs index 76eb67a..8a2c8d2 100644 --- a/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs +++ b/Tiku.Infrastructure/Content/Foundation/ContentManagementFoundation.cs @@ -11,9 +11,9 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class ContentManagementService +internal abstract partial class ContentManagementServiceBase { - private static readonly IReadOnlyDictionary Specs = + protected static readonly IReadOnlyDictionary Specs = new Dictionary(StringComparer.Ordinal) { ["questions"] = new( @@ -143,7 +143,7 @@ public sealed partial class ContentManagementService new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) }; - private async Task<(string Path, int Depth)> BuildNodePathAsync( + protected async Task<(string Path, int Depth)> BuildNodePathAsync( Guid tenantId, Guid entryId, Guid nodeId, @@ -153,7 +153,7 @@ public sealed partial class ContentManagementService var label = $"n_{nodeId:N}"; if (!parentId.HasValue) return (label, 0); - var parent = await dbContext.ContentNodes + var parent = await questionBankPersistence.ContentNodes .AsNoTracking() .Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value) .Select(node => new { node.Path, node.Depth }) @@ -165,17 +165,17 @@ public sealed partial class ContentManagementService return ($"{parent.Path}.{label}", parent.Depth + 1); } - private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken) + protected 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) + protected async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, entryId, "entry_not_found", cancellationToken); } - private async Task AssertEntryAsync( + protected async Task AssertEntryAsync( ContentManagementActor actor, CurrentDataScope scope, Guid? entryId, @@ -184,7 +184,7 @@ public sealed partial class ContentManagementService if (!entryId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); - var exists = await dbContext.ContentEntries + var exists = await questionBankPersistence.ContentEntries .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value) .ApplyDataScope( scope, @@ -194,12 +194,12 @@ public sealed partial class ContentManagementService if (!exists) throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } - private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) + protected async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); } - private async Task AssertNodeAsync( + protected async Task AssertNodeAsync( ContentManagementActor actor, CurrentDataScope scope, Guid? nodeId, @@ -208,7 +208,7 @@ public sealed partial class ContentManagementService if (!nodeId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); - var exists = await dbContext.ContentNodes + var exists = await questionBankPersistence.ContentNodes .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value) .ApplyDataScope( scope, @@ -218,7 +218,7 @@ public sealed partial class ContentManagementService if (!exists) throw new ContentManagementException("Content node was not found.", "node_not_found"); } - private async Task RequireDataScopeAsync( + protected async Task RequireDataScopeAsync( ContentManagementActor actor, CancellationToken cancellationToken) { @@ -229,7 +229,7 @@ public sealed partial class ContentManagementService return access.DataScope; } - private async Task AssertReferenceAsync( + protected async Task AssertReferenceAsync( Guid tenantId, Guid? id, string code, @@ -238,7 +238,7 @@ public sealed partial class ContentManagementService { if (!id.HasValue) return; - var exists = await dbContext.Set() + var exists = await questionBankPersistence.Set() .AnyAsync(entity => EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, @@ -247,7 +247,7 @@ public sealed partial class ContentManagementService if (!exists) throw new ContentManagementException("Referenced entity was not found in this tenant.", code); } - private static async Task ResolveEntityAsync( + protected static async Task ResolveEntityAsync( DbSet set, Guid tenantId, Guid? id, @@ -269,7 +269,7 @@ public sealed partial class ContentManagementService .SingleOrDefaultAsync(alternatePredicate, cancellationToken); } - private static async Task ResolveEntityByIdOrLegacyAsync( + protected static async Task ResolveEntityByIdOrLegacyAsync( DbSet set, Guid tenantId, Guid? id, @@ -295,7 +295,7 @@ public sealed partial class ContentManagementService cancellationToken); } - private static ContentEntryManagementItem ToEntryItem(ContentEntry entry) + protected static ContentEntryManagementItem ToEntryItem(ContentEntry entry) { return new ContentEntryManagementItem( entry.Id, @@ -316,7 +316,7 @@ public sealed partial class ContentManagementService entry.UpdatedAt); } - private static ContentNodeManagementItem ToNodeItem(ContentNode node) + protected static ContentNodeManagementItem ToNodeItem(ContentNode node) { return new ContentNodeManagementItem( node.Id, @@ -341,7 +341,7 @@ public sealed partial class ContentManagementService node.UpdatedAt); } - private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection) + protected static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection) { return new QuestionCollectionManagementItem( collection.Id, @@ -367,7 +367,7 @@ public sealed partial class ContentManagementService collection.UpdatedAt); } - private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item) + protected static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item) { return new QuestionCollectionItemManagementItem( item.Id, @@ -383,7 +383,7 @@ public sealed partial class ContentManagementService item.Metadata); } - private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint) + protected static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint) { return new PracticeBlueprintManagementItem( blueprint.Id, @@ -408,31 +408,31 @@ public sealed partial class ContentManagementService blueprint.UpdatedAt); } - private static int ResolveLimit(int? limit) + protected static int ResolveLimit(int? limit) { return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit; } - private static string? Normalize(string? value) + protected static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private static JsonElement JsonObjectOrDefault(JsonElement value) + protected static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ? JsonDefaults.Object() : value; } - private static JsonElement JsonArrayOrDefault(JsonElement value) + protected 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) + protected static TEnum Parse(string? value, TEnum fallback, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) return fallback; @@ -442,7 +442,7 @@ public sealed partial class ContentManagementService throw new ContentManagementException("Invalid enum value.", code); } - private static TEnum? ParseNullable(string? value, string code) + protected static TEnum? ParseNullable(string? value, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) return null; @@ -452,20 +452,20 @@ public sealed partial class ContentManagementService throw new ContentManagementException("Invalid enum value.", code); } - private static bool TryParse(string? value, out TEnum parsed) + protected static bool TryParse(string? value, out TEnum parsed) where TEnum : struct { return Enum.TryParse(value, true, out parsed); } - private static string EscapeCsv(string value) + protected static string EscapeCsv(string value) { return value.Contains(',') || value.Contains('"') || value.Contains('\n') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" : value; } - private static ImportSpec ResolveImportSpec(string importType) + protected static ImportSpec ResolveImportSpec(string importType) { var normalized = importType.Trim().ToLowerInvariant(); return Specs.TryGetValue(normalized, out var spec) @@ -473,7 +473,7 @@ public sealed partial class ContentManagementService : throw new ContentManagementException("Import type is not supported.", "import_type_invalid"); } - private static ImportFieldSpec Field( + protected static ImportFieldSpec Field( string field, string label, bool required, @@ -490,11 +490,11 @@ public sealed partial class ContentManagementService JsonSerializer.SerializeToElement(example)); } - private sealed record ImportSpec( + protected sealed record ImportSpec( string ImportType, string Title, string Description, IReadOnlyCollection Fields, string[][] CsvRows, object JsonExample); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs index ea3d072..369ee36 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs @@ -265,7 +265,7 @@ internal abstract partial class DirectContentServiceBase { if (!id.HasValue) return; - var exists = await dbContext.Set() + var exists = await unitOfWork.Set() .AnyAsync( item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); @@ -274,7 +274,7 @@ internal abstract partial class DirectContentServiceBase protected async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) { - var exists = await dbContext.ContentImportJobs.AnyAsync( + var exists = await questionBankPersistence.ContentImportJobs.AnyAsync( item => item.TenantId == tenantId && item.Id == jobId, cancellationToken); if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs index ee1f4db..2dbf28d 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs @@ -18,7 +18,7 @@ internal abstract partial class DirectContentServiceBase CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, + var item = await ResolveByIdOrLegacyAsync(jobsOperationsPersistence.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -33,9 +33,9 @@ internal abstract partial class DirectContentServiceBase item.BorderColor = Normalize(command.BorderColor); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) dbContext.Banners.Add(item); + if (isNew) jobsOperationsPersistence.Banners.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return item; } @@ -43,7 +43,7 @@ internal abstract partial class DirectContentServiceBase CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, + var item = await ResolveByIdOrLegacyAsync(jobsOperationsPersistence.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -53,16 +53,16 @@ internal abstract partial class DirectContentServiceBase 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); + if (isNew) jobsOperationsPersistence.Faqs.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return item; } protected async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { - var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, + var item = await ResolveByIdOrLegacyAsync(jobsOperationsPersistence.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -72,9 +72,9 @@ internal abstract partial class DirectContentServiceBase item.BackgroundColor = Normalize(command.BackgroundColor); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) dbContext.Announcements.Add(item); + if (isNew) jobsOperationsPersistence.Announcements.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return item; } @@ -84,7 +84,7 @@ internal abstract partial class DirectContentServiceBase 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, + var item = await ResolveByIdOrLegacyAsync(learningPersistence.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -98,9 +98,9 @@ internal abstract partial class DirectContentServiceBase item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.ExamDates.Add(item); + if (isNew) learningPersistence.ExamDates.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return item; } @@ -191,7 +191,7 @@ internal abstract partial class DirectContentServiceBase { if (!question.PrimaryCollectionId.HasValue) return; - var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( + var existing = await questionBankPersistence.QuestionCollectionItems.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value && @@ -204,12 +204,12 @@ internal abstract partial class DirectContentServiceBase actor.UserId, new QuestionLocator(QuestionSource.Tenant, question.Id), cancellationToken); - var nextOrder = await dbContext.QuestionCollectionItems + var nextOrder = await questionBankPersistence.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 + questionBankPersistence.QuestionCollectionItems.Add(new QuestionCollectionItem { TenantId = actor.TenantId, CollectionId = question.PrimaryCollectionId.Value, @@ -220,10 +220,10 @@ internal abstract partial class DirectContentServiceBase }); } - var collection = await dbContext.QuestionCollections.SingleAsync( + var collection = await questionBankPersistence.QuestionCollections.SingleAsync( item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value, cancellationToken); - collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync( + collection.QuestionCount = await questionBankPersistence.QuestionCollectionItems.CountAsync( item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value, cancellationToken) + (existing is null ? 1 : 0); collection.UpdatedBy = actor.UserId; @@ -238,7 +238,7 @@ internal abstract partial class DirectContentServiceBase { if (!unitId.HasValue) return (entryId, contentNodeId); - var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( + var unit = await contentAssetPersistence.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == unitId.Value, cancellationToken); if (unit is null) @@ -256,7 +256,7 @@ internal abstract partial class DirectContentServiceBase { if (!subjectId.HasValue) return (entryId, contentNodeId); - var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( + var subject = await contentAssetPersistence.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == subjectId.Value, cancellationToken); if (subject is null) @@ -274,7 +274,7 @@ internal abstract partial class DirectContentServiceBase { if (!chapterId.HasValue) return (entryId, contentNodeId); - var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( + var chapter = await contentAssetPersistence.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == chapterId.Value, cancellationToken); if (chapter is null) diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs index 8d9d5fd..6b2556a 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs @@ -7,14 +7,28 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Content; internal sealed record DirectContentServiceDependencies( - TikuDbContext DbContext, + IQuestionBankPersistence QuestionBankPersistence, + IContentAssetPersistence ContentAssetPersistence, + ICatalogPersistence CatalogPersistence, + ILearningPersistence LearningPersistence, + ITenantAdministrationPersistence TenantAdministrationPersistence, + IJobsOperationsPersistence JobsOperationsPersistence, IQuestionReferenceService QuestionReferenceService, ICurrentAccessContext CurrentAccessContext, IFeatureAccessService FeatureAccessService); internal abstract partial class DirectContentServiceBase(DirectContentServiceDependencies dependencies) { - protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected IQuestionBankPersistence questionBankPersistence { get; } = dependencies.QuestionBankPersistence; + protected IContentAssetPersistence contentAssetPersistence { get; } = dependencies.ContentAssetPersistence; + protected ICatalogPersistence catalogPersistence { get; } = dependencies.CatalogPersistence; + protected ILearningPersistence learningPersistence { get; } = dependencies.LearningPersistence; + + protected ITenantAdministrationPersistence tenantAdministrationPersistence { get; } = + dependencies.TenantAdministrationPersistence; + + protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence; + protected IModulePersistence unitOfWork { get; } = dependencies.QuestionBankPersistence; protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService; protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext; protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService; diff --git a/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs b/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs index 10d4f40..5a376df 100644 --- a/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs +++ b/Tiku.Infrastructure/Content/Handbook/HandbookManagementService.cs @@ -17,7 +17,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.HandbookSubjects.AsNoTracking() + var query = contentAssetPersistence.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); @@ -62,7 +62,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.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"); @@ -81,9 +81,9 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.HandbookSubjects.Add(item); + if (isNew) contentAssetPersistence.HandbookSubjects.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -92,7 +92,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = contentAssetPersistence.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); @@ -129,7 +129,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -148,9 +148,9 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.HandbookChapters.Add(item); + if (isNew) contentAssetPersistence.HandbookChapters.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -159,7 +159,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = contentAssetPersistence.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); @@ -196,7 +196,7 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -217,9 +217,9 @@ internal sealed class HandbookManagementService(DirectContentServiceDependencies item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.HandbookEntries.Add(item); + if (isNew) contentAssetPersistence.HandbookEntries.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } } diff --git a/Tiku.Infrastructure/Content/Imports/ContentImportService.cs b/Tiku.Infrastructure/Content/Imports/ContentImportService.cs index b378963..96f1f8e 100644 --- a/Tiku.Infrastructure/Content/Imports/ContentImportService.cs +++ b/Tiku.Infrastructure/Content/Imports/ContentImportService.cs @@ -42,19 +42,19 @@ internal sealed class ContentImportService( Guid jobId, CancellationToken cancellationToken = default) { - var job = await dbContext.ContentImportJobs.AsNoTracking() + var job = await questionBankPersistence.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() + var items = await questionBankPersistence.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() + var issues = await questionBankPersistence.ContentImportIssues.AsNoTracking() .Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId) .OrderBy(issue => issue.RowNo) .ThenBy(issue => issue.CreatedAt) @@ -80,7 +80,7 @@ internal sealed class ContentImportService( CancellationToken cancellationToken = default) { await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); - var issues = await dbContext.ContentImportIssues.AsNoTracking() + var issues = await questionBankPersistence.ContentImportIssues.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) .OrderBy(item => item.RowNo) .ThenBy(item => item.CreatedAt) @@ -104,7 +104,7 @@ internal sealed class ContentImportService( Guid jobId, CancellationToken cancellationToken = default) { - var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( + var job = await questionBankPersistence.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"); @@ -128,7 +128,7 @@ internal sealed class ContentImportService( counts } }); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); } @@ -137,7 +137,7 @@ internal sealed class ContentImportService( Guid jobId, CancellationToken cancellationToken = default) { - var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( + var job = await questionBankPersistence.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"); @@ -189,7 +189,7 @@ internal sealed class ContentImportService( StartedAt = execute ? DateTimeOffset.UtcNow : null, FinishedAt = execute ? DateTimeOffset.UtcNow : null }; - dbContext.ContentImportJobs.Add(job); + questionBankPersistence.ContentImportJobs.Add(job); var importItems = new List(); var rowNo = 1; @@ -217,7 +217,7 @@ internal sealed class ContentImportService( importItems.Add(importItem); } - dbContext.ContentImportItems.AddRange(importItems); + questionBankPersistence.ContentImportItems.AddRange(importItems); job.Summary = JsonSerializer.SerializeToElement(new { mode = execute ? "execute" : "preview", @@ -225,7 +225,7 @@ internal sealed class ContentImportService( note = "Synchronous direct migration import skeleton; async worker will be introduced later." }); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new SimpleImportResult( ToJobItem(job), importItems.Select(ToImportItem).ToArray(), diff --git a/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs b/Tiku.Infrastructure/Content/Nodes/ContentNodeManagementService.cs similarity index 91% rename from Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs rename to Tiku.Infrastructure/Content/Nodes/ContentNodeManagementService.cs index 4a3a2f0..9c8fcf7 100644 --- a/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs +++ b/Tiku.Infrastructure/Content/Nodes/ContentNodeManagementService.cs @@ -6,7 +6,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class ContentManagementService +internal sealed class ContentNodeManagementService(ContentManagementDependencies dependencies) + : ContentManagementServiceBase(dependencies), IContentNodeManagementService { public async Task> GetNodesAsync( ContentManagementActor actor, @@ -18,7 +19,7 @@ public sealed partial class ContentManagementService await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ContentNodes + var query = questionBankPersistence.ContentNodes .AsNoTracking() .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) .ApplyDataScope( @@ -76,7 +77,7 @@ public sealed partial class ContentManagementService Normalize(command.Id?.ToString("N")) ?? Guid.NewGuid().ToString("N"); var node = await ResolveEntityAsync( - dbContext.ContentNodes, + questionBankPersistence.ContentNodes, actor.TenantId, command.Id, item => item.EntryId == command.EntryId && item.NodeKey == nodeKey, @@ -121,17 +122,17 @@ public sealed partial class ContentManagementService node.Metadata = JsonObjectOrDefault(command.Metadata); node.UpdatedBy = actor.UserId; - if (isNew) dbContext.ContentNodes.Add(node); + if (isNew) questionBankPersistence.ContentNodes.Add(node); if (command.ParentId.HasValue) { - var parent = await dbContext.ContentNodes.SingleOrDefaultAsync( + var parent = await questionBankPersistence.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); + await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNodeItem(node)); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs b/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs index d19f371..5d4a8bd 100644 --- a/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs +++ b/Tiku.Infrastructure/Content/OperationContent/OperationContentManagementService.cs @@ -15,7 +15,7 @@ internal sealed class OperationContentManagementService(DirectContentServiceDepe { var items = NormalizeOperationKind(kind) switch { - "banners" => (await dbContext.Banners.AsNoTracking() + "banners" => (await jobsOperationsPersistence.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) @@ -23,7 +23,7 @@ internal sealed class OperationContentManagementService(DirectContentServiceDepe .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - "faqs" => (await dbContext.Faqs.AsNoTracking() + "faqs" => (await jobsOperationsPersistence.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) @@ -31,14 +31,14 @@ internal sealed class OperationContentManagementService(DirectContentServiceDepe .ThenBy(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - "announcements" => (await dbContext.Announcements.AsNoTracking() + "announcements" => (await jobsOperationsPersistence.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() + "exam-dates" => (await learningPersistence.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) diff --git a/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs b/Tiku.Infrastructure/Content/PracticeBlueprints/PracticeBlueprintManagementService.cs similarity index 93% rename from Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs rename to Tiku.Infrastructure/Content/PracticeBlueprints/PracticeBlueprintManagementService.cs index 2c8cb32..3187a75 100644 --- a/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs +++ b/Tiku.Infrastructure/Content/PracticeBlueprints/PracticeBlueprintManagementService.cs @@ -8,7 +8,8 @@ using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; -public sealed partial class ContentManagementService +internal sealed class PracticeBlueprintManagementService(ContentManagementDependencies dependencies) + : ContentManagementServiceBase(dependencies), IPracticeBlueprintManagementService { public async Task> GetPracticeBlueprintsAsync( ContentManagementActor actor, @@ -17,7 +18,7 @@ public sealed partial class ContentManagementService { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.PracticeBlueprints + var query = questionBankPersistence.PracticeBlueprints .AsNoTracking() .Where(blueprint => blueprint.TenantId == actor.TenantId) .ApplyDataScope( @@ -68,7 +69,7 @@ public sealed partial class ContentManagementService cancellationToken); var blueprint = await ResolveEntityByIdOrLegacyAsync( - dbContext.PracticeBlueprints, + questionBankPersistence.PracticeBlueprints, actor.TenantId, command.Id, command.LegacyId, @@ -111,9 +112,9 @@ public sealed partial class ContentManagementService blueprint.SortOrder = command.Order ?? 0; blueprint.UpdatedBy = actor.UserId; - if (isNew) dbContext.PracticeBlueprints.Add(blueprint); + if (isNew) questionBankPersistence.PracticeBlueprints.Add(blueprint); - await dbContext.SaveChangesAsync(cancellationToken); + await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBlueprintItem(blueprint)); } @@ -151,4 +152,4 @@ public sealed partial class ContentManagementService content, spec.Fields); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs b/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs index 241c9a9..3bf51e2 100644 --- a/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs +++ b/Tiku.Infrastructure/Content/Questions/QuestionManagementService.cs @@ -15,8 +15,8 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies { ValidateQuestionForPublication(command); await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var question = new Question @@ -30,15 +30,15 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies actor.TenantId, SaasQuotaMetricCatalog.PrivateQuestionCount, cancellationToken: cancellationToken); - dbContext.Questions.Add(question); - await dbContext.SaveChangesAsync(cancellationToken); + questionBankPersistence.Questions.Add(question); + await unitOfWork.SaveChangesAsync(cancellationToken); var version = BuildQuestionVersion(actor, question.Id, 1, command); - dbContext.QuestionVersions.Add(version); + questionBankPersistence.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } @@ -53,14 +53,14 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies ValidateQuestionForPublication(command); - var question = await dbContext.Questions.SingleOrDefaultAsync( + var question = await questionBankPersistence.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) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var wasCounted = question.Status != QuestionStatus.Archived; ApplyQuestion(question, command); @@ -73,17 +73,17 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies QuestionVersion? version; if (command.CreateVersion || !question.CurrentVersionId.HasValue) { - var nextVersionNo = await dbContext.QuestionVersions + var nextVersionNo = await questionBankPersistence.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); + questionBankPersistence.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; } else { - version = await dbContext.QuestionVersions.SingleOrDefaultAsync( + version = await questionBankPersistence.QuestionVersions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.QuestionId == question.Id && @@ -92,7 +92,7 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies if (version is null) { version = BuildQuestionVersion(actor, question.Id, 1, command); - dbContext.QuestionVersions.Add(version); + questionBankPersistence.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; } else @@ -103,7 +103,7 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (wasCounted && !isCounted) await featureAccessService.ReleaseQuotaAsync( actor.TenantId, diff --git a/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs b/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs index 30484e3..2facb09 100644 --- a/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs +++ b/Tiku.Infrastructure/Content/Scorelines/ScorelineManagementService.cs @@ -16,7 +16,7 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineFields.AsNoTracking() + var query = catalogPersistence.ScorelineFields.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) @@ -47,7 +47,7 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie 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, + var item = await ResolveByIdOrLegacyAsync(catalogPersistence.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"); @@ -66,9 +66,9 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie item.Placeholder = Normalize(command.Placeholder); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; - if (isNew) dbContext.ScorelineFields.Add(item); + if (isNew) catalogPersistence.ScorelineFields.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -79,7 +79,7 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineRecords.AsNoTracking() + var query = catalogPersistence.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); @@ -118,7 +118,7 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie 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, + var item = await ResolveByIdOrLegacyAsync(catalogPersistence.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"); @@ -131,9 +131,9 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie item.SchoolName = Normalize(command.SchoolName); item.MajorName = Normalize(command.MajorName); item.FieldValues = JsonObjectOrDefault(command.FieldValues); - if (isNew) dbContext.ScorelineRecords.Add(item); + if (isNew) catalogPersistence.ScorelineRecords.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -144,7 +144,7 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.ScorelineRecords.AsNoTracking() + var query = catalogPersistence.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); @@ -169,13 +169,13 @@ internal sealed class ScorelineManagementService(DirectContentServiceDependencie var items = new List(); foreach (var year in years.Items) { - var schoolCount = await dbContext.ScorelineRecords.AsNoTracking() + var schoolCount = await catalogPersistence.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() + var majorCount = await catalogPersistence.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Year == year) .Select(item => item.MajorId) .Where(id => id.HasValue) diff --git a/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs index cd30292..b4393c1 100644 --- a/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs +++ b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs @@ -15,7 +15,7 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = contentAssetPersistence.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)) @@ -44,7 +44,7 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -61,9 +61,9 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.VideoExplanations.Add(item); + if (isNew) contentAssetPersistence.VideoExplanations.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToVideoItem(item)); } @@ -76,7 +76,7 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de cancellationToken); await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); - var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( + var item = await contentAssetPersistence.QuestionVideos.SingleOrDefaultAsync( link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, cancellationToken); @@ -88,13 +88,13 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de 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); + if (isNew) contentAssetPersistence.QuestionVideos.Add(item); - var question = await dbContext.Questions.SingleAsync( + var question = await questionBankPersistence.Questions.SingleAsync( question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, cancellationToken); question.HasVideoExplanation = true; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionVideoItem(item)); } } diff --git a/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs b/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs index 77516b4..80b7a6b 100644 --- a/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs +++ b/Tiku.Infrastructure/Content/Vocabulary/VocabularyManagementService.cs @@ -17,7 +17,7 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.VocabularyUnits.AsNoTracking() + var query = contentAssetPersistence.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); @@ -56,7 +56,7 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.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"); @@ -71,9 +71,9 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.VocabularyUnits.Add(item); + if (isNew) contentAssetPersistence.VocabularyUnits.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -82,7 +82,7 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci AdminLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = contentAssetPersistence.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); @@ -119,7 +119,7 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, + var item = await ResolveByIdOrLegacyAsync(contentAssetPersistence.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -143,9 +143,9 @@ internal sealed class VocabularyManagementService(DirectContentServiceDependenci item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) dbContext.VocabularyWords.Add(item); + if (isNew) contentAssetPersistence.VocabularyWords.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } } diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index b19c70c..267bc5e 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -36,6 +36,26 @@ public static class DependencyInjection serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); }); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => + provider.GetRequiredService()); + services.AddScoped(provider => + provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); services.AddIdentityCore(options => { options.Password.RequiredLength = 8; @@ -89,4 +109,4 @@ public static class DependencyInjection services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options); return services; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs b/Tiku.Infrastructure/Growth/Analytics/ReferralAnalyticsService.cs similarity index 93% rename from Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs rename to Tiku.Infrastructure/Growth/Analytics/ReferralAnalyticsService.cs index 0cba3cf..48384ab 100644 --- a/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs +++ b/Tiku.Infrastructure/Growth/Analytics/ReferralAnalyticsService.cs @@ -4,7 +4,8 @@ using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Growth; -public sealed partial class ReferralService +internal sealed class ReferralAnalyticsService(ReferralServiceDependencies dependencies) + : ReferralServiceBase(dependencies), IReferralAnalyticsService { public async Task GetStatsAsync( ReferralAdminActor actor, @@ -24,7 +25,7 @@ public sealed partial class ReferralService { await AssertAdminAsync(actor, cancellationToken); var limit = Math.Clamp(query.Limit ?? 100, 1, 500); - var referrerIdsQuery = dbContext.ReferralLeads + var referrerIdsQuery = growthPersistence.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); if (query.ReferrerUserId.HasValue) @@ -60,7 +61,7 @@ public sealed partial class ReferralService var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - var leadsQuery = dbContext.ReferralLeads + var leadsQuery = growthPersistence.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -72,7 +73,7 @@ public sealed partial class ReferralService var leads = await leadsQuery .OrderByDescending(item => item.BoundAt) .ToArrayAsync(cancellationToken); - var paidStudentIds = await dbContext.Orders.AsNoTracking() + var paidStudentIds = await commercePersistence.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Status == OrderStatus.Paid && @@ -117,7 +118,7 @@ public sealed partial class ReferralService await AssertAdminAsync(actor, cancellationToken); var referrerUserId = query.ReferrerUserId ?? actor.UserId; await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); - var leads = await dbContext.ReferralLeads + var leads = await growthPersistence.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -127,4 +128,4 @@ public sealed partial class ReferralService .ToArrayAsync(cancellationToken); return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Growth/CommissionService.cs b/Tiku.Infrastructure/Growth/CommissionService.cs index 34fac73..464d69b 100644 --- a/Tiku.Infrastructure/Growth/CommissionService.cs +++ b/Tiku.Infrastructure/Growth/CommissionService.cs @@ -14,7 +14,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; public sealed class CommissionService( - TikuDbContext dbContext, + IGrowthPersistence growthPersistence, + ICommercePersistence commercePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, + IIdentityPersistence identityPersistence, ICurrentAccessContext currentAccessContext) : ICommissionService { public async Task GetSettingsAsync(CommissionAdminActor actor, @@ -35,7 +38,7 @@ public sealed class CommissionService( ParseEnum(command.SettlementCycle, settings.SettlementCycle, "invalid_commission_cycle"); settings.Config = command.Config ?? settings.Config; settings.UpdatedBy = actor.UserId; - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToSettingsItem(settings); } @@ -43,7 +46,7 @@ public sealed class CommissionService( UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var member = await dbContext.TenantMemberships + var member = await identityPersistence.TenantMemberships .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken) ?? throw new CommissionException("Commission member was not found.", @@ -63,10 +66,12 @@ public sealed class CommissionService( }); config["memberRates"] = JsonSerializer.SerializeToElement(memberRates); settings.Config = JsonSerializer.SerializeToElement(config); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return new { - member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig + member.UserId, + commissionRate = command.CommissionRate, + commissionConfig = command.CommissionConfig }; } @@ -95,7 +100,7 @@ public sealed class CommissionService( CommissionSettlementQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var items = dbContext.CommissionSettlements.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var items = growthPersistence.CommissionSettlements.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (query.ReferrerUserId.HasValue) items = items.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -111,7 +116,7 @@ public sealed class CommissionService( { await AssertAdminAsync(actor, cancellationToken); var settlement = await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken); - var items = await dbContext.CommissionSettlementItems.AsNoTracking() + var items = await growthPersistence.CommissionSettlementItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.SettlementId == settlementId) .OrderBy(item => item.SourcePaidAt) .ToArrayAsync(cancellationToken); @@ -122,7 +127,7 @@ public sealed class CommissionService( var bytes = Encoding.UTF8.GetBytes(content); var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); var filename = $"{settlement.SettlementNo}.{resolvedFormat}"; - dbContext.CommissionSettlementExportEvents.Add(new CommissionSettlementExportEvent + growthPersistence.CommissionSettlementExportEvents.Add(new CommissionSettlementExportEvent { TenantId = actor.TenantId, SettlementId = settlementId, @@ -135,7 +140,7 @@ public sealed class CommissionService( }); await AddAuditAsync(actor, "commission.settlement.exported", "commission_settlements", settlementId, new { format = resolvedFormat, filename, rowCount = items.Length, sha }, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return new CommissionExportItem(settlementId, filename, resolvedFormat, resolvedFormat == "json" ? "application/json" : "text/csv", items.Length, Convert.ToBase64String(bytes), sha, bytes.Length); @@ -173,9 +178,9 @@ public sealed class CommissionService( Remark = command.Remark, Metadata = command.Metadata ?? JsonDefaults.Object() }; - dbContext.CommissionSettlements.Add(settlement); + growthPersistence.CommissionSettlements.Add(settlement); foreach (var source in candidates) - dbContext.CommissionSettlementItems.Add(new CommissionSettlementItem + growthPersistence.CommissionSettlementItems.Add(new CommissionSettlementItem { TenantId = actor.TenantId, SettlementId = settlement.Id, @@ -192,7 +197,7 @@ public sealed class CommissionService( AttributionType = source.AttributionType, Metadata = JsonSerializer.SerializeToElement(new { source = "commission_generate" }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToSettlementItem(settlement); } @@ -222,7 +227,7 @@ public sealed class CommissionService( item.PaymentAccount = command.PaymentAccount ?? item.PaymentAccount; } - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToSettlementItem(item); } @@ -231,7 +236,7 @@ public sealed class CommissionService( { await AssertAdminAsync(actor, cancellationToken); await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken); - var items = await dbContext.CommissionSettlementProofs.AsNoTracking() + var items = await growthPersistence.CommissionSettlementProofs.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.SettlementId == settlementId) .OrderByDescending(item => item.CreatedAt) .ToArrayAsync(cancellationToken); @@ -260,8 +265,8 @@ public sealed class CommissionService( PaidAt = command.PaidAt, Metadata = command.Metadata ?? JsonDefaults.Object() }; - dbContext.CommissionSettlementProofs.Add(proof); - await dbContext.SaveChangesAsync(cancellationToken); + growthPersistence.CommissionSettlementProofs.Add(proof); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToProofItem(proof); } @@ -269,7 +274,7 @@ public sealed class CommissionService( UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var proof = await dbContext.CommissionSettlementProofs.FirstOrDefaultAsync( + var proof = await growthPersistence.CommissionSettlementProofs.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.ProofId, cancellationToken) ?? throw new CommissionException("Commission proof was not found.", "commission_proof_not_found"); var status = ParseEnum(command.Status, CommissionProofStatus.Approved, "invalid_commission_proof_status"); @@ -283,7 +288,7 @@ public sealed class CommissionService( proof.ReviewedAt = DateTimeOffset.UtcNow; proof.ReviewNote = command.ReviewNote ?? proof.ReviewNote; proof.Metadata = command.Metadata ?? proof.Metadata; - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToProofItem(proof); } @@ -296,16 +301,16 @@ public sealed class CommissionService( var start = new DateTimeOffset(startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); var end = new DateTimeOffset(endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); var settings = await GetSettingsCoreAsync(tenantId, cancellationToken); - var existing = await dbContext.CommissionSettlementItems.AsNoTracking() + var existing = await growthPersistence.CommissionSettlementItems.AsNoTracking() .Where(item => item.TenantId == tenantId) .Select(item => new { item.SourceType, item.SourceId, item.SettlementId }) .ToArrayAsync(cancellationToken); var result = new List(); var orders = await ( - from order in dbContext.Orders.AsNoTracking() - join lead in dbContext.ReferralLeads.AsNoTracking() + from order in commercePersistence.Orders.AsNoTracking() + join lead in growthPersistence.ReferralLeads.AsNoTracking() on new { order.TenantId, StudentUserId = order.UserId!.Value } equals new - { lead.TenantId, lead.StudentUserId } + { lead.TenantId, lead.StudentUserId } where order.TenantId == tenantId && order.UserId != null && lead.ReferrerUserId != null && order.Status == OrderStatus.Paid && order.PaidAt >= start && order.PaidAt < end select new { order, lead }) @@ -324,8 +329,8 @@ public sealed class CommissionService( } var codes = await ( - from code in dbContext.ActivationCodes.AsNoTracking() - join batch in dbContext.CodeBatches.AsNoTracking() + from code in commercePersistence.ActivationCodes.AsNoTracking() + join batch in commercePersistence.CodeBatches.AsNoTracking() on new { code.TenantId, code.BatchId } equals new { batch.TenantId, BatchId = (Guid?)batch.Id } into batches from batch in batches.DefaultIfEmpty() @@ -369,18 +374,18 @@ public sealed class CommissionService( private async Task GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken) { var settings = - await dbContext.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId, + await growthPersistence.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is not null) return settings; settings = new TenantCommissionSetting { TenantId = tenantId }; - dbContext.TenantCommissionSettings.Add(settings); + growthPersistence.TenantCommissionSettings.Add(settings); return settings; } private async Task GetSettlementAsync(Guid tenantId, Guid settlementId, CancellationToken cancellationToken) { - return await dbContext.CommissionSettlements.FirstOrDefaultAsync( + return await growthPersistence.CommissionSettlements.FirstOrDefaultAsync( item => item.TenantId == tenantId && item.Id == settlementId, cancellationToken) ?? throw new CommissionException("Commission settlement was not found.", "commission_settlement_not_found"); @@ -399,10 +404,14 @@ public sealed class CommissionService( private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { - TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, - TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) + TenantId = actor.TenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) }); await Task.CompletedTask.WaitAsync(cancellationToken); } diff --git a/Tiku.Infrastructure/Growth/CrmService.cs b/Tiku.Infrastructure/Growth/CrmService.cs index a806390..43a0dc4 100644 --- a/Tiku.Infrastructure/Growth/CrmService.cs +++ b/Tiku.Infrastructure/Growth/CrmService.cs @@ -11,7 +11,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; internal sealed class CrmService( - TikuDbContext dbContext, + IGrowthPersistence growthPersistence, + ITenancyPersistence tenancyPersistence, ITenantSecretProtector tenantSecretProtector, ICurrentAccessContext currentAccessContext) : ICrmService { @@ -32,7 +33,7 @@ internal sealed class CrmService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var config = await dbContext.CrmConfigs + var config = await growthPersistence.CrmConfigs .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId, cancellationToken); return ToConfigItem(config ?? new CrmConfig { TenantId = actor.TenantId }); @@ -51,12 +52,12 @@ internal sealed class CrmService( await UpsertSecretAsync(actor.TenantId, secretRef, command.Secret, cancellationToken); } - var config = await dbContext.CrmConfigs + var config = await growthPersistence.CrmConfigs .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId, cancellationToken); if (config is null) { config = new CrmConfig { TenantId = actor.TenantId }; - dbContext.CrmConfigs.Add(config); + growthPersistence.CrmConfigs.Add(config); } config.Enabled = command.Enabled; @@ -71,7 +72,7 @@ internal sealed class CrmService( config.AssignmentPool = EnsureArray(command.AssignmentPool); config.AssignmentConfig = EnsureObject(command.AssignmentConfig); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToConfigItem(config); } @@ -95,7 +96,7 @@ internal sealed class CrmService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var deadLetters = dbContext.CrmWebhookQueue + var deadLetters = growthPersistence.CrmWebhookQueue .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -115,13 +116,13 @@ internal sealed class CrmService( .OrderBy(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) .ToArrayAsync(cancellationToken); - var failed = await dbContext.CrmWebhookQueue.CountAsync( + var failed = await growthPersistence.CrmWebhookQueue.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Failed, cancellationToken); - var discarded = await dbContext.CrmWebhookQueue.CountAsync( + var discarded = await growthPersistence.CrmWebhookQueue.CountAsync( item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Discarded, cancellationToken); - var oldest = await dbContext.CrmWebhookQueue + var oldest = await growthPersistence.CrmWebhookQueue .Where(item => item.TenantId == actor.TenantId && item.Status == CrmWebhookQueueStatus.Failed) .OrderBy(item => item.CreatedAt) .Select(item => (DateTimeOffset?)item.CreatedAt) @@ -138,12 +139,12 @@ internal sealed class CrmService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var logs = dbContext.CrmWebhookLogs + var logs = growthPersistence.CrmWebhookLogs .AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.QueueId.HasValue) { - var queue = await dbContext.CrmWebhookQueue + var queue = await growthPersistence.CrmWebhookQueue .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value) .Select(item => new { item.Id, item.RecordId }) @@ -165,7 +166,7 @@ internal sealed class CrmService( CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var item = await dbContext.CrmWebhookQueue + var item = await growthPersistence.CrmWebhookQueue .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId, cancellationToken) ?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found"); @@ -193,7 +194,7 @@ internal sealed class CrmService( throw new CrmException("CRM queue action was invalid.", "invalid_crm_queue_action"); } - dbContext.CrmWebhookLogs.Add(new CrmWebhookLog + growthPersistence.CrmWebhookLogs.Add(new CrmWebhookLog { TenantId = actor.TenantId, RecordId = item.RecordId ?? item.Id.ToString(), @@ -204,13 +205,13 @@ internal sealed class CrmService( ResponseSummary = action, Attempt = item.Attempts }); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToQueueItem(item); } private IQueryable ApplyQueueQuery(Guid tenantId, CrmQueueQuery query) { - var items = dbContext.CrmWebhookQueue + var items = growthPersistence.CrmWebhookQueue .AsNoTracking() .Where(item => item.TenantId == tenantId); if (query.QueueId.HasValue) items = items.Where(item => item.Id == query.QueueId.Value); @@ -234,7 +235,7 @@ internal sealed class CrmService( string secret, CancellationToken cancellationToken) { - var item = await dbContext.TenantSecrets + var item = await tenancyPersistence.TenantSecrets .FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef, cancellationToken); if (item is null) @@ -247,7 +248,7 @@ internal sealed class CrmService( SecretKey = "default", SecretRef = secretRef }; - dbContext.TenantSecrets.Add(item); + tenancyPersistence.TenantSecrets.Add(item); } else { diff --git a/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs b/Tiku.Infrastructure/Growth/Foundation/ReferralServiceFoundation.cs similarity index 80% rename from Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs rename to Tiku.Infrastructure/Growth/Foundation/ReferralServiceFoundation.cs index 68692fc..6705a60 100644 --- a/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs +++ b/Tiku.Infrastructure/Growth/Foundation/ReferralServiceFoundation.cs @@ -11,9 +11,9 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Growth; -public sealed partial class ReferralService +internal abstract partial class ReferralServiceBase { - private async Task BindLeadCoreAsync( + protected async Task BindLeadCoreAsync( Guid tenantId, Guid studentUserId, Guid referrerUserId, @@ -27,7 +27,7 @@ public sealed partial class ReferralService await AssertActiveMemberAsync(tenantId, studentUserId, cancellationToken); await AssertActiveMemberAsync(tenantId, referrerUserId, cancellationToken); var now = DateTimeOffset.UtcNow; - var lead = await dbContext.ReferralLeads + var lead = await growthPersistence.ReferralLeads .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.StudentUserId == studentUserId, @@ -48,7 +48,7 @@ public sealed partial class ReferralService TenantId = tenantId, StudentUserId = studentUserId }; - dbContext.ReferralLeads.Add(lead); + growthPersistence.ReferralLeads.Add(lead); } lead.ReferrerUserId = referrerUserId; @@ -62,20 +62,20 @@ public sealed partial class ReferralService return lead; } - private async Task EnqueueCrmIfEnabledAsync( + protected async Task EnqueueCrmIfEnabledAsync( Guid tenantId, ReferralLead lead, string source, CancellationToken cancellationToken) { - var config = await dbContext.CrmConfigs + var config = await growthPersistence.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 + var existing = await growthPersistence.CrmWebhookQueue .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken); if (existing is not null) return existing; @@ -104,14 +104,14 @@ public sealed partial class ReferralService config.ExamType }) }; - dbContext.CrmWebhookQueue.Add(queue); + growthPersistence.CrmWebhookQueue.Add(queue); return queue; } - private async Task ResolveCodeCoreAsync(Guid tenantId, string code, + protected async Task ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken) { - return await dbContext.ReferralCodes + return await growthPersistence.ReferralCodes .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == tenantId && @@ -120,9 +120,9 @@ public sealed partial class ReferralService cancellationToken); } - private async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) + protected async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { - var exists = await dbContext.TenantMemberships.AnyAsync( + var exists = await identityPersistence.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && @@ -131,7 +131,7 @@ public sealed partial class ReferralService if (!exists) throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); } - private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) + protected async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || @@ -141,33 +141,33 @@ public sealed partial class ReferralService throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); } - private async Task BuildStatsAsync( + protected async Task BuildStatsAsync( Guid tenantId, Guid referrerUserId, CancellationToken cancellationToken) { - var user = await dbContext.Users.AsNoTracking() + var user = await identityPersistence.Users.AsNoTracking() .Where(item => item.Id == referrerUserId) .Select(item => new { item.Name, item.UserName, item.Phone }) .FirstOrDefaultAsync(cancellationToken); - var membership = await dbContext.TenantMemberships.AsNoTracking() + var membership = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId && item.UserId == referrerUserId) .Select(item => item.Role) .FirstOrDefaultAsync(cancellationToken); - var inviteCode = await dbContext.ReferralCodes.AsNoTracking() + var inviteCode = await growthPersistence.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() + var leads = await growthPersistence.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() + : await commercePersistence.Orders.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == OrderStatus.Paid && @@ -180,7 +180,7 @@ public sealed partial class ReferralService AmountCents = group.Sum(item => item.AmountCents) }) .ToArrayAsync(cancellationToken); - var trackCount = await dbContext.ReferralTracks.AsNoTracking() + var trackCount = await growthPersistence.ReferralTracks.AsNoTracking() .CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken); return new ReferralStatsItem( @@ -195,12 +195,12 @@ public sealed partial class ReferralService leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2)); } - private async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) + protected async Task GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken) { for (var attempt = 0; attempt < 20; attempt++) { var code = GenerateCode(); - var exists = await dbContext.ReferralCodes.AnyAsync( + var exists = await growthPersistence.ReferralCodes.AnyAsync( item => item.TenantId == tenantId && item.Code == code, cancellationToken); if (!exists) return code; @@ -209,7 +209,7 @@ public sealed partial class ReferralService throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed"); } - private static string GenerateCode() + protected static string GenerateCode() { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; Span bytes = stackalloc byte[8]; @@ -220,20 +220,20 @@ public sealed partial class ReferralService return new string(chars); } - private static Guid RequireUser(ReferralActor actor) + protected 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) + protected static string? NormalizeCode(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToUpperInvariant(); } - private static string NormalizeChoice( + protected static string NormalizeChoice( string? value, HashSet allowed, string defaultValue, @@ -247,7 +247,7 @@ public sealed partial class ReferralService : throw new ReferralException("Referral value was invalid.", errorCode); } - private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) + protected static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum { if (string.IsNullOrWhiteSpace(value)) return defaultValue; @@ -260,22 +260,22 @@ public sealed partial class ReferralService throw new ReferralException("Referral enum value was invalid.", errorCode); } - private static string? NormalizeOptional(string? value) + protected static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private static string? Truncate(string? value, int maxLength) + protected static string? Truncate(string? value, int maxLength) { return value is null || value.Length <= maxLength ? value : value[..maxLength]; } - private static string? FirstNonBlank(params string?[] values) + protected static string? FirstNonBlank(params string?[] values) { return values.Select(NormalizeOptional).FirstOrDefault(value => value is not null); } - private static ReferralTrackItem ToTrackItem(ReferralTrack item) + protected static ReferralTrackItem ToTrackItem(ReferralTrack item) { return new ReferralTrackItem( item.Id, @@ -287,7 +287,7 @@ public sealed partial class ReferralService item.CreatedAt); } - private static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed) + protected static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed) { return new ReferralLeadItem( item.Id, @@ -299,7 +299,7 @@ public sealed partial class ReferralService changed); } - private static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item) + protected static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item) { return new ReferralQrcodeItem( item.Id, @@ -312,12 +312,12 @@ public sealed partial class ReferralService item.Metadata); } - private static CrmQueuePreviewItem? ToQueuePreview(CrmWebhookQueueItem? item) + protected 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) + protected static ReferralTeamItem ToTeamItem(ReferralTeamEdge item) { return new ReferralTeamItem( item.Id, @@ -327,4 +327,4 @@ public sealed partial class ReferralService item.Status.ToString(), item.Metadata); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs deleted file mode 100644 index 4224edf..0000000 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Tiku.Application.Growth; -using Tiku.Application.Security; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Growth; - -public sealed partial class ReferralService( - TikuDbContext dbContext, - IReferralQrcodeGenerator qrcodeGenerator, - ICurrentAccessContext currentAccessContext) : IReferralService -{ - private static readonly HashSet AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) - { - "enter", - "register", - "purchase", - "share", - "scan", - "manual_bind" - }; - - private static readonly HashSet AllowedSources = new(StringComparer.OrdinalIgnoreCase) - { - "share", - "qrcode", - "timeline", - "miniapp", - "h5", - "manual", - "unknown" - }; -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/ReferralServiceDependencies.cs b/Tiku.Infrastructure/Growth/ReferralServiceDependencies.cs new file mode 100644 index 0000000..a7859b6 --- /dev/null +++ b/Tiku.Infrastructure/Growth/ReferralServiceDependencies.cs @@ -0,0 +1,40 @@ +using Tiku.Application.Growth; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; +namespace Tiku.Infrastructure.Growth; + +internal sealed record ReferralServiceDependencies( + IGrowthPersistence GrowthPersistence, + ICommercePersistence CommercePersistence, + IIdentityPersistence IdentityPersistence, + IReferralQrcodeGenerator QrcodeGenerator, + ICurrentAccessContext CurrentAccessContext); + +internal abstract partial class ReferralServiceBase(ReferralServiceDependencies dependencies) +{ + protected IGrowthPersistence growthPersistence { get; } = dependencies.GrowthPersistence; + protected ICommercePersistence commercePersistence { get; } = dependencies.CommercePersistence; + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected IReferralQrcodeGenerator qrcodeGenerator { get; } = dependencies.QrcodeGenerator; + protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext; + protected static readonly HashSet AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase) + { + "enter", + "register", + "purchase", + "share", + "scan", + "manual_bind" + }; + + protected static readonly HashSet AllowedSources = new(StringComparer.OrdinalIgnoreCase) + { + "share", + "qrcode", + "timeline", + "miniapp", + "h5", + "manual", + "unknown" + }; +} diff --git a/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs b/Tiku.Infrastructure/Growth/Student/StudentReferralService.cs similarity index 86% rename from Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs rename to Tiku.Infrastructure/Growth/Student/StudentReferralService.cs index e99bd82..3965ebc 100644 --- a/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs +++ b/Tiku.Infrastructure/Growth/Student/StudentReferralService.cs @@ -7,7 +7,8 @@ using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Growth; -public sealed partial class ReferralService +internal sealed class StudentReferralService(ReferralServiceDependencies dependencies) + : ReferralServiceBase(dependencies), IStudentReferralService { public async Task GetOrCreateInviteCodeAsync( ReferralActor actor, @@ -17,7 +18,7 @@ public sealed partial class ReferralService var userId = RequireUser(actor); await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); - var existing = await dbContext.ReferralCodes + var existing = await growthPersistence.ReferralCodes .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == userId, @@ -29,7 +30,7 @@ public sealed partial class ReferralService if (!string.IsNullOrWhiteSpace(command.LandingPath)) existing.LandingPath = command.LandingPath.Trim(); existing.Status = ReferralCodeStatus.Active; - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return new ReferralInviteItem(existing.Code); } @@ -43,8 +44,8 @@ public sealed partial class ReferralService LandingPath = NormalizeOptional(command.LandingPath), Metadata = JsonSerializer.SerializeToElement(new { source = "referral_invite_code" }) }; - dbContext.ReferralCodes.Add(referralCode); - await dbContext.SaveChangesAsync(cancellationToken); + growthPersistence.ReferralCodes.Add(referralCode); + await growthPersistence.SaveChangesAsync(cancellationToken); return new ReferralInviteItem(code); } @@ -57,11 +58,11 @@ public sealed partial class ReferralService 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() + from referralCode in growthPersistence.ReferralCodes.AsNoTracking() + join membership in identityPersistence.TenantMemberships.AsNoTracking() on new { referralCode.TenantId, referralCode.UserId } equals new - { membership.TenantId, membership.UserId } - join user in dbContext.Users.AsNoTracking() + { membership.TenantId, membership.UserId } + join user in identityPersistence.Users.AsNoTracking() on referralCode.UserId equals user.Id where referralCode.TenantId == actor.TenantId && referralCode.Code == code && @@ -100,7 +101,7 @@ public sealed partial class ReferralService 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); + await using var transaction = await growthPersistence.Database.BeginTransactionAsync(cancellationToken); var track = new ReferralTrack { TenantId = actor.TenantId, @@ -129,21 +130,21 @@ public sealed partial class ReferralService command.Metadata, cancellationToken); var setFirstTrack = lead.FirstTrackId is null; - if (setFirstTrack) await dbContext.SaveChangesAsync(cancellationToken); + if (setFirstTrack) await growthPersistence.SaveChangesAsync(cancellationToken); - dbContext.ReferralTracks.Add(track); + growthPersistence.ReferralTracks.Add(track); track.LeadId = lead.Id; - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); if (setFirstTrack) lead.FirstTrackId = track.Id; crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); } else { - dbContext.ReferralTracks.Add(track); + growthPersistence.ReferralTracks.Add(track); } - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue)); @@ -164,7 +165,7 @@ public sealed partial class ReferralService if (resolution.UserId == userId) throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - var existing = await dbContext.ReferralLeads + var existing = await growthPersistence.ReferralLeads .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == userId, @@ -184,7 +185,7 @@ public sealed partial class ReferralService ? null : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); } @@ -212,7 +213,7 @@ public sealed partial class ReferralService JsonSerializer.SerializeToElement(new { generatedBy = "external_url" })); var metadata = command.Metadata ?? generated.Metadata; - var item = await dbContext.ReferralQrcodes + var item = await growthPersistence.ReferralQrcodes .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Provider == generated.Provider && @@ -230,7 +231,7 @@ public sealed partial class ReferralService Page = page, Provider = generated.Provider }; - dbContext.ReferralQrcodes.Add(item); + growthPersistence.ReferralQrcodes.Add(item); } item.UserId = userId; @@ -239,7 +240,7 @@ public sealed partial class ReferralService item.Status = ReferralQrcodeStatus.Ready; item.ErrorMessage = null; item.Metadata = metadata; - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToQrcodeItem(item); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralAdministrationService.cs similarity index 85% rename from Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs rename to Tiku.Infrastructure/Growth/TenantAdmin/ReferralAdministrationService.cs index 73a2ebe..240b0f3 100644 --- a/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs +++ b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralAdministrationService.cs @@ -5,7 +5,10 @@ using Tiku.Domain.Growth; namespace Tiku.Infrastructure.Growth; -public sealed partial class ReferralService +internal sealed class ReferralAdministrationService( + ReferralServiceDependencies dependencies, + IStudentReferralService studentReferralService) + : ReferralServiceBase(dependencies), IReferralAdministrationService { public async Task ManualBindAsync( ReferralAdminActor actor, @@ -16,7 +19,7 @@ public sealed partial class ReferralService if (command.StudentUserId == command.ReferrerUserId) throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - var refCode = await dbContext.ReferralCodes + var refCode = await growthPersistence.ReferralCodes .Where(item => item.TenantId == actor.TenantId && item.UserId == command.ReferrerUserId && @@ -24,12 +27,12 @@ public sealed partial class ReferralService .Select(item => item.Code) .FirstOrDefaultAsync(cancellationToken); if (refCode is null) - refCode = (await GetOrCreateInviteCodeAsync( + refCode = (await studentReferralService.GetOrCreateInviteCodeAsync( new ReferralActor(actor.TenantId, command.ReferrerUserId), new ReferralInviteCommand("manual"), cancellationToken)).InviteCode; - var before = await dbContext.ReferralLeads + var before = await growthPersistence.ReferralLeads .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && @@ -50,7 +53,7 @@ public sealed partial class ReferralService var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId ? null : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); } @@ -61,7 +64,7 @@ public sealed partial class ReferralService CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var edges = dbContext.ReferralTeamEdges + var edges = growthPersistence.ReferralTeamEdges .AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.LeaderUserId.HasValue) edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); @@ -86,7 +89,7 @@ public sealed partial class ReferralService 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 + var edge = await growthPersistence.ReferralTeamEdges .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.MemberUserId == command.MemberUserId && @@ -100,13 +103,13 @@ public sealed partial class ReferralService MemberUserId = command.MemberUserId, RelationType = relationType }; - dbContext.ReferralTeamEdges.Add(edge); + growthPersistence.ReferralTeamEdges.Add(edge); } edge.LeaderUserId = command.LeaderUserId; edge.Status = status; edge.Metadata = command.Metadata ?? JsonDefaults.Object(); - await dbContext.SaveChangesAsync(cancellationToken); + await growthPersistence.SaveChangesAsync(cancellationToken); return ToTeamItem(edge); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs index 94cafca..457da05 100644 --- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -5,9 +5,11 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Jobs; internal sealed partial class BackgroundJobService( - TikuDbContext dbContext, + IJobsOperationsPersistence dbContext, ITenantExecutionScope tenantExecutionScope, IFeatureAccessService featureAccessService) : IBackgroundJobService { + private IJobsOperationsPersistence jobsOperationsPersistence { get; } = dbContext; + private IModulePersistence unitOfWork { get; } = dbContext; private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); } diff --git a/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs index 1cbeacd..3e8488b 100644 --- a/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs +++ b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs @@ -13,7 +13,7 @@ internal sealed partial class BackgroundJobService int limit = 50, CancellationToken cancellationToken = default) { - var query = dbContext.BackgroundJobs.AsNoTracking() + var query = jobsOperationsPersistence.BackgroundJobs.AsNoTracking() .Where(job => job.TenantId == tenantId); if (!string.IsNullOrWhiteSpace(jobType)) { @@ -33,7 +33,7 @@ internal sealed partial class BackgroundJobService Guid? tenantId, CancellationToken cancellationToken = default) { - var query = dbContext.BackgroundJobs.AsNoTracking().Where(item => item.Id == jobId); + var query = jobsOperationsPersistence.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); @@ -46,7 +46,7 @@ internal sealed partial class BackgroundJobService int limit = 100, CancellationToken cancellationToken = default) { - var query = dbContext.BackgroundJobs.AsNoTracking().AsQueryable(); + var query = jobsOperationsPersistence.BackgroundJobs.AsNoTracking().AsQueryable(); if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value); if (!string.IsNullOrWhiteSpace(jobType)) { @@ -69,7 +69,7 @@ internal sealed partial class BackgroundJobService string reason, CancellationToken cancellationToken = default) { - dbContext.ChangeTracker.Clear(); + unitOfWork.ChangeTracker.Clear(); if (string.IsNullOrWhiteSpace(reason)) throw new BackgroundJobException("background_job_cancel_reason_required", "Cancellation reason is required."); @@ -89,7 +89,7 @@ internal sealed partial class BackgroundJobService } AddMutationAudit(job, actorUserId, "background_job.cancel_requested"); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToItem(job); } @@ -99,7 +99,7 @@ internal sealed partial class BackgroundJobService Guid actorUserId, CancellationToken cancellationToken = default) { - dbContext.ChangeTracker.Clear(); + unitOfWork.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", @@ -116,7 +116,7 @@ internal sealed partial class BackgroundJobService job.CancellationRequestedBy = null; job.CancellationReason = null; AddMutationAudit(job, actorUserId, "background_job.retry_requested"); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToItem(job); } @@ -125,7 +125,7 @@ internal sealed partial class BackgroundJobService Guid? tenantId, CancellationToken cancellationToken) { - var query = dbContext.BackgroundJobs.Where(item => item.Id == jobId); + var query = jobsOperationsPersistence.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."); @@ -133,7 +133,7 @@ internal sealed partial class BackgroundJobService private void AddMutationAudit(BackgroundJob job, Guid actorUserId, string action) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = job.TenantId, ActorUserId = actorUserId, diff --git a/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs index 15cdfbd..8a6df40 100644 --- a/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs +++ b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs @@ -20,7 +20,7 @@ internal sealed partial class BackgroundJobService { var now = DateTimeOffset.UtcNow; var leaseExpiresAt = now.Add(LeaseDuration); - var claimedIds = await dbContext.Database.SqlQuery($""" + var claimedIds = await unitOfWork.Database.SqlQuery($""" UPDATE background_jobs AS job SET status = 'processing', locked_by = {workerId}, @@ -44,13 +44,13 @@ internal sealed partial class BackgroundJobService .ToArrayAsync(cancellationToken); var processed = 0; - dbContext.ChangeTracker.Clear(); + unitOfWork.ChangeTracker.Clear(); foreach (var jobId in claimedIds) { cancellationToken.ThrowIfCancellationRequested(); - var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken); + var job = await jobsOperationsPersistence.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken); if (await ProcessJobAsync(job, workerId, true, cancellationToken)) processed++; - dbContext.ChangeTracker.Clear(); + unitOfWork.ChangeTracker.Clear(); } return processed; @@ -64,7 +64,7 @@ internal sealed partial class BackgroundJobService CancellationToken cancellationToken = default) { var normalizedJobType = NormalizeJobType(jobType); - var claimed = await dbContext.BackgroundJobs + var claimed = await jobsOperationsPersistence.BackgroundJobs .Where(item => item.Id == jobId && item.TenantId == tenantId && item.JobType == normalizedJobType && item.Status == BackgroundJobStatus.Pending && item.RunAfter == null) @@ -75,8 +75,8 @@ internal sealed partial class BackgroundJobService .SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken); if (claimed == 0) return false; - dbContext.ChangeTracker.Clear(); - var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync( + unitOfWork.ChangeTracker.Clear(); + var job = await jobsOperationsPersistence.BackgroundJobs.SingleOrDefaultAsync( item => item.Id == jobId && item.TenantId == tenantId, cancellationToken); if (job is null) @@ -96,7 +96,7 @@ internal sealed partial class BackgroundJobService if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) || (alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId))) return false; - await dbContext.Entry(job).ReloadAsync(cancellationToken); + await unitOfWork.Entry(job).ReloadAsync(cancellationToken); if (job.CancellationRequestedAt.HasValue) { job.CompletedAt = DateTimeOffset.UtcNow; @@ -133,7 +133,7 @@ internal sealed partial class BackgroundJobService job.LockedBy = workerId; job.LockExpiresAt = now.Add(LeaseDuration); job.StartedAt = now; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); } try @@ -151,7 +151,7 @@ internal sealed partial class BackgroundJobService token); }, cancellationToken); - var cancellationRequested = await dbContext.BackgroundJobs.AsNoTracking() + var cancellationRequested = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking() .Where(item => item.Id == job.Id) .Select(item => item.CancellationRequestedAt != null) .SingleAsync(cancellationToken); @@ -213,7 +213,7 @@ internal sealed partial class BackgroundJobService string? lastError, CancellationToken cancellationToken) { - await dbContext.BackgroundJobs + await jobsOperationsPersistence.BackgroundJobs .Where(value => value.Id == job.Id && value.LockedBy == workerId) .ExecuteUpdateAsync(setters => setters .SetProperty(value => value.Status, status) diff --git a/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs index ec262a1..a0d433a 100644 --- a/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs +++ b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs @@ -15,7 +15,7 @@ internal sealed partial class BackgroundJobService var idempotencyKey = NormalizeIdempotencyKey(command.IdempotencyKey); if (idempotencyKey is not null) { - var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync( + var existing = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.JobType == normalizedJobType && item.IdempotencyKey == idempotencyKey, cancellationToken); @@ -53,15 +53,15 @@ internal sealed partial class BackgroundJobService RunAfter = command.RunAfter, MaxRetries = Math.Clamp(command.MaxRetries, 0, 20) }; - dbContext.BackgroundJobs.Add(job); + jobsOperationsPersistence.BackgroundJobs.Add(job); try { - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) when (idempotencyKey is not null) { - dbContext.ChangeTracker.Clear(); - var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync( + unitOfWork.ChangeTracker.Clear(); + var existing = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.JobType == normalizedJobType && item.IdempotencyKey == idempotencyKey, cancellationToken); diff --git a/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs b/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs index 4a5f7bc..8ab3e36 100644 --- a/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs +++ b/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs @@ -12,7 +12,7 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen LearningActor actor, CancellationToken cancellationToken = default) { - var answers = dbContext.AnswerRecords.AsNoTracking() + var answers = learningPersistence.AnswerRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.IsCurrent && @@ -21,22 +21,22 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen return new LearningStatsItem( await answers.CountAsync(cancellationToken), await answers.CountAsync(item => item.IsCorrect == true, cancellationToken), - await dbContext.WrongQuestions.AsNoTracking().CountAsync( + await learningPersistence.WrongQuestions.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null, cancellationToken), - await dbContext.FavoriteQuestions.AsNoTracking().CountAsync( + await learningPersistence.FavoriteQuestions.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken), - await dbContext.UserWordFavorites.AsNoTracking().CountAsync( + await learningPersistence.UserWordFavorites.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken), - await dbContext.UserWordProgress.AsNoTracking().CountAsync( + await learningPersistence.UserWordProgress.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken), - await dbContext.PracticeSessions.AsNoTracking().CountAsync( + await learningPersistence.PracticeSessions.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken), - await dbContext.PracticeSessionReports.AsNoTracking().CountAsync( + await learningPersistence.PracticeSessionReports.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PracticeReportStatus.Final, @@ -49,7 +49,7 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen CancellationToken cancellationToken = default) { var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit)); - var rows = await dbContext.AnswerRecords.AsNoTracking() + var rows = await learningPersistence.AnswerRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -77,7 +77,7 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var rows = await dbContext.AnswerRecords.AsNoTracking() + var rows = await learningPersistence.AnswerRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IsCurrent && item.GradingStatus != AnswerGradingStatus.PendingReview && @@ -95,7 +95,7 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = rows.Select(item => item.UserId).ToArray(); - var names = await dbContext.Users.AsNoTracking() + var names = await identityPersistence.Users.AsNoTracking() .Where(item => userIds.Contains(item.Id)) .ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken); var items = rows diff --git a/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs b/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs index e6f1d96..93c2bb4 100644 --- a/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs +++ b/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs @@ -22,7 +22,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) "Selected option indices must be zero-based non-negative values."); var now = DateTimeOffset.UtcNow; - var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item => + var sessionQuestion = await learningPersistence.PracticeSessionQuestions.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken); if (sessionQuestion is null) @@ -30,7 +30,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) "session_question_not_found", "An active practice session question was not found."); - var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item => + var session = await learningPersistence.PracticeSessions.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Id == sessionQuestion.PracticeSessionId, cancellationToken); @@ -39,7 +39,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) "Practice session was not found."); var requestHash = HashAnswer(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( + var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -64,12 +64,12 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) { session.Status = PracticeSessionStatus.Expired; session.Version++; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); throw new LearningValidationException("practice_session_expired", "The practice session has expired."); } EnsureAnswerSessionState(session, command); - var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer => + var current = await learningPersistence.AnswerRecords.SingleOrDefaultAsync(answer => answer.TenantId == actor.TenantId && answer.UserId == actor.UserId && answer.PracticeSessionId == session.Id && @@ -120,11 +120,11 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) AnsweredAt = now, CreatedAt = now }; - dbContext.AnswerRecords.Add(record); + learningPersistence.AnswerRecords.Add(record); session.Version++; session.LastClientSequence = command.ClientSequence; var response = ToItem(record, session.Version); - dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency + learningPersistence.LearningOperationIdempotencies.Add(new LearningOperationIdempotency { TenantId = actor.TenantId, UserId = actor.UserId, @@ -138,7 +138,7 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) try { - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); } catch (DbUpdateConcurrencyException) { @@ -149,8 +149,8 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies) exception.InnerException is PostgresException postgresException && postgresException.SqlState == PostgresErrorCodes.UniqueViolation) { - dbContext.ChangeTracker.Clear(); - var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => + unitOfWork.ChangeTracker.Clear(); + var replay = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.PracticeSessionId == session.Id && diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs index 34531a5..5950ccd 100644 --- a/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs +++ b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs @@ -36,7 +36,7 @@ internal abstract partial class LearningActivityServiceBase if (!command.BlueprintId.HasValue) return assembly; - var blueprint = await dbContext.PracticeBlueprints + var blueprint = await questionBankPersistence.PracticeBlueprints .AsNoTracking() .SingleOrDefaultAsync( item => @@ -69,7 +69,7 @@ internal abstract partial class LearningActivityServiceBase CancellationToken cancellationToken) { if (assembly.Mode == "wrong_review") - return await dbContext.WrongQuestions + return await learningPersistence.WrongQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -82,7 +82,7 @@ internal abstract partial class LearningActivityServiceBase .ToListAsync(cancellationToken); if (assembly.Mode == "favorite_review") - return await dbContext.FavoriteQuestions + return await learningPersistence.FavoriteQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -93,7 +93,7 @@ internal abstract partial class LearningActivityServiceBase .ToListAsync(cancellationToken); if (assembly.CollectionId.HasValue) - return await dbContext.QuestionCollectionItems + return await questionBankPersistence.QuestionCollectionItems .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -103,7 +103,7 @@ internal abstract partial class LearningActivityServiceBase .Select(item => item.QuestionReferenceId) .ToListAsync(cancellationToken); - var query = dbContext.Questions + var query = questionBankPersistence.Questions .AsNoTracking() .Where(question => question.TenantId == actor.TenantId && @@ -165,13 +165,14 @@ internal abstract partial class LearningActivityServiceBase "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); + var systemQuestionBank = provider.GetRequiredService(); + var systemLearning = provider.GetRequiredService(); return await ( - from reference in systemDbContext.TenantQuestionReferences.AsNoTracking() - join question in systemDbContext.Questions.AsNoTracking() + from reference in systemQuestionBank.TenantQuestionReferences.AsNoTracking() + join question in systemQuestionBank.Questions.AsNoTracking() on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } equals new { question.TenantId, question.Id } - join version in systemDbContext.QuestionVersions.AsNoTracking() + join version in systemQuestionBank.QuestionVersions.AsNoTracking() on new { TenantId = reference.QuestionOwnerTenantId, @@ -226,9 +227,10 @@ internal abstract partial class LearningActivityServiceBase "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); + var systemQuestionBank = provider.GetRequiredService(); + var systemLearning = provider.GetRequiredService(); return await ( - from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking() + from sessionQuestion in systemLearning.PracticeSessionQuestions.AsNoTracking() where sessionQuestion.TenantId == tenantId && sessionQuestion.PracticeSessionId == practiceSessionId orderby sessionQuestion.Position @@ -261,7 +263,7 @@ internal abstract partial class LearningActivityServiceBase if (!practiceSessionId.HasValue) throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - var session = await dbContext.PracticeSessions + var session = await learningPersistence.PracticeSessions .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && @@ -281,7 +283,7 @@ internal abstract partial class LearningActivityServiceBase PracticeSession session, CancellationToken cancellationToken) { - var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking() + var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.PracticeSessionId == session.Id) @@ -291,7 +293,7 @@ internal abstract partial class LearningActivityServiceBase throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot."); - var answers = await dbContext.AnswerRecords + var answers = await learningPersistence.AnswerRecords .AsNoTracking() .Where(answer => answer.TenantId == actor.TenantId && @@ -401,8 +403,8 @@ internal abstract partial class LearningActivityServiceBase scoringVersion = 1 }) }; - dbContext.PracticeSessionReports.Add(report); - dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection + learningPersistence.PracticeSessionReports.Add(report); + learningPersistence.PracticeSessionReportSections.Add(new PracticeSessionReportSection { TenantId = actor.TenantId, ReportId = report.Id, @@ -424,11 +426,11 @@ internal abstract partial class LearningActivityServiceBase latestAnswers.TryGetValue(question.Id, out var answer) && answer.GradingStatus == AnswerGradingStatus.Incorrect)) { - var wrongQuestion = await dbContext.WrongQuestions.FindAsync( + var wrongQuestion = await learningPersistence.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken); if (wrongQuestion is null) { - dbContext.WrongQuestions.Add(new WrongQuestion + learningPersistence.WrongQuestions.Add(new WrongQuestion { TenantId = actor.TenantId, UserId = actor.UserId, @@ -455,7 +457,7 @@ internal abstract partial class LearningActivityServiceBase Guid questionId, CancellationToken cancellationToken) { - var exists = await dbContext.Questions.AnyAsync( + var exists = await questionBankPersistence.Questions.AnyAsync( question => question.TenantId == tenantId && question.Id == questionId && @@ -470,7 +472,7 @@ internal abstract partial class LearningActivityServiceBase Guid wordId, CancellationToken cancellationToken) { - var exists = await dbContext.VocabularyWords.AnyAsync( + var exists = await contentAssetPersistence.VocabularyWords.AnyAsync( word => word.TenantId == tenantId && word.Id == wordId && diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs index c569c27..2415c2a 100644 --- a/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs +++ b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs @@ -8,7 +8,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal sealed record LearningServiceDependencies( - TikuDbContext DbContext, + ILearningPersistence LearningPersistence, + IQuestionBankPersistence QuestionBankPersistence, + IContentAssetPersistence ContentAssetPersistence, + IIdentityPersistence IdentityPersistence, IQuestionReferenceService QuestionReferenceService, IPublicQuestionAccessPolicy PublicQuestionAccessPolicy, ITenantExecutionScope TenantExecutionScope, @@ -16,7 +19,11 @@ internal sealed record LearningServiceDependencies( internal abstract partial class LearningActivityServiceBase(LearningServiceDependencies dependencies) { - protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected ILearningPersistence learningPersistence { get; } = dependencies.LearningPersistence; + protected IQuestionBankPersistence questionBankPersistence { get; } = dependencies.QuestionBankPersistence; + protected IContentAssetPersistence contentAssetPersistence { get; } = dependencies.ContentAssetPersistence; + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected IModulePersistence unitOfWork { get; } = dependencies.LearningPersistence; protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService; protected IPublicQuestionAccessPolicy publicQuestionAccessPolicy { get; } = dependencies.PublicQuestionAccessPolicy; protected ITenantExecutionScope tenantExecutionScope { get; } = dependencies.TenantExecutionScope; diff --git a/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs b/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs index 383acf3..f8225f8 100644 --- a/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs +++ b/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs @@ -19,7 +19,7 @@ internal sealed class PracticeReportService(LearningServiceDependencies dependen if (!filter.PracticeSessionId.HasValue) throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - var report = await dbContext.PracticeSessionReports + var report = await learningPersistence.PracticeSessionReports .AsNoTracking() .SingleOrDefaultAsync( item => @@ -40,7 +40,7 @@ internal sealed class PracticeReportService(LearningServiceDependencies dependen PracticeSessionFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.PracticeSessionReports + var query = learningPersistence.PracticeSessionReports .AsNoTracking() .Where(report => report.TenantId == actor.TenantId && @@ -63,7 +63,7 @@ internal sealed class PracticeReportService(LearningServiceDependencies dependen PracticeSessionFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.PracticeSessions + var query = learningPersistence.PracticeSessions .AsNoTracking() .Where(session => session.TenantId == actor.TenantId && @@ -73,7 +73,7 @@ internal sealed class PracticeReportService(LearningServiceDependencies dependen var rows = await query .GroupJoin( - dbContext.PracticeSessionReports.AsNoTracking(), + learningPersistence.PracticeSessionReports.AsNoTracking(), session => new { session.TenantId, PracticeSessionId = session.Id }, report => new { report.TenantId, report.PracticeSessionId }, (session, reports) => new { session, report = reports.FirstOrDefault() }) diff --git a/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs b/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs index 9b83de4..e9a9123 100644 --- a/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs +++ b/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs @@ -23,7 +23,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende "No published questions are available for this practice target."); - var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync( + var containsPlatformQuestion = await questionBankPersistence.TenantQuestionReferences.AsNoTracking().AnyAsync( reference => reference.TenantId == actor.TenantId && questionReferenceIds.Contains(reference.Id) && @@ -62,7 +62,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende ? JsonDefaults.Object() : command.Metadata }; - dbContext.PracticeSessions.Add(session); + learningPersistence.PracticeSessions.Add(session); var selections = await LoadQuestionSelectionsAsync( actor.TenantId, @@ -81,7 +81,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0 ? session.TotalScore.Value / selections.Count : (decimal?)null; - dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) => + learningPersistence.PracticeSessionQuestions.AddRange(selections.Select((selection, index) => new PracticeSessionQuestion { TenantId = actor.TenantId, @@ -105,7 +105,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende GradingRulesSnapshot = BuildGradingRules(selection), SnapshotVersion = 1 })); - dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent + learningPersistence.PracticeAccessEvents.Add(new PracticeAccessEvent { TenantId = actor.TenantId, UserId = actor.UserId, @@ -118,7 +118,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende Metadata = session.AccessSnapshot }); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToItem(session); } @@ -133,7 +133,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende session.Id, cancellationToken); - var answers = await dbContext.AnswerRecords + var answers = await learningPersistence.AnswerRecords .AsNoTracking() .Where(answer => answer.TenantId == actor.TenantId && @@ -160,10 +160,10 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende 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); + await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken); var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken); var requestHash = HashSubmission(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( + var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -191,7 +191,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende { session.Status = PracticeSessionStatus.Expired; session.Version++; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); throw new LearningValidationException("practice_session_expired", "The practice session has expired."); } @@ -207,7 +207,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende session.FinishedAt = report.SubmittedAt; session.Version++; var response = ToItem(report); - dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency + learningPersistence.LearningOperationIdempotencies.Add(new LearningOperationIdempotency { TenantId = actor.TenantId, UserId = actor.UserId, @@ -220,7 +220,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende }); try { - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch (DbUpdateConcurrencyException) diff --git a/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs b/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs index 01554e3..ccc11b6 100644 --- a/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs +++ b/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs @@ -15,7 +15,7 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var items = await dbContext.FavoriteQuestions + var items = await learningPersistence.FavoriteQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -45,14 +45,14 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen cancellationToken); var favorite = command.Favorite ?? true; - var item = await dbContext.FavoriteQuestions.FindAsync( + var item = await learningPersistence.FavoriteQuestions.FindAsync( [actor.TenantId, actor.UserId, reference.Id], cancellationToken); if (favorite) { if (item is null) - dbContext.FavoriteQuestions.Add(new FavoriteQuestion + learningPersistence.FavoriteQuestions.Add(new FavoriteQuestion { TenantId = actor.TenantId, UserId = actor.UserId, @@ -65,10 +65,10 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen } else if (item is not null) { - dbContext.FavoriteQuestions.Remove(item); + learningPersistence.FavoriteQuestions.Remove(item); } - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } @@ -77,7 +77,7 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.WrongQuestions + var query = learningPersistence.WrongQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -112,7 +112,7 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen actor.UserId, command.Locator, cancellationToken); - var item = await dbContext.WrongQuestions.FindAsync( + var item = await learningPersistence.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, reference.Id], cancellationToken); @@ -120,7 +120,7 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found."); item.ResolvedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new LearningActionResult(true); } @@ -129,7 +129,7 @@ internal sealed class QuestionReviewService(LearningServiceDependencies dependen LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var items = await dbContext.WrongQuestions.AsNoTracking() + var items = await learningPersistence.WrongQuestions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && diff --git a/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs b/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs index 7dcf958..44da38b 100644 --- a/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs +++ b/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs @@ -13,14 +13,14 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.UserWordProgress + var query = learningPersistence.UserWordProgress .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - query = query.Where(item => dbContext.VocabularyWords.Any(word => + query = query.Where(item => contentAssetPersistence.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); @@ -58,7 +58,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); var now = DateTimeOffset.UtcNow; - var item = await dbContext.UserWordProgress + var item = await learningPersistence.UserWordProgress .SingleOrDefaultAsync( progress => progress.TenantId == actor.TenantId && @@ -74,7 +74,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci UserId = actor.UserId, WordId = command.WordId }; - dbContext.UserWordProgress.Add(item); + learningPersistence.UserWordProgress.Add(item); } if (TryParseWordProgressStatus(command.Status, out var status)) @@ -105,7 +105,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci ? WordDueLevel.Again : item.DueLevel; - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return ToItem(item); } @@ -115,10 +115,10 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var query = dbContext.UserWordProgress.AsNoTracking() + var query = learningPersistence.UserWordProgress.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - query = query.Where(item => dbContext.VocabularyWords.Any(word => + query = query.Where(item => contentAssetPersistence.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); @@ -170,10 +170,10 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var query = dbContext.UserWordProgress.AsNoTracking() + var query = learningPersistence.UserWordProgress.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - query = query.Where(item => dbContext.VocabularyWords.Any(word => + query = query.Where(item => contentAssetPersistence.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); @@ -185,7 +185,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci 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( + await learningPersistence.UserWordFavorites.AsNoTracking().CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken)); } @@ -195,14 +195,14 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci LearningLimitFilter filter, CancellationToken cancellationToken = default) { - var query = dbContext.UserWordFavorites + var query = learningPersistence.UserWordFavorites .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - query = query.Where(item => dbContext.VocabularyWords.Any(word => + query = query.Where(item => contentAssetPersistence.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); @@ -227,7 +227,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); var favorite = command.Favorite ?? true; - var item = await dbContext.UserWordFavorites + var item = await learningPersistence.UserWordFavorites .SingleOrDefaultAsync( favoriteWord => favoriteWord.TenantId == actor.TenantId && @@ -245,7 +245,7 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci UserId = actor.UserId, WordId = command.WordId }; - dbContext.UserWordFavorites.Add(item); + learningPersistence.UserWordFavorites.Add(item); } item.Note = command.Note ?? item.Note; @@ -253,10 +253,10 @@ internal sealed class WordLearningService(LearningServiceDependencies dependenci } else if (item is not null) { - dbContext.UserWordFavorites.Remove(item); + learningPersistence.UserWordFavorites.Remove(item); } - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } } diff --git a/Tiku.Infrastructure/Modules/AuthModule.cs b/Tiku.Infrastructure/Modules/AuthModule.cs index 1969ebb..6e96ecb 100644 --- a/Tiku.Infrastructure/Modules/AuthModule.cs +++ b/Tiku.Infrastructure/Modules/AuthModule.cs @@ -18,11 +18,16 @@ internal static class AuthModule 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(); return services; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Modules/CommerceModule.cs b/Tiku.Infrastructure/Modules/CommerceModule.cs index 5562d2e..fe3fa2d 100644 --- a/Tiku.Infrastructure/Modules/CommerceModule.cs +++ b/Tiku.Infrastructure/Modules/CommerceModule.cs @@ -16,7 +16,12 @@ 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.AddScoped(); services.AddScoped(); @@ -29,7 +34,10 @@ internal static class CommerceModule services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Modules/ContentModule.cs b/Tiku.Infrastructure/Modules/ContentModule.cs index 6789451..2bcc237 100644 --- a/Tiku.Infrastructure/Modules/ContentModule.cs +++ b/Tiku.Infrastructure/Modules/ContentModule.cs @@ -29,7 +29,11 @@ internal static class ContentModule services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -48,7 +52,12 @@ internal static class ContentModule 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(); diff --git a/Tiku.Infrastructure/Modules/PlatformModule.cs b/Tiku.Infrastructure/Modules/PlatformModule.cs index 6113ff1..f8658d1 100644 --- a/Tiku.Infrastructure/Modules/PlatformModule.cs +++ b/Tiku.Infrastructure/Modules/PlatformModule.cs @@ -4,8 +4,8 @@ using Tiku.Application.PlatformAdmin.Operations; using Tiku.Application.PlatformBilling; using Tiku.Application.Jobs; using Tiku.Infrastructure.PlatformAdmin; -using Tiku.Infrastructure.PlatformAdmin.Operations; using Tiku.Infrastructure.PlatformAdmin.TenantProvisioning; +using Tiku.Infrastructure.PlatformAdmin.Operations; using Tiku.Infrastructure.PlatformBilling; namespace Tiku.Infrastructure; @@ -17,6 +17,7 @@ internal static class PlatformModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -24,7 +25,11 @@ internal static class PlatformModule services.AddScoped(); services.AddScoped(); services.AddOptions(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs index 4b4c3fa..54cbc58 100644 --- a/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs +++ b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs @@ -7,7 +7,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Notifications; -internal sealed class InAppNotificationProvider(TikuDbContext dbContext) : INotificationProvider +internal sealed class InAppNotificationProvider(IJobsOperationsPersistence dbContext) : INotificationProvider { public async Task UpsertInAppAsync( InAppNotificationRequest request, diff --git a/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs index 0f4dd7f..b1d5f13 100644 --- a/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs +++ b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs @@ -4,12 +4,12 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Observability; internal sealed class DependencyReadinessProbe( - TikuDbContext dbContext, + IJobsOperationsPersistence jobsOperationsPersistence, IRedisSecurityStore redisSecurityStore) : IDependencyReadinessProbe { public async Task CheckAsync(CancellationToken cancellationToken = default) { - var database = await dbContext.Database.CanConnectAsync(cancellationToken); + var database = await jobsOperationsPersistence.Database.CanConnectAsync(cancellationToken); var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken); return new DependencyReadiness(database && redis, DateTimeOffset.UtcNow); } diff --git a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs index fddb689..a16fcf6 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs @@ -25,7 +25,7 @@ internal sealed class ProductConfiguration : IEntityTypeConfiguration builder.Property(entity => entity.IsActive).HasDefaultValue(true); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); builder.HasIndex(entity => new - { entity.TenantId, entity.RegionId, entity.Type, entity.IsActive, entity.SortOrder }); + { entity.TenantId, entity.RegionId, entity.Type, entity.IsActive, entity.SortOrder }); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.RegionId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) diff --git a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs index 02b983e..8ce16bc 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs @@ -300,7 +300,7 @@ internal sealed class builder.Property(entity => entity.LastError).HasMaxLength(2000); builder.HasIndex(entity => new { entity.ProcessedAt, entity.CreatedAt }); builder.HasIndex(entity => new - { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version }); + { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version }); } } diff --git a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs index b9454bd..366a830 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs @@ -93,7 +93,7 @@ internal sealed class builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => new - { entity.TenantId, entity.InvoiceId, entity.ReminderType, entity.Channel, entity.ReminderDate }).IsUnique(); + { entity.TenantId, entity.InvoiceId, entity.ReminderType, entity.Channel, entity.ReminderDate }).IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.ReminderDate }); builder.HasIndex(entity => new { entity.InvoiceId, entity.ReminderDate }); builder.ToTable(table => diff --git a/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs index 4ceb27c..1835a3a 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs @@ -41,7 +41,7 @@ internal sealed class PointActivityClaimConfiguration : IEntityTypeConfiguration builder.Property(entity => entity.Metadata).IsJson("{}"); builder.Property(entity => entity.ClaimedAt).HasDefaultValueSql("now()"); builder.HasIndex(entity => new - { entity.TenantId, entity.UserId, entity.TaskId, entity.SourceType, entity.SourceId }) + { entity.TenantId, entity.UserId, entity.TaskId, entity.SourceType, entity.SourceId }) .IsUnique() .HasFilter("source_type is not null and source_id is not null"); builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt }); diff --git a/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs index e966f4a..2f3a902 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs @@ -42,7 +42,7 @@ internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration entity.FieldValues).IsJson("{}"); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); builder.HasIndex(entity => new - { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year }); + { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year }); builder.HasIndex(entity => new { entity.TenantId, entity.Year }); builder.HasIndex(entity => new { entity.TenantId, entity.Year, entity.SchoolName, entity.MajorName, entity.Id }) .IsDescending(false, true, false, false, false); diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs index cf51d9c..f7015f1 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs @@ -353,7 +353,7 @@ internal sealed class TenantStudentFollowupConfiguration : IEntityTypeConfigurat builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => new - { entity.TenantId, entity.StudentUserId, entity.Status, entity.DueAt, entity.CreatedAt }); + { entity.TenantId, entity.StudentUserId, entity.Status, entity.DueAt, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.AssignedToUserId, entity.Status, entity.DueAt }) .HasFilter("assigned_to_user_id is not null"); builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.Status, entity.DueAt }) diff --git a/Tiku.Infrastructure/Persistence/ModulePersistence.cs b/Tiku.Infrastructure/Persistence/ModulePersistence.cs new file mode 100644 index 0000000..8fe4b97 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/ModulePersistence.cs @@ -0,0 +1,296 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; +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; + +/// +/// Technical unit-of-work surface shared by the module persistence capabilities. +/// Business services must consume one or more owned module capabilities instead of TikuDbContext. +/// +public interface IModulePersistence +{ + long SaveVersion { get; } + DatabaseFacade Database { get; } + ChangeTracker ChangeTracker { get; } + DbSet Set() where TEntity : class; + EntityEntry Entry(object entity); + EntityEntry Entry(TEntity entity) where TEntity : class; + int SaveChanges(); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} + +public interface IIdentityPersistence : IModulePersistence +{ + DbSet Users { get; } + DbSet UserIdentities { get; } + DbSet TenantMemberships { get; } + DbSet SmsVerificationCodes { get; } + DbSet SmsChannels { get; } + DbSet SmsTemplates { get; } + DbSet SmsSendLogs { get; } + DbSet AuthLoginEvents { get; } + DbSet AuthSessions { get; } + DbSet AuthChallenges { get; } + DbSet SmsSendRateLimits { get; } +} + +public interface ITenancyPersistence : IModulePersistence +{ + DbSet Tenants { get; } + DbSet TenantDomains { get; } + DbSet TenantBrandings { get; } + DbSet TenantSettings { get; } + DbSet TenantAuthPolicies { get; } + DbSet TenantFrontendConfigs { get; } + DbSet TenantExternalProviders { get; } + DbSet TenantSecrets { get; } +} + +public interface ITenantAdministrationPersistence : IModulePersistence +{ + DbSet TenantClasses { get; } + DbSet TenantClassMembers { get; } + DbSet TenantStudentNotes { get; } + DbSet TenantStudentFollowups { get; } + DbSet StudentProfiles { get; } + DbSet Badges { get; } + DbSet UserBadges { get; } + DbSet TenantContentNotifications { get; } + DbSet TenantThemeTemplates { get; } + DbSet TenantThemeConfigs { get; } + DbSet TenantBillingProfiles { get; } + DbSet TenantBillingPolicies { get; } + DbSet TenantOwnerActivationGrants { get; } +} + +public interface ICatalogPersistence : IModulePersistence +{ + DbSet Regions { get; } + DbSet RegionModules { get; } + DbSet ModuleNodes { get; } + DbSet Schools { get; } + DbSet Majors { get; } + DbSet Subjects { get; } + DbSet Categories { get; } + DbSet TaxonomyNodes { get; } + DbSet QuestionTaxonomyAssignments { get; } + DbSet ScorelineFields { get; } + DbSet ScorelineRecords { get; } +} + +public interface IQuestionBankPersistence : IModulePersistence +{ + DbSet QuestionBanks { get; } + DbSet Questions { get; } + DbSet QuestionVersions { get; } + DbSet ContentEntries { get; } + DbSet ContentNodes { get; } + DbSet QuestionCollections { get; } + DbSet QuestionCollectionItems { get; } + DbSet PracticeBlueprints { get; } + DbSet QuestionTypeGroups { get; } + DbSet SubjectShares { get; } + DbSet ContentImportJobs { get; } + DbSet ContentImportItems { get; } + DbSet ContentImportIssues { get; } + DbSet TenantQuestionBankPreferences { get; } + DbSet TenantQuestionReferences { get; } +} + +public interface IContentAssetPersistence : IModulePersistence +{ + DbSet VocabularyUnits { get; } + DbSet VocabularyWords { get; } + DbSet HandbookSubjects { get; } + DbSet HandbookChapters { get; } + DbSet HandbookEntries { get; } + DbSet ContentAssets { get; } + DbSet ContentAssetAccessEvents { get; } + DbSet ContentAssetSecurityScanEvents { get; } + DbSet Images { get; } + DbSet AppAssets { get; } + DbSet VideoExplanations { get; } + DbSet QuestionVideos { get; } + DbSet VideoPlaybackProgress { get; } +} + +public interface ILearningPersistence : IModulePersistence +{ + DbSet UserWordProgress { get; } + DbSet UserWordFavorites { get; } + DbSet AiRecommendationReports { get; } + DbSet PracticeSessions { get; } + DbSet PracticeSessionQuestions { get; } + DbSet AnswerRecords { get; } + DbSet LearningOperationIdempotencies { get; } + DbSet FavoriteQuestions { get; } + DbSet WrongQuestions { get; } + DbSet RecentPractices { get; } + DbSet ExamDates { get; } + DbSet Reports { get; } + DbSet ReportStatusEvents { get; } + DbSet UserScoreEvents { get; } + DbSet PracticeDailyUsages { get; } + DbSet PracticeAccessEvents { get; } + DbSet PracticeSessionReports { get; } + DbSet PracticeSessionReportSections { get; } + DbSet DashboardDailyStats { get; } + DbSet RevenueDailyStats { get; } +} + +public interface ICommercePersistence : IModulePersistence +{ + DbSet Products { get; } + DbSet SvipPlans { get; } + DbSet Orders { get; } + DbSet OrderItems { get; } + DbSet Payments { get; } + DbSet PaymentEvents { get; } + DbSet Entitlements { get; } + DbSet CodeBatches { get; } + DbSet ActivationCodes { get; } + DbSet Coupons { get; } + DbSet CouponRedemptions { get; } + DbSet CommerceRefundRequests { get; } + DbSet CommerceRefundEvents { get; } + DbSet CommerceReconciliationBatches { get; } + DbSet CommerceReconciliationItems { get; } + DbSet CommerceReconciliationIssues { get; } + DbSet CommerceReconciliationIssueEvents { get; } + DbSet CommerceAdjustmentVouchers { get; } + DbSet CommerceAdjustmentVoucherEvents { get; } +} + +public interface IPointsPersistence : IModulePersistence +{ + DbSet PointActivityTasks { get; } + DbSet PointActivityClaims { get; } + DbSet PointExchangeItems { get; } + DbSet PointExchangeOrders { get; } +} + +public interface IGrowthPersistence : IModulePersistence +{ + DbSet ReferralTracks { get; } + DbSet ReferralCodes { get; } + DbSet ReferralLeads { get; } + DbSet ReferralTeamEdges { get; } + DbSet ReferralQrcodes { get; } + DbSet CrmConfigs { get; } + DbSet CrmWebhookQueue { get; } + DbSet CrmWebhookLogs { get; } + DbSet TenantCommissionSettings { get; } + DbSet CommissionSettlements { get; } + DbSet CommissionSettlementItems { get; } + DbSet CommissionSettlementProofs { get; } + DbSet CommissionSettlementExportEvents { get; } +} + +public interface IJobsOperationsPersistence : IModulePersistence +{ + DbSet Banners { get; } + DbSet Faqs { get; } + DbSet Announcements { get; } + DbSet AuditLogs { get; } + DbSet BackendPermissions { get; } + DbSet BackendMenus { get; } + DbSet TenantBackendRoles { get; } + DbSet TenantBackendRolePermissions { get; } + DbSet TenantBackendRoleMenus { get; } + DbSet TenantBackendUserRoles { get; } + DbSet PlatformBackendRoles { get; } + DbSet PlatformBackendRolePermissions { get; } + DbSet PlatformBackendRoleMenus { get; } + DbSet PlatformBackendUserRoles { get; } + DbSet AuthorizationScopeVersions { get; } + DbSet AuthorizationCacheInvalidations { get; } + DbSet BackgroundJobs { get; } + DbSet TenantLifecycleOperations { get; } + DbSet WorkerHeartbeats { get; } + DbSet UserNotifications { get; } +} + +public interface IPlatformControlPlanePersistence : IModulePersistence +{ + DbSet PlatformOperationIdempotencies { get; } + DbSet PlatformBillingInvoiceReminders { get; } + DbSet PlatformAuditAlertRules { get; } + DbSet PlatformAuditAlerts { get; } + DbSet PlatformBillingDunningNotificationChannels { get; } + DbSet PlatformBillingDunningNotificationEvents { get; } + DbSet PlatformPaymentApps { get; } + DbSet PlatformPaymentChannels { get; } + DbSet PlatformApprovalPolicies { get; } + DbSet PlatformApprovalRequests { get; } + DbSet PlatformConfigurationDefinitions { get; } + DbSet PlatformConfigurationVersions { get; } + DbSet PlatformNotificationTemplates { get; } + DbSet PlatformNotificationDeliveries { get; } + DbSet SaasFeatures { get; } + DbSet PermissionModules { get; } + DbSet SaasOfferings { get; } + DbSet SaasOfferingVersions { get; } + DbSet SaasOfferingVersionFeatures { get; } + DbSet SaasFeatureLimitDefinitions { get; } + DbSet SaasOfferingVersionLimits { get; } + DbSet TenantSaasSubscriptions { get; } + DbSet TenantSaasSubscriptionItems { get; } + DbSet TenantFeatureOverrides { get; } + DbSet TenantFeatureUsages { get; } + DbSet PlatformBillingQuotes { get; } + DbSet PlatformBillingQuoteItems { get; } + DbSet PlatformBillingOrders { get; } + DbSet PlatformBillingOrderItems { get; } + DbSet PlatformBillingPayments { get; } + DbSet PlatformBillingPaymentEvents { get; } + DbSet PlatformBillingRefunds { get; } + DbSet PlatformBillingInvoices { get; } + DbSet PocketBaseImportRuns { get; } + DbSet PocketBaseRawRecords { get; } + DbSet PocketBaseImportIssues { get; } +} + +// Explicit orchestration capabilities. These are intentionally limited to control-plane and bootstrap workflows; +// feature modules must consume the owned interfaces above directly. +public interface IPlatformAdministrationPersistence : IIdentityPersistence, ITenancyPersistence, + ITenantAdministrationPersistence, IJobsOperationsPersistence, IPlatformControlPlanePersistence +{ +} + +public interface IPlatformQuestionBankAdministrationPersistence : IQuestionBankPersistence, + IContentAssetPersistence, ITenancyPersistence, IJobsOperationsPersistence +{ +} + +public interface IPlatformTenantCapabilitiesPersistence : IIdentityPersistence, ITenancyPersistence, + ICommercePersistence, IGrowthPersistence, IJobsOperationsPersistence, IPlatformControlPlanePersistence +{ +} + +public interface IPlatformBillingPersistence : ITenancyPersistence, ITenantAdministrationPersistence, + IJobsOperationsPersistence, IPlatformControlPlanePersistence +{ +} + +public interface IOwnerActivationPersistence : IIdentityPersistence, ITenancyPersistence, + ITenantAdministrationPersistence, IJobsOperationsPersistence +{ +} + +public interface IBootstrapPersistence : IIdentityPersistence, ITenancyPersistence, + ITenantAdministrationPersistence, ICommercePersistence, IGrowthPersistence, IJobsOperationsPersistence, + IPlatformControlPlanePersistence +{ +} diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index 2d728bf..df8ada6 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -11,7 +11,13 @@ namespace Tiku.Infrastructure.Persistence; public sealed partial class TikuDbContext( DbContextOptions options, - ITenantContext tenantContext) : IdentityUserContext(options), IDataProtectionKeyContext + ITenantContext tenantContext) : IdentityUserContext(options), IDataProtectionKeyContext, + IIdentityPersistence, ITenancyPersistence, ITenantAdministrationPersistence, ICatalogPersistence, + IQuestionBankPersistence, IContentAssetPersistence, ILearningPersistence, ICommercePersistence, + IPointsPersistence, IGrowthPersistence, IJobsOperationsPersistence, IPlatformControlPlanePersistence, + IPlatformAdministrationPersistence, IPlatformQuestionBankAdministrationPersistence, + IPlatformTenantCapabilitiesPersistence, IPlatformBillingPersistence, IOwnerActivationPersistence, + IBootstrapPersistence { public TikuDbContext(DbContextOptions options) : this(options, CreateToolingTenantContext()) @@ -145,4 +151,4 @@ public sealed partial class TikuDbContext( if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.UpdatedAt = now; } } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Crm/PlatformCrmAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/Crm/PlatformCrmAdminService.cs new file mode 100644 index 0000000..446cb2a --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Crm/PlatformCrmAdminService.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformCrmAdminService +{ + public Task> GetConfigsAsync( + PlatformCapabilityActor actor, + PlatformCapabilityQuery query, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm configs list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = + from config in db.CrmConfigs.AsNoTracking() + join tenant in db.Tenants.AsNoTracking() on config.TenantId equals tenant.Id + select new { config, tenant.Name }; + if (query.TenantId.HasValue) values = values.Where(value => value.config.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + var enabled = IsEnabledStatus(query.Status); + values = values.Where(value => value.config.Enabled == enabled); + } + + var items = await values + .OrderByDescending(value => value.config.UpdatedAt) + .Take(Limit(query.Limit)) + .Select(value => ToCrmConfigItem(value.config, value.Name)) + .ToArrayAsync(token); + return new PlatformTenantCapabilityList(items); + }, cancellationToken); + } + + public Task UpsertConfigAsync( + PlatformCapabilityActor actor, + UpsertPlatformCrmConfigCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm config upsert", async (services, token) => + { + var db = services.GetRequiredService(); + var tenantName = await db.Tenants + .Where(tenant => tenant.Id == command.TenantId) + .Select(tenant => tenant.Name) + .SingleOrDefaultAsync(token) + ?? throw Error("Tenant was not found.", "tenant_not_found"); + var item = command.Id.HasValue + ? await db.CrmConfigs.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + : await db.CrmConfigs.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, token); + if (item is null) + { + item = new CrmConfig { TenantId = command.TenantId }; + db.CrmConfigs.Add(item); + } + + item.Enabled = command.Enabled; + item.Url = Normalize(command.Url); + item.SecretRef = Normalize(command.SecretRef); + item.FormName = Normalize(command.FormName); + item.ExamType = Normalize(command.ExamType); + item.TimeoutSeconds = command.TimeoutSeconds; + item.DelaySeconds = command.DelaySeconds; + item.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None); + item.AssignmentPool = command.AssignmentPool ?? JsonDefaults.Array(); + item.AssignmentConfig = command.AssignmentConfig ?? JsonDefaults.Object(); + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, + new { item.Enabled, item.Url, item.AssignmentMode }); + await db.SaveChangesAsync(token); + return ToCrmConfigItem(item, tenantName); + }, cancellationToken); + } + + public Task> GetLeadsAsync( + PlatformCapabilityActor actor, + PlatformCapabilityQuery query, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm leads list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.CrmWebhookQueue.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending)); + var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); + return new PlatformTenantCapabilityList(items); + }, cancellationToken); + } + + public Task RetryLeadAsync( + PlatformCapabilityActor actor, + PlatformCrmLeadRetryCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm lead retry", async (services, token) => + { + var db = services.GetRequiredService(); + var item = await db.CrmWebhookQueue.SingleOrDefaultAsync(value => value.Id == command.QueueId, token) + ?? throw Error("CRM queue item was not found.", "crm_queue_not_found"); + if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded + or CrmWebhookQueueStatus.Retrying)) + throw Error("Only failed CRM queue items can be retried.", "crm_queue_retry_invalid"); + + item.Status = CrmWebhookQueueStatus.Retrying; + item.NextAttemptAt = DateTimeOffset.UtcNow; + item.LastError = null; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, + new { command.Note }); + await db.SaveChangesAsync(token); + return item; + }, cancellationToken); + } + + public Task> GetLogsAsync( + PlatformCapabilityActor actor, + Guid? tenantId, + Guid? queueId, + int limit, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm logs list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.CrmWebhookLogs.AsNoTracking(); + if (tenantId.HasValue) values = values.Where(value => value.TenantId == tenantId); + if (queueId.HasValue) + { + var queueRecordId = await db.CrmWebhookQueue.AsNoTracking() + .Where(value => value.Id == queueId.Value) + .Select(value => value.RecordId) + .SingleOrDefaultAsync(token); + values = values.Where(value => value.RecordId == queueRecordId); + } + + var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(limit)).ToArrayAsync(token); + return new PlatformTenantCapabilityList(items); + }, cancellationToken); + } + + private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) + { + return new PlatformCrmConfigItem(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, + config.SecretRef, + config.FormName, config.ExamType, + config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, + config.AssignmentConfig, config.UpdatedAt); + } + + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static bool IsEnabledStatus(string value) + { + return value.Equals("active", StringComparison.OrdinalIgnoreCase) || + value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常"; + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(NormalizeEnum(value), true, out var parsed) ? parsed : fallback; + } + + private static string? NormalizeEnum(string? value) + { + return value?.Trim().Replace("-", "_", StringComparison.Ordinal); + } + + private static void AddAudit(IPlatformTenantCapabilitiesPersistence db, PlatformCapabilityActor actor, Guid tenantId, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + TenantId = tenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + } +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdministrationFoundation.Helpers.cs b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdministrationFoundation.Helpers.cs index 9f777b0..b52e335 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdministrationFoundation.Helpers.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdministrationFoundation.Helpers.cs @@ -39,7 +39,7 @@ internal abstract partial class PlatformAdministrationServiceBase } protected async Task ProvisioningReplayResultAsync( - TikuDbContext dbContext, + IPlatformAdministrationPersistence dbContext, Guid tenantId, CancellationToken cancellationToken) { @@ -66,7 +66,7 @@ internal abstract partial class PlatformAdministrationServiceBase } protected static async Task OwnerActivationReplayResultAsync( - TikuDbContext dbContext, + IPlatformAdministrationPersistence dbContext, Guid activationId, CancellationToken cancellationToken) { @@ -76,7 +76,7 @@ internal abstract partial class PlatformAdministrationServiceBase } protected async Task OwnerActivationStatusAsync( - TikuDbContext dbContext, + IPlatformAdministrationPersistence dbContext, Tenant tenant, CancellationToken cancellationToken) { @@ -103,7 +103,7 @@ internal abstract partial class PlatformAdministrationServiceBase protected Task ExecuteSystemAsync( string reason, - Func> operation, + Func> operation, CancellationToken cancellationToken) { return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); @@ -111,7 +111,7 @@ internal abstract partial class PlatformAdministrationServiceBase protected Task ExecuteSystemAsync( string reason, - Func> operation, + Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( @@ -122,11 +122,11 @@ internal abstract partial class PlatformAdministrationServiceBase reason, Guid.NewGuid().ToString("N"), true), - async (provider, _) => await operation(provider, provider.GetRequiredService()), + async (provider, _) => await operation(provider, provider.GetRequiredService()), cancellationToken); } - protected static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, + protected static async Task RequireTenantAsync(IPlatformAdministrationPersistence dbContext, Guid tenantId, CancellationToken cancellationToken) { var exists = @@ -135,7 +135,7 @@ internal abstract partial class PlatformAdministrationServiceBase if (!exists) throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); } - protected static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, + protected static void AddAudit(IPlatformAdministrationPersistence dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) { dbContext.AuditLogs.Add(new AuditLog @@ -437,7 +437,7 @@ internal abstract partial class PlatformAdministrationServiceBase } protected static async Task EnsureTenantOwnerRoleAsync( - TikuDbContext dbContext, + IPlatformAdministrationPersistence dbContext, Guid tenantId, Guid ownerUserId, CancellationToken cancellationToken) diff --git a/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs index acbe1e3..79fe422 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs @@ -12,7 +12,8 @@ using Tiku.Infrastructure.Storage; namespace Tiku.Infrastructure.PlatformAdmin.Operations; internal sealed class PlatformOperationsQueryService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IRedisSecurityStore redisSecurityStore, IAssetSecurityScanner assetSecurityScanner, IObjectStorageService objectStorageService, @@ -20,7 +21,7 @@ internal sealed class PlatformOperationsQueryService( { public async Task GetHealthAsync(CancellationToken cancellationToken = default) { - var database = await dbContext.Database.CanConnectAsync(cancellationToken); + var database = await platformControlPlanePersistence.Database.CanConnectAsync(cancellationToken); var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken); var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken); var storageProvider = objectStorageService.ConfiguredDefaultProvider(); @@ -30,7 +31,7 @@ internal sealed class PlatformOperationsQueryService( ObjectStorageProviders.LocalDev => true, _ => false }; - var heartbeat = await dbContext.WorkerHeartbeats.AsNoTracking() + var heartbeat = await jobsOperationsPersistence.WorkerHeartbeats.AsNoTracking() .MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken); var workerReady = heartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2); return new PlatformDependencyHealth( @@ -50,7 +51,7 @@ internal sealed class PlatformOperationsQueryService( CancellationToken cancellationToken = default) { var staleBefore = DateTimeOffset.UtcNow.AddMinutes(-2); - return await dbContext.WorkerHeartbeats.AsNoTracking() + return await jobsOperationsPersistence.WorkerHeartbeats.AsNoTracking() .OrderBy(item => item.WorkerId) .ThenBy(item => item.Processor) .Select(item => new PlatformWorkerState( @@ -70,14 +71,14 @@ internal sealed class PlatformOperationsQueryService( public async Task GetJobMetricsAsync(CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var counts = await dbContext.BackgroundJobs.AsNoTracking() + var counts = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking() .GroupBy(item => item.Status) .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) .ToArrayAsync(cancellationToken); - var oldest = await dbContext.BackgroundJobs.AsNoTracking() + var oldest = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking() .Where(item => item.Status == BackgroundJobStatus.Pending) .MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken); - var expired = await dbContext.BackgroundJobs.AsNoTracking() + var expired = await jobsOperationsPersistence.BackgroundJobs.AsNoTracking() .CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken); return new PlatformJobMetrics( @@ -92,16 +93,16 @@ internal sealed class PlatformOperationsQueryService( CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var approvals = await dbContext.PlatformApprovalRequests.AsNoTracking() + var approvals = await platformControlPlanePersistence.PlatformApprovalRequests.AsNoTracking() .GroupBy(item => item.Status) .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) .ToArrayAsync(cancellationToken); - var expired = await dbContext.PlatformApprovalRequests.AsNoTracking() + var expired = await platformControlPlanePersistence.PlatformApprovalRequests.AsNoTracking() .CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken); - var drafts = await dbContext.PlatformConfigurationVersions.AsNoTracking() + var drafts = await platformControlPlanePersistence.PlatformConfigurationVersions.AsNoTracking() .CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken); - var notifications = await dbContext.PlatformNotificationDeliveries.AsNoTracking() + var notifications = await platformControlPlanePersistence.PlatformNotificationDeliveries.AsNoTracking() .GroupBy(item => item.Status) .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) .ToArrayAsync(cancellationToken); diff --git a/Tiku.Infrastructure/PlatformAdmin/PaymentSettings/PlatformPaymentSettingsService.cs b/Tiku.Infrastructure/PlatformAdmin/PaymentSettings/PlatformPaymentSettingsService.cs new file mode 100644 index 0000000..1602550 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/PaymentSettings/PlatformPaymentSettingsService.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) + : IPlatformPaymentSettingsService +{ + public Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, + int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment apps list", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformPaymentApps.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(status)) + values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active)); + return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token); + }, cancellationToken); + } + + public Task UpsertAppAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment app upsert", async (services, token) => + { + var db = services.GetRequiredService(); + var code = NormalizeCode(command.AppCode); + var item = command.Id.HasValue + ? await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) + : await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.AppCode == code, token); + if (item is null) + { + item = new PlatformPaymentApp { AppCode = code }; + db.PlatformPaymentApps.Add(item); + } + + item.AppName = command.AppName.Trim(); + item.Status = command.Status; + item.SettlementMode = command.SettlementMode.Trim(); + item.Description = Normalize(command.Description); + item.Metadata = command.Metadata; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, + new { item.AppCode, item.Status }); + await db.SaveChangesAsync(token); + return item; + }, cancellationToken); + } + + public Task> GetChannelsAsync(PlatformCapabilityActor actor, + Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment channels list", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformPaymentChannels.AsNoTracking(); + if (appId.HasValue) values = values.Where(value => value.AppId == appId); + if (!string.IsNullOrWhiteSpace(status)) + values = values.Where(value => + value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active)); + return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)) + .ToArrayAsync(token); + }, cancellationToken); + } + + public Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment channel upsert", async (services, token) => + { + var db = services.GetRequiredService(); + if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) + throw Error("Platform payment app was not found.", "platform_payment_app_not_found"); + var provider = NormalizeCode(command.Provider); + var item = command.Id.HasValue + ? await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) + : await db.PlatformPaymentChannels.SingleOrDefaultAsync( + value => value.AppId == command.AppId && value.Provider == provider, token); + if (item is null) + { + item = new PlatformPaymentChannel { AppId = command.AppId, Provider = provider }; + db.PlatformPaymentChannels.Add(item); + } + + item.Mode = command.Mode.Trim(); + item.Status = command.Status; + item.DisplayName = command.DisplayName.Trim(); + item.SecretRef = Normalize(command.SecretRef); + item.CallbackPath = Normalize(command.CallbackPath); + item.Priority = command.Priority ?? item.Priority; + item.ConfigPublic = command.ConfigPublic; + item.Metadata = command.Metadata; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, + new { item.Provider, item.Status, item.SecretRef }); + await db.SaveChangesAsync(token); + return item; + }, cancellationToken); + } + + public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment channel disable", async (services, token) => + { + var db = services.GetRequiredService(); + var item = await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) + ?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found"); + item.Status = PlatformPaymentChannelStatus.Disabled; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, + new { item.Provider }); + await db.SaveChangesAsync(token); + return item; + }, cancellationToken); + } + + public Task> GetEventsAsync(PlatformCapabilityActor actor, + string? status, int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment events list", + async (services, token) => + await services.GetRequiredService().PlatformBillingPaymentEvents.AsNoTracking() + .OrderByDescending(value => value.CreatedAt) + .Take(Limit(limit)) + .ToArrayAsync(token), cancellationToken); + } + + public Task GetRebateSummaryAsync(PlatformCapabilityActor actor, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform rebate summary", async (services, token) => + { + var db = services.GetRequiredService(); + var settlements = db.CommissionSettlements.AsNoTracking(); + var gross = await settlements.SumAsync(value => (int?)value.GrossAmountCents, token) ?? 0; + var commission = await settlements.SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; + var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid) + .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; + var pending = await settlements + .Where(value => + value.Status == CommissionSettlementStatus.Approved || + value.Status == CommissionSettlementStatus.PendingReview) + .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; + var exceptions = await settlements.CountAsync( + value => value.Status == CommissionSettlementStatus.Rejected || + value.Status == CommissionSettlementStatus.Cancelled, token); + return new PlatformRebateSummary(gross, commission, pending, paid, exceptions); + }, cancellationToken); + } + + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } + + private static void AddPlatformAudit(IPlatformTenantCapabilitiesPersistence db, PlatformCapabilityActor actor, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + } +} diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs index fda7630..acb38f1 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs @@ -17,7 +17,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class PlatformApprovalService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IServiceScopeFactory scopeFactory, ITenantExecutionScope tenantExecutionScope, IOperationAuditService auditService, @@ -39,7 +40,7 @@ internal sealed class PlatformApprovalService( { Require(actor, BackendPermissions.PlatformApprovalView); await ExpirePendingAsync(cancellationToken); - var query = dbContext.PlatformApprovalRequests.AsNoTracking(); + var query = platformControlPlanePersistence.PlatformApprovalRequests.AsNoTracking(); if (status.HasValue) query = query.Where(item => item.Status == status.Value); return await query.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(limit, 1, 500)) @@ -58,7 +59,7 @@ internal sealed class PlatformApprovalService( { item.Status = PlatformApprovalRequestStatus.Expired; item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); } return ToItem(item); @@ -69,7 +70,7 @@ internal sealed class PlatformApprovalService( CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalView); - return await dbContext.PlatformApprovalPolicies.AsNoTracking() + return await platformControlPlanePersistence.PlatformApprovalPolicies.AsNoTracking() .OrderBy(item => item.Code) .Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); @@ -82,7 +83,7 @@ internal sealed class PlatformApprovalService( { Require(actor, BackendPermissions.PlatformApprovalPolicyManage); var policy = - await dbContext.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code, + await platformControlPlanePersistence.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code, cancellationToken) ?? throw Error("Approval policy was not found.", "approval_policy_not_found"); if (command.ExpiresAfterHours is < 1 or > 720 || command.AmountThresholdCents is <= 0) @@ -93,7 +94,7 @@ internal sealed class PlatformApprovalService( policy.ExpiresAfterHours = command.ExpiresAfterHours; policy.Conditions = command.Conditions.Clone(); policy.Version++; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.approval_policy.updated", "platform_approval_policies", policy.Id, new { policy.Code, policy.Version }, cancellationToken); return ToItem(policy); @@ -125,7 +126,7 @@ internal sealed class PlatformApprovalService( item.DecisionReason = RequiredReason(reason); item.DecidedAt = DateTimeOffset.UtcNow; item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.approval.cancelled", "platform_approval_requests", item.Id, new { item.RequestNo }, cancellationToken); return ToItem(item); @@ -156,7 +157,7 @@ internal sealed class PlatformApprovalService( "Resolve payment amount for platform approval", command.PaymentId.ToString("N"), true), - async (services, token) => await services.GetRequiredService() + async (services, token) => await services.GetRequiredService() .PlatformBillingPayments.AsNoTracking() .Where(item => item.Id == command.PaymentId) .Select(item => (int?)item.AmountCents) @@ -204,7 +205,7 @@ internal sealed class PlatformApprovalService( string idempotencyKey, CancellationToken cancellationToken = default) { - var isSuperAdmin = await dbContext.PlatformBackendRoles.AsNoTracking() + var isSuperAdmin = await jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking() .AnyAsync(role => role.Id == command.RoleId && role.Code == "platform_super_admin", cancellationToken); if (!isSuperAdmin) return await ExecuteImmediateAsync(() => @@ -218,7 +219,7 @@ internal sealed class PlatformApprovalService( public async Task ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default) { - var requestIds = await dbContext.PlatformApprovalRequests.AsNoTracking() + var requestIds = await platformControlPlanePersistence.PlatformApprovalRequests.AsNoTracking() .Where(item => item.Status == PlatformApprovalRequestStatus.Approved) .OrderBy(item => item.DecidedAt) .Select(item => item.Id) @@ -227,7 +228,7 @@ internal sealed class PlatformApprovalService( var processed = 0; foreach (var requestId in requestIds) { - var claimed = await dbContext.PlatformApprovalRequests + var claimed = await platformControlPlanePersistence.PlatformApprovalRequests .Where(item => item.Id == requestId && item.Status == PlatformApprovalRequestStatus.Approved) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, PlatformApprovalRequestStatus.Executing) @@ -235,7 +236,7 @@ internal sealed class PlatformApprovalService( .SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken); if (claimed == 0) continue; - dbContext.ChangeTracker.Clear(); + platformControlPlanePersistence.ChangeTracker.Clear(); var item = await RequiredRequestAsync(requestId, cancellationToken); try { @@ -251,7 +252,7 @@ internal sealed class PlatformApprovalService( } item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(item.DecidedBy ?? item.RequestedBy, item.Status == PlatformApprovalRequestStatus.Succeeded ? "platform.approval.executed" @@ -280,7 +281,7 @@ internal sealed class PlatformApprovalService( idempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey) ? throw Error("Idempotency-Key is required.", "idempotency_key_required") : idempotencyKey.Trim(); - var policy = await dbContext.PlatformApprovalPolicies.AsNoTracking() + var policy = await platformControlPlanePersistence.PlatformApprovalPolicies.AsNoTracking() .SingleOrDefaultAsync(item => item.Code == policyCode, cancellationToken) ?? throw Error("Approval policy is not configured.", "approval_policy_not_configured"); var requiresApproval = PlatformApprovalRules.RequiresApproval( @@ -290,7 +291,7 @@ internal sealed class PlatformApprovalService( var snapshot = RedactedSnapshot(command); var requestHash = Hash(snapshot.GetRawText()); - var existing = await dbContext.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item => + var existing = await platformControlPlanePersistence.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item => item.RequestedBy == actorUserId && item.CommandType == commandType && item.IdempotencyKey == idempotencyKey, cancellationToken); @@ -320,8 +321,8 @@ internal sealed class PlatformApprovalService( RequestReason = reason?.Trim(), ExpiresAt = DateTimeOffset.UtcNow.AddHours(policy.ExpiresAfterHours) }; - dbContext.PlatformApprovalRequests.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + platformControlPlanePersistence.PlatformApprovalRequests.Add(item); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actorUserId, "platform.approval.requested", "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode, item.CommandType, item.TargetType, item.TargetId, item.AmountCents }, cancellationToken); @@ -343,7 +344,7 @@ internal sealed class PlatformApprovalService( { item.Status = PlatformApprovalRequestStatus.Expired; item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); throw Error("Approval request has expired.", "approval_request_expired"); } @@ -360,7 +361,7 @@ internal sealed class PlatformApprovalService( item.DecisionReason = RequiredReason(reason); item.Status = approve ? PlatformApprovalRequestStatus.Approved : PlatformApprovalRequestStatus.Rejected; item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, approve ? "platform.approval.approved" : "platform.approval.rejected", "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode }, cancellationToken); return ToItem(item); @@ -415,7 +416,7 @@ internal sealed class PlatformApprovalService( private async Task RequiredRequestAsync(Guid requestId, CancellationToken cancellationToken) { - return await dbContext.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId, + return await platformControlPlanePersistence.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId, cancellationToken) ?? throw Error("Approval request was not found.", "approval_request_not_found"); } @@ -423,7 +424,7 @@ internal sealed class PlatformApprovalService( private async Task ExpirePendingAsync(CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; - await dbContext.PlatformApprovalRequests + await platformControlPlanePersistence.PlatformApprovalRequests .Where(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, PlatformApprovalRequestStatus.Expired) diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs index c2bd8bb..1b7e000 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs @@ -12,14 +12,15 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed partial class PlatformGovernanceService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IOperationAuditService auditService) : IPlatformGovernanceService { public async Task> GetConfigurationDefinitionsAsync( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category) + return await platformControlPlanePersistence.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category) .ThenBy(item => item.Code) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); } @@ -29,12 +30,12 @@ internal sealed partial class PlatformGovernanceService( CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var definitionId = await dbContext.PlatformConfigurationDefinitions.AsNoTracking() + var definitionId = await platformControlPlanePersistence.PlatformConfigurationDefinitions.AsNoTracking() .Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id) .SingleOrDefaultAsync(cancellationToken) ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); - var query = dbContext.PlatformConfigurationVersions.AsNoTracking() + var query = platformControlPlanePersistence.PlatformConfigurationVersions.AsNoTracking() .Where(item => item.DefinitionId == definitionId); if (!string.IsNullOrWhiteSpace(environment)) query = query.Where(item => item.Environment == NormalizeEnvironment(environment)); @@ -48,7 +49,7 @@ internal sealed partial class PlatformGovernanceService( { Require(actor, BackendPermissions.PlatformConfigurationManage); var definition = - await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync( + await platformControlPlanePersistence.PlatformConfigurationDefinitions.SingleOrDefaultAsync( item => item.Code == command.DefinitionCode, cancellationToken) ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); if (!definition.AllowRuntimeManagement) @@ -56,7 +57,7 @@ internal sealed partial class PlatformGovernanceService( "platform_configuration_runtime_forbidden"); var environment = NormalizeEnvironment(command.Environment); ValidateValue(definition, command.Value, command.SecretRef); - var nextVersion = (await dbContext.PlatformConfigurationVersions + var nextVersion = (await platformControlPlanePersistence.PlatformConfigurationVersions .Where(item => item.DefinitionId == definition.Id && item.Environment == environment) .MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1; var item = new PlatformConfigurationVersion @@ -69,8 +70,8 @@ internal sealed partial class PlatformGovernanceService( CreatedBy = actor.UserId, Reason = Required(command.Reason, "reason") }; - dbContext.PlatformConfigurationVersions.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); + platformControlPlanePersistence.PlatformConfigurationVersions.Add(item); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.configuration.draft_saved", "platform_configuration_versions", item.Id, new { definition.Code, item.Environment, item.Version }, cancellationToken); return ToItem(item); @@ -80,12 +81,12 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, + var item = await platformControlPlanePersistence.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); if (item.Status != PlatformConfigurationVersionStatus.Draft) throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft"); - var current = await dbContext.PlatformConfigurationVersions.Where(value => + var current = await platformControlPlanePersistence.PlatformConfigurationVersions.Where(value => value.DefinitionId == item.DefinitionId && value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published) .ToArrayAsync(cancellationToken); @@ -93,7 +94,7 @@ internal sealed partial class PlatformGovernanceService( item.Status = PlatformConfigurationVersionStatus.Published; item.PublishedBy = actor.UserId; item.PublishedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.configuration.published", "platform_configuration_versions", item.Id, new { item.DefinitionId, item.Environment, item.Version }, cancellationToken); return ToItem(item); @@ -103,10 +104,10 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var source = await dbContext.PlatformConfigurationVersions.AsNoTracking() + var source = await platformControlPlanePersistence.PlatformConfigurationVersions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken) ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); - var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item => + var nextVersion = (await platformControlPlanePersistence.PlatformConfigurationVersions.Where(item => item.DefinitionId == source.DefinitionId && item.Environment == source.Environment) .MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1; var rollback = new PlatformConfigurationVersion @@ -120,8 +121,8 @@ internal sealed partial class PlatformGovernanceService( RolledBackFromVersionId = source.Id, Reason = Required(reason, "reason") }; - dbContext.PlatformConfigurationVersions.Add(rollback); - await dbContext.SaveChangesAsync(cancellationToken); + platformControlPlanePersistence.PlatformConfigurationVersions.Add(rollback); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return await PublishConfigurationAsync(actor, rollback.Id, cancellationToken); } @@ -130,7 +131,7 @@ internal sealed partial class PlatformGovernanceService( CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - var values = dbContext.PlatformNotificationDeliveries.AsNoTracking(); + var values = platformControlPlanePersistence.PlatformNotificationDeliveries.AsNoTracking(); if (status.HasValue) values = values.Where(item => item.Status == status.Value); if (!string.IsNullOrWhiteSpace(query.Search)) values = values.Where(item => @@ -146,7 +147,7 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - return await dbContext.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code) + return await platformControlPlanePersistence.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); } @@ -157,19 +158,19 @@ internal sealed partial class PlatformGovernanceService( Require(actor, BackendPermissions.PlatformNotificationManage); var code = Required(command.Code, "code").ToLowerInvariant(); var item = command.Id.HasValue - ? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, + ? await platformControlPlanePersistence.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) - : await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, + : await platformControlPlanePersistence.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, cancellationToken); item ??= new PlatformNotificationTemplate { Code = code }; - if (dbContext.Entry(item).State == EntityState.Detached) dbContext.PlatformNotificationTemplates.Add(item); + if (platformControlPlanePersistence.Entry(item).State == EntityState.Detached) platformControlPlanePersistence.PlatformNotificationTemplates.Add(item); item.Name = Required(command.Name, "name"); item.Channel = command.Channel; item.SubjectTemplate = Required(command.SubjectTemplate, "subjectTemplate"); item.BodyTemplate = Required(command.BodyTemplate, "bodyTemplate"); item.Enabled = command.Enabled; item.Variables = command.Variables.Clone(); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates", item.Id, new { item.Code, item.Channel }, cancellationToken); return ToItem(item); @@ -180,7 +181,7 @@ internal sealed partial class PlatformGovernanceService( CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - var template = await dbContext.PlatformNotificationTemplates.AsNoTracking() + var template = await platformControlPlanePersistence.PlatformNotificationTemplates.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken) ?? throw Error("Notification template was not found.", "platform_notification_template_not_found"); @@ -188,16 +189,16 @@ internal sealed partial class PlatformGovernanceService( throw Error("Notification template is disabled.", "platform_notification_template_disabled"); var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal) .ToArray(); - var recipients = await (from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id - where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active - select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken); + var recipients = await (from binding in jobsOperationsPersistence.PlatformBackendUserRoles.AsNoTracking() + join role in jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id + where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active + select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken); var subject = Render(template.SubjectTemplate, command.Variables); var body = Render(template.BodyTemplate, command.Variables); var deliveries = new List(); foreach (var recipient in recipients) { - var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => + var existing = await platformControlPlanePersistence.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => item.TemplateId == template.Id && item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey, cancellationToken); @@ -217,11 +218,11 @@ internal sealed partial class PlatformGovernanceService( : PlatformNotificationDeliveryStatus.Pending, SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null }; - dbContext.PlatformNotificationDeliveries.Add(delivery); + platformControlPlanePersistence.PlatformNotificationDeliveries.Add(delivery); deliveries.Add(delivery); } - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id, new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken); return deliveries.Select(ToItem).ToArray(); @@ -231,7 +232,7 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, + var item = await platformControlPlanePersistence.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, cancellationToken) ?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found"); if (item.Status is not (PlatformNotificationDeliveryStatus.Failed @@ -241,7 +242,7 @@ internal sealed partial class PlatformGovernanceService( item.Status = PlatformNotificationDeliveryStatus.Pending; item.Attempts++; item.LastError = null; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries", item.Id, new { item.Channel, item.Attempts }, cancellationToken); return ToItem(item); diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs deleted file mode 100644 index ac0e151..0000000 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs +++ /dev/null @@ -1,1033 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Tiku.Application.Assets; -using Tiku.Application.Learning; -using Tiku.Application.PlatformAdmin; -using Tiku.Application.Security; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.PlatformAdmin; - -internal sealed class PlatformQuestionBankService( - ICurrentAccessContext currentAccessContext, - ITenantExecutionScope tenantExecutionScope) : IPlatformQuestionBankService -{ - private const int MaxPageSize = 100; - - private static readonly HashSet SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase) - { - "choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming" - }; - - public Task> GetBanksAsync( - PlatformAdminActor actor, - PlatformQuestionBankFilter filter, - CancellationToken cancellationToken = default) - { - return ExecuteAsync>(actor, "查询平台公共题库", - async (_, dbContext, tenantId, token) => - { - var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId); - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword)); - } - - if (!string.IsNullOrWhiteSpace(filter.Status) && - !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) - { - var status = ParseBankStatus(filter.Status); - query = query.Where(item => item.Status == status); - } - - var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token); - var entryIds = banks.Where(item => item.ContentEntryId.HasValue) - .Select(item => item.ContentEntryId!.Value).ToArray(); - var nodeCounts = await dbContext.ContentNodes.AsNoTracking() - .Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive) - .GroupBy(item => item.EntryId) - .Select(group => new { EntryId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.EntryId, item => item.Count, token); - var questionCounts = await dbContext.Questions.AsNoTracking() - .Where(item => - item.TenantId == tenantId && item.QuestionBankId.HasValue && - item.Status != QuestionStatus.Archived) - .GroupBy(item => item.QuestionBankId!.Value) - .Select(group => new { BankId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.BankId, item => item.Count, token); - return banks.Select(item => ToBankItem( - item, - item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) - ? nodes - : 0, - questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray(); - }, cancellationToken); - } - - public Task UpsertBankAsync( - PlatformAdminActor actor, - UpsertPlatformQuestionBankCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) => - { - if (string.IsNullOrWhiteSpace(command.Name)) throw Error("题库名称不能为空。", "question_bank_name_required"); - - var bank = command.Id.HasValue - ? await dbContext.QuestionBanks.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == command.Id.Value, token) - : null; - if (command.Id.HasValue && bank is null) throw Error("公共题库不存在。", "question_bank_not_found"); - - ContentEntry? trackedEntry = null; - if (bank is null) - { - var entry = new ContentEntry - { - TenantId = tenantId, - EntryKey = $"public-question-bank-{Guid.NewGuid():N}", - Name = command.Name.Trim(), - EntryType = ContentEntryType.QuestionPractice, - Visibility = ContentVisibility.Public, - IsActive = true, - CreatedBy = actor.UserId, - UpdatedBy = actor.UserId - }; - trackedEntry = entry; - bank = new QuestionBank - { - TenantId = tenantId, - ContentEntryId = entry.Id, - Status = QuestionBankStatus.Active - }; - dbContext.ContentEntries.Add(entry); - dbContext.QuestionBanks.Add(bank); - } - else if (!bank.ContentEntryId.HasValue) - { - var entry = new ContentEntry - { - TenantId = tenantId, - EntryKey = $"public-question-bank-{bank.Id:N}", - Name = command.Name.Trim(), - EntryType = ContentEntryType.QuestionPractice, - Visibility = ContentVisibility.Public, - IsActive = true, - CreatedBy = actor.UserId, - UpdatedBy = actor.UserId - }; - trackedEntry = entry; - dbContext.ContentEntries.Add(entry); - bank.ContentEntryId = entry.Id; - } - - bank.Name = command.Name.Trim(); - bank.Metadata = ObjectOrDefault(command.Metadata); - if (bank.ContentEntryId.HasValue) - { - var entry = trackedEntry ?? - await dbContext.ContentEntries.SingleAsync( - item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token); - entry.Name = bank.Name; - entry.UpdatedBy = actor.UserId; - } - - AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name }); - await dbContext.SaveChangesAsync(token); - return ToBankItem(bank, 0, 0); - }, cancellationToken); - } - - public Task ArchiveBankAsync( - PlatformAdminActor actor, - Guid bankId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); - if (await dbContext.Questions.AnyAsync( - item => item.TenantId == tenantId && item.QuestionBankId == bankId && - item.Status != QuestionStatus.Archived, token)) - throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty"); - - if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync( - item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token)) - throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived"); - - bank.Status = QuestionBankStatus.Archived; - AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name }); - await dbContext.SaveChangesAsync(token); - return ToBankItem(bank, 0, 0); - }, cancellationToken); - } - - public Task> GetNodesAsync( - PlatformAdminActor actor, - Guid bankId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync>(actor, "查询公共题库内容结构", - async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token); - var nodes = await dbContext.ContentNodes.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId) - .OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name) - .ToArrayAsync(token); - var counts = await dbContext.Questions.AsNoTracking() - .Where(item => - item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && - item.Status != QuestionStatus.Archived) - .GroupBy(item => item.ContentNodeId!.Value) - .Select(group => new { NodeId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.NodeId, item => item.Count, token); - return nodes.Select(item => - ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray(); - }, cancellationToken); - } - - public Task UpsertNodeAsync( - PlatformAdminActor actor, - UpsertPlatformQuestionBankNodeCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); - return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token); - }, cancellationToken); - } - - public Task> BatchCreateNodesAsync( - PlatformAdminActor actor, - BatchCreatePlatformQuestionBankNodesCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync>(actor, "批量创建章节或试卷", - async (_, dbContext, tenantId, token) => - { - if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper) - throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid"); - - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); - var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - if (names.Length is 0 or > 100) throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid"); - - var result = new List(); - var order = 0; - foreach (var name in names) - result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, - new UpsertPlatformQuestionBankNodeCommand( - null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, - JsonDefaults.Object()), token)); - - return result; - }, cancellationToken); - } - - public Task ArchiveNodeAsync( - PlatformAdminActor actor, - Guid nodeId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) => - { - var node = await dbContext.ContentNodes.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == nodeId, token) - ?? throw Error("内容节点不存在。", "question_bank_node_not_found"); - if (await dbContext.ContentNodes.AnyAsync( - item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token)) - throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children"); - - if (await dbContext.Questions.AnyAsync( - item => item.TenantId == tenantId && item.ContentNodeId == nodeId && - item.Status != QuestionStatus.Archived, token)) - throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions"); - - node.IsActive = false; - node.UpdatedBy = actor.UserId; - var bankId = await dbContext.QuestionBanks - .Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id) - .SingleAsync(token); - AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name }); - await dbContext.SaveChangesAsync(token); - return ToNodeItem(node, bankId, 0); - }, cancellationToken); - } - - public Task GetQuestionsAsync( - PlatformAdminActor actor, - PlatformQuestionBankFilter filter, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) => - { - if (!filter.QuestionBankId.HasValue) throw Error("请选择公共题库。", "question_bank_id_required"); - - await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token); - var query = dbContext.Questions.AsNoTracking().Where(item => - item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId); - if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId); - if (!string.IsNullOrWhiteSpace(filter.Type)) - { - var type = filter.Type.Trim(); - query = query.Where(item => item.Type == type); - } - - if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty); - if (!string.IsNullOrWhiteSpace(filter.Status) && - !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) - { - var status = ParseQuestionStatus(filter.Status); - query = query.Where(item => item.Status == status); - } - - if (!string.IsNullOrWhiteSpace(filter.Keyword)) - { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => - version.TenantId == tenantId && version.QuestionId == item.Id && - version.Id == item.CurrentVersionId && version.Content != null && - version.Content.Contains(keyword))); - } - - var page = Math.Max(1, filter.Page); - var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize); - var total = await query.CountAsync(token); - var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize) - .Take(pageSize).ToArrayAsync(token); - var versionIds = questions.Where(item => item.CurrentVersionId.HasValue) - .Select(item => item.CurrentVersionId!.Value).ToArray(); - var versions = await dbContext.QuestionVersions.AsNoTracking() - .Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)) - .ToDictionaryAsync(item => item.Id, token); - return new PlatformQuestionPage( - questions.Select(item => ToQuestionItem(item, - item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) - ? version - : null)).ToArray(), total, page, pageSize); - }, cancellationToken); - } - - public Task UpsertQuestionAsync( - PlatformAdminActor actor, - UpsertPlatformQuestionCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); - var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token); - ValidateQuestion(command); - var question = command.Id.HasValue - ? await dbContext.Questions.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, - token) - : null; - if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found"); - question ??= new Question - { - TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, - CreatedAt = DateTimeOffset.UtcNow - }; - if (!command.Id.HasValue) dbContext.Questions.Add(question); - ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); - await dbContext.SaveChangesAsync(token); - var nextVersion = await dbContext.QuestionVersions - .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) - .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; - var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); - dbContext.QuestionVersions.Add(version); - question.CurrentVersionId = version.Id; - AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, - new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo }); - await dbContext.SaveChangesAsync(token); - return ToQuestionItem(question, version); - }, cancellationToken); - } - - public Task ArchiveQuestionsAsync( - PlatformAdminActor actor, - ArchivePlatformQuestionsCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) => - { - var ids = command.QuestionIds.Distinct().Take(500).ToArray(); - if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required"); - var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)) - .ToArrayAsync(token); - foreach (var question in questions) question.Status = QuestionStatus.Archived; - AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), - new { questionIds = questions.Select(item => item.Id).ToArray() }); - await dbContext.SaveChangesAsync(token); - return questions.Length; - }, cancellationToken); - } - - public Task PreviewImportAsync(PlatformAdminActor actor, - PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) - { - return ImportAsync(actor, command, false, cancellationToken); - } - - public Task ExecuteImportAsync(PlatformAdminActor actor, - PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) - { - return ImportAsync(actor, command, true, cancellationToken); - } - - public Task GetImportAsync(PlatformAdminActor actor, Guid jobId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "查询公共题库导入结果", - async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), - cancellationToken); - } - - public Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, - AssetUploadSignCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) => - await provider.GetRequiredService().SignUploadAsync( - new AssetManagementActor(tenantId, actor.UserId), - command with { AssetType = "image", Category = "question", IsPublic = true }, token), - cancellationToken); - } - - public Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, - AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) => - await provider.GetRequiredService() - .ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), - cancellationToken); - } - - private async Task ImportAsync(PlatformAdminActor actor, - PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken) - { - return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); - var format = NormalizeImportFormat(command.Format); - ContentNode? targetNode = null; - if (command.ContentNodeId.HasValue) - targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token); - if (format == "simple" && targetNode is null) - throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required"); - - var job = new ContentImportJob - { - TenantId = tenantId, - CreatedBy = actor.UserId, - TargetQuestionBankId = bank.Id, - TargetContentNodeId = targetNode?.Id, - ImportType = ContentImportType.Questions, - SourceFormat = ImportSourceFormat.Json, - Status = execute ? ContentImportStatus.Importing : ContentImportStatus.Preview, - SourceName = string.IsNullOrWhiteSpace(command.SourceName) ? null : command.SourceName.Trim(), - SourceHash = Hash(command.Payload.GetRawText()), - DryRun = !execute, - RawPayload = command.Payload.Clone(), - StartedAt = execute ? DateTimeOffset.UtcNow : null - }; - if (execute) dbContext.ContentImportJobs.Add(job); - - var rows = format == "simple" - ? ExtractSimpleRows(command.Payload, targetNode!.Id) - : ExtractStructuredRows(command.Payload, targetNode?.Id); - job.TotalCount = rows.Count; - var importItems = new List(); - var issues = new List(); - var createdNodes = 0; - var inserted = 0; - var updated = 0; - var skipped = 0; - var nodeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var row in rows) - { - var item = new ContentImportItem - { - TenantId = tenantId, - JobId = job.Id, - RowNo = row.RowNo, - ExternalId = GetString(row.Question, "legacyId") ?? GetString(row.Question, "id"), - SourcePayload = row.Question.Clone(), - NormalizedPayload = row.Question.Clone() - }; - var validation = ValidateImportRow(row.Question); - if (validation is not null) - { - item.Status = ContentImportItemStatus.Invalid; - item.IssuesCount = 1; - issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, - validation.Value.Field, validation.Value.Message)); - job.ErrorCount++; - importItems.Add(item); - continue; - } - - job.ValidCount++; - item.Status = ContentImportItemStatus.Valid; - if (execute) - { - var node = targetNode; - if (format == "structured") - { - (node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, - targetNode, nodeCache, token); - createdNodes += made; - } - - if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required"); - var result = - await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token); - item.TargetType = "question"; - item.TargetId = result.Question.Id; - item.ContentHash = result.SourceHash; - item.Status = result.Action switch - { - "inserted" => ContentImportItemStatus.Inserted, - "updated" => ContentImportItemStatus.Updated, - _ => ContentImportItemStatus.Skipped - }; - if (result.Action == "inserted") inserted++; - else if (result.Action == "updated") updated++; - else skipped++; - } - - importItems.Add(item); - } - - job.InsertedCount = inserted; - job.UpdatedCount = updated; - job.SkippedCount = skipped; - job.Status = !execute ? ContentImportStatus.Preview : - job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed; - job.FinishedAt = execute ? DateTimeOffset.UtcNow : null; - job.Summary = JsonSerializer.SerializeToElement(new - { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount }); - job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question)); - if (execute) - { - dbContext.ContentImportItems.AddRange(importItems); - dbContext.ContentImportIssues.AddRange(issues); - AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, - new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount }); - await dbContext.SaveChangesAsync(token); - } - - var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), - issues.Select(ToIssueItem).ToArray()); - return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped); - }, cancellationToken); - } - - private async Task ExecuteAsync(PlatformAdminActor actor, string reason, - Func> action, - CancellationToken cancellationToken) - { - var access = await currentAccessContext.GetAsync(cancellationToken); - if (access.UserId != actor.UserId || - !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) - throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied"); - var correlationId = Guid.NewGuid().ToString("N"); - var platformTenantId = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), - "解析平台公共题库所属租户", correlationId, true), - async (provider, token) => - { - var dbContext = provider.GetRequiredService(); - var tenantIds = await dbContext.Tenants.AsNoTracking() - .Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2) - .ToArrayAsync(token); - if (tenantIds.Length != 1) - throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", - tenantIds.Length == 0 - ? "platform_question_owner_missing" - : "platform_question_owner_ambiguous"); - return tenantIds[0]; - }, cancellationToken); - return await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, - nameof(PlatformQuestionBankService), reason, correlationId), - async (provider, token) => - { - var dbContext = provider.GetRequiredService(); - return await action(provider, dbContext, platformTenantId, token); - }, cancellationToken); - } - - private static async Task UpsertNodeCoreAsync(TikuDbContext dbContext, - PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, - CancellationToken token) - { - if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required"); - ContentNode? parent = null; - if (command.ParentId.HasValue) - parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token); - var node = command.Id.HasValue - ? await dbContext.ContentNodes.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, - token) - : null; - if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found"); - node ??= new ContentNode - { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId }; - if (!command.Id.HasValue) dbContext.ContentNodes.Add(node); - node.ParentId = parent?.Id; - node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim(); - node.Name = command.Name.Trim(); - node.NodeType = command.NodeType; - node.Depth = parent is null ? 0 : parent.Depth + 1; - node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; - node.SortOrder = command.SortOrder; - node.IsActive = true; - node.IsSelectable = command.IsSelectable; - node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper; - node.Metadata = ObjectOrDefault(command.Metadata); - node.UpdatedBy = actor.UserId; - AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, - new { bankId = bank.Id, node.Name, node.NodeType }); - await dbContext.SaveChangesAsync(token); - return ToNodeItem(node, bank.Id, 0); - } - - private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext dbContext, - PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection path, - ContentNode? root, Dictionary cache, CancellationToken token) - { - var parent = root; - var created = 0; - foreach (var part in path) - { - var cacheKey = $"{parent?.Id:N}/{part.Key}"; - if (!cache.TryGetValue(cacheKey, out var node)) - { - var parentId = parent?.Id; - node = await dbContext.ContentNodes.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && - item.ParentId == parentId && item.NodeKey == part.Key, token); - if (node is null) - { - node = new ContentNode - { - TenantId = tenantId, - EntryId = bank.ContentEntryId!.Value, - ParentId = parent?.Id, - NodeKey = part.Key, - Name = part.Name, - NodeType = part.Type, - Depth = parent is null ? 0 : parent.Depth + 1, - Path = parent is null ? null : $"{parent.Path}.n{Guid.NewGuid():N}", - SortOrder = part.Order, - IsActive = true, - IsSelectable = true, - IsLeaf = part.Type is ContentNodeType.Chapter or ContentNodeType.Paper, - CreatedBy = actor.UserId, - UpdatedBy = actor.UserId - }; - node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; - dbContext.ContentNodes.Add(node); - await dbContext.SaveChangesAsync(token); - created++; - } - - cache[cacheKey] = node; - } - - parent = node; - } - - if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required"); - return (parent, created); - } - - private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync( - TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, - JsonElement payload, CancellationToken token) - { - var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id"); - var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText()); - var question = !string.IsNullOrWhiteSpace(legacyId) - ? await dbContext.Questions.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.LegacyId == legacyId, token) - : await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id) - .Join( - dbContext.QuestionVersions.Where(item => - item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, - version => version.Id, (item, _) => item) - .SingleOrDefaultAsync(token); - if (question is not null && question.QuestionBankId != bank.Id) - throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict"); - if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync( - item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) - return (question, "skipped", sourceHash); - var isNew = question is null; - question ??= new Question - { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId }; - if (isNew) dbContext.Questions.Add(question); - var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash); - ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); - await dbContext.SaveChangesAsync(token); - var nextVersion = await dbContext.QuestionVersions - .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) - .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; - var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); - dbContext.QuestionVersions.Add(version); - question.CurrentVersionId = version.Id; - await dbContext.SaveChangesAsync(token); - return (question, isNew ? "inserted" : "updated", sourceHash); - } - - private static List ExtractSimpleRows(JsonElement payload, Guid nodeId) - { - var array = payload.ValueKind == JsonValueKind.Array - ? payload - : - payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) - ? - questions - : default; - if (array.ValueKind != JsonValueKind.Array) - throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid"); - return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)) - .ToList(); - } - - private static List ExtractStructuredRows(JsonElement payload, Guid? rootNodeId) - { - if (payload.ValueKind != JsonValueKind.Object) - throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid"); - if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") - throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid"); - var rows = new List(); - WalkStructured(payload, [], rows, rootNodeId); - if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty"); - return rows; - } - - private static void WalkStructured(JsonElement current, List path, List rows, - Guid? rootNodeId) - { - if (current.ValueKind != JsonValueKind.Object) return; - if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array) - foreach (var question in questions.EnumerateArray()) - rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId)); - string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"]; - foreach (var property in childProperties) - { - if (!current.TryGetProperty(property, out var children) || - children.ValueKind != JsonValueKind.Array) continue; - foreach (var child in children.EnumerateArray()) - { - if (child.ValueKind != JsonValueKind.Object) continue; - var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点"; - var key = GetString(child, "code") ?? GetString(child, "key") ?? - GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}"; - var type = property switch - { - "subjects" => ContentNodeType.Subject, - "chapters" => ContentNodeType.Chapter, - "papers" => ContentNodeType.Paper, - _ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category) - }; - var next = new List(path) - { new(key, name, type, GetInt(child, "order") ?? path.Count) }; - WalkStructured(child, next, rows, rootNodeId); - } - } - } - - private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload) - { - if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。"); - if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && - string.IsNullOrWhiteSpace(GetString(payload, "title"))) - return ("question_content_required", "content", "题干不能为空。"); - var type = GetString(payload, "type") ?? "choice"; - if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。"); - return null; - } - - private static void ValidateQuestion(UpsertPlatformQuestionCommand command) - { - if (string.IsNullOrWhiteSpace(command.Content)) throw Error("题干不能为空。", "question_content_required"); - var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim(); - if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid"); - if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid"); - if (ParseQuestionStatus(command.Status) == QuestionStatus.Published && - !QuestionGrader.HasValidAuthoritativeAnswer( - type, - command.CorrectOptionIndex, - command.CorrectOptionIndices, - command.AnswerText)) - throw Error("发布题目必须提供有效的标准答案。", "question_grading_rule_invalid"); - } - - private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, - Guid nodeId) - { - question.QuestionBankId = command.QuestionBankId; - question.EntryId = entryId; - question.ContentNodeId = nodeId; - question.LegacyId = Normalize(command.LegacyId); - question.Type = Normalize(command.Type) ?? "choice"; - question.TypeLabel = Normalize(command.TypeLabel); - question.Difficulty = command.Difficulty; - question.Tags = ArrayOrDefault(command.Tags); - question.MediaUrl = Normalize(command.MediaUrl); - question.ExamMarkers = ObjectOrDefault(command.ExamMarkers); - question.Status = ParseQuestionStatus(command.Status); - } - - private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, - UpsertPlatformQuestionCommand command) - { - return new QuestionVersion - { - TenantId = tenantId, - QuestionId = questionId, - VersionNo = versionNo, - Content = Normalize(command.Content), - Options = ArrayOrDefault(command.Options), - CorrectOptionIndex = command.CorrectOptionIndex, - CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices), - AnswerText = Normalize(command.AnswerText), - Explanation = Normalize(command.Explanation), - SubQuestions = ArrayOrDefault(command.SubQuestions), - CodeLang = Normalize(command.CodeLang), - CodeTemplate = Normalize(command.CodeTemplate), - SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)), - CreatedBy = actor.UserId - }; - } - - private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, - string? legacyId, string sourceHash) - { - return new UpsertPlatformQuestionCommand( - null, bankId, nodeId, 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"), GetString(payload, "status") ?? "published", - GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash); - } - - private static async Task RequireBankAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, - CancellationToken token) - { - return await dbContext.QuestionBanks.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == bankId, token) ?? - throw Error("公共题库不存在。", "question_bank_not_found"); - } - - private static async Task RequireBankWithEntryAsync(TikuDbContext dbContext, Guid tenantId, - Guid bankId, CancellationToken token) - { - var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); - if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing"); - return bank; - } - - private static async Task RequireNodeAsync(TikuDbContext dbContext, Guid tenantId, QuestionBank bank, - Guid nodeId, CancellationToken token) - { - return await dbContext.ContentNodes.SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && - item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); - } - - private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) - { - return new PlatformQuestionBankItem(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, - questionCount, item.Metadata, - item.CreatedAt, item.UpdatedAt); - } - - private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) - { - return new PlatformQuestionBankNodeItem(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, - item.NodeType, item.Depth, - item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); - } - - private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) - { - return new PlatformQuestionItem(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, - item.ContentNodeId!.Value, - item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.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, item.MediaUrl, - item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); - } - - private static async Task LoadImportDetailAsync(TikuDbContext dbContext, Guid tenantId, - Guid jobId, CancellationToken token) - { - var job = await dbContext.ContentImportJobs.AsNoTracking() - .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? - throw Error("导入任务不存在。", "question_import_not_found"); - var items = await dbContext.ContentImportItems.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) - .Select(item => ToImportItem(item)).ToArrayAsync(token); - var issues = await dbContext.ContentImportIssues.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) - .Select(item => ToIssueItem(item)).ToArrayAsync(token); - return new ContentImportJobDetail(ToJobItem(job), items, issues); - } - - private static ContentImportJobItem ToJobItem(ContentImportJob item) - { - return new ContentImportJobItem(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, - item.TargetContentNodeId, - item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, - item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, - item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, - item.FinishedAt, item.CreatedAt, item.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 static ContentImportIssueModel ToIssueItem(ContentImportIssue item) - { - return new ContentImportIssueModel(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, - item.FieldPath, item.Message, - item.Details); - } - - private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, - string field, string message) - { - return new ContentImportIssue - { - TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, - Code = code, FieldPath = field, Message = message - }; - } - - 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 = "question_bank", - TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) - }); - } - - private static PlatformAdminException Error(string message, string code) - { - return new PlatformAdminException(message, code); - } - - private static string NormalizeImportFormat(string? value) - { - return value?.Trim().ToLowerInvariant() switch - { - "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", - _ => throw Error("导入格式不受支持。", "question_import_format_invalid") - }; - } - - private static QuestionBankStatus ParseBankStatus(string? value) - { - return value?.Trim().ToLowerInvariant() switch - { - "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active - }; - } - - private static QuestionStatus ParseQuestionStatus(string? value) - { - return value?.Trim().ToLowerInvariant() switch - { - "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, - _ => QuestionStatus.Published - }; - } - - private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) - { - return value?.Trim().ToLowerInvariant() switch - { - "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, - "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback - }; - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static JsonElement ObjectOrDefault(JsonElement value) - { - return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); - } - - private static JsonElement ArrayOrDefault(JsonElement value) - { - return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); - } - - private static string Hash(string value) - { - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - } - - private static string? GetString(JsonElement value, string name) - { - return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && - property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - } - - private static int? GetInt(JsonElement value, string name) - { - return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && - property.TryGetInt32(out var result) - ? result - : null; - } - - private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) - { - return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) - ? property.Clone() - : fallback; - } - - private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order); - - private sealed record ImportRow( - int RowNo, - JsonElement Question, - IReadOnlyCollection Path, - Guid? TargetNodeId); -} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs deleted file mode 100644 index cda46e8..0000000 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs +++ /dev/null @@ -1,805 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Tiku.Application.PlatformAdmin; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Common; -using Tiku.Domain.Growth; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.PlatformAdmin; - -internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformCrmAdminService -{ - public Task> GetConfigsAsync( - PlatformCapabilityActor actor, - PlatformCapabilityQuery query, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform crm configs list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = - from config in db.CrmConfigs.AsNoTracking() - join tenant in db.Tenants.AsNoTracking() on config.TenantId equals tenant.Id - select new { config, tenant.Name }; - if (query.TenantId.HasValue) values = values.Where(value => value.config.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - { - var enabled = IsEnabledStatus(query.Status); - values = values.Where(value => value.config.Enabled == enabled); - } - - var items = await values - .OrderByDescending(value => value.config.UpdatedAt) - .Take(Limit(query.Limit)) - .Select(value => ToCrmConfigItem(value.config, value.Name)) - .ToArrayAsync(token); - return new PlatformTenantCapabilityList(items); - }, cancellationToken); - } - - public Task UpsertConfigAsync( - PlatformCapabilityActor actor, - UpsertPlatformCrmConfigCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform crm config upsert", async (services, token) => - { - var db = services.GetRequiredService(); - var tenantName = await db.Tenants - .Where(tenant => tenant.Id == command.TenantId) - .Select(tenant => tenant.Name) - .SingleOrDefaultAsync(token) - ?? throw Error("Tenant was not found.", "tenant_not_found"); - var item = command.Id.HasValue - ? await db.CrmConfigs.SingleOrDefaultAsync( - value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) - : await db.CrmConfigs.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, token); - if (item is null) - { - item = new CrmConfig { TenantId = command.TenantId }; - db.CrmConfigs.Add(item); - } - - item.Enabled = command.Enabled; - item.Url = Normalize(command.Url); - item.SecretRef = Normalize(command.SecretRef); - item.FormName = Normalize(command.FormName); - item.ExamType = Normalize(command.ExamType); - item.TimeoutSeconds = command.TimeoutSeconds; - item.DelaySeconds = command.DelaySeconds; - item.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None); - item.AssignmentPool = command.AssignmentPool ?? JsonDefaults.Array(); - item.AssignmentConfig = command.AssignmentConfig ?? JsonDefaults.Object(); - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, - new { item.Enabled, item.Url, item.AssignmentMode }); - await db.SaveChangesAsync(token); - return ToCrmConfigItem(item, tenantName); - }, cancellationToken); - } - - public Task> GetLeadsAsync( - PlatformCapabilityActor actor, - PlatformCapabilityQuery query, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform crm leads list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.CrmWebhookQueue.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending)); - var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) - .ToArrayAsync(token); - return new PlatformTenantCapabilityList(items); - }, cancellationToken); - } - - public Task RetryLeadAsync( - PlatformCapabilityActor actor, - PlatformCrmLeadRetryCommand command, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform crm lead retry", async (services, token) => - { - var db = services.GetRequiredService(); - var item = await db.CrmWebhookQueue.SingleOrDefaultAsync(value => value.Id == command.QueueId, token) - ?? throw Error("CRM queue item was not found.", "crm_queue_not_found"); - if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded - or CrmWebhookQueueStatus.Retrying)) - throw Error("Only failed CRM queue items can be retried.", "crm_queue_retry_invalid"); - - item.Status = CrmWebhookQueueStatus.Retrying; - item.NextAttemptAt = DateTimeOffset.UtcNow; - item.LastError = null; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, - new { command.Note }); - await db.SaveChangesAsync(token); - return item; - }, cancellationToken); - } - - public Task> GetLogsAsync( - PlatformCapabilityActor actor, - Guid? tenantId, - Guid? queueId, - int limit, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform crm logs list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.CrmWebhookLogs.AsNoTracking(); - if (tenantId.HasValue) values = values.Where(value => value.TenantId == tenantId); - if (queueId.HasValue) - { - var queueRecordId = await db.CrmWebhookQueue.AsNoTracking() - .Where(value => value.Id == queueId.Value) - .Select(value => value.RecordId) - .SingleOrDefaultAsync(token); - values = values.Where(value => value.RecordId == queueRecordId); - } - - var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(limit)).ToArrayAsync(token); - return new PlatformTenantCapabilityList(items); - }, cancellationToken); - } - - private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) - { - return new PlatformCrmConfigItem(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, - config.SecretRef, - config.FormName, config.ExamType, - config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, - config.AssignmentConfig, config.UpdatedAt); - } - - private Task ExecuteAsync(string reason, - Func> operation, CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, - Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - } - - private static bool IsEnabledStatus(string value) - { - return value.Equals("active", StringComparison.OrdinalIgnoreCase) || - value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常"; - } - - private static PlatformCapabilityException Error(string message, string code) - { - return new PlatformCapabilityException(message, code); - } - - private static int Limit(int value) - { - return Math.Clamp(value, 1, 500); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static T ParseEnum(string? value, T fallback) where T : struct, Enum - { - return Enum.TryParse(NormalizeEnum(value), true, out var parsed) ? parsed : fallback; - } - - private static string? NormalizeEnum(string? value) - { - return value?.Trim().Replace("-", "_", StringComparison.Ordinal); - } - - private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, - string targetType, Guid targetId, object details) - { - db.AuditLogs.Add(new AuditLog - { - TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, - TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) - }); - } -} - -internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformSmsAdminService -{ - public Task> GetChannelsAsync(PlatformCapabilityActor actor, - PlatformCapabilityQuery query, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms channels list", async (services, token) => - { - var db = services.GetRequiredService(); - var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, - TimeSpan.Zero); - var sentCounts = await db.SmsSendLogs.AsNoTracking() - .Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && - value.ChannelId.HasValue) - .GroupBy(value => value.ChannelId!.Value) - .Select(group => new { ChannelId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(value => value.ChannelId, value => value.Count, token); - var values = - from channel in db.SmsChannels.AsNoTracking() - join tenant in db.Tenants.AsNoTracking() on channel.TenantId equals tenant.Id - select new { channel, tenant.Name }; - if (query.TenantId.HasValue) values = values.Where(value => value.channel.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - values = values.Where(value => - value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); - var rows = await values.OrderBy(value => value.channel.Priority) - .ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - return new PlatformTenantCapabilityList(rows.Select(value => - ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray()); - }, cancellationToken); - } - - public Task UpsertChannelAsync(PlatformCapabilityActor actor, - UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms channel upsert", async (services, token) => - { - var db = services.GetRequiredService(); - var tenantName = await TenantNameAsync(db, command.TenantId, token); - var provider = NormalizeCode(command.Provider); - var scene = NormalizeCode(command.Scene); - var item = command.Id.HasValue - ? await db.SmsChannels.SingleOrDefaultAsync( - value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) - : await db.SmsChannels.SingleOrDefaultAsync( - value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, - token); - if (item is null) - { - item = new SmsChannel { TenantId = command.TenantId }; - db.SmsChannels.Add(item); - } - - item.Provider = provider; - item.Name = command.Name.Trim(); - item.Signature = command.Signature.Trim(); - item.Scene = scene; - item.Status = command.Status; - item.SecretRef = Normalize(command.SecretRef); - item.Priority = command.Priority ?? item.Priority; - item.MonthlyQuota = Math.Max(0, command.MonthlyQuota ?? item.MonthlyQuota); - item.ConfigPublic = command.ConfigPublic; - item.Metadata = command.Metadata; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, - new { item.Provider, item.Scene, item.Status }); - await db.SaveChangesAsync(token); - return ToChannelItem(item, tenantName, 0); - }, cancellationToken); - } - - public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms channel disable", async (services, token) => - { - var db = services.GetRequiredService(); - var item = await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) - ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); - item.Status = TenantExternalProviderStatus.Disabled; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, - new { item.Provider, item.Scene }); - await db.SaveChangesAsync(token); - return ToChannelItem(item, await TenantNameAsync(db, item.TenantId, token), 0); - }, cancellationToken); - } - - public Task> GetTemplatesAsync(PlatformCapabilityActor actor, - PlatformCapabilityQuery query, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms templates list", async (services, token) => - { - var db = services.GetRequiredService(); - var sentCounts = await db.SmsSendLogs.AsNoTracking() - .Where(value => value.TemplateId.HasValue) - .GroupBy(value => value.TemplateId!.Value) - .Select(group => new - { - TemplateId = group.Key, Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), - Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) - }) - .ToDictionaryAsync(value => value.TemplateId, token); - var values = - from template in db.SmsTemplates.AsNoTracking() - join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals - new { channel.TenantId, ChannelId = channel.Id } - join tenant in db.Tenants.AsNoTracking() on template.TenantId equals tenant.Id - select new { template, channel.Name, TenantName = tenant.Name }; - if (query.TenantId.HasValue) values = values.Where(value => value.template.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - values = values.Where(value => - value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active)); - var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)) - .ToArrayAsync(token); - return new PlatformTenantCapabilityList(rows.Select(value => - { - var counts = sentCounts.GetValueOrDefault(value.template.Id); - return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, - counts?.Failed ?? 0); - }).ToArray()); - }, cancellationToken); - } - - public Task UpsertTemplateAsync(PlatformCapabilityActor actor, - UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms template upsert", async (services, token) => - { - var db = services.GetRequiredService(); - var channel = await db.SmsChannels.AsNoTracking() - .SingleOrDefaultAsync( - value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token) - ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); - var item = command.Id.HasValue - ? await db.SmsTemplates.SingleOrDefaultAsync( - value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) - : await db.SmsTemplates.SingleOrDefaultAsync( - value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token); - if (item is null) - { - item = new SmsTemplate { TenantId = command.TenantId }; - db.SmsTemplates.Add(item); - } - - item.ChannelId = command.ChannelId; - item.Code = NormalizeCode(command.Code); - item.Name = command.Name.Trim(); - item.Type = command.Type; - item.AuditStatus = command.AuditStatus; - item.Status = command.Status; - item.ProviderTemplateCode = Normalize(command.ProviderTemplateCode); - item.Content = command.Content.Trim(); - item.Remark = Normalize(command.Remark); - item.Metadata = command.Metadata; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, - new { item.Code, item.AuditStatus, item.Status }); - await db.SaveChangesAsync(token); - return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channel.Name, 0, 0); - }, cancellationToken); - } - - public Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, - CancellationToken cancellationToken = default) - { - return ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, - "platform.sms.template.review_submitted", cancellationToken); - } - - public Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, - CancellationToken cancellationToken = default) - { - return ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, - "platform.sms.template.disabled", cancellationToken); - } - - public Task> GetLogsAsync(PlatformCapabilityActor actor, - PlatformCapabilityQuery query, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform sms logs list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.SmsSendLogs.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent)); - return new PlatformTenantCapabilityList(await values.OrderByDescending(value => value.CreatedAt) - .Take(Limit(query.Limit)).ToArrayAsync(token)); - }, cancellationToken); - } - - private Task ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, - SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, - CancellationToken cancellationToken) - { - return ExecuteAsync(action, async (services, token) => - { - var db = services.GetRequiredService(); - var item = await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == templateId, token) - ?? throw Error("SMS template was not found.", "sms_template_not_found"); - var channelName = await db.SmsChannels.AsNoTracking() - .Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId) - .Select(value => value.Name).SingleAsync(token); - if (auditStatus.HasValue) - { - item.AuditStatus = auditStatus.Value; - item.SubmittedAt = DateTimeOffset.UtcNow; - } - - if (status.HasValue) - { - item.Status = status.Value; - item.DisabledAt = DateTimeOffset.UtcNow; - } - - item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, - new { item.Code, item.AuditStatus, item.Status }); - await db.SaveChangesAsync(token); - return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channelName, 0, 0); - }, cancellationToken); - } - - private Task ExecuteAsync(string reason, - Func> operation, CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, - Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - } - - private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) - { - return new PlatformSmsChannelItem(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, - channel.Signature, - channel.Scene, channel.Status, - channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, - channel.Metadata, channel.UpdatedAt); - } - - private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, - int sent, int failed) - { - return new PlatformSmsTemplateItem(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, - template.Code, - template.Name, template.Type, - template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, - sent, failed, template.UpdatedAt); - } - - private static async Task TenantNameAsync(TikuDbContext db, Guid tenantId, CancellationToken token) - { - return await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name) - .SingleOrDefaultAsync(token) - ?? throw Error("Tenant was not found.", "tenant_not_found"); - } - - private static PlatformCapabilityException Error(string message, string code) - { - return new PlatformCapabilityException(message, code); - } - - private static int Limit(int value) - { - return Math.Clamp(value, 1, 500); - } - - private static string NormalizeCode(string value) - { - return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static T ParseEnum(string? value, T fallback) where T : struct, Enum - { - return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) - ? parsed - : fallback; - } - - private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, - string targetType, Guid targetId, object details) - { - db.AuditLogs.Add(new AuditLog - { - TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, - TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) - }); - } -} - -internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) - : IPlatformPaymentSettingsService -{ - public Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, - int limit, CancellationToken cancellationToken = default) - { - return ExecuteAsync>("platform payment apps list", - async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformPaymentApps.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(status)) - values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active)); - return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token); - }, cancellationToken); - } - - public Task UpsertAppAsync(PlatformCapabilityActor actor, - UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform payment app upsert", async (services, token) => - { - var db = services.GetRequiredService(); - var code = NormalizeCode(command.AppCode); - var item = command.Id.HasValue - ? await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) - : await db.PlatformPaymentApps.SingleOrDefaultAsync(value => value.AppCode == code, token); - if (item is null) - { - item = new PlatformPaymentApp { AppCode = code }; - db.PlatformPaymentApps.Add(item); - } - - item.AppName = command.AppName.Trim(); - item.Status = command.Status; - item.SettlementMode = command.SettlementMode.Trim(); - item.Description = Normalize(command.Description); - item.Metadata = command.Metadata; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, - new { item.AppCode, item.Status }); - await db.SaveChangesAsync(token); - return item; - }, cancellationToken); - } - - public Task> GetChannelsAsync(PlatformCapabilityActor actor, - Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) - { - return ExecuteAsync>("platform payment channels list", - async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformPaymentChannels.AsNoTracking(); - if (appId.HasValue) values = values.Where(value => value.AppId == appId); - if (!string.IsNullOrWhiteSpace(status)) - values = values.Where(value => - value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active)); - return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)) - .ToArrayAsync(token); - }, cancellationToken); - } - - public Task UpsertChannelAsync(PlatformCapabilityActor actor, - UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform payment channel upsert", async (services, token) => - { - var db = services.GetRequiredService(); - if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) - throw Error("Platform payment app was not found.", "platform_payment_app_not_found"); - var provider = NormalizeCode(command.Provider); - var item = command.Id.HasValue - ? await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) - : await db.PlatformPaymentChannels.SingleOrDefaultAsync( - value => value.AppId == command.AppId && value.Provider == provider, token); - if (item is null) - { - item = new PlatformPaymentChannel { AppId = command.AppId, Provider = provider }; - db.PlatformPaymentChannels.Add(item); - } - - item.Mode = command.Mode.Trim(); - item.Status = command.Status; - item.DisplayName = command.DisplayName.Trim(); - item.SecretRef = Normalize(command.SecretRef); - item.CallbackPath = Normalize(command.CallbackPath); - item.Priority = command.Priority ?? item.Priority; - item.ConfigPublic = command.ConfigPublic; - item.Metadata = command.Metadata; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, - new { item.Provider, item.Status, item.SecretRef }); - await db.SaveChangesAsync(token); - return item; - }, cancellationToken); - } - - public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform payment channel disable", async (services, token) => - { - var db = services.GetRequiredService(); - var item = await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) - ?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found"); - item.Status = PlatformPaymentChannelStatus.Disabled; - item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, - new { item.Provider }); - await db.SaveChangesAsync(token); - return item; - }, cancellationToken); - } - - public Task> GetEventsAsync(PlatformCapabilityActor actor, - string? status, int limit, CancellationToken cancellationToken = default) - { - return ExecuteAsync>("platform payment events list", - async (services, token) => - await services.GetRequiredService().PlatformBillingPaymentEvents.AsNoTracking() - .OrderByDescending(value => value.CreatedAt) - .Take(Limit(limit)) - .ToArrayAsync(token), cancellationToken); - } - - public Task GetRebateSummaryAsync(PlatformCapabilityActor actor, - CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform rebate summary", async (services, token) => - { - var db = services.GetRequiredService(); - var settlements = db.CommissionSettlements.AsNoTracking(); - var gross = await settlements.SumAsync(value => (int?)value.GrossAmountCents, token) ?? 0; - var commission = await settlements.SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid) - .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var pending = await settlements - .Where(value => - value.Status == CommissionSettlementStatus.Approved || - value.Status == CommissionSettlementStatus.PendingReview) - .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var exceptions = await settlements.CountAsync( - value => value.Status == CommissionSettlementStatus.Rejected || - value.Status == CommissionSettlementStatus.Cancelled, token); - return new PlatformRebateSummary(gross, commission, pending, paid, exceptions); - }, cancellationToken); - } - - private Task ExecuteAsync(string reason, - Func> operation, CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, - Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - } - - private static PlatformCapabilityException Error(string message, string code) - { - return new PlatformCapabilityException(message, code); - } - - private static int Limit(int value) - { - return Math.Clamp(value, 1, 500); - } - - private static string NormalizeCode(string value) - { - return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); - } - - private static string? Normalize(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static T ParseEnum(string? value, T fallback) where T : struct, Enum - { - return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) - ? parsed - : fallback; - } - - private static void AddPlatformAudit(TikuDbContext db, PlatformCapabilityActor actor, string action, - string targetType, Guid targetId, object details) - { - db.AuditLogs.Add(new AuditLog - { - ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), - Details = JsonSerializer.SerializeToElement(details) - }); - } -} - -internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) - : IPlatformTenantPaymentAdminService -{ - public Task> GetAppsAsync(PlatformCapabilityActor actor, - PlatformCapabilityQuery query, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform tenant payment apps list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.TenantExternalProviders.AsNoTracking() - .Where(value => value.Capability == TenantExternalProviderCapability.Payment); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) - values = values.Where(value => - value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); - var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority) - .Take(Limit(query.Limit)) - .Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, - value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, - value.Metadata, value.CreatedAt, value.UpdatedAt)) - .ToArrayAsync(token); - return new PlatformTenantCapabilityList(items); - }, cancellationToken); - } - - public Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, - UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform tenant payment app upsert", async (services, token) => - { - if (command.Capability != TenantExternalProviderCapability.Payment) - throw Error("Tenant payment capability is required.", "tenant_payment_capability_required"); - var item = await services.GetRequiredService() - .UpsertProviderAsync(tenantId, command, token); - services.GetRequiredService().AuditLogs.Add(new AuditLog - { - TenantId = tenantId, - ActorUserId = actor.UserId, - Action = "platform.tenant_payment.app.upserted", - TargetType = "tenant_external_providers", - TargetId = item.Id.ToString(), - Details = JsonSerializer.SerializeToElement(new { item.Provider, item.Status, item.SecretRef }) - }); - await services.GetRequiredService().SaveChangesAsync(token); - return item; - }, cancellationToken); - } - - public Task> GetEventsAsync(PlatformCapabilityActor actor, - PlatformCapabilityQuery query, CancellationToken cancellationToken = default) - { - return ExecuteAsync("platform tenant payment events list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PaymentEvents.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) - .Select(value => new - { - value.Id, - value.TenantId, - value.PaymentId, - value.Provider, - value.EventType, - value.EventId, - value.SignatureValid, - value.ProcessedAt, - value.Error, - value.CreatedAt - }) - .Cast() - .ToArrayAsync(token); - return new PlatformTenantCapabilityList(items); - }, cancellationToken); - } - - private Task ExecuteAsync(string reason, - Func> operation, CancellationToken cancellationToken) - { - return tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), - reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - } - - private static PlatformCapabilityException Error(string message, string code) - { - return new PlatformCapabilityException(message, code); - } - - private static int Limit(int value) - { - return Math.Clamp(value, 1, 500); - } - - private static T ParseEnum(string? value, T fallback) where T : struct, Enum - { - return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) - ? parsed - : fallback; - } -} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetFoundation.cs new file mode 100644 index 0000000..16e7286 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetFoundation.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase +{ + protected Task SignQuestionAssetUploadCoreAsync(PlatformAdminActor actor, + AssetUploadSignCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) => + await provider.GetRequiredService().SignUploadAsync( + new AssetManagementActor(tenantId, actor.UserId), + command with { AssetType = "image", Category = "question", IsPublic = true }, token), + cancellationToken); + } + + protected Task ConfirmQuestionAssetUploadCoreAsync(PlatformAdminActor actor, + AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) => + await provider.GetRequiredService() + .ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), + cancellationToken); + } + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetService.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetService.cs new file mode 100644 index 0000000..90cdb24 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Assets/PlatformQuestionAssetService.cs @@ -0,0 +1,24 @@ +using Tiku.Application.Assets; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionAssetService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) + : PlatformQuestionBankServiceBase(currentAccessContext, tenantExecutionScope), IPlatformQuestionAssetService +{ + public Task SignQuestionAssetUploadAsync( + PlatformAdminActor actor, + AssetUploadSignCommand command, + CancellationToken cancellationToken = default) => + SignQuestionAssetUploadCoreAsync(actor, command, cancellationToken); + + public Task ConfirmQuestionAssetUploadAsync( + PlatformAdminActor actor, + AssetUploadConfirmCommand command, + CancellationToken cancellationToken = default) => + ConfirmQuestionAssetUploadCoreAsync(actor, command, cancellationToken); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogFoundation.cs new file mode 100644 index 0000000..23e0ec7 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogFoundation.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase +{ + protected Task> GetBanksCoreAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "查询平台公共题库", + async (_, dbContext, tenantId, token) => + { + var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword)); + } + + if (!string.IsNullOrWhiteSpace(filter.Status) && + !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + var status = ParseBankStatus(filter.Status); + query = query.Where(item => item.Status == status); + } + + var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token); + var entryIds = banks.Where(item => item.ContentEntryId.HasValue) + .Select(item => item.ContentEntryId!.Value).ToArray(); + var nodeCounts = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive) + .GroupBy(item => item.EntryId) + .Select(group => new { EntryId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.EntryId, item => item.Count, token); + var questionCounts = await dbContext.Questions.AsNoTracking() + .Where(item => + item.TenantId == tenantId && item.QuestionBankId.HasValue && + item.Status != QuestionStatus.Archived) + .GroupBy(item => item.QuestionBankId!.Value) + .Select(group => new { BankId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.BankId, item => item.Count, token); + return banks.Select(item => ToBankItem( + item, + item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) + ? nodes + : 0, + questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray(); + }, cancellationToken); + } + + protected Task UpsertBankCoreAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) => + { + if (string.IsNullOrWhiteSpace(command.Name)) throw Error("题库名称不能为空。", "question_bank_name_required"); + + var bank = command.Id.HasValue + ? await dbContext.QuestionBanks.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value, token) + : null; + if (command.Id.HasValue && bank is null) throw Error("公共题库不存在。", "question_bank_not_found"); + + ContentEntry? trackedEntry = null; + if (bank is null) + { + var entry = new ContentEntry + { + TenantId = tenantId, + EntryKey = $"public-question-bank-{Guid.NewGuid():N}", + Name = command.Name.Trim(), + EntryType = ContentEntryType.QuestionPractice, + Visibility = ContentVisibility.Public, + IsActive = true, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + trackedEntry = entry; + bank = new QuestionBank + { + TenantId = tenantId, + ContentEntryId = entry.Id, + Status = QuestionBankStatus.Active + }; + dbContext.ContentEntries.Add(entry); + dbContext.QuestionBanks.Add(bank); + } + else if (!bank.ContentEntryId.HasValue) + { + var entry = new ContentEntry + { + TenantId = tenantId, + EntryKey = $"public-question-bank-{bank.Id:N}", + Name = command.Name.Trim(), + EntryType = ContentEntryType.QuestionPractice, + Visibility = ContentVisibility.Public, + IsActive = true, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + trackedEntry = entry; + dbContext.ContentEntries.Add(entry); + bank.ContentEntryId = entry.Id; + } + + bank.Name = command.Name.Trim(); + bank.Metadata = ObjectOrDefault(command.Metadata); + if (bank.ContentEntryId.HasValue) + { + var entry = trackedEntry ?? + await dbContext.ContentEntries.SingleAsync( + item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token); + entry.Name = bank.Name; + entry.UpdatedBy = actor.UserId; + } + + AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name }); + await dbContext.SaveChangesAsync(token); + return ToBankItem(bank, 0, 0); + }, cancellationToken); + } + + protected Task ArchiveBankCoreAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); + if (await dbContext.Questions.AnyAsync( + item => item.TenantId == tenantId && item.QuestionBankId == bankId && + item.Status != QuestionStatus.Archived, token)) + throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty"); + + if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token)) + throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived"); + + bank.Status = QuestionBankStatus.Archived; + AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name }); + await dbContext.SaveChangesAsync(token); + return ToBankItem(bank, 0, 0); + }, cancellationToken); + } + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogService.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogService.cs new file mode 100644 index 0000000..c7a7f74 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Catalog/PlatformQuestionBankCatalogService.cs @@ -0,0 +1,29 @@ +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionBankCatalogService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) + : PlatformQuestionBankServiceBase(currentAccessContext, tenantExecutionScope), IPlatformQuestionBankCatalogService +{ + public Task> GetBanksAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) => + GetBanksCoreAsync(actor, filter, cancellationToken); + + public Task UpsertBankAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankCommand command, + CancellationToken cancellationToken = default) => + UpsertBankCoreAsync(actor, command, cancellationToken); + + public Task ArchiveBankAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) => + ArchiveBankCoreAsync(actor, bankId, cancellationToken); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Foundation/PlatformQuestionBankFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Foundation/PlatformQuestionBankFoundation.cs new file mode 100644 index 0000000..98c8fc2 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Foundation/PlatformQuestionBankFoundation.cs @@ -0,0 +1,278 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) +{ + private const int MaxPageSize = 100; + + private static readonly HashSet SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase) + { + "choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming" + }; + + private async Task ExecuteAsync(PlatformAdminActor actor, string reason, + Func> action, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (access.UserId != actor.UserId || + !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) + throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied"); + var correlationId = Guid.NewGuid().ToString("N"); + var platformTenantId = await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, "PlatformQuestionBankService", + "解析平台公共题库所属租户", correlationId, true), + async (provider, token) => + { + var dbContext = provider.GetRequiredService(); + var tenantIds = await dbContext.Tenants.AsNoTracking() + .Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2) + .ToArrayAsync(token); + if (tenantIds.Length != 1) + throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", + tenantIds.Length == 0 + ? "platform_question_owner_missing" + : "platform_question_owner_ambiguous"); + return tenantIds[0]; + }, cancellationToken); + return await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, + "PlatformQuestionBankService", reason, correlationId), + async (provider, token) => + { + var dbContext = provider.GetRequiredService(); + return await action(provider, dbContext, platformTenantId, token); + }, cancellationToken); + } + + private static async Task RequireBankAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, Guid bankId, + CancellationToken token) + { + return await dbContext.QuestionBanks.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == bankId, token) ?? + throw Error("公共题库不存在。", "question_bank_not_found"); + } + + private static async Task RequireBankWithEntryAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, + Guid bankId, CancellationToken token) + { + var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); + if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing"); + return bank; + } + + private static async Task RequireNodeAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, QuestionBank bank, + Guid nodeId, CancellationToken token) + { + return await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && + item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); + } + + private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) + { + return new PlatformQuestionBankItem(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, + questionCount, item.Metadata, + item.CreatedAt, item.UpdatedAt); + } + + private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) + { + return new PlatformQuestionBankNodeItem(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, + item.NodeType, item.Depth, + item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); + } + + private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) + { + return new PlatformQuestionItem(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, + item.ContentNodeId!.Value, + item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.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, item.MediaUrl, + item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); + } + + private static async Task LoadImportDetailAsync(IPlatformQuestionBankAdministrationPersistence dbContext, Guid tenantId, + Guid jobId, CancellationToken token) + { + var job = await dbContext.ContentImportJobs.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? + throw Error("导入任务不存在。", "question_import_not_found"); + var items = await dbContext.ContentImportItems.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) + .Select(item => ToImportItem(item)).ToArrayAsync(token); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) + .Select(item => ToIssueItem(item)).ToArrayAsync(token); + return new ContentImportJobDetail(ToJobItem(job), items, issues); + } + + private static ContentImportJobItem ToJobItem(ContentImportJob item) + { + return new ContentImportJobItem(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, + item.TargetContentNodeId, + item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, + item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, + item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, + item.FinishedAt, item.CreatedAt, item.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 static ContentImportIssueModel ToIssueItem(ContentImportIssue item) + { + return new ContentImportIssueModel(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, + item.FieldPath, item.Message, + item.Details); + } + + private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, + string field, string message) + { + return new ContentImportIssue + { + TenantId = tenantId, + JobId = jobId, + ItemId = itemId, + RowNo = rowNo, + Severity = ImportIssueSeverity.Error, + Code = code, + FieldPath = field, + Message = message + }; + } + + private static void AddAudit(IPlatformQuestionBankAdministrationPersistence dbContext, PlatformAdminActor actor, string action, Guid targetId, + object details) + { + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, + Action = action, + TargetType = "question_bank", + TargetId = targetId.ToString("N"), + Details = JsonSerializer.SerializeToElement(details) + }); + } + + private static PlatformAdminException Error(string message, string code) + { + return new PlatformAdminException(message, code); + } + + private static string NormalizeImportFormat(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "simple" or "json" => "simple", + "structured-v2" or "structured" or "v2" => "structured", + _ => throw Error("导入格式不受支持。", "question_import_format_invalid") + }; + } + + private static QuestionBankStatus ParseBankStatus(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "archived" or "已归档" => QuestionBankStatus.Archived, + _ => QuestionBankStatus.Active + }; + } + + private static QuestionStatus ParseQuestionStatus(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "draft" or "草稿" => QuestionStatus.Draft, + "archived" or "已归档" => QuestionStatus.Archived, + _ => QuestionStatus.Published + }; + } + + private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) + { + return value?.Trim().ToLowerInvariant() switch + { + "subject" => ContentNodeType.Subject, + "chapter" => ContentNodeType.Chapter, + "paper" => ContentNodeType.Paper, + "category" => ContentNodeType.Category, + _ => fallback + }; + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static JsonElement ObjectOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); + } + + private static JsonElement ArrayOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); + } + + private static string Hash(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + + private static string? GetString(JsonElement value, string name) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static int? GetInt(JsonElement value, string name) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.TryGetInt32(out var result) + ? result + : null; + } + + private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) + ? property.Clone() + : fallback; + } + + private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order); + + private sealed record ImportRow( + int RowNo, + JsonElement Question, + IReadOnlyCollection Path, + Guid? TargetNodeId); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportFoundation.cs new file mode 100644 index 0000000..7c28df6 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportFoundation.cs @@ -0,0 +1,335 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase +{ + protected Task PreviewImportCoreAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) + { + return ImportAsync(actor, command, false, cancellationToken); + } + + protected Task ExecuteImportCoreAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) + { + return ImportAsync(actor, command, true, cancellationToken); + } + + protected Task GetImportCoreAsync(PlatformAdminActor actor, Guid jobId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "查询公共题库导入结果", + async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), + cancellationToken); + } + + private async Task ImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken) + { + return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var format = NormalizeImportFormat(command.Format); + ContentNode? targetNode = null; + if (command.ContentNodeId.HasValue) + targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token); + if (format == "simple" && targetNode is null) + throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required"); + + var job = new ContentImportJob + { + TenantId = tenantId, + CreatedBy = actor.UserId, + TargetQuestionBankId = bank.Id, + TargetContentNodeId = targetNode?.Id, + ImportType = ContentImportType.Questions, + SourceFormat = ImportSourceFormat.Json, + Status = execute ? ContentImportStatus.Importing : ContentImportStatus.Preview, + SourceName = string.IsNullOrWhiteSpace(command.SourceName) ? null : command.SourceName.Trim(), + SourceHash = Hash(command.Payload.GetRawText()), + DryRun = !execute, + RawPayload = command.Payload.Clone(), + StartedAt = execute ? DateTimeOffset.UtcNow : null + }; + if (execute) dbContext.ContentImportJobs.Add(job); + + var rows = format == "simple" + ? ExtractSimpleRows(command.Payload, targetNode!.Id) + : ExtractStructuredRows(command.Payload, targetNode?.Id); + job.TotalCount = rows.Count; + var importItems = new List(); + var issues = new List(); + var createdNodes = 0; + var inserted = 0; + var updated = 0; + var skipped = 0; + var nodeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + { + var item = new ContentImportItem + { + TenantId = tenantId, + JobId = job.Id, + RowNo = row.RowNo, + ExternalId = GetString(row.Question, "legacyId") ?? GetString(row.Question, "id"), + SourcePayload = row.Question.Clone(), + NormalizedPayload = row.Question.Clone() + }; + var validation = ValidateImportRow(row.Question); + if (validation is not null) + { + item.Status = ContentImportItemStatus.Invalid; + item.IssuesCount = 1; + issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, + validation.Value.Field, validation.Value.Message)); + job.ErrorCount++; + importItems.Add(item); + continue; + } + + job.ValidCount++; + item.Status = ContentImportItemStatus.Valid; + if (execute) + { + var node = targetNode; + if (format == "structured") + { + (node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, + targetNode, nodeCache, token); + createdNodes += made; + } + + if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required"); + var result = + await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token); + item.TargetType = "question"; + item.TargetId = result.Question.Id; + item.ContentHash = result.SourceHash; + item.Status = result.Action switch + { + "inserted" => ContentImportItemStatus.Inserted, + "updated" => ContentImportItemStatus.Updated, + _ => ContentImportItemStatus.Skipped + }; + if (result.Action == "inserted") inserted++; + else if (result.Action == "updated") updated++; + else skipped++; + } + + importItems.Add(item); + } + + job.InsertedCount = inserted; + job.UpdatedCount = updated; + job.SkippedCount = skipped; + job.Status = !execute ? ContentImportStatus.Preview : + job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed; + job.FinishedAt = execute ? DateTimeOffset.UtcNow : null; + job.Summary = JsonSerializer.SerializeToElement(new + { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount }); + job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question)); + if (execute) + { + dbContext.ContentImportItems.AddRange(importItems); + dbContext.ContentImportIssues.AddRange(issues); + AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, + new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount }); + await dbContext.SaveChangesAsync(token); + } + + var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), + issues.Select(ToIssueItem).ToArray()); + return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped); + }, cancellationToken); + } + + private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(IPlatformQuestionBankAdministrationPersistence dbContext, + PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection path, + ContentNode? root, Dictionary cache, CancellationToken token) + { + var parent = root; + var created = 0; + foreach (var part in path) + { + var cacheKey = $"{parent?.Id:N}/{part.Key}"; + if (!cache.TryGetValue(cacheKey, out var node)) + { + var parentId = parent?.Id; + node = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && + item.ParentId == parentId && item.NodeKey == part.Key, token); + if (node is null) + { + node = new ContentNode + { + TenantId = tenantId, + EntryId = bank.ContentEntryId!.Value, + ParentId = parent?.Id, + NodeKey = part.Key, + Name = part.Name, + NodeType = part.Type, + Depth = parent is null ? 0 : parent.Depth + 1, + Path = parent is null ? null : $"{parent.Path}.n{Guid.NewGuid():N}", + SortOrder = part.Order, + IsActive = true, + IsSelectable = true, + IsLeaf = part.Type is ContentNodeType.Chapter or ContentNodeType.Paper, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; + dbContext.ContentNodes.Add(node); + await dbContext.SaveChangesAsync(token); + created++; + } + + cache[cacheKey] = node; + } + + parent = node; + } + + if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required"); + return (parent, created); + } + + private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync( + IPlatformQuestionBankAdministrationPersistence dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, + JsonElement payload, CancellationToken token) + { + var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id"); + var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText()); + var question = !string.IsNullOrWhiteSpace(legacyId) + ? await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.LegacyId == legacyId, token) + : await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id) + .Join( + dbContext.QuestionVersions.Where(item => + item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, + version => version.Id, (item, _) => item) + .SingleOrDefaultAsync(token); + if (question is not null && question.QuestionBankId != bank.Id) + throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict"); + if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync( + item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) + return (question, "skipped", sourceHash); + var isNew = question is null; + question ??= new Question + { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId }; + if (isNew) dbContext.Questions.Add(question); + var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash); + ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); + await dbContext.SaveChangesAsync(token); + var nextVersion = await dbContext.QuestionVersions + .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + await dbContext.SaveChangesAsync(token); + return (question, isNew ? "inserted" : "updated", sourceHash); + } + + private static List ExtractSimpleRows(JsonElement payload, Guid nodeId) + { + var array = payload.ValueKind == JsonValueKind.Array + ? payload + : + payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) + ? + questions + : default; + if (array.ValueKind != JsonValueKind.Array) + throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid"); + return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)) + .ToList(); + } + + private static List ExtractStructuredRows(JsonElement payload, Guid? rootNodeId) + { + if (payload.ValueKind != JsonValueKind.Object) + throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid"); + if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") + throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid"); + var rows = new List(); + WalkStructured(payload, [], rows, rootNodeId); + if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty"); + return rows; + } + + private static void WalkStructured(JsonElement current, List path, List rows, + Guid? rootNodeId) + { + if (current.ValueKind != JsonValueKind.Object) return; + if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array) + foreach (var question in questions.EnumerateArray()) + rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId)); + string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"]; + foreach (var property in childProperties) + { + if (!current.TryGetProperty(property, out var children) || + children.ValueKind != JsonValueKind.Array) continue; + foreach (var child in children.EnumerateArray()) + { + if (child.ValueKind != JsonValueKind.Object) continue; + var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点"; + var key = GetString(child, "code") ?? GetString(child, "key") ?? + GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}"; + var type = property switch + { + "subjects" => ContentNodeType.Subject, + "chapters" => ContentNodeType.Chapter, + "papers" => ContentNodeType.Paper, + _ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category) + }; + var next = new List(path) + { new(key, name, type, GetInt(child, "order") ?? path.Count) }; + WalkStructured(child, next, rows, rootNodeId); + } + } + } + + private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。"); + if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && + string.IsNullOrWhiteSpace(GetString(payload, "title"))) + return ("question_content_required", "content", "题干不能为空。"); + var type = GetString(payload, "type") ?? "choice"; + if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。"); + return null; + } + + private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, + string? legacyId, string sourceHash) + { + return new UpsertPlatformQuestionCommand( + null, bankId, nodeId, 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"), GetString(payload, "status") ?? "published", + GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash); + } + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportService.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportService.cs new file mode 100644 index 0000000..4b0c4b4 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Imports/PlatformQuestionImportService.cs @@ -0,0 +1,30 @@ +using Tiku.Application.Assets; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionImportService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) + : PlatformQuestionBankServiceBase(currentAccessContext, tenantExecutionScope), IPlatformQuestionImportService +{ + public Task PreviewImportAsync( + PlatformAdminActor actor, + PlatformQuestionImportCommand command, + CancellationToken cancellationToken = default) => + PreviewImportCoreAsync(actor, command, cancellationToken); + + public Task ExecuteImportAsync( + PlatformAdminActor actor, + PlatformQuestionImportCommand command, + CancellationToken cancellationToken = default) => + ExecuteImportCoreAsync(actor, command, cancellationToken); + + public Task GetImportAsync( + PlatformAdminActor actor, + Guid jobId, + CancellationToken cancellationToken = default) => + GetImportCoreAsync(actor, jobId, cancellationToken); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeFoundation.cs new file mode 100644 index 0000000..3298d74 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeFoundation.cs @@ -0,0 +1,151 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase +{ + protected Task> GetNodesCoreAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "查询公共题库内容结构", + async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token); + var nodes = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId) + .OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name) + .ToArrayAsync(token); + var counts = await dbContext.Questions.AsNoTracking() + .Where(item => + item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && + item.Status != QuestionStatus.Archived) + .GroupBy(item => item.ContentNodeId!.Value) + .Select(group => new { NodeId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.NodeId, item => item.Count, token); + return nodes.Select(item => + ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray(); + }, cancellationToken); + } + + protected Task UpsertNodeCoreServiceAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankNodeCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token); + }, cancellationToken); + } + + protected Task> BatchCreateNodesCoreAsync( + PlatformAdminActor actor, + BatchCreatePlatformQuestionBankNodesCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "批量创建章节或试卷", + async (_, dbContext, tenantId, token) => + { + if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper) + throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid"); + + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (names.Length is 0 or > 100) throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid"); + + var result = new List(); + var order = 0; + foreach (var name in names) + result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, + new UpsertPlatformQuestionBankNodeCommand( + null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, + JsonDefaults.Object()), token)); + + return result; + }, cancellationToken); + } + + protected Task ArchiveNodeCoreAsync( + PlatformAdminActor actor, + Guid nodeId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) => + { + var node = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == nodeId, token) + ?? throw Error("内容节点不存在。", "question_bank_node_not_found"); + if (await dbContext.ContentNodes.AnyAsync( + item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token)) + throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children"); + + if (await dbContext.Questions.AnyAsync( + item => item.TenantId == tenantId && item.ContentNodeId == nodeId && + item.Status != QuestionStatus.Archived, token)) + throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions"); + + node.IsActive = false; + node.UpdatedBy = actor.UserId; + var bankId = await dbContext.QuestionBanks + .Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id) + .SingleAsync(token); + AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name }); + await dbContext.SaveChangesAsync(token); + return ToNodeItem(node, bankId, 0); + }, cancellationToken); + } + + private static async Task UpsertNodeCoreAsync(IPlatformQuestionBankAdministrationPersistence dbContext, + PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, + CancellationToken token) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required"); + ContentNode? parent = null; + if (command.ParentId.HasValue) + parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token); + var node = command.Id.HasValue + ? await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, + token) + : null; + if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found"); + node ??= new ContentNode + { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId }; + if (!command.Id.HasValue) dbContext.ContentNodes.Add(node); + node.ParentId = parent?.Id; + node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim(); + node.Name = command.Name.Trim(); + node.NodeType = command.NodeType; + node.Depth = parent is null ? 0 : parent.Depth + 1; + node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; + node.SortOrder = command.SortOrder; + node.IsActive = true; + node.IsSelectable = command.IsSelectable; + node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper; + node.Metadata = ObjectOrDefault(command.Metadata); + node.UpdatedBy = actor.UserId; + AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, + new { bankId = bank.Id, node.Name, node.NodeType }); + await dbContext.SaveChangesAsync(token); + return ToNodeItem(node, bank.Id, 0); + } + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeService.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeService.cs new file mode 100644 index 0000000..69152d3 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Nodes/PlatformQuestionBankNodeService.cs @@ -0,0 +1,35 @@ +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionBankNodeService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) + : PlatformQuestionBankServiceBase(currentAccessContext, tenantExecutionScope), IPlatformQuestionBankNodeService +{ + public Task> GetNodesAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) => + GetNodesCoreAsync(actor, bankId, cancellationToken); + + public Task UpsertNodeAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankNodeCommand command, + CancellationToken cancellationToken = default) => + UpsertNodeCoreServiceAsync(actor, command, cancellationToken); + + public Task> BatchCreateNodesAsync( + PlatformAdminActor actor, + BatchCreatePlatformQuestionBankNodesCommand command, + CancellationToken cancellationToken = default) => + BatchCreateNodesCoreAsync(actor, command, cancellationToken); + + public Task ArchiveNodeAsync( + PlatformAdminActor actor, + Guid nodeId, + CancellationToken cancellationToken = default) => + ArchiveNodeCoreAsync(actor, nodeId, cancellationToken); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationFoundation.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationFoundation.cs new file mode 100644 index 0000000..279f57d --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationFoundation.cs @@ -0,0 +1,186 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.Learning; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal abstract partial class PlatformQuestionBankServiceBase +{ + protected Task GetQuestionsCoreAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) => + { + if (!filter.QuestionBankId.HasValue) throw Error("请选择公共题库。", "question_bank_id_required"); + + await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token); + var query = dbContext.Questions.AsNoTracking().Where(item => + item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId); + if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId); + if (!string.IsNullOrWhiteSpace(filter.Type)) + { + var type = filter.Type.Trim(); + query = query.Where(item => item.Type == type); + } + + if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty); + if (!string.IsNullOrWhiteSpace(filter.Status) && + !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + var status = ParseQuestionStatus(filter.Status); + query = query.Where(item => item.Status == status); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => + version.TenantId == tenantId && version.QuestionId == item.Id && + version.Id == item.CurrentVersionId && version.Content != null && + version.Content.Contains(keyword))); + } + + var page = Math.Max(1, filter.Page); + var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize); + var total = await query.CountAsync(token); + var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize) + .Take(pageSize).ToArrayAsync(token); + var versionIds = questions.Where(item => item.CurrentVersionId.HasValue) + .Select(item => item.CurrentVersionId!.Value).ToArray(); + var versions = await dbContext.QuestionVersions.AsNoTracking() + .Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)) + .ToDictionaryAsync(item => item.Id, token); + return new PlatformQuestionPage( + questions.Select(item => ToQuestionItem(item, + item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) + ? version + : null)).ToArray(), total, page, pageSize); + }, cancellationToken); + } + + protected Task UpsertQuestionCoreAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token); + ValidateQuestion(command); + var question = command.Id.HasValue + ? await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, + token) + : null; + if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found"); + question ??= new Question + { + TenantId = tenantId, + QuestionBankId = bank.Id, + EntryId = bank.ContentEntryId, + CreatedAt = DateTimeOffset.UtcNow + }; + if (!command.Id.HasValue) dbContext.Questions.Add(question); + ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); + await dbContext.SaveChangesAsync(token); + var nextVersion = await dbContext.QuestionVersions + .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, + new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo }); + await dbContext.SaveChangesAsync(token); + return ToQuestionItem(question, version); + }, cancellationToken); + } + + protected Task ArchiveQuestionsCoreAsync( + PlatformAdminActor actor, + ArchivePlatformQuestionsCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) => + { + var ids = command.QuestionIds.Distinct().Take(500).ToArray(); + if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required"); + var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)) + .ToArrayAsync(token); + foreach (var question in questions) question.Status = QuestionStatus.Archived; + AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), + new { questionIds = questions.Select(item => item.Id).ToArray() }); + await dbContext.SaveChangesAsync(token); + return questions.Length; + }, cancellationToken); + } + + private static void ValidateQuestion(UpsertPlatformQuestionCommand command) + { + if (string.IsNullOrWhiteSpace(command.Content)) throw Error("题干不能为空。", "question_content_required"); + var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim(); + if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid"); + if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid"); + if (ParseQuestionStatus(command.Status) == QuestionStatus.Published && + !QuestionGrader.HasValidAuthoritativeAnswer( + type, + command.CorrectOptionIndex, + command.CorrectOptionIndices, + command.AnswerText)) + throw Error("发布题目必须提供有效的标准答案。", "question_grading_rule_invalid"); + } + + private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, + Guid nodeId) + { + question.QuestionBankId = command.QuestionBankId; + question.EntryId = entryId; + question.ContentNodeId = nodeId; + question.LegacyId = Normalize(command.LegacyId); + question.Type = Normalize(command.Type) ?? "choice"; + question.TypeLabel = Normalize(command.TypeLabel); + question.Difficulty = command.Difficulty; + question.Tags = ArrayOrDefault(command.Tags); + question.MediaUrl = Normalize(command.MediaUrl); + question.ExamMarkers = ObjectOrDefault(command.ExamMarkers); + question.Status = ParseQuestionStatus(command.Status); + } + + private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, + UpsertPlatformQuestionCommand command) + { + return new QuestionVersion + { + TenantId = tenantId, + QuestionId = questionId, + VersionNo = versionNo, + Content = Normalize(command.Content), + Options = ArrayOrDefault(command.Options), + CorrectOptionIndex = command.CorrectOptionIndex, + CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices), + AnswerText = Normalize(command.AnswerText), + Explanation = Normalize(command.Explanation), + SubQuestions = ArrayOrDefault(command.SubQuestions), + CodeLang = Normalize(command.CodeLang), + CodeTemplate = Normalize(command.CodeTemplate), + SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)), + CreatedBy = actor.UserId + }; + } + +} diff --git a/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationService.cs b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationService.cs new file mode 100644 index 0000000..b698174 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/QuestionBanks/Questions/PlatformQuestionAdministrationService.cs @@ -0,0 +1,29 @@ +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionAdministrationService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) + : PlatformQuestionBankServiceBase(currentAccessContext, tenantExecutionScope), IPlatformQuestionAdministrationService +{ + public Task GetQuestionsAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) => + GetQuestionsCoreAsync(actor, filter, cancellationToken); + + public Task UpsertQuestionAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionCommand command, + CancellationToken cancellationToken = default) => + UpsertQuestionCoreAsync(actor, command, cancellationToken); + + public Task ArchiveQuestionsAsync( + PlatformAdminActor actor, + ArchivePlatformQuestionsCommand command, + CancellationToken cancellationToken = default) => + ArchiveQuestionsCoreAsync(actor, command, cancellationToken); +} diff --git a/Tiku.Infrastructure/PlatformAdmin/Sms/PlatformSmsAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/Sms/PlatformSmsAdminService.cs new file mode 100644 index 0000000..5d1494c --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/Sms/PlatformSmsAdminService.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformSmsAdminService +{ + public Task> GetChannelsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channels list", async (services, token) => + { + var db = services.GetRequiredService(); + var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, + TimeSpan.Zero); + var sentCounts = await db.SmsSendLogs.AsNoTracking() + .Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && + value.ChannelId.HasValue) + .GroupBy(value => value.ChannelId!.Value) + .Select(group => new { ChannelId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(value => value.ChannelId, value => value.Count, token); + var values = + from channel in db.SmsChannels.AsNoTracking() + join tenant in db.Tenants.AsNoTracking() on channel.TenantId equals tenant.Id + select new { channel, tenant.Name }; + if (query.TenantId.HasValue) values = values.Where(value => value.channel.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); + var rows = await values.OrderBy(value => value.channel.Priority) + .ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + return new PlatformTenantCapabilityList(rows.Select(value => + ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray()); + }, cancellationToken); + } + + public Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channel upsert", async (services, token) => + { + var db = services.GetRequiredService(); + var tenantName = await TenantNameAsync(db, command.TenantId, token); + var provider = NormalizeCode(command.Provider); + var scene = NormalizeCode(command.Scene); + var item = command.Id.HasValue + ? await db.SmsChannels.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + : await db.SmsChannels.SingleOrDefaultAsync( + value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, + token); + if (item is null) + { + item = new SmsChannel { TenantId = command.TenantId }; + db.SmsChannels.Add(item); + } + + item.Provider = provider; + item.Name = command.Name.Trim(); + item.Signature = command.Signature.Trim(); + item.Scene = scene; + item.Status = command.Status; + item.SecretRef = Normalize(command.SecretRef); + item.Priority = command.Priority ?? item.Priority; + item.MonthlyQuota = Math.Max(0, command.MonthlyQuota ?? item.MonthlyQuota); + item.ConfigPublic = command.ConfigPublic; + item.Metadata = command.Metadata; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, + new { item.Provider, item.Scene, item.Status }); + await db.SaveChangesAsync(token); + return ToChannelItem(item, tenantName, 0); + }, cancellationToken); + } + + public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channel disable", async (services, token) => + { + var db = services.GetRequiredService(); + var item = await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) + ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); + item.Status = TenantExternalProviderStatus.Disabled; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, + new { item.Provider, item.Scene }); + await db.SaveChangesAsync(token); + return ToChannelItem(item, await TenantNameAsync(db, item.TenantId, token), 0); + }, cancellationToken); + } + + public Task> GetTemplatesAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms templates list", async (services, token) => + { + var db = services.GetRequiredService(); + var sentCounts = await db.SmsSendLogs.AsNoTracking() + .Where(value => value.TemplateId.HasValue) + .GroupBy(value => value.TemplateId!.Value) + .Select(group => new + { + TemplateId = group.Key, + Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), + Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) + }) + .ToDictionaryAsync(value => value.TemplateId, token); + var values = + from template in db.SmsTemplates.AsNoTracking() + join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals + new { channel.TenantId, ChannelId = channel.Id } + join tenant in db.Tenants.AsNoTracking() on template.TenantId equals tenant.Id + select new { template, channel.Name, TenantName = tenant.Name }; + if (query.TenantId.HasValue) values = values.Where(value => value.template.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active)); + var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); + return new PlatformTenantCapabilityList(rows.Select(value => + { + var counts = sentCounts.GetValueOrDefault(value.template.Id); + return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, + counts?.Failed ?? 0); + }).ToArray()); + }, cancellationToken); + } + + public Task UpsertTemplateAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms template upsert", async (services, token) => + { + var db = services.GetRequiredService(); + var channel = await db.SmsChannels.AsNoTracking() + .SingleOrDefaultAsync( + value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token) + ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); + var item = command.Id.HasValue + ? await db.SmsTemplates.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + : await db.SmsTemplates.SingleOrDefaultAsync( + value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token); + if (item is null) + { + item = new SmsTemplate { TenantId = command.TenantId }; + db.SmsTemplates.Add(item); + } + + item.ChannelId = command.ChannelId; + item.Code = NormalizeCode(command.Code); + item.Name = command.Name.Trim(); + item.Type = command.Type; + item.AuditStatus = command.AuditStatus; + item.Status = command.Status; + item.ProviderTemplateCode = Normalize(command.ProviderTemplateCode); + item.Content = command.Content.Trim(); + item.Remark = Normalize(command.Remark); + item.Metadata = command.Metadata; + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, + new { item.Code, item.AuditStatus, item.Status }); + await db.SaveChangesAsync(token); + return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channel.Name, 0, 0); + }, cancellationToken); + } + + public Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default) + { + return ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, + "platform.sms.template.review_submitted", cancellationToken); + } + + public Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default) + { + return ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, + "platform.sms.template.disabled", cancellationToken); + } + + public Task> GetLogsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms logs list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.SmsSendLogs.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent)); + return new PlatformTenantCapabilityList(await values.OrderByDescending(value => value.CreatedAt) + .Take(Limit(query.Limit)).ToArrayAsync(token)); + }, cancellationToken); + } + + private Task ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, + SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, + CancellationToken cancellationToken) + { + return ExecuteAsync(action, async (services, token) => + { + var db = services.GetRequiredService(); + var item = await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == templateId, token) + ?? throw Error("SMS template was not found.", "sms_template_not_found"); + var channelName = await db.SmsChannels.AsNoTracking() + .Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId) + .Select(value => value.Name).SingleAsync(token); + if (auditStatus.HasValue) + { + item.AuditStatus = auditStatus.Value; + item.SubmittedAt = DateTimeOffset.UtcNow; + } + + if (status.HasValue) + { + item.Status = status.Value; + item.DisabledAt = DateTimeOffset.UtcNow; + } + + item.UpdatedAt = DateTimeOffset.UtcNow; + AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, + new { item.Code, item.AuditStatus, item.Status }); + await db.SaveChangesAsync(token); + return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channelName, 0, 0); + }, cancellationToken); + } + + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) + { + return new PlatformSmsChannelItem(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, + channel.Signature, + channel.Scene, channel.Status, + channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, + channel.Metadata, channel.UpdatedAt); + } + + private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, + int sent, int failed) + { + return new PlatformSmsTemplateItem(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, + template.Code, + template.Name, template.Type, + template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, + sent, failed, template.UpdatedAt); + } + + private static async Task TenantNameAsync(IPlatformTenantCapabilitiesPersistence db, Guid tenantId, CancellationToken token) + { + return await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name) + .SingleOrDefaultAsync(token) + ?? throw Error("Tenant was not found.", "tenant_not_found"); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } + + private static void AddAudit(IPlatformTenantCapabilitiesPersistence db, PlatformCapabilityActor actor, Guid tenantId, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + TenantId = tenantId, + ActorUserId = actor.UserId, + Action = action, + TargetType = targetType, + TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + } +} diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantPayments/PlatformTenantPaymentAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/TenantPayments/PlatformTenantPaymentAdminService.cs new file mode 100644 index 0000000..78afeb0 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/TenantPayments/PlatformTenantPaymentAdminService.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Common; +using Tiku.Domain.Growth; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) + : IPlatformTenantPaymentAdminService +{ + public Task> GetAppsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment apps list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.TenantExternalProviders.AsNoTracking() + .Where(value => value.Capability == TenantExternalProviderCapability.Payment); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); + var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority) + .Take(Limit(query.Limit)) + .Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, + value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, + value.Metadata, value.CreatedAt, value.UpdatedAt)) + .ToArrayAsync(token); + return new PlatformTenantCapabilityList(items); + }, cancellationToken); + } + + public Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, + UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment app upsert", async (services, token) => + { + if (command.Capability != TenantExternalProviderCapability.Payment) + throw Error("Tenant payment capability is required.", "tenant_payment_capability_required"); + var item = await services.GetRequiredService() + .UpsertProviderAsync(tenantId, command, token); + services.GetRequiredService().AuditLogs.Add(new AuditLog + { + TenantId = tenantId, + ActorUserId = actor.UserId, + Action = "platform.tenant_payment.app.upserted", + TargetType = "tenant_external_providers", + TargetId = item.Id.ToString(), + Details = JsonSerializer.SerializeToElement(new { item.Provider, item.Status, item.SecretRef }) + }); + await services.GetRequiredService().SaveChangesAsync(token); + return item; + }, cancellationToken); + } + + public Task> GetEventsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment events list", async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PaymentEvents.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) + .Select(value => new + { + value.Id, + value.TenantId, + value.PaymentId, + value.Provider, + value.EventType, + value.EventId, + value.SignatureValid, + value.ProcessedAt, + value.Error, + value.CreatedAt + }) + .Cast() + .ToArrayAsync(token); + return new PlatformTenantCapabilityList(items); + }, cancellationToken); + } + + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), + reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantExportJobHandler.cs b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantExportJobHandler.cs index b46e15a..ea742ad 100644 --- a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantExportJobHandler.cs +++ b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantExportJobHandler.cs @@ -14,7 +14,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin.TenantProvisioning; internal sealed class TenantExportJobHandler( - TikuDbContext dbContext, + ITenancyPersistence tenancyPersistence, + IIdentityPersistence identityPersistence, + IContentAssetPersistence contentAssetPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IObjectStorageService storage) : IBackgroundJobHandler { public string JobType => "tenant_export"; @@ -25,25 +28,25 @@ internal sealed class TenantExportJobHandler( { var operationId = BackgroundJobPayload.GetGuid(context.Payload, "operationId") ?? throw new InvalidOperationException("tenant_export job requires operationId."); - var operation = await dbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item => + var operation = await jobsOperationsPersistence.TenantLifecycleOperations.SingleOrDefaultAsync(item => item.TenantId == context.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 dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz"); try { - var tenant = await dbContext.Tenants.AsNoTracking() + var tenant = await tenancyPersistence.Tenants.AsNoTracking() .SingleAsync(item => item.Id == context.TenantId, cancellationToken); - var memberships = await dbContext.TenantMemberships.AsNoTracking() + var memberships = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == context.TenantId) .Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt }) .ToArrayAsync(cancellationToken); - var domains = await dbContext.TenantDomains.AsNoTracking() + var domains = await tenancyPersistence.TenantDomains.AsNoTracking() .Where(item => item.TenantId == context.TenantId) .Select(item => new { @@ -56,7 +59,7 @@ internal sealed class TenantExportJobHandler( item.UpdatedAt }) .ToArrayAsync(cancellationToken); - var assets = await dbContext.ContentAssets.AsNoTracking() + var assets = await contentAssetPersistence.ContentAssets.AsNoTracking() .Where(item => item.TenantId == context.TenantId && item.Status == ContentStatus.Active) .ToArrayAsync(cancellationToken); @@ -175,7 +178,7 @@ internal sealed class TenantExportJobHandler( SecurityScanStatus = AssetSecurityScanStatus.NotRequired, Source = "tenant_export" }; - dbContext.ContentAssets.Add(exportAsset); + contentAssetPersistence.ContentAssets.Add(exportAsset); operation.ExportAssetId = exportAsset.Id; operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.CompletedAt = DateTimeOffset.UtcNow; @@ -185,7 +188,7 @@ internal sealed class TenantExportJobHandler( written.SizeBytes, assetCount = assets.Length }); - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); return new BackgroundJobHandlerResult(operation.Result, exportAsset.Id); } catch (Exception exception) when (exception is not OperationCanceledException) @@ -193,7 +196,7 @@ internal sealed class TenantExportJobHandler( operation.Status = TenantLifecycleOperationStatus.Failed; operation.LastError = exception.Message; operation.CompletedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); throw; } finally diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantProvisioningReadinessProbe.cs b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantProvisioningReadinessProbe.cs new file mode 100644 index 0000000..6a3f525 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantProvisioningReadinessProbe.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Platform; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin.TenantProvisioning; + +internal sealed class TenantProvisioningReadinessProbe( + ITenantExecutionScope tenantExecutionScope) : ITenantProvisioningReadinessProbe +{ + public Task IsPublishedBaseOfferingAvailableAsync( + string offeringCode, + CancellationToken cancellationToken = default) + { + var normalizedCode = offeringCode.Trim().ToLowerInvariant(); + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest( + null, + SystemScopeCallerType.Platform, + nameof(TenantProvisioningReadinessProbe), + "Validate the production default tenant offering", + Guid.NewGuid().ToString("N"), + true), + async (services, token) => + { + var persistence = services.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + return await ( + from offering in persistence.SaasOfferings.AsNoTracking() + join version in persistence.SaasOfferingVersions.AsNoTracking() + on offering.Id equals version.OfferingId + where offering.Code == normalizedCode && + offering.Type == SaasOfferingType.BasePlan && + offering.Status == SaasOfferingStatus.Active && + version.Status == SaasOfferingVersionStatus.Published && + (version.EffectiveAt == null || version.EffectiveAt <= now) + select version.Id).AnyAsync(token); + }, + cancellationToken); + } +} diff --git a/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs b/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs index cd53801..0348cdd 100644 --- a/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs +++ b/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs @@ -21,7 +21,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class CommercialBillingProcessor( - TikuDbContext directoryDbContext, + IPlatformBillingPersistence directoryDbContext, ITenantContext tenantContext, ITenantExecutionScope tenantExecutionScope, IOptions options, @@ -70,7 +70,7 @@ internal sealed class CommercialBillingProcessor( Scope(candidate.TenantId, "Generate subscription renewal receivable", candidate.SubscriptionId), async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var subscription = await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId, token); @@ -144,7 +144,7 @@ internal sealed class CommercialBillingProcessor( Scope(candidate.TenantId, "Generate billing reminder", candidate.InvoiceId), async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var invoice = await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token); var dueDate = invoice.DueDate!.Value; @@ -237,7 +237,7 @@ internal sealed class CommercialBillingProcessor( Scope(candidate.TenantId, "Execute approved SaaS refund", candidate.RefundId), async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var refund = await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token); var payment = @@ -284,7 +284,7 @@ internal sealed class CommercialBillingProcessor( TargetType = "platform_billing_refunds", TargetId = refund.Id.ToString(), Details = JsonSerializer.SerializeToElement(new - { refund.AmountCents, refund.SubscriptionEffect }) + { refund.AmountCents, refund.SubscriptionEffect }) }); } catch (Exception exception) @@ -318,7 +318,7 @@ internal sealed class CommercialBillingProcessor( Scope(candidate.TenantId, "Dispatch billing dunning notification", candidate.EventId), async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync( value => value.Id == candidate.EventId, token); var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking() @@ -385,7 +385,7 @@ internal sealed class CommercialBillingProcessor( return candidates.Length; } - private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund, + private async Task ApplyRefundEffectAsync(IPlatformBillingPersistence db, PlatformBillingRefund refund, CancellationToken cancellationToken) { if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) return; @@ -478,7 +478,7 @@ internal sealed class CommercialBillingProcessor( }; } - private static async Task BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId, + private static async Task BillingProfileSnapshotAsync(IPlatformBillingPersistence db, Guid tenantId, CancellationToken cancellationToken) { var profile = await db.TenantBillingProfiles.AsNoTracking() diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs index 4dd7956..f669a32 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs @@ -20,7 +20,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync>("list SaaS orders", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.PlatformBillingOrders.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -36,7 +36,7 @@ internal sealed class PlatformBillingAdminService( return ExecuteAsync>("list SaaS payments", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.PlatformBillingPayments.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -51,7 +51,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync>("list SaaS refunds", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.PlatformBillingRefunds.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -67,7 +67,7 @@ internal sealed class PlatformBillingAdminService( return ExecuteAsync>("list SaaS invoices", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.PlatformBillingInvoices.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -82,7 +82,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync>("list SaaS usage", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.TenantFeatureUsages.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode) @@ -96,7 +96,7 @@ internal sealed class PlatformBillingAdminService( return ExecuteAsync>("list SaaS invoice reminders", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.PlatformBillingInvoiceReminders.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -113,7 +113,7 @@ internal sealed class PlatformBillingAdminService( return ExecuteAsync>("list SaaS subscriptions", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var values = db.TenantSaasSubscriptions.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -130,7 +130,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("confirm manual SaaS payment", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var payment = await db.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) ?? throw Error("Platform billing payment was not found.", @@ -161,7 +161,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("upsert tenant feature override", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var featureCode = command.FeatureCode.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(command.Reason)) throw Error("Override reason is required.", "platform_billing_reason_required"); @@ -188,7 +188,7 @@ internal sealed class PlatformBillingAdminService( TargetType = "tenant_feature_overrides", TargetId = item.Id.ToString(), Details = JsonSerializer.SerializeToElement(new - { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason }) + { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason }) }); await db.SaveChangesAsync(token); await services.GetRequiredService() @@ -204,7 +204,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("grant tenant SaaS trial", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); var existingRequest = await db.PlatformOperationIdempotencies.AsNoTracking() .SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId && @@ -308,7 +308,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("request SaaS refund", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); var payment = await db.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) @@ -375,7 +375,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("retry SaaS refund", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); @@ -395,7 +395,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync("get SaaS commercial metrics", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var now = DateTimeOffset.UtcNow; var periodStart = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); var subscriptions = await ( @@ -449,7 +449,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync($"{action} SaaS subscription", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var subscription = await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId, token) @@ -525,7 +525,7 @@ internal sealed class PlatformBillingAdminService( { return ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); @@ -545,7 +545,7 @@ internal sealed class PlatformBillingAdminService( }, cancellationToken); } - private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId, + private static void AddAudit(IPlatformBillingPersistence db, Guid actorUserId, Guid tenantId, string action, Guid targetId, string? reason) { db.AuditLogs.Add(new AuditLog diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs index 2eb4d40..0e01fc7 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs @@ -27,7 +27,7 @@ internal sealed class PlatformBillingNotificationService( "Settle platform billing notification", parsed.EventId, true), async (services, token) => { - var db = services.GetRequiredService(); + var db = services.GetRequiredService(); var order = await db.PlatformBillingOrders.AsNoTracking() .SingleOrDefaultAsync(value => value.OrderNo == parsed.OrderNo, token) ?? throw Error("Platform billing order was not found.", "platform_billing_order_not_found"); diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs index 10d087a..79c8104 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs @@ -56,7 +56,7 @@ internal sealed class PlatformBillingPaymentGateway( true), async (services, token) => { - var dbContext = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); var platformTenantId = await dbContext.Tenants.AsNoTracking() .Where(value => value.Mode == TenantMode.PlatformOwned) .Select(value => value.Id) diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs index 3e4d6ab..576c4d1 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs @@ -11,7 +11,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class PlatformBillingSettlementService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + ITenancyPersistence tenancyPersistence, + ITenantAdministrationPersistence tenantAdministrationPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService { public async Task MarkPaidAsync( @@ -25,21 +28,21 @@ internal sealed class PlatformBillingSettlementService( CancellationToken cancellationToken = default) { var payment = - await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, + await platformControlPlanePersistence.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, cancellationToken) ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); - if (await dbContext.PlatformBillingPaymentEvents.AnyAsync(value => + if (await platformControlPlanePersistence.PlatformBillingPaymentEvents.AnyAsync(value => value.TenantId == payment.TenantId && value.Provider == payment.Provider && value.ProviderEventId == providerEventId, cancellationToken)) return payment; - var order = await dbContext.PlatformBillingOrders.SingleAsync(value => + var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleAsync(value => value.TenantId == payment.TenantId && value.Id == payment.OrderId, cancellationToken); if (payment.AmountCents != order.TotalAmountCents) throw Error("Payment amount does not match the order.", "platform_billing_payment_amount_mismatch"); - dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent + platformControlPlanePersistence.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent { TenantId = payment.TenantId, PaymentId = payment.Id, @@ -50,7 +53,7 @@ internal sealed class PlatformBillingSettlementService( }); if (payment.Status == PlatformBillingPaymentStatus.Succeeded) { - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return payment; } @@ -64,17 +67,17 @@ internal sealed class PlatformBillingSettlementService( payment.PaidAt = paidAt; order.Status = PlatformBillingOrderStatus.Paid; order.PaidAt = paidAt; - var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken); + var tenant = await tenancyPersistence.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken); tenant.BillingStatus = BillingStatus.Active; - var orderItems = await dbContext.PlatformBillingOrderItems.AsNoTracking() + var orderItems = await platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking() .Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id) .ToArrayAsync(cancellationToken); var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan) ?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing"); - var baseVersion = await dbContext.SaasOfferingVersions.AsNoTracking() + var baseVersion = await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() .SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken); - var subscription = await dbContext.TenantSaasSubscriptions + var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken); var now = paidAt; @@ -89,7 +92,7 @@ internal sealed class PlatformBillingSettlementService( CurrentPeriodStart = now, CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle) }; - dbContext.TenantSaasSubscriptions.Add(subscription); + platformControlPlanePersistence.TenantSaasSubscriptions.Add(subscription); } else if (order.Purpose == PlatformBillingOrderPurpose.Renewal) { @@ -122,7 +125,7 @@ internal sealed class PlatformBillingSettlementService( if (order.Purpose != PlatformBillingOrderPurpose.Downgrade) { - var existingItems = await dbContext.TenantSaasSubscriptionItems + var existingItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems .Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Active) .ToArrayAsync(cancellationToken); @@ -132,7 +135,7 @@ internal sealed class PlatformBillingSettlementService( existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } - dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem + platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, SubscriptionId = subscription.Id, @@ -148,7 +151,7 @@ internal sealed class PlatformBillingSettlementService( } else { - var existingScheduledItems = await dbContext.TenantSaasSubscriptionItems + var existingScheduledItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems .Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Scheduled) @@ -159,7 +162,7 @@ internal sealed class PlatformBillingSettlementService( existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } - dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem + platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, SubscriptionId = subscription.Id, @@ -174,7 +177,7 @@ internal sealed class PlatformBillingSettlementService( })); } - var invoice = await dbContext.PlatformBillingInvoices.SingleOrDefaultAsync(value => + var invoice = await platformControlPlanePersistence.PlatformBillingInvoices.SingleOrDefaultAsync(value => value.TenantId == order.TenantId && value.OrderId == order.Id, cancellationToken); if (invoice is null) { @@ -188,12 +191,12 @@ internal sealed class PlatformBillingSettlementService( IssuedAt = now, BillingProfileSnapshot = await LoadBillingProfileSnapshotAsync(order.TenantId, cancellationToken) }; - dbContext.PlatformBillingInvoices.Add(invoice); + platformControlPlanePersistence.PlatformBillingInvoices.Add(invoice); } invoice.Status = PlatformBillingInvoiceStatus.Paid; invoice.PaidAt = now; - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = order.TenantId, ActorUserId = actorUserId, @@ -201,9 +204,9 @@ internal sealed class PlatformBillingSettlementService( TargetType = "platform_billing_orders", TargetId = order.Id.ToString(), Details = JsonSerializer.SerializeToElement(new - { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose }) + { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken); return payment; @@ -211,7 +214,7 @@ internal sealed class PlatformBillingSettlementService( private async Task LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken) { - var profile = await dbContext.TenantBillingProfiles.AsNoTracking() + var profile = await tenantAdministrationPersistence.TenantBillingProfiles.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken); return profile is null ? JsonDefaults.Object() diff --git a/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs b/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs index 6414109..48d5564 100644 --- a/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs +++ b/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs @@ -9,7 +9,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class SaasCatalogAdminService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence dbContext, IOperationAuditService auditService) : ISaasCatalogAdminService { public async Task GetCatalogAsync( @@ -297,8 +297,8 @@ internal sealed class SaasCatalogAdminService( CancellationToken cancellationToken) { var query = from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - select new { Version = version, Offering = offering }; + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + select new { Version = version, Offering = offering }; if (versionId.HasValue) query = query.Where(value => value.Version.Id == versionId); var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code) .ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken); diff --git a/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs b/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs index 39f7f9f..6bdd184 100644 --- a/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs +++ b/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs @@ -12,7 +12,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class SaasSubscriptionLifecycleService( - TikuDbContext directoryDbContext, + IPlatformControlPlanePersistence directoryPersistence, ITenantContext tenantContext, ITenantExecutionScope tenantExecutionScope, IOptions options) : ISaasSubscriptionLifecycleService @@ -29,7 +29,7 @@ internal sealed class SaasSubscriptionLifecycleService( "SaaS subscription lifecycle discovery requires a global system context."); var effectiveAt = asOf ?? DateTimeOffset.UtcNow; - var tenantIds = await directoryDbContext.TenantSaasSubscriptions.AsNoTracking() + var tenantIds = await directoryPersistence.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => subscription.CurrentPeriodEnd <= effectiveAt && (subscription.Status == TenantSaasSubscriptionStatus.Trial || @@ -66,7 +66,9 @@ internal sealed class SaasSubscriptionLifecycleService( DateTimeOffset asOf, CancellationToken cancellationToken) { - var dbContext = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); + var tenancyPersistence = services.GetRequiredService(); + var jobsOperationsPersistence = services.GetRequiredService(); var subscription = await dbContext.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => @@ -83,7 +85,7 @@ internal sealed class SaasSubscriptionLifecycleService( var items = await dbContext.TenantSaasSubscriptionItems .Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id) .ToArrayAsync(cancellationToken); - var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken); + var tenant = await tenancyPersistence.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken); string transition; if (subscription.CancelAtPeriodEnd) @@ -167,7 +169,7 @@ internal sealed class SaasSubscriptionLifecycleService( } subscription.LifecycleVersion++; - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = tenantId, Action = $"platform_billing.subscription.{transition}", @@ -217,4 +219,4 @@ internal sealed class SaasSubscriptionLifecycleService( item.Status = TenantSaasSubscriptionItemStatus.Cancelled; } } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs index df1c608..5532cb2 100644 --- a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs +++ b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs @@ -12,7 +12,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class TenantBillingService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IPlatformBillingPaymentGateway paymentGateway, IPlatformBillingSettlementService settlementService, IFeatureAccessService featureAccessService, @@ -26,7 +27,7 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var features = await dbContext.SaasFeatures.AsNoTracking() + var features = await platformControlPlanePersistence.SaasFeatures.AsNoTracking() .Where(value => value.Status == SaasFeatureStatus.Active && !value.IsCore) .OrderBy(value => value.SortOrder).ThenBy(value => value.Code) .ToArrayAsync(cancellationToken); @@ -43,7 +44,7 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var idempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"); - var existingQuote = await dbContext.PlatformBillingQuotes.AsNoTracking() + var existingQuote = await platformControlPlanePersistence.PlatformBillingQuotes.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey, cancellationToken); if (existingQuote is not null) return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken); @@ -54,8 +55,8 @@ internal sealed class TenantBillingService( .ToArray(); var now = DateTimeOffset.UtcNow; var versions = await ( - from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + from version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() + join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where requestedIds.Contains(version.Id) && version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active && @@ -71,10 +72,10 @@ internal sealed class TenantBillingService( if (versions.Select(value => value.Version.Currency).Distinct(StringComparer.Ordinal).Count() != 1) throw Error("All quote items must use the same currency.", "platform_billing_currency_mismatch"); - var featureRows = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + var featureRows = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) .ToArrayAsync(cancellationToken); - var limitRows = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + var limitRows = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) .ToArrayAsync(cancellationToken); var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal) @@ -97,8 +98,8 @@ internal sealed class TenantBillingService( FeatureSnapshot = JsonSerializer.SerializeToElement(featureCodes), LimitSnapshot = JsonSerializer.SerializeToElement(limits) }; - dbContext.PlatformBillingQuotes.Add(quote); - dbContext.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem + platformControlPlanePersistence.PlatformBillingQuotes.Add(quote); + platformControlPlanePersistence.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem { TenantId = actor.TenantId, QuoteId = quote.Id, @@ -123,7 +124,7 @@ internal sealed class TenantBillingService( .ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue) }) })); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return await LoadQuoteAsync(actor.TenantId, quote.Id, cancellationToken); } @@ -133,23 +134,23 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var key = Required(command.IdempotencyKey, "idempotencyKey"); - var existing = await dbContext.PlatformBillingOrders.AsNoTracking() + var existing = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); if (existing is not null) return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken); var now = DateTimeOffset.UtcNow; - var quote = await dbContext.PlatformBillingQuotes.SingleOrDefaultAsync(value => + var quote = await platformControlPlanePersistence.PlatformBillingQuotes.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken) ?? throw Error("Quote was not found.", "platform_billing_quote_not_found"); if (quote.Status != PlatformBillingQuoteStatus.Active || quote.ExpiresAt <= now) { quote.Status = quote.ExpiresAt <= now ? PlatformBillingQuoteStatus.Expired : quote.Status; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); throw Error("Quote is no longer active.", "platform_billing_quote_expired"); } - var quoteItems = await dbContext.PlatformBillingQuoteItems.AsNoTracking() + var quoteItems = await platformControlPlanePersistence.PlatformBillingQuoteItems.AsNoTracking() .Where(value => value.TenantId == actor.TenantId && value.QuoteId == quote.Id) .ToArrayAsync(cancellationToken); var order = new PlatformBillingOrder @@ -171,8 +172,8 @@ internal sealed class TenantBillingService( quote.LimitSnapshot }) }; - dbContext.PlatformBillingOrders.Add(order); - dbContext.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem + platformControlPlanePersistence.PlatformBillingOrders.Add(order); + platformControlPlanePersistence.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem { TenantId = actor.TenantId, OrderId = order.Id, @@ -184,7 +185,7 @@ internal sealed class TenantBillingService( Snapshot = value.Snapshot })); quote.Status = PlatformBillingQuoteStatus.Converted; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return await LoadOrderAsync(actor.TenantId, order.OrderNo, cancellationToken); } @@ -194,12 +195,12 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var key = Required(command.IdempotencyKey, "idempotencyKey"); - var existing = await dbContext.PlatformBillingPayments.AsNoTracking() + var existing = await platformControlPlanePersistence.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); if (existing is not null) return ToPaymentView(existing); - var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value => + var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); if (order.Status != PlatformBillingOrderStatus.PendingPayment || order.ExpiresAt <= DateTimeOffset.UtcNow) @@ -207,7 +208,7 @@ internal sealed class TenantBillingService( if (order.ExpiresAt <= DateTimeOffset.UtcNow && order.Status == PlatformBillingOrderStatus.PendingPayment) { order.Status = PlatformBillingOrderStatus.Expired; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); } throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid"); @@ -224,8 +225,8 @@ internal sealed class TenantBillingService( Method = Required(command.Method, "method"), AmountCents = order.TotalAmountCents }; - dbContext.PlatformBillingPayments.Add(payment); - await dbContext.SaveChangesAsync(cancellationToken); + platformControlPlanePersistence.PlatformBillingPayments.Add(payment); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); if (order.TotalAmountCents == 0) { @@ -259,13 +260,13 @@ internal sealed class TenantBillingService( cancellationToken); payment.ProviderTradeNo = result.ProviderTradeNo; payment.ClientPayload = result.ClientPayload; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return ToPaymentView(payment); } catch { payment.Status = PlatformBillingPaymentStatus.Failed; - await dbContext.SaveChangesAsync(CancellationToken.None); + await platformControlPlanePersistence.SaveChangesAsync(CancellationToken.None); throw; } } @@ -275,7 +276,7 @@ internal sealed class TenantBillingService( int limit, CancellationToken cancellationToken = default) { - var orderNos = await dbContext.PlatformBillingOrders.AsNoTracking() + var orderNos = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) @@ -296,7 +297,7 @@ internal sealed class TenantBillingService( TenantBillingActor actor, CancellationToken cancellationToken = default) { - var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() + var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken); @@ -309,16 +310,16 @@ internal sealed class TenantBillingService( string idempotencyKey, CancellationToken cancellationToken = default) { - var current = await dbContext.TenantSaasSubscriptions.AsNoTracking() + var current = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken); var currentAmount = current is null ? 0 - : await dbContext.SaasOfferingVersions.AsNoTracking() + : await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() .Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents) .SingleAsync(cancellationToken); - var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking() + var requestedAmount = await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() .Where(value => value.Id == command.BaseOfferingVersionId) .Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken) ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); @@ -339,12 +340,12 @@ internal sealed class TenantBillingService( string idempotencyKey, CancellationToken cancellationToken = default) { - var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() + var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); - var addOns = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() + var addOns = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == actor.TenantId && value.SubscriptionId == subscription.Id && value.ItemType == TenantSaasSubscriptionItemType.AddOn && value.Status == TenantSaasSubscriptionItemStatus.Active) @@ -361,14 +362,14 @@ internal sealed class TenantBillingService( TenantBillingActor actor, CancellationToken cancellationToken = default) { - var subscription = await dbContext.TenantSaasSubscriptions + var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); subscription.CancelAtPeriodEnd = true; subscription.CancelledAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return await ToSubscriptionViewAsync(subscription, cancellationToken); } @@ -383,7 +384,7 @@ internal sealed class TenantBillingService( int limit, CancellationToken cancellationToken = default) { - return await dbContext.PlatformBillingInvoices.AsNoTracking() + return await platformControlPlanePersistence.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) @@ -395,7 +396,7 @@ internal sealed class TenantBillingService( int limit, CancellationToken cancellationToken = default) { - return await dbContext.PlatformBillingInvoices.AsNoTracking() + return await platformControlPlanePersistence.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) @@ -418,20 +419,20 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var normalized = Required(orderNo, "orderNo"); - var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value => + var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.OrderNo == normalized, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); if (order.Status != PlatformBillingOrderStatus.PendingPayment) throw Error("Only a pending order can be cancelled.", "platform_billing_order_not_cancellable"); order.Status = PlatformBillingOrderStatus.Cancelled; order.CancelledAt = DateTimeOffset.UtcNow; - var receivables = await dbContext.PlatformBillingInvoices + var receivables = await platformControlPlanePersistence.PlatformBillingInvoices .Where(value => value.TenantId == actor.TenantId && value.OrderId == order.Id && (value.Status == PlatformBillingInvoiceStatus.Draft || value.Status == PlatformBillingInvoiceStatus.Issued)) .ToArrayAsync(cancellationToken); foreach (var receivable in receivables) receivable.Status = PlatformBillingInvoiceStatus.Void; - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, @@ -439,7 +440,7 @@ internal sealed class TenantBillingService( TargetType = "platform_billing_orders", TargetId = order.Id.ToString() }); - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return await LoadOrderAsync(actor.TenantId, normalized, cancellationToken); } @@ -448,7 +449,7 @@ internal sealed class TenantBillingService( int limit, CancellationToken cancellationToken = default) { - return await dbContext.PlatformBillingRefunds.AsNoTracking() + return await platformControlPlanePersistence.PlatformBillingRefunds.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) @@ -458,7 +459,7 @@ internal sealed class TenantBillingService( private async Task LoadQuoteAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) { - var quote = await dbContext.PlatformBillingQuotes.AsNoTracking() + var quote = await platformControlPlanePersistence.PlatformBillingQuotes.AsNoTracking() .SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken); var items = await LoadQuoteItemsAsync(tenantId, quote.Id, cancellationToken); return new PlatformBillingQuoteView( @@ -480,15 +481,15 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken) { var normalized = Required(orderNo, "orderNo"); - var order = await dbContext.PlatformBillingOrders.AsNoTracking() + var order = await platformControlPlanePersistence.PlatformBillingOrders.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); var items = await ( - from item in dbContext.PlatformBillingOrderItems.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version + from item in platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking() + join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version .Id - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where item.TenantId == tenantId && item.OrderId == order.Id orderby item.ItemType, offering.Code select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, @@ -502,10 +503,10 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken) { return await ( - from item in dbContext.PlatformBillingQuoteItems.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version + from item in platformControlPlanePersistence.PlatformBillingQuoteItems.AsNoTracking() + join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version .Id - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where item.TenantId == tenantId && item.QuoteId == quoteId orderby item.ItemType, offering.Code select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, @@ -516,14 +517,14 @@ internal sealed class TenantBillingService( private async Task ToSubscriptionViewAsync(TenantSaasSubscription subscription, CancellationToken cancellationToken) { - var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() + var versionIds = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Active) .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); if (!versionIds.Contains(subscription.BaseOfferingVersionId)) versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; - var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + var features = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) .Distinct() @@ -545,8 +546,8 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken) { var rows = await ( - from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + from version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() + join offering in platformControlPlanePersistence.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active && (version.EffectiveAt == null || version.EffectiveAt <= now) @@ -555,9 +556,9 @@ internal sealed class TenantBillingService( .ToArrayAsync(cancellationToken); var latest = rows.GroupBy(value => value.Offering.Id).Select(group => group.First()).ToArray(); var ids = latest.Select(value => value.Version.Id).ToArray(); - var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + var features = await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); - var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + var limits = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking() .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); return latest.Select(row => new SaasOfferingVersionItem( row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type, @@ -620,4 +621,4 @@ internal sealed class TenantBillingService( return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _)) .ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Points/PointAdministrationService.cs b/Tiku.Infrastructure/Points/PointAdministrationService.cs index 5a7e55a..0d5ea91 100644 --- a/Tiku.Infrastructure/Points/PointAdministrationService.cs +++ b/Tiku.Infrastructure/Points/PointAdministrationService.cs @@ -15,7 +15,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var tasks = dbContext.PointActivityTasks.AsNoTracking() + var tasks = pointsPersistence.PointActivityTasks.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status)); @@ -38,16 +38,16 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task"); var task = command.Id.HasValue - ? await dbContext.PointActivityTasks.SingleOrDefaultAsync( + ? await pointsPersistence.PointActivityTasks.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, cancellationToken) - : await dbContext.PointActivityTasks.SingleOrDefaultAsync( + : await pointsPersistence.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); + pointsPersistence.PointActivityTasks.Add(task); } task.TaskKey = command.TaskKey.Trim(); @@ -63,7 +63,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc task.Rules = JsonObjectOrDefault(command.Rules); task.Metadata = JsonObjectOrDefault(command.Metadata); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return task; } @@ -73,7 +73,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var claims = dbContext.PointActivityClaims.AsNoTracking() + var claims = pointsPersistence.PointActivityClaims.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.UserId.HasValue) claims = claims.Where(item => item.UserId == query.UserId.Value); @@ -90,7 +90,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var items = dbContext.PointExchangeItems.AsNoTracking() + var items = pointsPersistence.PointExchangeItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status)); @@ -116,16 +116,16 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item"); var item = command.Id.HasValue - ? await dbContext.PointExchangeItems.SingleOrDefaultAsync( + ? await pointsPersistence.PointExchangeItems.SingleOrDefaultAsync( entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value, cancellationToken) - : await dbContext.PointExchangeItems.SingleOrDefaultAsync( + : await pointsPersistence.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); + pointsPersistence.PointExchangeItems.Add(item); } item.RegionId = command.RegionId; @@ -143,7 +143,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload); item.Metadata = JsonObjectOrDefault(command.Metadata); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return item; } @@ -153,7 +153,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var orders = dbContext.PointExchangeOrders.AsNoTracking() + var orders = pointsPersistence.PointExchangeOrders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.UserId.HasValue) orders = orders.Where(item => item.UserId == query.UserId.Value); @@ -173,7 +173,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var order = await dbContext.PointExchangeOrders + var order = await pointsPersistence.PointExchangeOrders .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken) ?? throw new CommerceException("Point exchange order was not found.", @@ -189,7 +189,7 @@ internal sealed class PointAdministrationService(CommerceAdministrationDependenc order.CancelledAt ??= DateTimeOffset.UtcNow; } - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return order; } } diff --git a/Tiku.Infrastructure/Points/PointService.cs b/Tiku.Infrastructure/Points/PointService.cs index 2dd7a04..6d22a33 100644 --- a/Tiku.Infrastructure/Points/PointService.cs +++ b/Tiku.Infrastructure/Points/PointService.cs @@ -10,7 +10,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Points; -public sealed class PointService(TikuDbContext dbContext) : IPointService +public sealed class PointService(IPointsPersistence pointsPersistence, + ILearningPersistence learningPersistence, + ICommercePersistence commercePersistence, + IIdentityPersistence identityPersistence) : IPointService { public async Task GetSummaryAsync( PointActor actor, @@ -28,7 +31,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var limit = Math.Clamp(query.Limit ?? 50, 1, 200); - var tasks = await dbContext.PointActivityTasks + var tasks = await pointsPersistence.PointActivityTasks .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -40,7 +43,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService .Take(limit) .ToArrayAsync(cancellationToken); var taskIds = tasks.Select(item => item.Id).ToArray(); - var claimCounts = await dbContext.PointActivityClaims + var claimCounts = await pointsPersistence.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -67,7 +70,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService await AssertActiveMemberAsync(actor, cancellationToken); var taskKey = NormalizeKey(command.TaskKey, "task_key_required"); var now = DateTimeOffset.UtcNow; - var task = await dbContext.PointActivityTasks + var task = await pointsPersistence.PointActivityTasks .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.TaskKey == taskKey, @@ -81,7 +84,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService if (!string.IsNullOrWhiteSpace(command.SourceType) && command.SourceId.HasValue) { var sourceType = NormalizeOptional(command.SourceType); - var existing = await dbContext.PointActivityClaims + var existing = await pointsPersistence.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -95,7 +98,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService if (existing is not null) return ToClaimItem(existing); } - var claimedCount = await dbContext.PointActivityClaims.CountAsync( + var claimedCount = await pointsPersistence.PointActivityClaims.CountAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -119,9 +122,9 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService ClaimedAt = now, Metadata = JsonSerializer.SerializeToElement(new { source = "student_points" }) }; - dbContext.PointActivityClaims.Add(claim); + pointsPersistence.PointActivityClaims.Add(claim); var balanceAfter = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints + task.Points; - dbContext.UserScoreEvents.Add(new UserScoreEvent + learningPersistence.UserScoreEvents.Add(new UserScoreEvent { TenantId = actor.TenantId, UserId = actor.UserId, @@ -139,7 +142,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService task.TaskKey }) }); - await dbContext.SaveChangesAsync(cancellationToken); + await pointsPersistence.SaveChangesAsync(cancellationToken); return ToClaimItem(claim); } @@ -151,7 +154,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; - var items = dbContext.PointExchangeItems + var items = pointsPersistence.PointExchangeItems .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -175,11 +178,11 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - await using var transaction = dbContext.Database.IsRelational() - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + await using var transaction = pointsPersistence.Database.IsRelational() + ? await pointsPersistence.Database.BeginTransactionAsync(cancellationToken) : null; var now = DateTimeOffset.UtcNow; - var item = await dbContext.PointExchangeItems + var item = await pointsPersistence.PointExchangeItems .SingleOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.ItemId, @@ -214,8 +217,8 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService FulfillmentSnapshot = item.FulfillmentPayload, Metadata = JsonSerializer.SerializeToElement(new { source = "student_points_exchange" }) }; - dbContext.PointExchangeOrders.Add(order); - dbContext.UserScoreEvents.Add(new UserScoreEvent + pointsPersistence.PointExchangeOrders.Add(order); + learningPersistence.UserScoreEvents.Add(new UserScoreEvent { TenantId = actor.TenantId, UserId = actor.UserId, @@ -235,7 +238,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService if (item.ItemType == PointExchangeItemType.Entitlement) await GrantEntitlementAsync(actor, item, order, now, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await pointsPersistence.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return ToExchangeOrderItem(order); } @@ -246,7 +249,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); - var orders = dbContext.PointExchangeOrders + var orders = pointsPersistence.PointExchangeOrders .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) @@ -263,14 +266,14 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService PointActor actor, CancellationToken cancellationToken) { - var earned = await dbContext.PointActivityClaims + var earned = await pointsPersistence.PointActivityClaims .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PointActivityClaimStatus.Claimed) .SumAsync(item => (int?)item.Points, cancellationToken) ?? 0; - var spent = await dbContext.PointExchangeOrders + var spent = await pointsPersistence.PointExchangeOrders .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && @@ -288,7 +291,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService CancellationToken cancellationToken) { var days = item.Days ?? ReadInt(item.FulfillmentPayload, "days") ?? 0; - var current = await dbContext.Entitlements + var current = await commercePersistence.Entitlements .Where(entry => entry.TenantId == actor.TenantId && entry.UserId == actor.UserId && @@ -298,7 +301,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService .FirstOrDefaultAsync(cancellationToken); if (current is null) { - dbContext.Entitlements.Add(new Entitlement + commercePersistence.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = actor.UserId, @@ -324,7 +327,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService private async Task AssertActiveMemberAsync(PointActor actor, CancellationToken cancellationToken) { - var exists = await dbContext.TenantMemberships.AnyAsync(item => + var exists = await identityPersistence.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active, diff --git a/Tiku.Infrastructure/Profile/ProfileService.cs b/Tiku.Infrastructure/Profile/ProfileService.cs index 1f11739..74aefcb 100644 --- a/Tiku.Infrastructure/Profile/ProfileService.cs +++ b/Tiku.Infrastructure/Profile/ProfileService.cs @@ -15,7 +15,13 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Profile; -public sealed class ProfileService(TikuDbContext dbContext) : IProfileService +public sealed class ProfileService(IIdentityPersistence identityPersistence, + ITenantAdministrationPersistence tenantAdministrationPersistence, + ICatalogPersistence catalogPersistence, + ILearningPersistence learningPersistence, + ICommercePersistence commercePersistence, + IPointsPersistence pointsPersistence, + IJobsOperationsPersistence jobsOperationsPersistence) : IProfileService { private static readonly HashSet AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase) { @@ -38,7 +44,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { var profile = await EnsureProfileAsync(actor, cancellationToken); - var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken) + var user = await identityPersistence.Users.FindAsync([actor.UserId], cancellationToken) ?? throw new ProfileException("Current user was not found.", "profile_user_not_found"); if (!string.IsNullOrWhiteSpace(command.Name)) user.Name = command.Name.Trim(); @@ -71,7 +77,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService if (command.RecentActivities.HasValue) profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); return await BuildProfileItemAsync(actor, profile, null, cancellationToken); } @@ -82,7 +88,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { var profile = await EnsureProfileAsync(actor, cancellationToken); var limit = Math.Clamp(query.Limit ?? 5, 1, 20); - var items = await dbContext.ExamDates.AsNoTracking() + var items = await learningPersistence.ExamDates.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IsActive && @@ -123,7 +129,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { await EnsureProfileAsync(actor, cancellationToken); - var notifications = dbContext.UserNotifications.AsNoTracking() + var notifications = jobsOperationsPersistence.UserNotifications.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) notifications = notifications.Where(item => item.Status == ParseNotificationStatus(query.Status)); @@ -150,7 +156,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService if (status == NotificationStatus.Unread) throw new ProfileException("Notification status cannot be set to unread.", "invalid_notification_status"); - var notifications = await dbContext.UserNotifications + var notifications = await jobsOperationsPersistence.UserNotifications .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -162,7 +168,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService notification.ReadAt = status == NotificationStatus.Read ? DateTimeOffset.UtcNow : notification.ReadAt; } - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); return new ProfileNotificationList( notifications.Select(ToNotificationItem).ToArray(), await BuildNotificationSummaryAsync(actor, cancellationToken)); @@ -174,7 +180,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { await EnsureProfileAsync(actor, cancellationToken); - var badgesQuery = dbContext.Badges.AsNoTracking() + var badgesQuery = tenantAdministrationPersistence.Badges.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IsActive); if (!string.IsNullOrWhiteSpace(query.Category)) { @@ -188,7 +194,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService .Take(Math.Clamp(query.Limit ?? 100, 1, 200)) .ToArrayAsync(cancellationToken); var badgeIds = badges.Select(item => item.Id).ToArray(); - var grants = await dbContext.UserBadges.AsNoTracking() + var grants = await tenantAdministrationPersistence.UserBadges.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -225,7 +231,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { await EnsureProfileAsync(actor, cancellationToken); - var feedbacks = dbContext.Reports.AsNoTracking() + var feedbacks = learningPersistence.Reports.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) feedbacks = feedbacks.Where(item => item.Status == ParseReportStatus(query.Status)); @@ -264,8 +270,8 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService Attachments = JsonArrayOrDefault(command.Attachments), Metadata = JsonObjectOrDefault(command.Metadata) }; - dbContext.Reports.Add(feedback); - dbContext.ReportStatusEvents.Add(new ReportStatusEvent + learningPersistence.Reports.Add(feedback); + learningPersistence.ReportStatusEvents.Add(new ReportStatusEvent { TenantId = actor.TenantId, ReportId = feedback.Id, @@ -273,7 +279,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService Note = "student submitted feedback", ActorUserId = actor.UserId }); - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); return ToFeedbackItem(feedback); } @@ -285,7 +291,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService var today = DateOnly.FromDateTime(DateTime.UtcNow); var sourceId = CreateDeterministicGuid($"{actor.TenantId:N}:{actor.UserId:N}:check-in:{today:yyyyMMdd}"); var idempotencyKey = $"check-in:{actor.TenantId:N}:{actor.UserId:N}:{today:yyyyMMdd}"; - var existingEvent = await dbContext.UserScoreEvents.AsNoTracking() + var existingEvent = await learningPersistence.UserScoreEvents.AsNoTracking() .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && @@ -294,7 +300,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService cancellationToken); if (existingEvent is not null) { - var existingClaim = await dbContext.PointActivityClaims.AsNoTracking() + var existingClaim = await pointsPersistence.PointActivityClaims.AsNoTracking() .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && @@ -311,7 +317,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService existingEvent.Id); } - var task = await dbContext.PointActivityTasks + var task = await pointsPersistence.PointActivityTasks .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityTaskStatus.Active && @@ -339,7 +345,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService source = "profile_check_in" }) }; - dbContext.PointActivityClaims.Add(claim); + pointsPersistence.PointActivityClaims.Add(claim); var balanceAfter = await CalculatePointBalanceAsync(actor, cancellationToken) + task.Points; var scoreEvent = new UserScoreEvent { @@ -359,9 +365,9 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService }), CreatedAt = now }; - dbContext.UserScoreEvents.Add(scoreEvent); + learningPersistence.UserScoreEvents.Add(scoreEvent); profile.LastCheckInDate = today; - await dbContext.SaveChangesAsync(cancellationToken); + await identityPersistence.SaveChangesAsync(cancellationToken); return new CheckInResult(today, false, task.Points, balanceAfter, claim.Id, scoreEvent.Id); } @@ -371,7 +377,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { await EnsureProfileAsync(actor, cancellationToken); - var events = dbContext.UserScoreEvents.AsNoTracking() + var events = learningPersistence.UserScoreEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.SourceType)) { @@ -403,12 +409,12 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService ProfileActor actor, CancellationToken cancellationToken) { - var profile = await dbContext.StudentProfiles + var profile = await tenantAdministrationPersistence.StudentProfiles .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken); if (profile is not null) return profile; - var membershipExists = await dbContext.TenantMemberships.AnyAsync( + var membershipExists = await identityPersistence.TenantMemberships.AnyAsync( membership => membership.TenantId == actor.TenantId && membership.UserId == actor.UserId && @@ -423,8 +429,8 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService UserId = actor.UserId, AvatarPreset = "male" }; - dbContext.StudentProfiles.Add(profile); - await dbContext.SaveChangesAsync(cancellationToken); + tenantAdministrationPersistence.StudentProfiles.Add(profile); + await identityPersistence.SaveChangesAsync(cancellationToken); return profile; } @@ -432,7 +438,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService ProfileActor actor, CancellationToken cancellationToken) { - var items = await dbContext.UserNotifications.AsNoTracking() + var items = await jobsOperationsPersistence.UserNotifications.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId) .GroupBy(item => item.Status) .Select(group => new { Status = group.Key, Count = group.Count() }) @@ -446,28 +452,28 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService int? recentLimit, CancellationToken cancellationToken) { - var user = await dbContext.Users.AsNoTracking() + var user = await identityPersistence.Users.AsNoTracking() .SingleAsync(item => item.Id == actor.UserId, cancellationToken); var regionName = profile.RegionId.HasValue - ? await dbContext.Regions.AsNoTracking() + ? await catalogPersistence.Regions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == profile.RegionId.Value) .Select(item => item.Name) .SingleOrDefaultAsync(cancellationToken) : null; var schoolName = profile.SelectedSchoolId.HasValue - ? await dbContext.Schools.AsNoTracking() + ? await catalogPersistence.Schools.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedSchoolId.Value) .Select(item => item.Name) .SingleOrDefaultAsync(cancellationToken) : null; var majorName = profile.SelectedMajorId.HasValue - ? await dbContext.Majors.AsNoTracking() + ? await catalogPersistence.Majors.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedMajorId.Value) .Select(item => item.Name) .SingleOrDefaultAsync(cancellationToken) : null; var limit = Math.Clamp(recentLimit ?? 8, 1, 50); - var recentPractices = await dbContext.RecentPractices.AsNoTracking() + var recentPractices = await learningPersistence.RecentPractices.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId) .OrderByDescending(item => item.LastPracticeAt) .ThenByDescending(item => item.LastAccessAt) @@ -481,7 +487,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService item.LastAccessAt, item.LastPracticeAt)) .ToArrayAsync(cancellationToken); - var entitlement = await dbContext.Entitlements.AsNoTracking() + var entitlement = await commercePersistence.Entitlements.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -529,7 +535,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { if (!id.HasValue) return; - var exists = await dbContext.Set().AnyAsync( + var exists = await identityPersistence.Set().AnyAsync( entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); if (!exists) throw new ProfileException("Profile reference was not found.", code); @@ -623,13 +629,13 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService private async Task CalculatePointBalanceAsync(ProfileActor actor, CancellationToken cancellationToken) { - var earned = await dbContext.PointActivityClaims.AsNoTracking() + var earned = await pointsPersistence.PointActivityClaims.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == PointActivityClaimStatus.Claimed) .SumAsync(item => (int?)item.Points, cancellationToken) ?? 0; - var spent = await dbContext.PointExchangeOrders.AsNoTracking() + var spent = await pointsPersistence.PointExchangeOrders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && @@ -645,4 +651,4 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService bytes.AsSpan(0, 16).CopyTo(guidBytes); return new Guid(guidBytes); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Properties/AssemblyInfo.cs b/Tiku.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..185bc4b --- /dev/null +++ b/Tiku.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tiku.UnitTests")] diff --git a/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs b/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs index f75406f..864b3f7 100644 --- a/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs +++ b/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs @@ -7,7 +7,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.QuestionBanks; public sealed class PublicQuestionAccessPolicy( - TikuDbContext dbContext, + ITenancyPersistence dbContext, IFeatureAccessService featureAccessService) : IPublicQuestionAccessPolicy { public async Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default) diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs index 605e3bf..0eaa757 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs @@ -12,7 +12,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.QuestionBanks; public sealed class QuestionBankQueryService( - TikuDbContext dbContext, + IQuestionBankPersistence questionBankPersistence, IPublicQuestionAccessPolicy accessPolicy, ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService { @@ -28,7 +28,7 @@ public sealed class QuestionBankQueryService( var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit); var tenantItems = filter.Source == QuestionSource.Platform ? [] - : await dbContext.QuestionBanks + : await questionBankPersistence.QuestionBanks .AsNoTracking() .Where(bank => bank.TenantId == filter.TenantId && @@ -56,10 +56,11 @@ public sealed class QuestionBankQueryService( "List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); - return await systemDbContext.QuestionBanks.AsNoTracking() + var systemQuestionBank = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); + return await systemQuestionBank.QuestionBanks.AsNoTracking() .Join( - systemDbContext.Tenants.AsNoTracking() + systemTenancy.Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned), bank => bank.TenantId, tenant => tenant.Id, @@ -97,7 +98,7 @@ public sealed class QuestionBankQueryService( var tenantItems = filter.Source == QuestionSource.Platform ? [] : await ProjectQuestions( - dbContext, + questionBankPersistence, ApplyQuestionFilters(BaseQuestionQuery(), filter) .OrderByDescending(question => question.CreatedAt) .Take(limit), @@ -135,7 +136,7 @@ public sealed class QuestionBankQueryService( return new CatalogList(platformItems); } - var questionExists = await dbContext.Questions + var questionExists = await questionBankPersistence.Questions .AsNoTracking() .AnyAsync( question => @@ -146,7 +147,7 @@ public sealed class QuestionBankQueryService( if (!questionExists) throw new QuestionBankNotFoundException("Question was not found."); - var items = await dbContext.QuestionVersions + var items = await questionBankPersistence.QuestionVersions .AsNoTracking() .Where(version => version.TenantId == filter.TenantId && @@ -173,7 +174,7 @@ public sealed class QuestionBankQueryService( private IQueryable BaseQuestionQuery() { - return dbContext.Questions + return questionBankPersistence.Questions .AsNoTracking() .Where(question => question.Status == QuestionStatus.Published); } @@ -201,7 +202,7 @@ public sealed class QuestionBankQueryService( if (filter.CollectionId.HasValue) query = query.Where(question => question.PrimaryCollectionId == filter.CollectionId.Value || - dbContext.QuestionCollectionItems.Any(item => + questionBankPersistence.QuestionCollectionItems.Any(item => item.TenantId == question.TenantId && item.QuestionId == question.Id && item.CollectionId == filter.CollectionId.Value)); @@ -220,7 +221,7 @@ public sealed class QuestionBankQueryService( query = query.Where(question => question.Type.Contains(keyword) || (question.TypeLabel != null && question.TypeLabel.Contains(keyword)) || - dbContext.QuestionVersions.Any(version => + questionBankPersistence.QuestionVersions.Any(version => version.TenantId == question.TenantId && version.QuestionId == question.Id && version.Id == question.CurrentVersionId && @@ -241,7 +242,7 @@ public sealed class QuestionBankQueryService( } private static IQueryable ProjectQuestions( - TikuDbContext context, + IQuestionBankPersistence context, IQueryable questions, QuestionSource source) { @@ -314,12 +315,13 @@ public sealed class QuestionBankQueryService( "List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); - var platformTenantId = await systemDbContext.Tenants.AsNoTracking() + var systemQuestionBank = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); + var platformTenantId = await systemTenancy.Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned) .Select(tenant => tenant.Id) .SingleAsync(token); - var query = systemDbContext.Questions.AsNoTracking().Where(question => + var query = systemQuestionBank.Questions.AsNoTracking().Where(question => question.TenantId == platformTenantId && question.Status == QuestionStatus.Published && (!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) && @@ -341,7 +343,7 @@ public sealed class QuestionBankQueryService( } return await ProjectQuestions( - systemDbContext, + systemQuestionBank, query.OrderByDescending(question => question.CreatedAt).Take(limit), QuestionSource.Platform) .ToArrayAsync(token); @@ -360,18 +362,19 @@ public sealed class QuestionBankQueryService( "Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); - var platformQuestion = await systemDbContext.Questions.AsNoTracking() + var systemQuestionBank = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); + var platformQuestion = await systemQuestionBank.Questions.AsNoTracking() .Where(question => question.Id == questionId && question.Status == QuestionStatus.Published) .Join( - systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), + systemTenancy.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), question => question.TenantId, tenant => tenant.Id, (question, tenant) => new { question.TenantId, question.Id }) .SingleOrDefaultAsync(token); if (platformQuestion is null) throw new QuestionBankNotFoundException("Question was not found."); - return await systemDbContext.QuestionVersions.AsNoTracking() + return await systemQuestionBank.QuestionVersions.AsNoTracking() .Where(version => version.TenantId == platformQuestion.TenantId && version.QuestionId == platformQuestion.Id) @@ -399,4 +402,4 @@ public sealed class QuestionBankQueryService( { return Math.Clamp(limit ?? defaultLimit, 1, maxLimit); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs index aad03a0..9eb7b3a 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs @@ -10,7 +10,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.QuestionBanks; public sealed class QuestionReferenceService( - TikuDbContext dbContext, + IQuestionBankPersistence questionBankPersistence, IPublicQuestionAccessPolicy accessPolicy, ITenantExecutionScope tenantExecutionScope) : IQuestionReferenceService { @@ -28,7 +28,7 @@ public sealed class QuestionReferenceService( _ => throw new QuestionLocatorException("question_source_invalid", "Question source is invalid.") }; - var existing = await dbContext.TenantQuestionReferences.SingleOrDefaultAsync( + var existing = await questionBankPersistence.TenantQuestionReferences.SingleOrDefaultAsync( reference => reference.TenantId == tenantId && reference.QuestionOwnerTenantId == ownerTenantId && @@ -44,7 +44,7 @@ public sealed class QuestionReferenceService( Source = locator.Source, CreatedBy = userId }; - dbContext.TenantQuestionReferences.Add(reference); + questionBankPersistence.TenantQuestionReferences.Add(reference); return reference; } @@ -53,7 +53,7 @@ public sealed class QuestionReferenceService( Guid questionId, CancellationToken cancellationToken) { - var exists = await dbContext.Questions.AsNoTracking().AnyAsync( + var exists = await questionBankPersistence.Questions.AsNoTracking().AnyAsync( question => question.TenantId == tenantId && question.Id == questionId && @@ -76,13 +76,14 @@ public sealed class QuestionReferenceService( "Resolve a platform question for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { - var systemDbContext = provider.GetRequiredService(); - return await systemDbContext.Questions.AsNoTracking() + var systemQuestionBank = provider.GetRequiredService(); + var systemTenancy = provider.GetRequiredService(); + return await systemQuestionBank.Questions.AsNoTracking() .Where(question => question.Id == questionId && question.Status == QuestionStatus.Published) .Join( - systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), + systemTenancy.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), question => question.TenantId, tenant => tenant.Id, (question, tenant) => (Guid?)tenant.Id) @@ -94,4 +95,4 @@ public sealed class QuestionReferenceService( "question_not_found", "Platform question was not found."); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs index 3f6e17d..563f16c 100644 --- a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs +++ b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs @@ -9,7 +9,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Scoreline; public sealed partial class ScorelineQueryService( - TikuDbContext dbContext, + ICatalogPersistence dbContext, ScorelineRecordQuery recordQuery) : IScorelineQueryService { public async Task> GetFieldsAsync( diff --git a/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs b/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs index 17dda4d..2d7d2a7 100644 --- a/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs +++ b/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs @@ -5,7 +5,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed class AuthorizationCacheInvalidationProcessor( - TikuDbContext dbContext, + IJobsOperationsPersistence dbContext, IAccessSecurityCache cache) : IAuthorizationCacheInvalidationProcessor { public async Task ProcessPendingAsync(int batchSize = 100, CancellationToken cancellationToken = default) diff --git a/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs b/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs index 640b83c..988a1c6 100644 --- a/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs +++ b/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs @@ -7,7 +7,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed class AuthorizationStateInvalidator( - TikuDbContext dbContext, + IJobsOperationsPersistence dbContext, IAccessSecurityCache cache) : IAuthorizationStateInvalidator { public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) diff --git a/Tiku.Infrastructure/Security/CurrentAccessContext.cs b/Tiku.Infrastructure/Security/CurrentAccessContext.cs index bc28355..cebce75 100644 --- a/Tiku.Infrastructure/Security/CurrentAccessContext.cs +++ b/Tiku.Infrastructure/Security/CurrentAccessContext.cs @@ -15,7 +15,9 @@ internal sealed class CurrentAccessContext( ICurrentUser currentUser, ITenantContext tenantContext, IRequestSecurityState requestSecurityState, - TikuDbContext dbContext, + IIdentityPersistence identityPersistence, + ITenancyPersistence tenancyPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IMemoryCache memoryCache, IAuthorizationSnapshotCache snapshotCache, IOptions cacheOptions) : ICurrentAccessContext @@ -48,7 +50,7 @@ internal sealed class CurrentAccessContext( cancellationToken); if (!isValidated) { - var isUserActive = await dbContext.Users.AsNoTracking() + var isUserActive = await identityPersistence.Users.AsNoTracking() .AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken); if (!isUserActive) return new CurrentAccessSnapshot( @@ -76,9 +78,9 @@ internal sealed class CurrentAccessContext( if (!isValidated) { - var isTenantActive = await dbContext.Tenants.AsNoTracking() + var isTenantActive = await tenancyPersistence.Tenants.AsNoTracking() .AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken); - var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking() + var isActiveMember = isTenantActive && await identityPersistence.TenantMemberships.AsNoTracking() .AnyAsync( membership => membership.TenantId == tenantId && membership.UserId == userId && @@ -96,8 +98,8 @@ internal sealed class CurrentAccessContext( } var tenantRoles = await ( - from userRole in dbContext.TenantBackendUserRoles.AsNoTracking() - join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + from userRole in jobsOperationsPersistence.TenantBackendUserRoles.AsNoTracking() + join role in jobsOperationsPersistence.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id where userRole.TenantId == tenantId && userRole.UserId == userId && role.Status == BackendRoleStatus.Active @@ -108,8 +110,8 @@ internal sealed class CurrentAccessContext( var tenantPermissions = roleIds.Length == 0 ? new HashSet(StringComparer.Ordinal) : (await ( - from binding in dbContext.TenantBackendRolePermissions.AsNoTracking() - join permission in dbContext.BackendPermissions.AsNoTracking() + from binding in jobsOperationsPersistence.TenantBackendRolePermissions.AsNoTracking() + join permission in jobsOperationsPersistence.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && @@ -133,11 +135,11 @@ internal sealed class CurrentAccessContext( private async Task> LoadPlatformPermissionsAsync(Guid userId, CancellationToken cancellationToken) { return (await ( - from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id - join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding + from userRole in jobsOperationsPersistence.PlatformBackendUserRoles.AsNoTracking() + join role in jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + join binding in jobsOperationsPersistence.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding .RoleId - join permission in dbContext.BackendPermissions.AsNoTracking() + join permission in jobsOperationsPersistence.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code where userRole.UserId == userId && role.Status == BackendRoleStatus.Active && @@ -230,8 +232,8 @@ internal sealed class CurrentAccessContext( await LoadPlatformPermissionsAsync(userId, cancellationToken), CurrentDataScope.Self); var roles = await ( - from userRole in dbContext.TenantBackendUserRoles.AsNoTracking() - join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + from userRole in jobsOperationsPersistence.TenantBackendUserRoles.AsNoTracking() + join role in jobsOperationsPersistence.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id where userRole.TenantId == tenantId && userRole.UserId == userId && role.Status == BackendRoleStatus.Active select new { role.Id, role.DataScope }) @@ -239,8 +241,8 @@ internal sealed class CurrentAccessContext( var roleIds = roles.Select(role => role.Id).ToArray(); var permissions = roleIds.Length == 0 ? new HashSet(StringComparer.Ordinal) - : (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking() - join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals + : (await (from binding in jobsOperationsPersistence.TenantBackendRolePermissions.AsNoTracking() + join permission in jobsOperationsPersistence.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) diff --git a/Tiku.Infrastructure/Security/FeatureAccessService.cs b/Tiku.Infrastructure/Security/FeatureAccessService.cs index ce58fe8..b0c50b6 100644 --- a/Tiku.Infrastructure/Security/FeatureAccessService.cs +++ b/Tiku.Infrastructure/Security/FeatureAccessService.cs @@ -6,7 +6,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed class FeatureAccessService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, ITenantFeatureSnapshotProvider snapshotProvider) : IFeatureAccessService { public async Task EvaluateAsync( @@ -39,8 +40,8 @@ internal sealed class FeatureAccessService( { var requested = permissionCodes.Distinct(StringComparer.Ordinal).ToArray(); var permissions = await ( - from permission in dbContext.BackendPermissions.AsNoTracking() - join module in dbContext.PermissionModules.AsNoTracking() + from permission in jobsOperationsPersistence.BackendPermissions.AsNoTracking() + join module in platformControlPlanePersistence.PermissionModules.AsNoTracking() on permission.PermissionModuleCode equals module.Code where requested.Contains(permission.Code) select new { permission.Code, module.RequiredFeatureCode }) @@ -60,7 +61,7 @@ internal sealed class FeatureAccessService( CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var rows = await dbContext.Database.SqlQuery($""" + var rows = await platformControlPlanePersistence.Database.SqlQuery($""" WITH current_subscription AS ( SELECT subscription.id, subscription.base_offering_version_id, @@ -155,7 +156,7 @@ internal sealed class FeatureAccessService( // subscription does not cap this operation, rather than a zero allowance. return true; - var updated = await dbContext.TenantFeatureUsages + var updated = await platformControlPlanePersistence.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd && @@ -169,14 +170,14 @@ internal sealed class FeatureAccessService( .SetProperty(value => value.UpdatedAt, now), cancellationToken); if (updated == 1) return true; - var exists = await dbContext.TenantFeatureUsages.AnyAsync(value => + var exists = await platformControlPlanePersistence.TenantFeatureUsages.AnyAsync(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd, cancellationToken); if (exists || amount > limit) return false; - dbContext.TenantFeatureUsages.Add(new TenantFeatureUsage + platformControlPlanePersistence.TenantFeatureUsages.Add(new TenantFeatureUsage { TenantId = tenantId, MetricCode = normalized, @@ -189,13 +190,13 @@ internal sealed class FeatureAccessService( }); try { - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return true; } catch (DbUpdateException exception) { foreach (var entry in exception.Entries) entry.State = EntityState.Detached; - return await dbContext.TenantFeatureUsages + return await platformControlPlanePersistence.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd && @@ -219,7 +220,7 @@ internal sealed class FeatureAccessService( var now = DateTimeOffset.UtcNow; var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken); if (subscription is null) return; - await dbContext.TenantFeatureUsages + await platformControlPlanePersistence.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd) @@ -234,7 +235,7 @@ internal sealed class FeatureAccessService( DateTimeOffset now, CancellationToken cancellationToken) { - return await dbContext.TenantSaasSubscriptions.AsNoTracking() + return await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == tenantId && (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active)) @@ -255,7 +256,7 @@ internal sealed class FeatureAccessService( DateTimeOffset now, CancellationToken cancellationToken) { - var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() + var versionIds = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == tenantId && value.SubscriptionId == subscriptionId && value.Status == TenantSaasSubscriptionItemStatus.Active && value.StartsAt <= now && value.EndsAt > now) @@ -263,7 +264,7 @@ internal sealed class FeatureAccessService( .ToArrayAsync(cancellationToken); if (!versionIds.Contains(baseVersionId)) versionIds = [.. versionIds, baseVersionId]; - return await dbContext.SaasOfferingVersionLimits.AsNoTracking() + return await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .GroupBy(value => value.MetricCode) .Select(group => new { MetricCode = group.Key, Limit = group.Sum(value => value.LimitValue) }) diff --git a/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs b/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs index 4a31829..b6bda18 100644 --- a/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs +++ b/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs @@ -11,7 +11,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed class FeatureUsageReconciliationService( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IJobsOperationsPersistence jobsOperationsPersistence, ITenantExecutionScope tenantExecutionScope, IOptions options) : IFeatureUsageReconciliationService { @@ -37,8 +38,13 @@ internal sealed class FeatureUsageReconciliationService( request.CorrelationId), async (provider, token) => { - var dbContext = provider.GetRequiredService(); - return await ReconcileCoreAsync(dbContext, request.TenantId, token); + return await ReconcileCoreAsync( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + request.TenantId, + token); }, cancellationToken); } @@ -49,22 +55,22 @@ internal sealed class FeatureUsageReconciliationService( var now = DateTimeOffset.UtcNow; var cutoff = now.AddMinutes(-Math.Clamp(options.Value.IntervalMinutes, 1, 24 * 60)); - var candidates = await dbContext.TenantSaasSubscriptions.AsNoTracking() + var candidates = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => (subscription.Status == TenantSaasSubscriptionStatus.Trial || subscription.Status == TenantSaasSubscriptionStatus.Active) && subscription.StartsAt <= now && subscription.CurrentPeriodEnd > now && - dbContext.TenantSaasSubscriptionItems.Any(item => + platformControlPlanePersistence.TenantSaasSubscriptionItems.Any(item => item.TenantId == subscription.TenantId && item.SubscriptionId == subscription.Id && item.Status == TenantSaasSubscriptionItemStatus.Active && item.StartsAt <= now && item.EndsAt > now && - dbContext.SaasOfferingVersionLimits.Any(limit => + platformControlPlanePersistence.SaasOfferingVersionLimits.Any(limit => limit.OfferingVersionId == item.OfferingVersionId && CurrentMetrics.Contains(limit.MetricCode))) && - !dbContext.AuditLogs.Any(audit => + !jobsOperationsPersistence.AuditLogs.Any(audit => audit.TenantId == subscription.TenantId && audit.Action == "system_scope.completed" && audit.TargetId != null && @@ -90,12 +96,15 @@ internal sealed class FeatureUsageReconciliationService( } private static async Task> ReconcileCoreAsync( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + IIdentityPersistence identityPersistence, + IQuestionBankPersistence questionBankPersistence, + IContentAssetPersistence contentAssetPersistence, Guid tenantId, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; - var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() + var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(item => item.TenantId == tenantId && (item.Status == TenantSaasSubscriptionStatus.Trial || item.Status == TenantSaasSubscriptionStatus.Active) && @@ -112,7 +121,7 @@ internal sealed class FeatureUsageReconciliationService( .FirstOrDefaultAsync(cancellationToken); if (subscription is null) return []; - var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() + var versionIds = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking() .Where(item => item.TenantId == tenantId && item.SubscriptionId == subscription.Id && item.Status == TenantSaasSubscriptionItemStatus.Active && @@ -123,7 +132,7 @@ internal sealed class FeatureUsageReconciliationService( if (!versionIds.Contains(subscription.BaseOfferingVersionId)) versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; - var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + var limits = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking() .Where(item => versionIds.Contains(item.OfferingVersionId) && CurrentMetrics.Contains(item.MetricCode)) .GroupBy(item => item.MetricCode) .Select(group => new { MetricCode = group.Key, LimitValue = group.Sum(item => item.LimitValue) }) @@ -133,31 +142,31 @@ internal sealed class FeatureUsageReconciliationService( var actual = new Dictionary(StringComparer.Ordinal) { - [SaasQuotaMetricCatalog.StaffCount] = await dbContext.TenantMemberships.AsNoTracking() + [SaasQuotaMetricCatalog.StaffCount] = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == MembershipStatus.Active && item.Role != TenantRole.Student) .Select(item => item.UserId) .Distinct() .LongCountAsync(cancellationToken), - [SaasQuotaMetricCatalog.StudentCount] = await dbContext.TenantMemberships.AsNoTracking() + [SaasQuotaMetricCatalog.StudentCount] = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == MembershipStatus.Active && item.Role == TenantRole.Student) .Select(item => item.UserId) .Distinct() .LongCountAsync(cancellationToken), - [SaasQuotaMetricCatalog.PrivateQuestionCount] = await dbContext.Questions.AsNoTracking() + [SaasQuotaMetricCatalog.PrivateQuestionCount] = await questionBankPersistence.Questions.AsNoTracking() .LongCountAsync(item => item.TenantId == tenantId && item.Status != QuestionStatus.Archived, cancellationToken), - [SaasQuotaMetricCatalog.StorageBytes] = await dbContext.ContentAssets.AsNoTracking() + [SaasQuotaMetricCatalog.StorageBytes] = await contentAssetPersistence.ContentAssets.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == ContentStatus.Active && item.VerifiedSizeBytes > 0) .SumAsync(item => item.VerifiedSizeBytes ?? 0, cancellationToken) }; - var existing = await dbContext.TenantFeatureUsages + var existing = await platformControlPlanePersistence.TenantFeatureUsages .Where(item => item.TenantId == tenantId && item.PeriodStart == subscription.CurrentPeriodStart && item.PeriodEnd == subscription.CurrentPeriodEnd && @@ -180,7 +189,7 @@ internal sealed class FeatureUsageReconciliationService( PeriodEnd = subscription.CurrentPeriodEnd, Version = 1 }; - dbContext.TenantFeatureUsages.Add(usage); + platformControlPlanePersistence.TenantFeatureUsages.Add(usage); } var trackedUsage = usage!; @@ -197,7 +206,7 @@ internal sealed class FeatureUsageReconciliationService( result.Add(new ReconciledFeatureUsage(limit.Key, actualValue, limit.Value, warning, exceeded)); } - await dbContext.SaveChangesAsync(cancellationToken); + await platformControlPlanePersistence.SaveChangesAsync(cancellationToken); return result; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs index e88ed02..bc6106c 100644 --- a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs +++ b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs @@ -90,7 +90,8 @@ internal interface ITenantFeatureSnapshotProvider } internal sealed class TenantFeatureSnapshotProvider( - TikuDbContext dbContext, + IPlatformControlPlanePersistence platformControlPlanePersistence, + ITenancyPersistence tenancyPersistence, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger logger) : ITenantFeatureSnapshotProvider @@ -114,7 +115,7 @@ internal sealed class TenantFeatureSnapshotProvider( CancellationToken cancellationToken = default) { var requestKey = (tenantId, operation); - var saveVersion = dbContext.SaveVersion; + var saveVersion = platformControlPlanePersistence.SaveVersion; if (!requestCache.TryGetValue(requestKey, out var cached) || cached.SaveVersion != saveVersion) { var snapshotTask = GetCoreAsync( @@ -186,12 +187,12 @@ internal sealed class TenantFeatureSnapshotProvider( FeatureAccessOperation operation, CancellationToken cancellationToken) { - var tenant = await dbContext.Tenants.AsNoTracking() + var tenant = await tenancyPersistence.Tenants.AsNoTracking() .Where(value => value.Id == tenantId) .Select(value => new { Status = (TenantStatus?)value.Status, - Subscription = dbContext.TenantSaasSubscriptions.AsNoTracking() + Subscription = platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => subscription.TenantId == tenantId) .OrderByDescending(subscription => subscription.UpdatedAt) .Select(subscription => new TenantSubscriptionSnapshot( @@ -204,13 +205,13 @@ internal sealed class TenantFeatureSnapshotProvider( }) .SingleOrDefaultAsync(cancellationToken); - var features = await dbContext.SaasFeatures.AsNoTracking() + var features = await platformControlPlanePersistence.SaasFeatures.AsNoTracking() .Where(value => value.Status == SaasFeatureStatus.Active) .Select(value => new TenantFeatureDefinition(value.Code, value.IsCore)) .ToArrayAsync(cancellationToken); var now = DateTimeOffset.UtcNow; - var overrides = await dbContext.TenantFeatureOverrides.AsNoTracking() + var overrides = await platformControlPlanePersistence.TenantFeatureOverrides.AsNoTracking() .Where(value => value.TenantId == tenantId && (value.ExpiresAt == null || value.ExpiresAt > now)) .ToDictionaryAsync(value => value.FeatureCode, value => value.Mode, StringComparer.Ordinal, cancellationToken); @@ -218,7 +219,7 @@ internal sealed class TenantFeatureSnapshotProvider( var purchased = new HashSet(StringComparer.Ordinal); if (tenant?.Subscription is { } subscription) { - var eligibleVersionIds = dbContext.TenantSaasSubscriptionItems.AsNoTracking() + var eligibleVersionIds = platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id && (operation == FeatureAccessOperation.Read ? value.Status != TenantSaasSubscriptionItemStatus.Pending && @@ -227,10 +228,10 @@ internal sealed class TenantFeatureSnapshotProvider( : value.Status == TenantSaasSubscriptionItemStatus.Active && value.StartsAt <= now && value.EndsAt > now)) .Select(value => value.OfferingVersionId) - .Concat(dbContext.TenantSaasSubscriptions.AsNoTracking() + .Concat(platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.Id == subscription.Id) .Select(value => value.BaseOfferingVersionId)); - purchased = (await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + purchased = (await platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => eligibleVersionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) .Distinct() diff --git a/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs index 9ca8e73..7867104 100644 --- a/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs +++ b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs @@ -5,7 +5,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.StudyContent; -public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyContentQueryService +public sealed class StudyContentQueryService(IContentAssetPersistence dbContext) : IStudyContentQueryService { private const int DefaultLimit = 500; private const int MaxLimit = 2000; diff --git a/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs index df5db30..9c28474 100644 --- a/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs +++ b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs @@ -7,18 +7,19 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; -internal sealed class PublicTenantConfigurationQuery(TikuDbContext dbContext) +internal sealed class PublicTenantConfigurationQuery(ITenancyPersistence tenancyPersistence, + ITenantAdministrationPersistence tenantAdministrationPersistence) : IPublicTenantConfigurationQuery { public async Task GetAsync( Guid tenantId, CancellationToken cancellationToken = default) { - var branding = await dbContext.TenantBrandings.AsNoTracking() + var branding = await tenancyPersistence.TenantBrandings.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - var settings = await dbContext.TenantSettings.AsNoTracking() + var settings = await tenancyPersistence.TenantSettings.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - var theme = await dbContext.TenantThemeConfigs.AsNoTracking() + var theme = await tenantAdministrationPersistence.TenantThemeConfigs.AsNoTracking() .SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Status == TenantThemeConfigStatus.Published, cancellationToken); diff --git a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs index 1fee012..bb81fcf 100644 --- a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs @@ -142,7 +142,8 @@ public sealed class TenantRuntimeCacheInvalidator( } public sealed class TenantDomainLifecycleService( - TikuDbContext dbContext, + ITenancyPersistence tenancyPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IDomainOwnershipVerifier ownershipVerifier, IDomainGatewayProvisioner gatewayProvisioner, ITenantRuntimeCacheInvalidator cacheInvalidator, @@ -154,7 +155,7 @@ public sealed class TenantDomainLifecycleService( { if (!options.Enabled) return 0; - var domains = await dbContext.TenantDomains + var domains = await tenancyPersistence.TenantDomains .Where(domain => domain.DomainType == TenantDomainType.Custom && (domain.Status == TenantDomainStatus.Pending || domain.Status == TenantDomainStatus.Failed)) @@ -164,7 +165,7 @@ public sealed class TenantDomainLifecycleService( foreach (var domain in domains) await ProcessAsync(domain, cancellationToken); - if (domains.Length > 0) await dbContext.SaveChangesAsync(cancellationToken); + if (domains.Length > 0) await tenancyPersistence.SaveChangesAsync(cancellationToken); return domains.Length; } @@ -197,7 +198,7 @@ public sealed class TenantDomainLifecycleService( domain.TlsReadyAt ??= DateTimeOffset.UtcNow; domain.Status = TenantDomainStatus.Active; domain.LastFailureReason = null; - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = domain.TenantId, Action = "tenant.domain.activated", diff --git a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs index 37b6198..782f290 100644 --- a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs +++ b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs @@ -41,7 +41,7 @@ public sealed class TenantExecutionScope( request.TargetTenantId, request.CorrelationId, request.Reason); - var dbContext = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); var started = DateTimeOffset.UtcNow; var stopwatch = Stopwatch.StartNew(); await using var transaction = dbContext.Database.IsRelational() @@ -97,7 +97,7 @@ public sealed class TenantExecutionScope( } private static async Task WriteAuditAsync( - TikuDbContext dbContext, + IJobsOperationsPersistence dbContext, SystemScopeRequest request, string action, DateTimeOffset startedAt, diff --git a/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs index eb23f23..478dce3 100644 --- a/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs @@ -8,7 +8,7 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantExternalProviderConfigService( - TikuDbContext dbContext, + ITenancyPersistence dbContext, ITenantSecretService tenantSecretService) : ITenantExternalProviderConfigService { public async Task GetActiveProviderAsync( diff --git a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs index 84bdaf8..56aac18 100644 --- a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs @@ -11,7 +11,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; public sealed class TenantFrontendConfigService( - TikuDbContext dbContext, + ITenancyPersistence tenancyPersistence, + IIdentityPersistence identityPersistence, + IPlatformControlPlanePersistence platformControlPlanePersistence, IMemoryCache cache, IFeatureAccessService featureAccessService, ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantFrontendConfigService @@ -22,10 +24,10 @@ public sealed class TenantFrontendConfigService( Guid tenantId, CancellationToken cancellationToken = default) { - var config = await dbContext.TenantFrontendConfigs.AsNoTracking() + var config = await tenancyPersistence.TenantFrontendConfigs.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (config is not null) return ToItem(config); - var tenantName = await dbContext.Tenants.AsNoTracking() + var tenantName = await tenancyPersistence.Tenants.AsNoTracking() .Where(item => item.Id == tenantId) .Select(item => item.Name) .SingleOrDefaultAsync(cancellationToken) ?? "启知教育"; @@ -38,16 +40,16 @@ public sealed class TenantFrontendConfigService( CancellationToken cancellationToken = default) { Validate(draft); - var config = await dbContext.TenantFrontendConfigs + var config = await tenancyPersistence.TenantFrontendConfigs .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (config is null) { - var tenantName = await dbContext.Tenants.AsNoTracking() + var tenantName = await tenancyPersistence.Tenants.AsNoTracking() .Where(item => item.Id == tenantId) .Select(item => item.Name) .SingleAsync(cancellationToken); config = CreateDefault(tenantId, tenantName); - dbContext.TenantFrontendConfigs.Add(config); + tenancyPersistence.TenantFrontendConfigs.Add(config); } config.DraftBranding = draft.Branding.Clone(); @@ -55,7 +57,7 @@ public sealed class TenantFrontendConfigService( config.DraftFeatures = draft.Features.Clone(); config.DraftNavigation = draft.Navigation.Clone(); config.DraftHomeModules = draft.HomeModules.Clone(); - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); return ToItem(config); } @@ -64,7 +66,7 @@ public sealed class TenantFrontendConfigService( int expectedVersion, CancellationToken cancellationToken = default) { - var config = await dbContext.TenantFrontendConfigs + var config = await tenancyPersistence.TenantFrontendConfigs .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) ?? throw new TenantFrontendConfigException( "frontend_config_not_found", @@ -83,7 +85,7 @@ public sealed class TenantFrontendConfigService( config.PublishedHomeModules = config.DraftHomeModules.Clone(); config.ConfigVersion++; config.PublishedAt = DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); cache.Remove(CacheKey(tenantId)); await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); return ToItem(config); @@ -96,12 +98,12 @@ public sealed class TenantFrontendConfigService( if (cache.TryGetValue(CacheKey(tenantId), out var cached) && cached is not null) return cached; - var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync( + var tenant = await tenancyPersistence.Tenants.AsNoTracking().SingleOrDefaultAsync( item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) ?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found."); var now = DateTimeOffset.UtcNow; - var hasActiveSubscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(item => + var hasActiveSubscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking().AnyAsync(item => item.TenantId == tenantId && (item.Status == TenantSaasSubscriptionStatus.Trial || item.Status == TenantSaasSubscriptionStatus.Active) && @@ -110,10 +112,10 @@ public sealed class TenantFrontendConfigService( if (!hasActiveSubscription) throw new TenantFrontendConfigException("subscription_inactive", "An active tenant subscription is required."); - var config = await dbContext.TenantFrontendConfigs.AsNoTracking() + var config = await tenancyPersistence.TenantFrontendConfigs.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) ?? CreateDefault(tenantId, tenant.Name); - var ownerActivated = tenant.OwnerUserId.HasValue && await dbContext.Users.AsNoTracking().AnyAsync( + var ownerActivated = tenant.OwnerUserId.HasValue && await identityPersistence.Users.AsNoTracking().AnyAsync( user => user.Id == tenant.OwnerUserId && user.Status == UserStatus.Active && !user.ForcePasswordChange && user.PasswordHash != null, cancellationToken); @@ -138,7 +140,7 @@ public sealed class TenantFrontendConfigService( .Where(code => code != SaasFeatureCatalog.CoreBackoffice) .Order(StringComparer.Ordinal) .ToArray(), - (await dbContext.TenantAuthPolicies.AsNoTracking() + (await tenancyPersistence.TenantAuthPolicies.AsNoTracking() .Where(item => item.TenantId == tenantId) .Select(item => item.AllowedStudentLoginMethods) .SingleOrDefaultAsync(cancellationToken) ?? ["password"]) diff --git a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs index e9fd1a8..ec877d6 100644 --- a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs @@ -13,7 +13,10 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantLifecycleService( - TikuDbContext dbContext, + ITenancyPersistence tenancyPersistence, + IIdentityPersistence identityPersistence, + IContentAssetPersistence contentAssetPersistence, + IJobsOperationsPersistence jobsOperationsPersistence, IBackgroundJobQueue backgroundJobService, IAuthSessionStore sessionStore, ITenantRuntimeCacheInvalidator runtimeCacheInvalidator, @@ -28,12 +31,12 @@ internal sealed class TenantLifecycleService( Guid tenantId, CancellationToken cancellationToken = default) { - var tenant = await dbContext.Tenants.AsNoTracking() + var tenant = await tenancyPersistence.Tenants.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); var blockers = new List(); if (tenant.Status == TenantStatus.Archived) blockers.Add("tenant_already_archived"); - if (await dbContext.BackgroundJobs.AnyAsync(item => + if (await jobsOperationsPersistence.BackgroundJobs.AnyAsync(item => item.TenantId == tenantId && item.Status == BackgroundJobStatus.Processing, cancellationToken)) @@ -50,8 +53,8 @@ internal sealed class TenantLifecycleService( { await RequireTenantAsync(tenantId, cancellationToken); var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Export, null); - dbContext.TenantLifecycleOperations.Add(operation); - await dbContext.SaveChangesAsync(cancellationToken); + jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); + await tenancyPersistence.SaveChangesAsync(cancellationToken); await backgroundJobService.EnqueueAsync( new CreateBackgroundJobCommand( tenantId, @@ -69,7 +72,7 @@ internal sealed class TenantLifecycleService( Guid operationId, CancellationToken cancellationToken = default) { - var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( + var operation = await jobsOperationsPersistence.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operationId, cancellationToken); return operation is null ? null : ToItem(operation); @@ -80,14 +83,14 @@ internal sealed class TenantLifecycleService( Guid operationId, CancellationToken cancellationToken = default) { - var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( + var operation = await jobsOperationsPersistence.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operationId && item.OperationType == TenantLifecycleOperationType.Export && item.Status == TenantLifecycleOperationStatus.Succeeded, cancellationToken) ?? throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready."); var asset = operation.ExportAssetId.HasValue - ? await dbContext.ContentAssets.AsNoTracking().SingleOrDefaultAsync( + ? await contentAssetPersistence.ContentAssets.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operation.ExportAssetId.Value, cancellationToken) : null; @@ -119,12 +122,12 @@ internal sealed class TenantLifecycleService( operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Archived; - await dbContext.TenantDomains.Where(item => item.TenantId == tenantId) + await tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), cancellationToken); - dbContext.TenantLifecycleOperations.Add(operation); + jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason); - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); await RevokeTenantSessionsAsync(tenantId, cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); @@ -143,12 +146,12 @@ internal sealed class TenantLifecycleService( operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Suspended; - await dbContext.TenantDomains.Where(item => item.TenantId == tenantId) + await tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), cancellationToken); - dbContext.TenantLifecycleOperations.Add(operation); + jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason); - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); } @@ -160,9 +163,9 @@ internal sealed class TenantLifecycleService( string reason, CancellationToken cancellationToken = default) { - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var transaction = await tenancyPersistence.Database.BeginTransactionAsync(cancellationToken); var tenant = await RequireTenantAsync(tenantId, cancellationToken); - var target = await dbContext.TenantMemberships.SingleOrDefaultAsync(item => + var target = await identityPersistence.TenantMemberships.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active, cancellationToken) ?? throw new TenantLifecycleException( "tenant_owner_target_not_active_member", @@ -171,7 +174,7 @@ internal sealed class TenantLifecycleService( if (previousOwnerId == targetUserId) throw new TenantLifecycleException("tenant_owner_unchanged", "Target user is already the tenant owner."); var previous = previousOwnerId.HasValue - ? await dbContext.TenantMemberships.SingleOrDefaultAsync(item => + ? await identityPersistence.TenantMemberships.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value, cancellationToken) : null; @@ -179,22 +182,22 @@ internal sealed class TenantLifecycleService( target.Role = TenantRole.TenantOwner; tenant.OwnerUserId = targetUserId; - var ownerRoleId = await dbContext.TenantBackendRoles + var ownerRoleId = await jobsOperationsPersistence.TenantBackendRoles .Where(item => item.TenantId == tenantId && item.Code == "tenant_owner") .Select(item => (Guid?)item.Id) .SingleOrDefaultAsync(cancellationToken); if (ownerRoleId.HasValue) { if (previousOwnerId.HasValue) - await dbContext.TenantBackendUserRoles + await jobsOperationsPersistence.TenantBackendUserRoles .Where(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value && item.RoleId == ownerRoleId.Value) .ExecuteDeleteAsync(cancellationToken); - if (!await dbContext.TenantBackendUserRoles.AnyAsync(item => + if (!await jobsOperationsPersistence.TenantBackendUserRoles.AnyAsync(item => item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value, cancellationToken)) - dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + jobsOperationsPersistence.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = targetUserId, @@ -207,9 +210,9 @@ internal sealed class TenantLifecycleService( operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; operation.Result = JsonSerializer.SerializeToElement(new { previousOwnerId, newOwnerId = targetUserId }); - dbContext.TenantLifecycleOperations.Add(operation); + jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason); - await dbContext.SaveChangesAsync(cancellationToken); + await tenancyPersistence.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); if (previousOwnerId.HasValue) @@ -223,7 +226,7 @@ internal sealed class TenantLifecycleService( private Task HasRecentExportAsync(Guid tenantId, CancellationToken cancellationToken) { var cutoff = DateTimeOffset.UtcNow - RecentExportWindow; - return dbContext.TenantLifecycleOperations.AnyAsync(item => + return jobsOperationsPersistence.TenantLifecycleOperations.AnyAsync(item => item.TenantId == tenantId && item.OperationType == TenantLifecycleOperationType.Export && item.Status == TenantLifecycleOperationStatus.Succeeded && @@ -233,13 +236,13 @@ internal sealed class TenantLifecycleService( private async Task RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) { - return await dbContext.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? + return await tenancyPersistence.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); } private async Task RevokeTenantSessionsAsync(Guid tenantId, CancellationToken cancellationToken) { - var userIds = await dbContext.TenantMemberships.AsNoTracking() + var userIds = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId) .Select(item => item.UserId) .Distinct() @@ -274,7 +277,7 @@ internal sealed class TenantLifecycleService( private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actorUserId, @@ -310,4 +313,4 @@ internal sealed class TenantLifecycleService( operation.CreatedAt, operation.CompletedAt); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs b/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs index b6290aa..54ad504 100644 --- a/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs @@ -9,38 +9,41 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantOnboardingService( - TikuDbContext dbContext, + ITenancyPersistence tenancyPersistence, + IIdentityPersistence identityPersistence, + ITenantAdministrationPersistence tenantAdministrationPersistence, + IPlatformControlPlanePersistence platformControlPlanePersistence, IFeatureAccessService featureAccessService) : ITenantOnboardingService { public async Task GetStatusAsync( Guid tenantId, CancellationToken cancellationToken = default) { - var tenant = await dbContext.Tenants.AsNoTracking() + var tenant = await tenancyPersistence.Tenants.AsNoTracking() .SingleOrDefaultAsync(value => value.Id == tenantId, cancellationToken) ?? throw new TenantExternalProviderException("Tenant was not found.", "tenant_not_found"); var ownerActivated = tenant.OwnerUserId.HasValue && await ( - from membership in dbContext.TenantMemberships.AsNoTracking() - join user in dbContext.Users.AsNoTracking() on membership.UserId equals user.Id + from membership in identityPersistence.TenantMemberships.AsNoTracking() + join user in identityPersistence.Users.AsNoTracking() on membership.UserId equals user.Id where membership.TenantId == tenantId && membership.UserId == tenant.OwnerUserId && membership.Role == TenantRole.TenantOwner && membership.Status == MembershipStatus.Active && user.Status == UserStatus.Active && !user.ForcePasswordChange select membership.Id).AnyAsync(cancellationToken); var now = DateTimeOffset.UtcNow; - var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => + var subscriptionActive = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) && value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken); - var billingPolicyConfigured = await dbContext.TenantBillingPolicies.AsNoTracking().AnyAsync(value => + var billingPolicyConfigured = await tenantAdministrationPersistence.TenantBillingPolicies.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && value.RenewalLeadDays >= 1 && value.RenewalLeadDays <= 90 && value.DefaultPaymentProvider != string.Empty, cancellationToken); - var primaryDomainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => + var primaryDomainActive = await tenancyPersistence.TenantDomains.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active, cancellationToken); - var frontendPublished = await dbContext.TenantFrontendConfigs.AsNoTracking().AnyAsync(value => + var frontendPublished = await tenancyPersistence.TenantFrontendConfigs.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && value.PublishedAt != null, cancellationToken); - var loginMethods = await dbContext.TenantAuthPolicies.AsNoTracking() + var loginMethods = await tenancyPersistence.TenantAuthPolicies.AsNoTracking() .Where(value => value.TenantId == tenantId) .Select(value => value.AllowedStudentLoginMethods) .SingleOrDefaultAsync(cancellationToken) ?? ["password"]; @@ -57,7 +60,7 @@ internal sealed class TenantOnboardingService( SaasFeatureCatalog.Handbook }); var smsRequired = loginMethods.Any(value => string.Equals(value, "sms", StringComparison.OrdinalIgnoreCase)); - var providers = await dbContext.TenantExternalProviders.AsNoTracking() + var providers = await tenancyPersistence.TenantExternalProviders.AsNoTracking() .Where(value => value.TenantId == tenantId && value.Status == TenantExternalProviderStatus.Active) .Select(value => value.Capability) .Distinct() diff --git a/Tiku.Infrastructure/TenantAdmin/Classes/TenantClassService.cs b/Tiku.Infrastructure/TenantAdmin/Classes/TenantClassService.cs index d41a905..b84abd4 100644 --- a/Tiku.Infrastructure/TenantAdmin/Classes/TenantClassService.cs +++ b/Tiku.Infrastructure/TenantAdmin/Classes/TenantClassService.cs @@ -21,7 +21,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantClasses.AsNoTracking() + var query = tenantAdministrationPersistence.TenantClasses.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, @@ -51,16 +51,16 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen .Select(item => new { Class = item, - RegionName = dbContext.Regions + RegionName = catalogPersistence.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId) .Select(region => region.Name) .FirstOrDefault(), - StudentCount = dbContext.TenantClassMembers.Count(member => + StudentCount = tenantAdministrationPersistence.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && member.MemberType == TenantClassMemberType.Student), - StaffCount = dbContext.TenantClassMembers.Count(member => + StaffCount = tenantAdministrationPersistence.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && @@ -83,7 +83,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen 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, + var item = await ResolveTenantEntityAsync(tenantAdministrationPersistence.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)) @@ -104,13 +104,13 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) dbContext.TenantClasses.Add(item); + if (isNew) tenantAdministrationPersistence.TenantClasses.Add(item); await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); var regionName = item.RegionId.HasValue - ? await dbContext.Regions + ? await catalogPersistence.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value) .Select(region => region.Name) .FirstOrDefaultAsync(cancellationToken) @@ -126,7 +126,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var item = await dbContext.TenantClasses + var item = await tenantAdministrationPersistence.TenantClasses .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId) .ApplyDataScope( scope, @@ -139,7 +139,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen item.Status = TenantRecordStatus.Disabled; item.UpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassItem(item, null, 0, 0)); } @@ -152,12 +152,12 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken); var classIds = scope.ClassIds.ToArray(); var regionIds = scope.RegionIds.ToArray(); - var query = dbContext.TenantClassMembers.AsNoTracking() + var query = tenantAdministrationPersistence.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 => + item => classIds.Contains(item.ClassId) || tenantAdministrationPersistence.TenantClasses.Any(tenantClass => tenantClass.TenantId == actor.TenantId && tenantClass.Id == item.ClassId && tenantClass.RegionId.HasValue && @@ -177,7 +177,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen .ThenBy(item => item.JoinedAt) .Take(ResolveLimit(filter.Limit)) .Join( - dbContext.Users.AsNoTracking(), + identityPersistence.Users.AsNoTracking(), member => member.UserId, user => user.Id, (member, user) => ToClassMemberItem(member, ToUserSummary(user))) @@ -193,8 +193,8 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen { 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) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.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"); @@ -206,7 +206,7 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); - var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => + var item = await tenantAdministrationPersistence.TenantClassMembers.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.ClassId == command.ClassId && member.UserId == user.Id && @@ -225,10 +225,10 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) dbContext.TenantClassMembers.Add(item); + if (isNew) tenantAdministrationPersistence.TenantClassMembers.Add(item); await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } @@ -241,12 +241,12 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen var scope = await RequireDataScopeAsync(actor, cancellationToken); var classIds = scope.ClassIds.ToArray(); var regionIds = scope.RegionIds.ToArray(); - var item = await dbContext.TenantClassMembers + var item = await tenantAdministrationPersistence.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 => + member => classIds.Contains(member.ClassId) || tenantAdministrationPersistence.TenantClasses.Any(tenantClass => tenantClass.TenantId == actor.TenantId && tenantClass.Id == member.ClassId && tenantClass.RegionId.HasValue && @@ -254,12 +254,12 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen .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); + var user = await identityPersistence.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); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } } diff --git a/Tiku.Infrastructure/TenantAdmin/Dashboard/StatisticsAggregationJobHandler.cs b/Tiku.Infrastructure/TenantAdmin/Dashboard/StatisticsAggregationJobHandler.cs index 0d44f9b..50221da 100644 --- a/Tiku.Infrastructure/TenantAdmin/Dashboard/StatisticsAggregationJobHandler.cs +++ b/Tiku.Infrastructure/TenantAdmin/Dashboard/StatisticsAggregationJobHandler.cs @@ -8,7 +8,8 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.TenantAdmin.Dashboard; internal sealed class StatisticsAggregationJobHandler( - TikuDbContext dbContext, + ILearningPersistence learningPersistence, + ICommercePersistence commercePersistence, IFeatureUsageReconciliationService featureUsageReconciliationService) : IBackgroundJobHandler { public string JobType => "statistics_aggregation"; @@ -18,15 +19,15 @@ internal sealed class StatisticsAggregationJobHandler( CancellationToken cancellationToken = default) { var since = DateTimeOffset.UtcNow.AddDays(-7); - var activeLearnerCount = await dbContext.PracticeSessions + var activeLearnerCount = await learningPersistence.PracticeSessions .Where(item => item.TenantId == context.TenantId && item.StartedAt >= since) .Select(item => item.UserId) .Distinct() .CountAsync(cancellationToken); - var paidOrderCount = await dbContext.Orders + var paidOrderCount = await commercePersistence.Orders .CountAsync(item => item.TenantId == context.TenantId && item.Status == OrderStatus.Paid, cancellationToken); - var revenueCents = await dbContext.Orders + var revenueCents = await commercePersistence.Orders .Where(item => item.TenantId == context.TenantId && (item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || diff --git a/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDashboardService.cs b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDashboardService.cs index 7201039..ab79e53 100644 --- a/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDashboardService.cs +++ b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDashboardService.cs @@ -21,20 +21,20 @@ internal sealed class TenantAdminDashboardService(TenantAdminServiceDependencies var classIds = scope.ClassIds.ToArray(); var now = DateTimeOffset.UtcNow; var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); - var scopedClasses = dbContext.TenantClasses.AsNoTracking() + var scopedClasses = tenantAdministrationPersistence.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() + var scopedStudents = tenantAdministrationPersistence.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() + var scopedOrders = commercePersistence.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, @@ -44,26 +44,26 @@ internal sealed class TenantAdminDashboardService(TenantAdminServiceDependencies var studentCount = await scopedStudents.CountAsync(cancellationToken); var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); - var staffCount = await dbContext.TenantMemberships.AsNoTracking() + var staffCount = await identityPersistence.TenantMemberships.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status == MembershipStatus.Active && item.Role != TenantRole.Student, cancellationToken); - var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() + var activePracticeCount = await learningPersistence.PracticeSessions.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.FinishedAt == null && (!item.ExpiresAt.HasValue || item.ExpiresAt > now), cancellationToken); - var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() + var todayPracticeCount = await learningPersistence.PracticeSessions.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); - var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() + var pendingFollowupCount = await tenantAdministrationPersistence.TenantStudentFollowups.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), cancellationToken); - var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() + var unreadNotificationCount = await jobsOperationsPersistence.UserNotifications.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); @@ -71,14 +71,14 @@ internal sealed class TenantAdminDashboardService(TenantAdminServiceDependencies .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() + var pendingRefundCount = await commercePersistence.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() + var openReconciliationIssueCount = await commercePersistence.CommerceReconciliationIssues.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && diff --git a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantDomainProviderService.cs b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantDomainProviderService.cs index 9515c4f..8405fcd 100644 --- a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantDomainProviderService.cs +++ b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantDomainProviderService.cs @@ -21,7 +21,7 @@ internal sealed class TenantDomainProviderService(TenantAdminServiceDependencies CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var items = await dbContext.TenantDomains.AsNoTracking() + var items = await tenancyPersistence.TenantDomains.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .OrderByDescending(item => item.IsPrimary) .ThenBy(item => item.CreatedAt) @@ -49,7 +49,7 @@ internal sealed class TenantDomainProviderService(TenantAdminServiceDependencies var host = generated.Host; if (command.IsPrimary) { - var primaryDomains = await dbContext.TenantDomains + var primaryDomains = await tenancyPersistence.TenantDomains .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) .ToArrayAsync(cancellationToken); foreach (var domain in primaryDomains) domain.IsPrimary = false; @@ -64,9 +64,9 @@ internal sealed class TenantDomainProviderService(TenantAdminServiceDependencies IsPrimary = command.IsPrimary, VerificationToken = generated.VerificationToken }; - dbContext.TenantDomains.Add(item); + tenancyPersistence.TenantDomains.Add(item); await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToDomainItem(item)); } @@ -104,7 +104,7 @@ internal sealed class TenantDomainProviderService(TenantAdminServiceDependencies await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToAuthProviderItem(item)); } } diff --git a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantEngagementService.cs b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantEngagementService.cs index 5b510c4..7e173f1 100644 --- a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantEngagementService.cs +++ b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantEngagementService.cs @@ -22,7 +22,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = tenantAdministrationPersistence.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Category)) query = query.Where(item => item.Category == filter.Category.Trim()); @@ -45,7 +45,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, + var item = await ResolveTenantEntityAsync(tenantAdministrationPersistence.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -62,10 +62,10 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) dbContext.Badges.Add(item); + if (isNew) tenantAdministrationPersistence.Badges.Add(item); await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBadgeItem(item)); } @@ -77,18 +77,18 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.UserBadges.AsNoTracking() + var query = tenantAdministrationPersistence.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 => + (tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && @@ -104,10 +104,10 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep 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() + var users = await identityPersistence.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); - var badges = await dbContext.Badges.AsNoTracking() + var badges = await tenantAdministrationPersistence.Badges.AsNoTracking() .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id)) .ToDictionaryAsync(badge => badge.Id, cancellationToken); @@ -128,7 +128,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); - var badge = await dbContext.Badges.FirstOrDefaultAsync( + var badge = await tenantAdministrationPersistence.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"); @@ -136,7 +136,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep 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( + var grant = await tenantAdministrationPersistence.UserBadges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId, cancellationToken); var isNew = grant is null; @@ -152,7 +152,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep grant.Note = Normalize(command.Note) ?? grant.Note; grant.GrantedBy ??= actor.UserId; grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; - if (isNew) dbContext.UserBadges.Add(grant); + if (isNew) tenantAdministrationPersistence.UserBadges.Add(grant); var dedupeKey = $"badge:{grant.Id:N}"; await notificationProvider.UpsertInAppAsync( @@ -173,8 +173,8 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep cancellationToken); await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - var user = await dbContext.Users.AsNoTracking() + await unitOfWork.SaveChangesAsync(cancellationToken); + var user = await identityPersistence.Users.AsNoTracking() .FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); } @@ -187,17 +187,17 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.UserNotifications.AsNoTracking() + var query = jobsOperationsPersistence.UserNotifications.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => + item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && @@ -248,7 +248,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep cancellationToken); await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNotificationItem(item)); } @@ -260,18 +260,18 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.Reports.AsNoTracking() + var query = learningPersistence.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 => + (tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && @@ -299,7 +299,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = reports.Select(item => item.UserId).OfType().Distinct().ToArray(); - var users = await dbContext.Users.AsNoTracking() + var users = await identityPersistence.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(reports.Select(report => @@ -316,18 +316,18 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var report = await dbContext.Reports + var report = await learningPersistence.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 => + (tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId.Value && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && @@ -346,7 +346,7 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep report.HandledAt = DateTimeOffset.UtcNow; } - dbContext.ReportStatusEvents.Add(new ReportStatusEvent + learningPersistence.ReportStatusEvents.Add(new ReportStatusEvent { TenantId = actor.TenantId, ReportId = report.Id, @@ -357,9 +357,9 @@ internal sealed class TenantEngagementService(TenantAdminServiceDependencies dep Metadata = JsonObjectOrDefault(command.Metadata) }); await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); var user = report.UserId.HasValue - ? await dbContext.Users.AsNoTracking() + ? await identityPersistence.Users.AsNoTracking() .FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) : null; return new ContentManagementResult(ToFeedbackItem(report, user)); diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.DataAccess.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.DataAccess.cs index 0d39498..667d352 100644 --- a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.DataAccess.cs +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.DataAccess.cs @@ -18,7 +18,7 @@ internal abstract partial class TenantAdminServiceBase User? user = null; if (command.UserId.HasValue) { - user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, + user = await identityPersistence.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); if (user is null) throw new TenantAdminDirectException("User was not found.", "user_not_found"); } @@ -27,7 +27,7 @@ internal abstract partial class TenantAdminServiceBase var phone = Normalize(command.Phone); var email = Normalize(command.Email); var username = Normalize(command.Username); - user = await dbContext.Users.FirstOrDefaultAsync(item => + user = await identityPersistence.Users.FirstOrDefaultAsync(item => (phone != null && item.Phone == phone) || (email != null && item.Email == email) || (username != null && item.UserName == username), @@ -48,7 +48,7 @@ internal abstract partial class TenantAdminServiceBase PrimaryRole = primaryRole, RawProfile = JsonDefaults.Object() }; - dbContext.Users.Add(user); + identityPersistence.Users.Add(user); } } @@ -67,7 +67,7 @@ internal abstract partial class TenantAdminServiceBase TenantRole role, CancellationToken cancellationToken) { - var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => + var membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Role == role, cancellationToken); if (membership is null) @@ -85,7 +85,7 @@ internal abstract partial class TenantAdminServiceBase Role = role, Status = MembershipStatus.Active }; - dbContext.TenantMemberships.Add(membership); + identityPersistence.TenantMemberships.Add(membership); } else { @@ -119,7 +119,7 @@ internal abstract partial class TenantAdminServiceBase CancellationToken cancellationToken, Guid? excludedMembershipId = null) { - var query = dbContext.TenantMemberships.AsNoTracking().Where(item => + var query = identityPersistence.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active); @@ -142,7 +142,7 @@ internal abstract partial class TenantAdminServiceBase JsonElement moduleSelections, CancellationToken cancellationToken) { - var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => + var profile = await tenantAdministrationPersistence.StudentProfiles.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId, cancellationToken); if (profile is null) @@ -156,7 +156,7 @@ internal abstract partial class TenantAdminServiceBase ModuleSelections = JsonDefaults.Object(), RecentActivities = JsonDefaults.Array() }; - dbContext.StudentProfiles.Add(profile); + tenantAdministrationPersistence.StudentProfiles.Add(profile); } profile.RegionId = regionId ?? profile.RegionId; @@ -188,7 +188,7 @@ internal abstract partial class TenantAdminServiceBase 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( + else if (row.RegionId.HasValue && !await catalogPersistence.Regions.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) reason = "region_not_found"; else if (row.ClassId.HasValue) @@ -224,7 +224,7 @@ internal abstract partial class TenantAdminServiceBase JsonElement metadata, CancellationToken cancellationToken) { - var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => + var item = await tenantAdministrationPersistence.TenantClassMembers.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.ClassId == classId && member.UserId == userId && @@ -240,7 +240,7 @@ internal abstract partial class TenantAdminServiceBase MemberType = memberType, JoinedAt = DateTimeOffset.UtcNow }; - dbContext.TenantClassMembers.Add(item); + tenantAdministrationPersistence.TenantClassMembers.Add(item); } item.Status = status; @@ -253,7 +253,7 @@ internal abstract partial class TenantAdminServiceBase Guid tenantId, CancellationToken cancellationToken) { - var settings = await dbContext.TenantSettings.AsNoTracking() + var settings = await tenancyPersistence.TenantSettings.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null || settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || @@ -270,11 +270,11 @@ internal abstract partial class TenantAdminServiceBase CancellationToken cancellationToken) { var settings = - await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + await tenancyPersistence.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null) { settings = new TenantSettings { TenantId = tenantId }; - dbContext.TenantSettings.Add(settings); + tenancyPersistence.TenantSettings.Add(settings); } var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object @@ -295,7 +295,7 @@ internal abstract partial class TenantAdminServiceBase if (rules.Length == 0) return []; var regionIds = scope.RegionIds.ToArray(); - var students = await dbContext.StudentProfiles.AsNoTracking() + var students = await tenantAdministrationPersistence.StudentProfiles.AsNoTracking() .Where(profile => profile.TenantId == actor.TenantId) .ApplyDataScope( scope, @@ -304,7 +304,7 @@ internal abstract partial class TenantAdminServiceBase .Select(profile => new { Profile = profile, - User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() + User = identityPersistence.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() }) .ToArrayAsync(cancellationToken); var today = DateOnly.FromDateTime(DateTime.UtcNow); @@ -350,7 +350,7 @@ internal abstract partial class TenantAdminServiceBase protected async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { - var exists = await dbContext.TenantMemberships.AnyAsync( + var exists = await identityPersistence.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"); @@ -364,17 +364,17 @@ internal abstract partial class TenantAdminServiceBase { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var exists = await dbContext.TenantMemberships + var exists = await identityPersistence.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 => + item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && @@ -387,7 +387,7 @@ internal abstract partial class TenantAdminServiceBase { if (!userId.HasValue) return; - var exists = await dbContext.TenantMemberships.AnyAsync( + var exists = await identityPersistence.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"); @@ -405,7 +405,7 @@ internal abstract partial class TenantAdminServiceBase CancellationToken cancellationToken) { const string roleCode = "tenant_owner"; - var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync( + var role = await jobsOperationsPersistence.TenantBackendRoles.FirstOrDefaultAsync( item => item.TenantId == tenantId && item.Code == roleCode, cancellationToken); if (role is null) @@ -420,7 +420,7 @@ internal abstract partial class TenantAdminServiceBase Description = "系统内置租户所有者角色", DataScope = JsonSerializer.SerializeToElement(new { mode = "All" }) }; - dbContext.TenantBackendRoles.Add(role); + jobsOperationsPersistence.TenantBackendRoles.Add(role); } else { @@ -430,13 +430,13 @@ internal abstract partial class TenantAdminServiceBase } var tenantPermissionCodes = BackendPermissions.Tenant.ToArray(); - var existingPermissionCodes = await dbContext.BackendPermissions + var existingPermissionCodes = await jobsOperationsPersistence.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 + jobsOperationsPersistence.BackendPermissions.Add(new BackendPermission { Code = permissionCode, Name = permissionCode, @@ -445,11 +445,11 @@ internal abstract partial class TenantAdminServiceBase IsSystem = true }); - var boundPermissionCodes = await dbContext.TenantBackendRolePermissions + var boundPermissionCodes = await jobsOperationsPersistence.TenantBackendRolePermissions .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) .Select(binding => binding.PermissionCode) .ToArrayAsync(cancellationToken); - dbContext.TenantBackendRolePermissions.AddRange( + jobsOperationsPersistence.TenantBackendRolePermissions.AddRange( tenantPermissionCodes .Except(boundPermissionCodes, StringComparer.Ordinal) .Select(permissionCode => new TenantBackendRolePermission @@ -459,10 +459,10 @@ internal abstract partial class TenantAdminServiceBase PermissionCode = permissionCode })); - if (!await dbContext.TenantBackendUserRoles.AnyAsync( + if (!await jobsOperationsPersistence.TenantBackendUserRoles.AnyAsync( binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, cancellationToken)) - dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole + jobsOperationsPersistence.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = userId, @@ -477,10 +477,10 @@ internal abstract partial class TenantAdminServiceBase CancellationToken cancellationToken) { var branding = - await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + await tenancyPersistence.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (branding is null) { - var tenantName = await dbContext.Tenants + var tenantName = await tenancyPersistence.Tenants .Where(tenant => tenant.Id == tenantId) .Select(tenant => tenant.Name) .FirstOrDefaultAsync(cancellationToken); @@ -489,7 +489,7 @@ internal abstract partial class TenantAdminServiceBase TenantId = tenantId, BrandName = tenantName ?? "租户题库" }; - dbContext.TenantBrandings.Add(branding); + tenancyPersistence.TenantBrandings.Add(branding); } branding.Theme = theme.Clone(); @@ -500,7 +500,7 @@ internal abstract partial class TenantAdminServiceBase { if (!classId.HasValue) return; - var exists = await dbContext.TenantClasses.AnyAsync( + var exists = await tenantAdministrationPersistence.TenantClasses.AnyAsync( item => item.TenantId == tenantId && item.Id == classId.Value, cancellationToken); if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); @@ -516,7 +516,7 @@ internal abstract partial class TenantAdminServiceBase var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var exists = await dbContext.TenantClasses + var exists = await tenantAdministrationPersistence.TenantClasses .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value) .ApplyDataScope( scope, @@ -556,7 +556,7 @@ internal abstract partial class TenantAdminServiceBase { if (!id.HasValue) return; - var exists = await dbContext.Set() + var exists = await unitOfWork.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); } @@ -590,7 +590,7 @@ internal abstract partial class TenantAdminServiceBase Guid targetId, CancellationToken cancellationToken) { - dbContext.AuditLogs.Add(new AuditLog + jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.MappingAndValidation.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.MappingAndValidation.cs index 9742718..66b5b24 100644 --- a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.MappingAndValidation.cs +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.MappingAndValidation.cs @@ -412,8 +412,8 @@ internal abstract partial class TenantAdminServiceBase 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() + from binding in jobsOperationsPersistence.TenantBackendUserRoles.AsNoTracking() + join backendRole in jobsOperationsPersistence.TenantBackendRoles.AsNoTracking() on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } where binding.TenantId == actor.TenantId && diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.cs index de36a97..530fb8f 100644 --- a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.cs +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminFoundation.cs @@ -7,7 +7,14 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.TenantAdmin; internal sealed record TenantAdminServiceDependencies( - TikuDbContext DbContext, + IIdentityPersistence IdentityPersistence, + ITenancyPersistence TenancyPersistence, + ITenantAdministrationPersistence TenantAdministrationPersistence, + ICatalogPersistence CatalogPersistence, + ILearningPersistence LearningPersistence, + ICommercePersistence CommercePersistence, + IPointsPersistence PointsPersistence, + IJobsOperationsPersistence JobsOperationsPersistence, ITenantExternalProviderConfigService ProviderConfigService, INotificationProvider NotificationProvider, ICurrentAccessContext CurrentAccessContext, @@ -17,7 +24,18 @@ internal sealed record TenantAdminServiceDependencies( internal abstract partial class TenantAdminServiceBase(TenantAdminServiceDependencies dependencies) { - protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence; + protected ITenancyPersistence tenancyPersistence { get; } = dependencies.TenancyPersistence; + + protected ITenantAdministrationPersistence tenantAdministrationPersistence { get; } = + dependencies.TenantAdministrationPersistence; + + protected ICatalogPersistence catalogPersistence { get; } = dependencies.CatalogPersistence; + protected ILearningPersistence learningPersistence { get; } = dependencies.LearningPersistence; + protected ICommercePersistence commercePersistence { get; } = dependencies.CommercePersistence; + protected IPointsPersistence pointsPersistence { get; } = dependencies.PointsPersistence; + protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence; + protected IModulePersistence unitOfWork { get; } = dependencies.TenantAdministrationPersistence; protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService; protected INotificationProvider notificationProvider { get; } = dependencies.NotificationProvider; protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext; diff --git a/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantMemberAccessService.cs b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantMemberAccessService.cs index cd8e1ed..1b84f8c 100644 --- a/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantMemberAccessService.cs +++ b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantMemberAccessService.cs @@ -17,7 +17,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = identityPersistence.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")); @@ -28,7 +28,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => dbContext.Users.Any(user => + query = query.Where(item => identityPersistence.Users.Any(user => user.Id == item.UserId && ((user.UserName != null && user.UserName.Contains(keyword)) || (user.Phone != null && user.Phone.Contains(keyword)) || @@ -45,7 +45,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = memberships.Select(item => item.UserId).ToArray(); - var users = await dbContext.Users.AsNoTracking() + var users = await identityPersistence.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); @@ -62,8 +62,8 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role"); var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status"); @@ -76,7 +76,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d TenantMembership? membership = null; if (command.MembershipId.HasValue) { - membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, cancellationToken); if (membership is null) @@ -84,7 +84,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d } else { - membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role, cancellationToken); } @@ -126,7 +126,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d membership.UserId = user.Id; membership.Role = role; membership.Status = status; - if (isNew) dbContext.TenantMemberships.Add(membership); + if (isNew) identityPersistence.TenantMemberships.Add(membership); if (status != MembershipStatus.Active) await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); @@ -134,7 +134,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (wasStaffCounted && !willStaffBeCounted) await featureAccessService.ReleaseQuotaAsync( actor.TenantId, @@ -160,10 +160,10 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - await using var transaction = dbContext.Database.CurrentTransaction is null - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; - var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + var membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == membershipId, cancellationToken); if (membership is null) @@ -182,7 +182,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d 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); + await unitOfWork.SaveChangesAsync(cancellationToken); if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted) await featureAccessService.ReleaseQuotaAsync( actor.TenantId, @@ -192,7 +192,7 @@ internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies d if (transaction is not null) await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); - var user = await dbContext.Users.AsNoTracking() + var user = await identityPersistence.Users.AsNoTracking() .SingleAsync(item => item.Id == membership.UserId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } diff --git a/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantSiteSettingsService.cs b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantSiteSettingsService.cs index e11e413..15b220d 100644 --- a/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantSiteSettingsService.cs +++ b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantSiteSettingsService.cs @@ -18,7 +18,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + var query = jobsOperationsPersistence.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Action)) { var action = filter.Action.Trim(); @@ -35,7 +35,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d .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() + var users = await identityPersistence.Users.AsNoTracking() .Where(user => actorIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); @@ -66,12 +66,12 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); - var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, + var item = await tenancyPersistence.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantBranding { TenantId = actor.TenantId }; - dbContext.TenantBrandings.Add(item); + tenancyPersistence.TenantBrandings.Add(item); } item.BrandName = command.BrandName.Trim(); @@ -83,7 +83,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBrandingItem(item)); } @@ -94,19 +94,19 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d { await RequireAllDataScopeAsync(actor, cancellationToken); AssertNoSecrets(command.PublicConfig, "public_config"); - var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, + var item = await tenancyPersistence.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantSettings { TenantId = actor.TenantId }; - dbContext.TenantSettings.Add(item); + tenancyPersistence.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); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToSettingsItem(item)); } @@ -116,7 +116,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d { await RequireAllDataScopeAsync(actor, cancellationToken); await Task.CompletedTask.WaitAsync(cancellationToken); - var items = await dbContext.TenantThemeTemplates.AsNoTracking() + var items = await tenantAdministrationPersistence.TenantThemeTemplates.AsNoTracking() .Where(item => item.Status == TenantThemeTemplateStatus.Active) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Code) @@ -137,11 +137,11 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var item = await dbContext.TenantThemeConfigs.AsNoTracking() + var item = await tenantAdministrationPersistence.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() + var branding = await tenancyPersistence.TenantBrandings.AsNoTracking() .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); return new ContentManagementResult(new TenantThemeItem( actor.TenantId, @@ -165,18 +165,18 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); - var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + var template = await tenantAdministrationPersistence.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, + var item = await tenantAdministrationPersistence.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); if (item is null) { item = new TenantThemeConfig { TenantId = actor.TenantId }; - dbContext.TenantThemeConfigs.Add(item); + tenantAdministrationPersistence.TenantThemeConfigs.Add(item); } item.DraftTemplateCode = template.Code; @@ -185,7 +185,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d item.Status = TenantThemeConfigStatus.Draft; item.DraftUpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToThemeItem(item)); } @@ -195,7 +195,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, + var item = await tenantAdministrationPersistence.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); JsonElement activeTheme; JsonElement activeAssets; @@ -212,14 +212,14 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d else { ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); - var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + var template = await tenantAdministrationPersistence.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); + if (unitOfWork.Entry(item).State == EntityState.Detached) tenantAdministrationPersistence.TenantThemeConfigs.Add(item); activeTheme = MergeJsonObjects(template.Theme, command.Theme); activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); @@ -227,7 +227,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d } item ??= new TenantThemeConfig { TenantId = actor.TenantId }; - if (dbContext.Entry(item).State == EntityState.Detached) dbContext.TenantThemeConfigs.Add(item); + if (unitOfWork.Entry(item).State == EntityState.Detached) tenantAdministrationPersistence.TenantThemeConfigs.Add(item); item.ActiveTemplateCode = templateCode; item.ActiveTheme = activeTheme.Clone(); @@ -240,7 +240,7 @@ internal sealed class TenantSiteSettingsService(TenantAdminServiceDependencies d 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); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToThemeItem(item)); } } diff --git a/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantStudentEngagementService.cs b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantStudentEngagementService.cs index 408f57d..9a21914 100644 --- a/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantStudentEngagementService.cs +++ b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantStudentEngagementService.cs @@ -18,17 +18,17 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantStudentNotes.AsNoTracking() + var query = tenantAdministrationPersistence.TenantStudentNotes.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => + item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && @@ -58,17 +58,17 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - item = await dbContext.TenantStudentNotes + item = await tenantAdministrationPersistence.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 => + note => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == note.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == note.StudentUserId && member.Status == TenantClassMemberStatus.Active && @@ -93,10 +93,10 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc item.IsPinned = command.IsPinned ?? item.IsPinned; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) dbContext.TenantStudentNotes.Add(item); + if (isNew) tenantAdministrationPersistence.TenantStudentNotes.Add(item); await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNoteItem(item)); } @@ -108,19 +108,19 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - var query = dbContext.TenantStudentFollowups.AsNoTracking() + var query = tenantAdministrationPersistence.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 => + tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && @@ -159,7 +159,7 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); - item = await dbContext.TenantStudentFollowups + item = await tenantAdministrationPersistence.TenantStudentFollowups .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) .ApplyDataScope( scope, @@ -167,12 +167,12 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc followup.AssignedToUserId == actor.UserId || followup.CreatedBy == actor.UserId, followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || - dbContext.StudentProfiles.Any(profile => + tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == followup.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == followup.StudentUserId && member.Status == TenantClassMemberStatus.Active && @@ -204,11 +204,11 @@ internal sealed class TenantStudentEngagementService(TenantAdminServiceDependenc item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) dbContext.TenantStudentFollowups.Add(item); + if (isNew) tenantAdministrationPersistence.TenantStudentFollowups.Add(item); await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToFollowupItem(item)); } } diff --git a/Tiku.Infrastructure/TenantAdmin/Students/TenantStudentService.cs b/Tiku.Infrastructure/TenantAdmin/Students/TenantStudentService.cs index 7d2b9c2..85a05d3 100644 --- a/Tiku.Infrastructure/TenantAdmin/Students/TenantStudentService.cs +++ b/Tiku.Infrastructure/TenantAdmin/Students/TenantStudentService.cs @@ -23,17 +23,17 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend 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() + var query = identityPersistence.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 => + item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && @@ -42,7 +42,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend if (filter.ClassId.HasValue) { await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); - query = query.Where(item => dbContext.TenantClassMembers.Any(member => + query = query.Where(item => tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.ClassId == filter.ClassId.Value && member.UserId == item.UserId && @@ -51,7 +51,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend } if (filter.RegionId.HasValue) - query = query.Where(item => dbContext.StudentProfiles.Any(profile => + query = query.Where(item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId == filter.RegionId.Value)); @@ -59,7 +59,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => dbContext.Users.Any(user => + query = query.Where(item => identityPersistence.Users.Any(user => user.Id == item.UserId && ((user.UserName != null && user.UserName.Contains(keyword)) || (user.Phone != null && user.Phone.Contains(keyword)) || @@ -72,24 +72,24 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = memberships.Select(item => item.UserId).ToArray(); - var users = await dbContext.Users.AsNoTracking() + var users = await identityPersistence.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); - var profiles = await dbContext.StudentProfiles.AsNoTracking() + var profiles = await tenantAdministrationPersistence.StudentProfiles.AsNoTracking() .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId)) .ToDictionaryAsync(profile => profile.UserId, cancellationToken); - var regions = await dbContext.Regions.AsNoTracking() + var regions = await catalogPersistence.Regions.AsNoTracking() .Where(region => region.TenantId == actor.TenantId) .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken); - var schools = await dbContext.Schools.AsNoTracking() + var schools = await catalogPersistence.Schools.AsNoTracking() .Where(school => school.TenantId == actor.TenantId) .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken); - var majors = await dbContext.Majors.AsNoTracking() + var majors = await catalogPersistence.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 + from member in tenantAdministrationPersistence.TenantClassMembers.AsNoTracking() + join tenantClass in tenantAdministrationPersistence.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id where member.TenantId == actor.TenantId && tenantClass.TenantId == actor.TenantId && userIds.Contains(member.UserId) && @@ -146,8 +146,8 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend 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) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var user = await ResolveUserAsync(command.User, "student", cancellationToken); if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId)) @@ -168,7 +168,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend cancellationToken); await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult( @@ -191,7 +191,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend 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 + var membership = await identityPersistence.TenantMemberships .Where(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && @@ -199,12 +199,12 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend .ApplyDataScope( scope, item => item.UserId == actor.UserId, - item => dbContext.StudentProfiles.Any(profile => + item => tenantAdministrationPersistence.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || - dbContext.TenantClassMembers.Any(member => + tenantAdministrationPersistence.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && @@ -213,8 +213,8 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend 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) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var wasCounted = await IsUserCountedForMetricAsync( actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken); @@ -233,7 +233,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (wasCounted && !willBeCounted) await featureAccessService.ReleaseQuotaAsync( actor.TenantId, @@ -270,8 +270,8 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend 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) + await using var transaction = unitOfWork.Database.CurrentTransaction is null + ? await unitOfWork.Database.BeginTransactionAsync(cancellationToken) : null; var createdOrUpdated = 0; var classAssigned = 0; @@ -313,7 +313,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend } await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems); } @@ -348,7 +348,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } @@ -375,7 +375,7 @@ internal sealed class TenantStudentService(TenantAdminServiceDependencies depend await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } } diff --git a/Tiku.Infrastructure/TenantAdmin/Supervision/TenantSupervisionService.cs b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantSupervisionService.cs index a8690eb..112ebb9 100644 --- a/Tiku.Infrastructure/TenantAdmin/Supervision/TenantSupervisionService.cs +++ b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantSupervisionService.cs @@ -46,7 +46,7 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de cancellationToken); await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -75,7 +75,7 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de var created = 0; foreach (var student in riskStudents) { - if (await dbContext.TenantStudentFollowups.AnyAsync(item => + if (await tenantAdministrationPersistence.TenantStudentFollowups.AnyAsync(item => item.TenantId == actor.TenantId && item.StudentUserId == student.UserId && item.Status != StudentFollowupStatus.Done && @@ -83,7 +83,7 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de cancellationToken)) continue; - dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup + tenantAdministrationPersistence.TenantStudentFollowups.Add(new TenantStudentFollowup { TenantId = actor.TenantId, StudentUserId = student.UserId, @@ -103,7 +103,7 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); return new TenantSupervisionGenerateResult(created); } @@ -114,16 +114,16 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de await RequireDataScopeAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; return new TenantFollowupReport( - await dbContext.TenantStudentFollowups.CountAsync( + await tenantAdministrationPersistence.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync( + await tenantAdministrationPersistence.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync( + await tenantAdministrationPersistence.TenantStudentFollowups.CountAsync( item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => + await tenantAdministrationPersistence.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.DueAt.HasValue && item.DueAt < now && @@ -138,15 +138,15 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de { await RequireDataScopeAsync(actor, cancellationToken); return new TenantFeedbackReport( - await dbContext.Reports.CountAsync( + await learningPersistence.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), - await dbContext.Reports.CountAsync( + await learningPersistence.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), - await dbContext.Reports.CountAsync( + await learningPersistence.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), - await dbContext.Reports.CountAsync( + await learningPersistence.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), - await dbContext.Reports.CountAsync( + await learningPersistence.Reports.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); } @@ -155,21 +155,21 @@ internal sealed class TenantSupervisionService(TenantAdminServiceDependencies de CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); - var negativeScoreUsers = await dbContext.Users + var negativeScoreUsers = await identityPersistence.Users .Where(user => user.Score < 0 && - dbContext.TenantMemberships.Any(member => + identityPersistence.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 + var highClaimUsers = await pointsPersistence.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( + var cancelledExchangeOrders = await pointsPersistence.PointExchangeOrders.CountAsync( item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, cancellationToken); return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); diff --git a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs index cc889df..cb63ded 100644 --- a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs @@ -33,7 +33,7 @@ public sealed class CommissionEndpointTests var settings = await client.PutAsJsonAsync( "/api/tenant/commission/settings", new UpdateCommissionSettingsDto - { DefaultRate = 0.2m, MinSettlementCents = 1, SettlementCycle = "monthly" }); + { DefaultRate = 0.2m, MinSettlementCents = 1, SettlementCycle = "monthly" }); var sources = await client.GetAsync( $"/api/tenant/commission/orders?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); @@ -164,7 +164,7 @@ public sealed class CommissionEndpointTests private static TenantMembership Membership(LoginSeed seed, TenantRole role) { return new TenantMembership - { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active }; + { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active }; } private static async Task LoginAsync(HttpClient client, LoginSeed seed) diff --git a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs index 8836220..cd6884f 100644 --- a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs +++ b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs @@ -30,16 +30,24 @@ public sealed class CurrentQuotaEnforcementTests new User { Id = studentB, Phone = "13940000003", Name = "Reconcile Student B" }, new TenantMembership { - TenantId = seed.TenantId, UserId = teacherId, Role = TenantRole.Teacher, + TenantId = seed.TenantId, + UserId = teacherId, + Role = TenantRole.Teacher, Status = MembershipStatus.Active }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentA, Role = TenantRole.Student, Status = MembershipStatus.Active + TenantId = seed.TenantId, + UserId = studentA, + Role = TenantRole.Student, + Status = MembershipStatus.Active }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentB, Role = TenantRole.Student, Status = MembershipStatus.Active + TenantId = seed.TenantId, + UserId = studentB, + Role = TenantRole.Student, + Status = MembershipStatus.Active }, new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Draft }, diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs index 15603eb..0571bb7 100644 --- a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -256,7 +256,11 @@ public sealed class DirectContentEndpointTests new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" }, new Major { - Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" + Id = majorId, + TenantId = seed.TenantId, + RegionId = regionId, + SchoolId = schoolId, + Name = "视觉传达" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); diff --git a/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs b/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs index 7d1b0b6..31d4baa 100644 --- a/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs @@ -156,12 +156,15 @@ public sealed class P0OperationsLifecycleTests { entities.Add(new User { - Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", + Id = secondUserId.Value, + Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Next owner" }.WithTestPassword()); entities.Add(new TenantMembership { - TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin, + TenantId = tenantId, + UserId = secondUserId.Value, + Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); } diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs index 0c51447..3d6a8a9 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -319,24 +319,40 @@ public sealed class PlatformAdminEndpointTests await factory.SeedAsync( new Tenant { - Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, + Id = tenantA, + Slug = "capability-a", + Name = "Capability A", + Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new Tenant { - Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, + Id = tenantB, + Slug = "capability-b", + Name = "Capability B", + Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new CrmWebhookQueueItem { - Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", - Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", + Id = failedQueueId, + TenantId = tenantA, + RecordId = "lead-a", + Source = "tenant.student.crm_push", + Status = CrmWebhookQueueStatus.Failed, + Attempts = 2, + IdempotencyKey = "platform-capability-lead-a", LastError = "timeout" }, new CrmWebhookQueueItem { - Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", - Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", + Id = otherQueueId, + TenantId = tenantB, + RecordId = "lead-b", + Source = "tenant.student.crm_push", + Status = CrmWebhookQueueStatus.Failed, + Attempts = 1, + IdempotencyKey = "platform-capability-lead-b", LastError = "still failed" }); using var client = factory.CreateClient(); @@ -582,7 +598,7 @@ public sealed class PlatformAdminEndpointTests var ignoreResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/events/ignore", new ResolvePlatformBillingDunningEventDto - { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); + { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); var disableResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/channels/disable", new DisablePlatformBillingDunningChannelDto @@ -726,12 +742,12 @@ public sealed class PlatformAdminEndpointTests var tenantId = tenantIds[0]; using (var pendingIssueRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") - { - Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto - { - Reason = "Domain is not ready yet" - }) - }) + { + Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto + { + Reason = "Domain is not ready yet" + }) + }) { pendingIssueRequest.Headers.Add("Idempotency-Key", $"pending-{Guid.NewGuid():N}"); var pendingIssue = await client.SendAsync(pendingIssueRequest); @@ -824,14 +840,14 @@ public sealed class PlatformAdminEndpointTests using (var revokedRequest = new HttpRequestMessage( HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") - { - Content = JsonContent.Create(new CompleteOwnerActivationDto - { - ActivationId = firstActivationId, - Token = firstActivationToken, - NewPassword = "ActivatedOwner2026" - }) - }) + { + Content = JsonContent.Create(new CompleteOwnerActivationDto + { + ActivationId = firstActivationId, + Token = firstActivationToken, + NewPassword = "ActivatedOwner2026" + }) + }) { revokedRequest.Headers.Add("Origin", $"https://{primaryHost}"); var revoked = await browserClient.SendAsync(revokedRequest); diff --git a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs index 4a51c95..0cfc8cf 100644 --- a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs @@ -37,7 +37,9 @@ public sealed class PlatformApprovalEndpointTests var (requester, approver) = await SeedActorsAsync(factory, permissions); var tenant = new Tenant { - Slug = $"approval-{Guid.NewGuid():N}"[..30], Name = "Approval Tenant", Status = TenantStatus.Active, + Slug = $"approval-{Guid.NewGuid():N}"[..30], + Name = "Approval Tenant", + Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }; await factory.SeedAsync( @@ -55,7 +57,7 @@ public sealed class PlatformApprovalEndpointTests 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" }) + { TenantId = tenant.Id, Status = TenantStatus.Archived, Reason = "Close expired customer" }) }; archive.Headers.Add("Idempotency-Key", "archive-approval-1"); var submission = await requesterClient.SendAsync(archive); @@ -111,13 +113,18 @@ public sealed class PlatformApprovalEndpointTests .Distinct(StringComparer.Ordinal) .Select(code => new PermissionModule { - Code = code, Name = code, Area = BackendPermissionArea.Platform, + Code = code, + Name = code, + Area = BackendPermissionArea.Platform, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] }); var permissions = permissionCodes.Select(code => new BackendPermission { - Code = code, Name = code, Area = BackendPermissionArea.Platform, - PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true + Code = code, + Name = code, + Area = BackendPermissionArea.Platform, + PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), + IsSystem = true }); await factory.SeedAsync([ .. modules, .. permissions, diff --git a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs index 6781af5..504c221 100644 --- a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs @@ -38,12 +38,19 @@ public sealed class PlatformQuestionBankEndpointTests await factory.SeedAsync( new Tenant { - Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, - Status = TenantStatus.Active, BillingStatus = BillingStatus.Active + Id = platformTenantId, + Slug = "platform-content-test", + Name = "平台公共内容", + Mode = TenantMode.PlatformOwned, + Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active }, new Tenant { - Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, + Id = ordinaryTenantId, + Slug = "ordinary-question-bank", + Name = "普通租户", + Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new QuestionBank { Id = ordinaryBankId, TenantId = ordinaryTenantId, Name = "普通租户私有题库" }); @@ -204,7 +211,10 @@ public sealed class PlatformQuestionBankEndpointTests await factory.SeedAsync( new Tenant { - Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned + Id = Guid.NewGuid(), + Slug = "platform-content-auth", + Name = "平台公共内容", + Mode = TenantMode.PlatformOwned }); var platformWithoutPermission = await SeedPlatformUserAsync(factory, BackendPermissions.PlatformDashboardView); using var client = factory.CreateClient(); @@ -255,18 +265,28 @@ public sealed class PlatformQuestionBankEndpointTests new PermissionModule { Code = moduleCode, Name = "平台公共题库", Area = BackendPermissionArea.Platform }, new BackendPermission { - Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, - PermissionModuleCode = moduleCode, IsSystem = true + Code = permissionCode, + Name = "平台公共题库运营", + Area = BackendPermissionArea.Platform, + PermissionModuleCode = moduleCode, + IsSystem = true }, new User { - Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, - NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", + Id = userId, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Name = "平台题库运营", + PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(), new PlatformBackendRole { - Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", + Id = roleId, + Code = $"platform_question_bank_{roleId:N}", + Name = "平台题库运营", Status = BackendRoleStatus.Active }, new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = permissionCode }, diff --git a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs index 7b7db81..a6fefdf 100644 --- a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs +++ b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs @@ -102,7 +102,7 @@ public sealed class ProductionConfigurationTests }; var explicitHosts = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary - { ["AllowedHosts"] = "admin.example.com;api.example.com" }) + { ["AllowedHosts"] = "admin.example.com;api.example.com" }) .Build(); Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true)); } diff --git a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs index 0c90a65..b393383 100644 --- a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs @@ -36,7 +36,11 @@ public sealed class ProfileEndpointTests new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" }, new Major { - Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" + Id = majorId, + TenantId = seed.TenantId, + RegionId = regionId, + SchoolId = schoolId, + Name = "视觉传达" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); diff --git a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs index a2205f0..31a4cf6 100644 --- a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs @@ -69,7 +69,7 @@ public sealed class QuestionBankEndpointTests new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" }, new QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" }, new QuestionCollection - { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active }, + { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active }, new Question { Id = excludedQuestionId, diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index 92b92a4..05b4c9f 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -28,12 +28,14 @@ public sealed class TenantAdminDirectEndpointTests new User { Id = studentId, Phone = "13900009901", Name = "概览学生" }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + TenantId = seed.TenantId, + UserId = studentId, + Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, new TenantClass - { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active }, + { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active }, new TenantStudentFollowup { TenantId = seed.TenantId, @@ -88,13 +90,18 @@ public sealed class TenantAdminDirectEndpointTests new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" }, new TenantClass { - Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", + Id = classId, + TenantId = seed.TenantId, + RegionId = regionId, + Name = "批量班级", Status = TenantRecordStatus.Active }, new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 }, new TenantMembership { - TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, + TenantId = seed.TenantId, + UserId = existingStudentId, + Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile @@ -107,11 +114,13 @@ public sealed class TenantAdminDirectEndpointTests }, new Report { - TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, + TenantId = seed.TenantId, + UserId = existingStudentId, + Status = ReportStatus.Pending, Type = ReportType.Suggestion }, new PointActivityTask - { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 }, + { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 }, new PointActivityClaim { TenantId = seed.TenantId, @@ -122,7 +131,7 @@ public sealed class TenantAdminDirectEndpointTests Status = PointActivityClaimStatus.Claimed }, new PointExchangeItem - { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 }, + { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 }, new PointExchangeOrder { TenantId = seed.TenantId, @@ -281,7 +290,9 @@ public sealed class TenantAdminDirectEndpointTests new User { Id = studentId, Phone = "13900000002", Name = "李同学" }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + TenantId = seed.TenantId, + UserId = studentId, + Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }); @@ -330,7 +341,9 @@ public sealed class TenantAdminDirectEndpointTests new User { Id = studentId, Phone = "13900000003", Name = "王同学" }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + TenantId = seed.TenantId, + UserId = studentId, + Role = TenantRole.Student, Status = MembershipStatus.Active }, new AuthSession @@ -501,7 +514,9 @@ public sealed class TenantAdminDirectEndpointTests new User { Id = studentId, Phone = "13900000005", Name = "反馈学生" }, new TenantMembership { - TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + TenantId = seed.TenantId, + UserId = studentId, + Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, diff --git a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs index 9735091..5bfb16b 100644 --- a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs @@ -31,7 +31,7 @@ public sealed class VideoEndpointTests var videoId = Guid.NewGuid(); await factory.SeedAsync( new Question - { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, + { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new VideoExplanation { Id = videoId, diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index 17f9415..e2acfba 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -29,20 +29,81 @@ public sealed class ArchitectureBoundaryTests $"API controllers crossed the Application boundary:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); } + [Fact] + public void Business_services_do_not_depend_on_the_concrete_tiku_db_context() + { + var root = FindRepositoryRoot(); + var infrastructureRoot = Path.Combine(root, "Tiku.Infrastructure"); + var allowedInfrastructureFile = Path.Combine(infrastructureRoot, "DependencyInjection.cs"); + var infrastructureViolations = Directory.EnumerateFiles( + infrastructureRoot, + "*.cs", + SearchOption.AllDirectories) + .Where(path => !path.StartsWith( + Path.Combine(infrastructureRoot, "Persistence") + Path.DirectorySeparatorChar, + StringComparison.Ordinal) && + !path.Equals(allowedInfrastructureFile, StringComparison.Ordinal)) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => candidate.line.Contains("TikuDbContext", StringComparison.Ordinal)) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}"); + + var apiRoot = Path.Combine(root, "Tiku.Api"); + var allowedApiFile = Path.Combine(apiRoot, "Configuration", "DataProtectionExtensions.cs"); + var apiViolations = Directory.EnumerateFiles(apiRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Equals(allowedApiFile, StringComparison.Ordinal)) + .SelectMany(path => File.ReadLines(path) + .Select((line, index) => new { path, line, lineNumber = index + 1 })) + .Where(candidate => + candidate.line.Contains("TikuDbContext", StringComparison.Ordinal) || + candidate.line.Contains("Tiku.Infrastructure.Persistence", StringComparison.Ordinal)) + .Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}"); + + var violations = infrastructureViolations.Concat(apiViolations).ToArray(); + Assert.True( + violations.Length == 0, + $"Concrete DbContext escaped the persistence composition root:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); + } + + [Fact] + public void Owned_module_persistence_capabilities_do_not_duplicate_db_sets() + { + var ownedCapabilities = new[] + { + typeof(Tiku.Infrastructure.Persistence.IIdentityPersistence), + typeof(Tiku.Infrastructure.Persistence.ITenancyPersistence), + typeof(Tiku.Infrastructure.Persistence.ITenantAdministrationPersistence), + typeof(Tiku.Infrastructure.Persistence.ICatalogPersistence), + typeof(Tiku.Infrastructure.Persistence.IQuestionBankPersistence), + typeof(Tiku.Infrastructure.Persistence.IContentAssetPersistence), + typeof(Tiku.Infrastructure.Persistence.ILearningPersistence), + typeof(Tiku.Infrastructure.Persistence.ICommercePersistence), + typeof(Tiku.Infrastructure.Persistence.IPointsPersistence), + typeof(Tiku.Infrastructure.Persistence.IGrowthPersistence), + typeof(Tiku.Infrastructure.Persistence.IJobsOperationsPersistence), + typeof(Tiku.Infrastructure.Persistence.IPlatformControlPlanePersistence) + }; + var duplicates = ownedCapabilities + .SelectMany(capability => capability.GetProperties(BindingFlags.Instance | BindingFlags.Public | + BindingFlags.DeclaredOnly) + .Where(property => property.PropertyType.IsGenericType && + property.PropertyType.GetGenericTypeDefinition() == typeof(Microsoft.EntityFrameworkCore.DbSet<>)) + .Select(property => new { Capability = capability.Name, property.Name })) + .GroupBy(item => item.Name, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => $"{group.Key}: {string.Join(", ", group.Select(item => item.Capability))}") + .ToArray(); + + Assert.True( + duplicates.Length == 0, + $"DbSet ownership was duplicated:{Environment.NewLine}{string.Join(Environment.NewLine, duplicates)}"); + } + [Fact] public void Service_classes_do_not_exceed_the_hard_size_limit_or_grow_legacy_debt() { var root = FindRepositoryRoot(); - var legacyLineBudgets = new Dictionary(StringComparer.Ordinal) - { - ["ContentManagementService"] = 1096, - ["PlatformQuestionBankService"] = 1033, - ["CommerceService"] = 983, - ["AssetManagementService"] = 948, - ["AuthService"] = 876, - ["ReferralService"] = 849, - ["PlatformTenantCapabilitiesService"] = 805 - }; + var legacyLineBudgets = new Dictionary(StringComparer.Ordinal); var serviceGroups = Directory.EnumerateFiles( Path.Combine(root, "Tiku.Infrastructure"), @@ -154,7 +215,19 @@ public sealed class ArchitectureBoundaryTests "ILearningActivityService", "LearningActivityService", "IPlatformAdminService", - "PlatformAdminService" + "PlatformAdminService", + "IContentManagementService", + "ContentManagementService", + "ICommerceService", + "CommerceService", + "IAssetManagementService", + "AssetManagementService", + "IAuthService", + "AuthService", + "IReferralService", + "ReferralService", + "IPlatformQuestionBankService", + "PlatformQuestionBankService" }; var applicationTypes = typeof(Tiku.Application.TenantAdmin.ITenantAdminDashboardService).Assembly.GetTypes(); var infrastructureTypes = typeof(Tiku.Infrastructure.Persistence.TikuDbContext).Assembly.GetTypes(); @@ -257,11 +330,11 @@ public sealed class ArchitectureBoundaryTests public void BackendAuthorizationDoesNotUseMembershipBusinessRoles() { var root = FindRepositoryRoot(); - var files = new[] - { - Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs"), - Path.Combine(root, "Tiku.Infrastructure", "Growth", "ReferralService.cs") - } + var files = new[] { Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs") } + .Concat(Directory.EnumerateFiles( + Path.Combine(root, "Tiku.Infrastructure", "Growth"), + "*Referral*.cs", + SearchOption.AllDirectories)) .Concat(Directory.EnumerateFiles( Path.Combine(root, "Tiku.Api", "Controllers"), "Tenant*Controller.cs", @@ -288,6 +361,7 @@ public sealed class ArchitectureBoundaryTests var allowedFiles = new[] { "TikuDbContext.cs", + "ModulePersistence.cs", "AuthSessionStore.cs", "SessionStore.cs" }; @@ -518,7 +592,29 @@ public sealed class ArchitectureBoundaryTests typeof(Tiku.Application.Content.IScorelineManagementService), typeof(Tiku.Application.Content.IVideoManagementService), typeof(Tiku.Application.Content.IOperationContentManagementService), + typeof(Tiku.Application.Content.IContentEntryManagementService), + typeof(Tiku.Application.Content.IContentNodeManagementService), + typeof(Tiku.Application.Content.IQuestionCollectionManagementService), + typeof(Tiku.Application.Content.IPracticeBlueprintManagementService), typeof(Tiku.Application.Content.IContentImportService), + typeof(Tiku.Application.Assets.IAssetCatalogManagementService), + typeof(Tiku.Application.Assets.IAssetUploadManagementService), + typeof(Tiku.Application.Assets.IAssetLifecycleManagementService), + typeof(Tiku.Application.Assets.IAssetAuditQueryService), + typeof(Tiku.Application.Assets.IAssetImportJobQueryService), + typeof(Tiku.Application.Auth.IPasswordLoginService), + typeof(Tiku.Application.Auth.ISmsLoginService), + typeof(Tiku.Application.Auth.IWechatLoginService), + typeof(Tiku.Application.Auth.IAuthSessionService), + typeof(Tiku.Application.Auth.IPasswordLifecycleService), + typeof(Tiku.Application.Growth.IStudentReferralService), + typeof(Tiku.Application.Growth.IReferralAdministrationService), + typeof(Tiku.Application.Growth.IReferralAnalyticsService), + typeof(Tiku.Application.PlatformAdmin.IPlatformQuestionBankCatalogService), + typeof(Tiku.Application.PlatformAdmin.IPlatformQuestionBankNodeService), + typeof(Tiku.Application.PlatformAdmin.IPlatformQuestionAdministrationService), + typeof(Tiku.Application.PlatformAdmin.IPlatformQuestionImportService), + typeof(Tiku.Application.PlatformAdmin.IPlatformQuestionAssetService), typeof(Tiku.Application.Commerce.IPaymentConfigurationService), typeof(Tiku.Application.Commerce.ICommerceOrderAdministrationService), typeof(Tiku.Application.Commerce.IActivationCodeAdministrationService), @@ -526,6 +622,11 @@ public sealed class ArchitectureBoundaryTests typeof(Tiku.Application.Commerce.IRefundAdministrationService), typeof(Tiku.Application.Commerce.IReconciliationAdministrationService), typeof(Tiku.Application.Commerce.ICommerceAdjustmentService), + typeof(Tiku.Application.Commerce.ICommerceOrderService), + typeof(Tiku.Application.Commerce.ICommercePaymentService), + typeof(Tiku.Application.Commerce.ICommerceEntitlementService), + typeof(Tiku.Application.Commerce.ICommerceCouponService), + typeof(Tiku.Application.Commerce.ICommercePaymentNotificationService), typeof(Tiku.Application.Points.IPointAdministrationService), typeof(Tiku.Application.Learning.ILearningAnalyticsService), typeof(Tiku.Application.Learning.IAnsweringService), diff --git a/Tiku.IntegrationTests/ModulePersistenceBoundaryTests.cs b/Tiku.IntegrationTests/ModulePersistenceBoundaryTests.cs new file mode 100644 index 0000000..300b1a1 --- /dev/null +++ b/Tiku.IntegrationTests/ModulePersistenceBoundaryTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests; + +public sealed class ModulePersistenceBoundaryTests +{ + [Fact] + public void Module_persistence_capabilities_share_one_scoped_db_context() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddApplication(); + services.AddInfrastructure( + "Host=127.0.0.1;Port=5432;Database=tiku_module_boundary_test;Username=postgres;Password=unused"); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + using var scope = provider.CreateScope(); + var concrete = scope.ServiceProvider.GetRequiredService(); + var capabilityTypes = new[] + { + typeof(IIdentityPersistence), + typeof(ITenancyPersistence), + typeof(ITenantAdministrationPersistence), + typeof(ICatalogPersistence), + typeof(IQuestionBankPersistence), + typeof(IContentAssetPersistence), + typeof(ILearningPersistence), + typeof(ICommercePersistence), + typeof(IPointsPersistence), + typeof(IGrowthPersistence), + typeof(IJobsOperationsPersistence), + typeof(IPlatformControlPlanePersistence), + typeof(IPlatformAdministrationPersistence), + typeof(IPlatformQuestionBankAdministrationPersistence), + typeof(IPlatformTenantCapabilitiesPersistence), + typeof(IPlatformBillingPersistence), + typeof(IOwnerActivationPersistence), + typeof(IBootstrapPersistence) + }; + + foreach (var capabilityType in capabilityTypes) + Assert.Same(concrete, scope.ServiceProvider.GetRequiredService(capabilityType)); + } +} diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index fba045e..68b3c9f 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -111,12 +111,12 @@ public sealed class AuthServiceTests this.scope = scope; DbContext = scope.ServiceProvider.GetRequiredService(); UserManager = scope.ServiceProvider.GetRequiredService>(); - AuthService = scope.ServiceProvider.GetRequiredService(); + AuthService = scope.ServiceProvider.GetRequiredService(); } public TikuDbContext DbContext { get; } public UserManager UserManager { get; } - public IAuthService AuthService { get; } + public IPasswordLoginService AuthService { get; } public Guid TenantId { get; private set; } public Guid UserId { get; private set; } @@ -163,7 +163,11 @@ public sealed class AuthServiceTests services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); var provider = services.BuildServiceProvider(); var scope = provider.CreateAsyncScope(); @@ -350,4 +354,4 @@ public sealed class AuthServiceTests throw new NotSupportedException(); } } -} \ No newline at end of file +} diff --git a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs index 7f0e3b0..aecb232 100644 --- a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs +++ b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs @@ -129,6 +129,7 @@ public sealed class PlatformAdminBootstrapperTests services.AddLogging(); services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(provider => provider.GetRequiredService()); services.AddIdentityCore(options => { options.Password.RequiredLength = 8; @@ -143,4 +144,4 @@ public sealed class PlatformAdminBootstrapperTests services.AddDataProtection().UseEphemeralDataProtectionProvider(); return services.BuildServiceProvider(); } -} \ No newline at end of file +} diff --git a/docs/architecture/module-data-ownership.md b/docs/architecture/module-data-ownership.md new file mode 100644 index 0000000..9dcafd5 --- /dev/null +++ b/docs/architecture/module-data-ownership.md @@ -0,0 +1,27 @@ +# 模块数据所有权 + +本仓库保留一个物理 `TikuDbContext`、一个 EF Model、一个 Migration Snapshot 和一个请求级工作单元。 +业务实现不得直接依赖具体 Context,而应依赖下表中的持久化能力。所有能力在同一作用域内解析到同一个 +`TikuDbContext`,因此租户过滤、SaveChanges 拦截器、ChangeTracker 和本地事务语义保持一致。 + +| 能力 | 所有者 | 数据范围 | +| --- | --- | --- | +| `IIdentityPersistence` | Identity/Auth | 用户、身份、成员关系、认证会话、短信验证 | +| `ITenancyPersistence` | Tenancy | 租户、域名、品牌、运行时配置、外部身份源和租户密钥 | +| `ITenantAdministrationPersistence` | TenantAdmin | 班级、学生档案、跟进、主题、徽章和租户账务策略 | +| `ICatalogPersistence` | Catalog | 地区、院校、专业、科目、分类、Taxonomy 和分数线 | +| `IQuestionBankPersistence` | QuestionBank | 题库、题目版本、内容节点、集合、组卷蓝图和导入 | +| `IContentAssetPersistence` | Content/Assets | 词汇、手册、文件、图片、视频和访问/扫描事件 | +| `ILearningPersistence` | Learning | 练习、答题、错题、收藏、报告、学习积分流水 | +| `ICommercePersistence` | Commerce | 商品、订单、支付、权益、优惠券、退款、对账和调账 | +| `IPointsPersistence` | Points | 积分任务、领取和兑换 | +| `IGrowthPersistence` | Growth | 推荐、CRM 和佣金结算 | +| `IJobsOperationsPersistence` | Platform Core/Jobs | 权限、审计、后台任务、通知和授权缓存失效 | +| `IPlatformControlPlanePersistence` | Platform | SaaS 商品目录、订阅、平台账单、治理和导入控制面 | + +跨模块读取优先调用数据所有者的 Application 查询合同;跨模块修改调用所有者命令合同,不传递 +tracked entity 或 `IQueryable`。控制面、平台题库、平台账单、Owner 激活和 Bootstrap 允许使用显式的 +组合持久化能力,但不得在普通功能模块注册或注入这些组合能力。 + +数据库结构变更仍由单一维护者串行生成和合并 Migration/Snapshot。Gitea 不使用 CODEOWNERS;团队所有权 +以本文件和模块目录为准。