refactor(architecture): harden module boundaries
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-04 12:10:36 +08:00
parent b38f12e60b
commit 33375a38d7
218 changed files with 5124 additions and 3828 deletions

View File

@@ -24,10 +24,13 @@ internal static class ApiPresentationExtensions
services.AddProblemDetails();
services.AddScoped<TenantAdminActorResolver>();
services.AddScoped<DirectContentActorResolver>();
services.AddScoped<TenantContentCapabilitySet>();
services.AddScoped<CommerceAdminActorResolver>();
services.AddScoped<CommerceRequestContextResolver>();
services.AddScoped<LearningActorResolver>();
services.AddScoped<PlatformAdminActorResolver>();
services.AddScoped<AuthRequestContextResolver>();
services.AddScoped<AuthCapabilitySet>();
return services;
}

View File

@@ -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<TenantProvisioningOptions> options,
ILogger<TenantProvisioningStartupValidator> 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<TikuDbContext>();
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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string> RoleCodes,
IReadOnlyDictionary<string, string>? Variables,
[Required] [MaxLength(200)] string IdempotencyKey)
[Required][MaxLength(200)] string IdempotencyKey)
{
public SendPlatformNotificationCommand ToCommand()
{

View File

@@ -10,12 +10,12 @@ namespace Tiku.Api.Contracts;
/// </summary>
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(
/// </summary>
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(
/// </summary>
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<string>? FeatureCodes,
IReadOnlyDictionary<string, long>? Limits,
@@ -94,7 +94,7 @@ public sealed record CreatePlatformBillingQuoteDto(
Guid BaseOfferingVersionId,
IReadOnlyCollection<Guid>? AddOnOfferingVersionIds,
PlatformBillingOrderPurpose Purpose,
[Required] [MaxLength(200)] string IdempotencyKey)
[Required][MaxLength(200)] string IdempotencyKey)
{
public CreatePlatformBillingQuoteCommand ToCommand()
{
@@ -106,7 +106,7 @@ public sealed record CreatePlatformBillingQuoteDto(
/// <summary>
/// 创建平台账务订单请求 DTO。
/// </summary>
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。
/// </summary>
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<Guid>? AddOnOfferingVersionIds,
[Required] [MaxLength(200)] string IdempotencyKey)
[Required][MaxLength(200)] string IdempotencyKey)
{
public ChangeTenantSubscriptionCommand ToCommand()
{
@@ -149,7 +149,7 @@ public sealed record ChangeTenantSubscriptionDto(
/// <summary>
/// Idempotent租户账务请求 DTO。
/// </summary>
public sealed record IdempotentTenantBillingDto([Required] [MaxLength(200)] string IdempotencyKey);
public sealed record IdempotentTenantBillingDto([Required][MaxLength(200)] string IdempotencyKey);
/// <summary>
/// 确认人工平台支付请求 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(
/// </summary>
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(
/// <summary>平台修改订阅状态。</summary>
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(
}
/// <summary>审核或重试 SaaS 退款。</summary>
public sealed record ReviewPlatformRefundDto([Required] [MaxLength(1000)] string Reason)
public sealed record ReviewPlatformRefundDto([Required][MaxLength(1000)] string Reason)
{
public ReviewPlatformRefundCommand ToCommand(Guid refundId)
{

View File

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

View File

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

View File

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

View File

@@ -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<ActionResult<CurrentEntitlementItem>> 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<Guid> 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<PaymentNotificationProcessResult> 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,

View File

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

View File

@@ -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<ActionResult<PlatformQuestionBankItem>> 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<ActionResult<IReadOnlyCollection<PlatformQuestionBankNodeItem>>> 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<ActionResult<PlatformQuestionBankNodeItem>> 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<ActionResult<ContentImportJobDetail>> 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");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<TikuDbContext>();
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId)
var auditService = scope.ServiceProvider.GetRequiredService<IOperationAuditService>();
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);
}
}
}

View File

@@ -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<AllowAnonymousAttribute>().Any() ||
action.Attributes.OfType<AllowAnonymousAttribute>().Any();
if (anonymous) continue;
foreach (var action in controller.Actions)
{
var anonymous = controller.Attributes.OfType<AllowAnonymousAttribute>().Any() ||
action.Attributes.OfType<AllowAnonymousAttribute>().Any();
if (anonymous) continue;
var policies = controller.Attributes.OfType<AuthorizeAttribute>()
.Concat(action.Attributes.OfType<AuthorizeAttribute>())
.Select(attribute => attribute.Policy)
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Cast<string>()
.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<RequireSaasFeatureAttribute>()
.Concat(action.Attributes.OfType<RequireSaasFeatureAttribute>())
.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<RequireSaasFeatureFromRouteAttribute>()
.Select(attribute => $"route:{attribute.RouteValueName}"))
.ToArray();
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
.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<AuthorizeAttribute>()
.Concat(action.Attributes.OfType<AuthorizeAttribute>())
.Select(attribute => attribute.Policy)
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Cast<string>()
.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<RequireSaasFeatureAttribute>()
.Concat(action.Attributes.OfType<RequireSaasFeatureAttribute>())
.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<RequireSaasFeatureFromRouteAttribute>()
.Select(attribute => $"route:{attribute.RouteValueName}"))
.ToArray();
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
.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)

View File

@@ -3,7 +3,7 @@ using Tiku.Application.Content;
namespace Tiku.Application.Assets;
public interface IAssetManagementService
public interface IAssetCatalogManagementService
{
Task<CatalogList<ContentAssetManagementItem>> GetAssetsAsync(
AssetManagementActor actor,
@@ -15,6 +15,10 @@ public interface IAssetManagementService
UpsertAssetCommand command,
CancellationToken cancellationToken = default);
}
public interface IAssetUploadManagementService
{
Task<AssetUploadSignResult> SignUploadAsync(
AssetManagementActor actor,
AssetUploadSignCommand command,
@@ -25,6 +29,10 @@ public interface IAssetManagementService
AssetUploadConfirmCommand command,
CancellationToken cancellationToken = default);
}
public interface IAssetLifecycleManagementService
{
Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
AssetManagementActor actor,
Guid assetId,
@@ -40,6 +48,10 @@ public interface IAssetManagementService
AssetAccessSignCommand command,
CancellationToken cancellationToken = default);
}
public interface IAssetAuditQueryService
{
Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
AssetManagementActor actor,
AssetEventFilter filter,
@@ -50,6 +62,10 @@ public interface IAssetManagementService
AssetEventFilter filter,
CancellationToken cancellationToken = default);
}
public interface IAssetImportJobQueryService
{
Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
AssetManagementActor actor,
ImportJobFilter filter,
@@ -59,4 +75,4 @@ public interface IAssetManagementService
AssetManagementActor actor,
Guid jobId,
CancellationToken cancellationToken = default);
}
}

View File

@@ -1,15 +1,23 @@
namespace Tiku.Application.Auth;
public interface IAuthService
public interface IPasswordLoginService
{
Task<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default);
}
public interface ISmsLoginService
{
Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default);
}
public interface IWechatLoginService
{
Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default);
@@ -18,6 +26,10 @@ public interface IAuthService
WechatLoginRequest request,
CancellationToken cancellationToken = default);
}
public interface IAuthSessionService
{
Task<AuthTokenPair> RefreshAsync(
RefreshSessionRequest request,
CancellationToken cancellationToken = default);
@@ -28,6 +40,10 @@ public interface IAuthService
Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default);
}
public interface IPasswordLifecycleService
{
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);
@@ -43,4 +59,4 @@ public interface IAuthService
Task<AuthenticationResult> ChangePasswordAsync(
AuthenticatedPasswordChangeRequest request,
CancellationToken cancellationToken = default);
}
}

View File

@@ -112,7 +112,7 @@ public sealed record PaymentNotificationProcessResult(
string Status,
bool Idempotent);
public interface ICommerceService
public interface ICommerceOrderService
{
Task<CommerceOrderItem> CreateOrderAsync(
CommerceActor actor,
@@ -129,15 +129,27 @@ public interface ICommerceService
string orderNo,
CancellationToken cancellationToken = default);
}
public interface ICommercePaymentService
{
Task<CommercePaymentItem> CreatePaymentAsync(
CommerceActor actor,
CreateCommercePaymentCommand command,
CancellationToken cancellationToken = default);
}
public interface ICommerceEntitlementService
{
Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
CommerceActor actor,
CancellationToken cancellationToken = default);
}
public interface ICommerceCouponService
{
Task<CommerceCouponItem> ClaimCouponAsync(
CommerceActor actor,
ClaimCommerceCouponCommand command,
@@ -153,6 +165,10 @@ public interface ICommerceService
CheckCommerceCouponCommand command,
CancellationToken cancellationToken = default);
}
public interface ICommercePaymentNotificationService
{
Task<PaymentNotificationProcessResult> 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;
}
}

View File

@@ -2,7 +2,7 @@ using Tiku.Application.Catalog;
namespace Tiku.Application.Content;
public interface IContentManagementService
public interface IContentEntryManagementService
{
Task<CatalogList<ContentEntryManagementItem>> GetEntriesAsync(
ContentManagementActor actor,
@@ -13,7 +13,10 @@ public interface IContentManagementService
ContentManagementActor actor,
UpsertContentEntryCommand command,
CancellationToken cancellationToken = default);
}
public interface IContentNodeManagementService
{
Task<CatalogList<ContentNodeManagementItem>> GetNodesAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
@@ -23,7 +26,10 @@ public interface IContentManagementService
ContentManagementActor actor,
UpsertContentNodeCommand command,
CancellationToken cancellationToken = default);
}
public interface IQuestionCollectionManagementService
{
Task<CatalogList<QuestionCollectionManagementItem>> GetCollectionsAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
@@ -38,7 +44,10 @@ public interface IContentManagementService
ContentManagementActor actor,
ReplaceCollectionItemsCommand command,
CancellationToken cancellationToken = default);
}
public interface IPracticeBlueprintManagementService
{
Task<CatalogList<PracticeBlueprintManagementItem>> GetPracticeBlueprintsAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
@@ -52,4 +61,4 @@ public interface IContentManagementService
ImportFieldMappingItem GetImportFieldMapping(string importType);
ImportTemplateItem GetImportTemplate(string importType, string? format);
}
}

View File

@@ -141,7 +141,7 @@ public sealed record ReferralTeamItem(
string Status,
JsonElement Metadata);
public interface IReferralService
public interface IStudentReferralService
{
Task<ReferralInviteItem> GetOrCreateInviteCodeAsync(
ReferralActor actor,
@@ -170,6 +170,10 @@ public interface IReferralService
ReferralQrcodeCommand command,
CancellationToken cancellationToken = default);
}
public interface IReferralAnalyticsService
{
Task<ReferralStatsItem> GetStatsAsync(
ReferralAdminActor actor,
ReferralStatsQuery query,
@@ -190,6 +194,10 @@ public interface IReferralService
ReferralStatsQuery query,
CancellationToken cancellationToken = default);
}
public interface IReferralAdministrationService
{
Task<ReferralBindResult> 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;
}
}

View File

@@ -0,0 +1,8 @@
namespace Tiku.Application.PlatformAdmin;
public interface ITenantProvisioningReadinessProbe
{
Task<bool> IsPublishedBaseOfferingAvailableAsync(
string offeringCode,
CancellationToken cancellationToken = default);
}

View File

@@ -135,7 +135,7 @@ public sealed record PlatformQuestionImportResult(
int UpdatedQuestionCount,
int SkippedQuestionCount);
public interface IPlatformQuestionBankService
public interface IPlatformQuestionBankCatalogService
{
Task<IReadOnlyCollection<PlatformQuestionBankItem>> GetBanksAsync(PlatformAdminActor actor,
PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default);
@@ -146,6 +146,10 @@ public interface IPlatformQuestionBankService
Task<PlatformQuestionBankItem> ArchiveBankAsync(PlatformAdminActor actor, Guid bankId,
CancellationToken cancellationToken = default);
}
public interface IPlatformQuestionBankNodeService
{
Task<IReadOnlyCollection<PlatformQuestionBankNodeItem>> GetNodesAsync(PlatformAdminActor actor, Guid bankId,
CancellationToken cancellationToken = default);
@@ -158,6 +162,10 @@ public interface IPlatformQuestionBankService
Task<PlatformQuestionBankNodeItem> ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId,
CancellationToken cancellationToken = default);
}
public interface IPlatformQuestionAdministrationService
{
Task<PlatformQuestionPage> GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter,
CancellationToken cancellationToken = default);
@@ -167,6 +175,10 @@ public interface IPlatformQuestionBankService
Task<int> ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command,
CancellationToken cancellationToken = default);
}
public interface IPlatformQuestionImportService
{
Task<PlatformQuestionImportResult> PreviewImportAsync(PlatformAdminActor actor,
PlatformQuestionImportCommand command, CancellationToken cancellationToken = default);
@@ -176,9 +188,13 @@ public interface IPlatformQuestionBankService
Task<ContentImportJobDetail> GetImportAsync(PlatformAdminActor actor, Guid jobId,
CancellationToken cancellationToken = default);
}
public interface IPlatformQuestionAssetService
{
Task<AssetUploadSignResult> SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command,
CancellationToken cancellationToken = default);
Task<AssetUploadConfirmResult> ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor,
AssetUploadConfirmCommand command, CancellationToken cancellationToken = default);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CatalogList<ContentAssetAccessEventItem>> 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<ContentAssetSecurityScanEventItem>(items);
}
}
}

View File

@@ -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<CatalogList<ContentAssetManagementItem>> 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<ContentAssetManagementItem>(ToItem(asset));
}
}
}

View File

@@ -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<ContentAsset> ResolveUploadAssetAsync(
protected async Task<ContentAsset> 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<ContentAsset> ResolveManagementAssetAsync(
protected async Task<ContentAsset> 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<AssetManagementSignedAccessResult> SignAssetAccessAsync(
protected async Task<AssetManagementSignedAccessResult> 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<ContentAssetType>(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<ContentVisibility>(value, true, out var parsed))
@@ -341,7 +341,7 @@ public sealed partial class AssetManagementService
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
protected static TEnum ParseEnum<TEnum>(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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
internal sealed class AuthAdministrationService(
TikuDbContext dbContext,
IIdentityPersistence identityPersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
UserManager<User> 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);
}
}

View File

@@ -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<User> signInManager,
UserManager<User> 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"];
}

View File

@@ -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<User> SignInManager,
UserManager<User> 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<User> signInManager { get; } = dependencies.SignInManager;
protected UserManager<User> 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"];
}

View File

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

View File

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

View File

@@ -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<AuthChallenge> FindChallengeAsync(
protected async Task<AuthChallenge> 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<AuthenticationResult> CompleteSuccessfulLoginAsync(
protected async Task<AuthenticationResult> 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<AuthenticationResult> IssueAuthenticatedResultAsync(
protected async Task<AuthenticationResult> 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<AuthenticationResult> LoginWithWechatAsync(
protected async Task<AuthenticationResult> LoginWithWechatAsync(
WechatLoginRequest request,
string provider,
IReadOnlyList<string> 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<WechatProviderOptions> LoadWechatProviderOptionsAsync(
protected async Task<WechatProviderOptions> LoadWechatProviderOptionsAsync(
Guid tenantId,
string provider,
IReadOnlyList<string> aliases,
@@ -238,14 +238,14 @@ public sealed partial class AuthService
return new WechatProviderOptions(appId, appSecret);
}
private async Task<User> UpsertWechatUserAsync(
protected async Task<User> 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<User?> FindUserByWechatUnionIdAsync(
protected async Task<User?> 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<TenantMembership?> FindActiveMembershipAsync(
protected async Task<TenantMembership?> 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<AuthenticationResult> CreateChallengeResultAsync(
protected async Task<AuthenticationResult> 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<bool> HasBackendPermissionsAsync(
protected async Task<bool> 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
});
}
}
}

View File

@@ -58,7 +58,7 @@ internal sealed class OwnerActivationService(
true),
async (services, token) =>
{
var dbContext = services.GetRequiredService<TikuDbContext>();
var dbContext = services.GetRequiredService<IOwnerActivationPersistence>();
var grant = await dbContext.TenantOwnerActivationGrants
.SingleOrDefaultAsync(value => value.Id == request.ActivationId, token)
?? throw Error("Owner activation was not found.", "owner_activation_invalid");

View File

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

View File

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

View File

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

View File

@@ -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<AuthTokenPair> RefreshAsync(
RefreshSessionRequest request,
@@ -28,4 +29,4 @@ public sealed partial class AuthService
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
}
}
}

View File

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

View File

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

View File

@@ -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<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
@@ -27,4 +28,4 @@ public sealed partial class AuthService
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
cancellationToken);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<bool> 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<string> permissionCodes,
IReadOnlySet<string> 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())

View File

@@ -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<User> userManager)
{
public const string SuperAdminRoleCode = "platform_super_admin";

View File

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

View File

@@ -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<TikuDbContext>();
var systemCatalog = provider.GetRequiredService<ICatalogPersistence>();
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
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<TikuDbContext>();
return await systemDbContext.TaxonomyNodes.AsNoTracking()
var systemCatalog = provider.GetRequiredService<ICatalogPersistence>();
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
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<TikuDbContext>()
async (provider, token) => await provider.GetRequiredService<ITenancyPersistence>()
.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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CouponApplication?> ApplyCouponForOrderAsync(
protected async Task<CouponApplication?> 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<CouponApplication> ResolveCouponForCheckAsync(
protected async Task<CouponApplication> 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<CouponApplication> ResolveOrClaimCouponByCodeAsync(
protected async Task<CouponApplication> 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<CouponApplication> ResolveCouponByCodeForCheckAsync(
protected async Task<CouponApplication> 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<CouponApplication> ResolveCouponByRedemptionAsync(
protected async Task<CouponApplication> 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<Coupon> FindCouponByCodeAsync(
protected async Task<Coupon> 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<Order> FindActorOrderAsync(
protected async Task<Order> 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<OrderStatus>(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<CouponRedemptionStatus>(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<byte> 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Region>(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<School>(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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(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<Major>(item);
}
}

View File

@@ -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<CatalogList<ContentEntryManagementItem>> 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<ContentEntryManagementItem>(ToEntryItem(entry));
}
}
}

View File

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

View File

@@ -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<string, ImportSpec> Specs =
protected static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
new Dictionary<string, ImportSpec>(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<Region>(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<ContentEntry>(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<ContentNode>(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<CurrentDataScope> RequireDataScopeAsync(
protected async Task<CurrentDataScope> RequireDataScopeAsync(
ContentManagementActor actor,
CancellationToken cancellationToken)
{
@@ -229,7 +229,7 @@ public sealed partial class ContentManagementService
return access.DataScope;
}
private async Task AssertReferenceAsync<TEntity>(
protected async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,
string code,
@@ -238,7 +238,7 @@ public sealed partial class ContentManagementService
{
if (!id.HasValue) return;
var exists = await dbContext.Set<TEntity>()
var exists = await questionBankPersistence.Set<TEntity>()
.AnyAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(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<TEntity?> ResolveEntityAsync<TEntity>(
protected static async Task<TEntity?> ResolveEntityAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
@@ -269,7 +269,7 @@ public sealed partial class ContentManagementService
.SingleOrDefaultAsync(alternatePredicate, cancellationToken);
}
private static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
protected static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> 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<TEnum>(string? value, TEnum fallback, string code)
protected static TEnum Parse<TEnum>(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<TEnum>(string? value, string code)
protected static TEnum? ParseNullable<TEnum>(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<TEnum>(string? value, out TEnum parsed)
protected static bool TryParse<TEnum>(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<ImportFieldSpec> Fields,
string[][] CsvRows,
object JsonExample);
}
}

View File

@@ -265,7 +265,7 @@ internal abstract partial class DirectContentServiceBase
{
if (!id.HasValue) return;
var exists = await dbContext.Set<TEntity>()
var exists = await unitOfWork.Set<TEntity>()
.AnyAsync(
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(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");

View File

@@ -18,7 +18,7 @@ internal abstract partial class DirectContentServiceBase
CancellationToken cancellationToken)
{
await AssertReferenceAsync<Region>(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<Region>(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<Announcement> 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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(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)

View File

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

View File

@@ -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<ContentNode>(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<HandbookSubject>(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<ContentNode>(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<HandbookChapter>(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<ContentNode>(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<HandbookEntry>(item);
}
}

View File

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

View File

@@ -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<CatalogList<ContentNodeManagementItem>> 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<ContentNodeManagementItem>(ToNodeItem(node));
}
}
}

View File

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

View File

@@ -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<CatalogList<PracticeBlueprintManagementItem>> 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<PracticeBlueprintManagementItem>(ToBlueprintItem(blueprint));
}
@@ -151,4 +152,4 @@ public sealed partial class ContentManagementService
content,
spec.Fields);
}
}
}

View File

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

View File

@@ -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<Region>(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<ScorelineField>(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<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
await AssertReferenceAsync<Major>(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<ScorelineRecord>(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<ScorelineTrendItem>();
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)

View File

@@ -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<Subject>(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<VideoManagementItem>(ToVideoItem(item));
}
@@ -76,7 +76,7 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de
cancellationToken);
await AssertReferenceAsync<VideoExplanation>(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<QuestionVideoManagementItem>(ToQuestionVideoItem(item));
}
}

View File

@@ -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<ContentNode>(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<VocabularyUnit>(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<ContentNode>(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<VocabularyWord>(item);
}
}

Some files were not shown because too many files have changed in this diff Show More