refactor(architecture): harden module boundaries
Some checks failed
ci / release-gate (push) Has been cancelled
Some checks failed
ci / release-gate (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
17
Tiku.Api/Controllers/AuthCapabilitySet.cs
Normal file
17
Tiku.Api/Controllers/AuthCapabilitySet.cs
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
Tiku.Api/Controllers/CommerceRequestContextResolver.cs
Normal file
37
Tiku.Api/Controllers/CommerceRequestContextResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
Tiku.Api/Controllers/TenantContentCapabilitySet.cs
Normal file
26
Tiku.Api/Controllers/TenantContentCapabilitySet.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Tiku.Application.PlatformAdmin;
|
||||
|
||||
public interface ITenantProvisioningReadinessProbe
|
||||
{
|
||||
Task<bool> IsPublishedBaseOfferingAvailableAsync(
|
||||
string offeringCode,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
30
Tiku.Infrastructure/Assets/AssetManagementDependencies.cs
Normal file
30
Tiku.Infrastructure/Assets/AssetManagementDependencies.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
44
Tiku.Infrastructure/Auth/AuthServiceDependencies.cs
Normal file
44
Tiku.Infrastructure/Auth/AuthServiceDependencies.cs
Normal 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"];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
19
Tiku.Infrastructure/Commerce/CommerceServiceDependencies.cs
Normal file
19
Tiku.Infrastructure/Commerce/CommerceServiceDependencies.cs
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Tiku.Infrastructure/Content/ContentManagementDependencies.cs
Normal file
20
Tiku.Infrastructure/Content/ContentManagementDependencies.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user