diff --git a/Directory.Build.props b/Directory.Build.props index fca2783..e95d177 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - - + + - + diff --git a/TIKU-BACKEND.slnx b/TIKU-BACKEND.slnx index 1071a5c..bf5db79 100644 --- a/TIKU-BACKEND.slnx +++ b/TIKU-BACKEND.slnx @@ -1,14 +1,14 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/Tiku.Api/ApiProgramMarker.cs b/Tiku.Api/ApiProgramMarker.cs index c563f59..46142b7 100644 --- a/Tiku.Api/ApiProgramMarker.cs +++ b/Tiku.Api/ApiProgramMarker.cs @@ -1,3 +1,3 @@ namespace Tiku.Api; -public sealed class ApiProgramMarker; +public sealed class ApiProgramMarker; \ No newline at end of file diff --git a/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs b/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs index 9078077..a439145 100644 --- a/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs +++ b/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs @@ -13,10 +13,7 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!options.EnableDevelopmentLocalhostBypass) - { - return; - } + if (!options.EnableDevelopmentLocalhostBypass) return; var interval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600)); while (!stoppingToken.IsCancellationRequested) @@ -30,9 +27,7 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( .GetRequiredService() .ProcessPendingAsync(stoppingToken); if (processed > 0) - { logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed); - } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -46,4 +41,4 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( await Task.Delay(interval, stoppingToken); } } -} +} \ No newline at end of file diff --git a/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs b/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs index 7d9d8f5..9cce2bd 100644 --- a/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs +++ b/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs @@ -6,9 +6,13 @@ namespace Tiku.Api.Caching; internal sealed class TenantPublicCacheInvalidator(IOutputCacheStore outputCacheStore) : ITenantPublicCacheInvalidator { - public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) => - outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken); - - public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) => + public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) + { await InvalidateCoreAsync(tenantId, cancellationToken); -} + } + + public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) + { + return outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken); + } +} \ No newline at end of file diff --git a/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs b/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs index 9ff7feb..1008ee2 100644 --- a/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs +++ b/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs @@ -30,8 +30,10 @@ internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy return ValueTask.CompletedTask; } - public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => - ValueTask.CompletedTask; + public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) { @@ -39,10 +41,8 @@ internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy if (response.StatusCode != StatusCodes.Status200OK || !StringValues.IsNullOrEmpty(response.Headers.SetCookie) || context.HttpContext.User.Identity?.IsAuthenticated == true) - { context.AllowCacheStorage = false; - } return ValueTask.CompletedTask; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index 06b6ded..d2d74fd 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -9,7 +9,7 @@ internal static class ApiPresentationExtensions internal static IServiceCollection AddApiPresentation(this IServiceCollection services) { services.AddControllers(options => - options.Conventions.Add(new EndpointAuthorizationMetadataConvention())) + options.Conventions.Add(new EndpointAuthorizationMetadataConvention())) .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); @@ -23,4 +23,4 @@ internal static class ApiPresentationExtensions return services; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs index 6f2d687..b255284 100644 --- a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs +++ b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs @@ -42,4 +42,4 @@ public static class ApplicationBuilderExtensions return app; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/AuthenticationExtensions.cs b/Tiku.Api/Configuration/AuthenticationExtensions.cs index 464bd20..66a2980 100644 --- a/Tiku.Api/Configuration/AuthenticationExtensions.cs +++ b/Tiku.Api/Configuration/AuthenticationExtensions.cs @@ -3,6 +3,7 @@ using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Tiku.Api.Options; using Tiku.Api.Security; @@ -98,9 +99,7 @@ internal static class AuthenticationExtensions if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) && IsSameOriginBrowserRequest(context.Request) && context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken)) - { context.Token = accessToken; - } return Task.CompletedTask; }, OnTokenValidated = ValidateTokenAsync, @@ -111,16 +110,11 @@ internal static class AuthenticationExtensions private static bool IsSameOriginBrowserRequest(HttpRequest request) { var source = request.Headers.Origin.ToString(); - if (string.IsNullOrWhiteSpace(source)) - { - source = request.Headers.Referer.ToString(); - } + if (string.IsNullOrWhiteSpace(source)) source = request.Headers.Referer.ToString(); if (Uri.TryCreate(source, UriKind.Absolute, out var uri)) - { return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); - } return string.Equals( request.Headers["Sec-Fetch-Site"].ToString(), @@ -154,14 +148,14 @@ internal static class AuthenticationExtensions var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId) ? parsedTenantId : (Guid?)null; - if (realm is null || (realm == AuthRealm.Tenant) != tenantId.HasValue) + if (realm is null || realm == AuthRealm.Tenant != tenantId.HasValue) { context.Fail("Token scope and tenant claims are inconsistent."); return; } var resolutionOptions = context.HttpContext.RequestServices - .GetRequiredService>().Value; + .GetRequiredService>().Value; var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.'); var isPlatformHost = resolutionOptions.PlatformHosts.Any(host => string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)); @@ -218,10 +212,7 @@ internal static class AuthenticationExtensions private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context) { - if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) - { - return; - } + if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) return; context.HandleResponse(); context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -232,4 +223,4 @@ internal static class AuthenticationExtensions Extensions = { ["code"] = "tenant_context_conflict" } }); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/DataProtectionExtensions.cs b/Tiku.Api/Configuration/DataProtectionExtensions.cs index d5eee4b..c96fa4f 100644 --- a/Tiku.Api/Configuration/DataProtectionExtensions.cs +++ b/Tiku.Api/Configuration/DataProtectionExtensions.cs @@ -25,20 +25,15 @@ internal static class DataProtectionExtensions .Get() ?? new DataProtectionKeyRingOptions(); ApplyEnvironmentOverrides(keyRingOptions, configuration); if (!DataProtectionKeyRingOptions.BeValid(keyRingOptions, requireProtectedKeys)) - { throw new InvalidOperationException( "Data Protection requires an application name and, outside Development, an X509 certificate path."); - } var dataProtection = services .AddDataProtection() .SetApplicationName(keyRingOptions.ApplicationName.Trim()) .PersistKeysToDbContext(); var certificate = keyRingOptions.LoadCertificate(requireProtectedKeys); - if (certificate is not null) - { - dataProtection.ProtectKeysWithCertificate(certificate); - } + if (certificate is not null) dataProtection.ProtectKeysWithCertificate(certificate); return services; } @@ -54,4 +49,4 @@ internal static class DataProtectionExtensions options.CertificatePassword = configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs index 9ce5326..8f44516 100644 --- a/Tiku.Api/Configuration/DependencyInjection.cs +++ b/Tiku.Api/Configuration/DependencyInjection.cs @@ -1,13 +1,14 @@ -using Serilog; -using Tiku.Application; -using Tiku.Infrastructure; -using Tiku.Infrastructure.Security; -using Tiku.Api.Caching; -using Microsoft.AspNetCore.ResponseCompression; using System.IO.Compression; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Serilog; +using Tiku.Api.Caching; +using Tiku.Api.Options; +using Tiku.Application; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Microsoft.Extensions.DependencyInjection.Extensions; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Security; namespace Tiku.Api.Configuration; @@ -16,10 +17,10 @@ public static class DependencyInjection public static WebApplicationBuilder AddApiServices(this WebApplicationBuilder builder) { builder.Services.AddSerilog((services, configuration) => configuration - .ReadFrom.Configuration(builder.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext(), - preserveStaticLogger: true); + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(), + true); builder.Services.AddApiPresentation(); builder.Services.AddHealthChecks(); @@ -28,11 +29,12 @@ public static class DependencyInjection builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment); builder.Services.AddApiRateLimiting(builder.Configuration); - var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString( + var connectionString = OptionsValidation.ResolveDatabaseConnectionString( builder.Configuration, builder.Environment.IsDevelopment()); builder.Services.AddInfrastructure(connectionString); - var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; + var redisConnectionString = + builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; builder.Services.AddOptions() .Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty) .Validate( @@ -46,14 +48,10 @@ public static class DependencyInjection "Authorization cache durations must be positive and jitter must be between 0 and 50 percent.") .ValidateOnStart(); if (!string.IsNullOrWhiteSpace(redisConnectionString)) - { builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName); - } else if (builder.Environment.IsProduction()) - { throw new InvalidOperationException( "Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL."); - } builder.Services.AddOutputCache(options => { @@ -63,13 +61,11 @@ public static class DependencyInjection builder.Services.RemoveAll(); builder.Services.AddSingleton(); if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(redisConnectionString)) - { builder.Services.AddStackExchangeRedisOutputCache(options => { options.Configuration = redisConnectionString; options.InstanceName = $"tiku:{builder.Environment.EnvironmentName.ToLowerInvariant()}:output:"; }); - } builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; @@ -77,7 +73,8 @@ public static class DependencyInjection options.Providers.Add(); options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]); }); - builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment); builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment); @@ -85,4 +82,4 @@ public static class DependencyInjection return builder; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs index e954624..ad0ac08 100644 --- a/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs +++ b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs @@ -1,7 +1,9 @@ +using Microsoft.Extensions.Options; using Tiku.Application.Auth; +using Tiku.Application.Storage; +using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Storage; -using Tiku.Infrastructure.Assets; namespace Tiku.Api.Configuration; @@ -54,21 +56,21 @@ internal static class ExternalServiceOptionsExtensions services.AddOptions() .Validate( options => !environment.IsProduction() || - options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss, + options.DefaultProvider == ObjectStorageProviders.AliyunOss, "Production managed storage must use the configured Aliyun OSS provider.") .ValidateOnStart(); services.AddOptions() - .Validate>( + .Validate>( (aliyun, storage) => !environment.IsProduction() || - storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss || + storage.Value.DefaultProvider != ObjectStorageProviders.AliyunOss || aliyun.IsConfigured, "Aliyun OSS credentials and region or endpoint are required when it is the default provider.") .ValidateOnStart(); services.AddOptions() .Bind(configuration.GetSection(ClamAvOptions.SectionName)) .Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.") - .Validate>( + .Validate>( (clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes, "ClamAV StreamMaxLength must be greater than or equal to the storage max upload size.") .ValidateOnStart(); @@ -104,8 +106,10 @@ internal static class ExternalServiceOptionsExtensions return services; } - private static string[] SplitLegacyList(string? value, string[] fallback) => - string.IsNullOrWhiteSpace(value) + private static string[] SplitLegacyList(string? value, string[] fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); -} + } +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs index 2361ee4..6f1df3f 100644 --- a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs +++ b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs @@ -2,8 +2,8 @@ using System.Net; using Microsoft.AspNetCore.HttpOverrides; using Tiku.Api.Background; using Tiku.Api.Options; -using Tiku.Application.Tenancy; using Tiku.Application.PlatformAdmin; +using Tiku.Application.Tenancy; namespace Tiku.Api.Configuration; @@ -17,7 +17,8 @@ internal static class NetworkConfigurationExtensions services.AddOptions() .Bind(configuration.GetSection(TenantResolutionOptions.SectionName)) .Validate( - options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()), + options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, + environment.IsProduction()), "Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.") .ValidateOnStart(); services.Configure(options => @@ -34,22 +35,15 @@ internal static class NetworkConfigurationExtensions .GetSection(TenantResolutionOptions.SectionName) .Get() ?? new TenantResolutionOptions(); foreach (var address in resolution.TrustedProxyAddresses) - { if (IPAddress.TryParse(address, out var proxy)) - { options.KnownProxies.Add(proxy); - } - } }); services.AddOptions() .Bind(configuration.GetSection("TenantDomains")) .Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass, "The .localhost domain lifecycle bypass can only be enabled in Development.") .ValidateOnStart(); - if (environment.IsDevelopment()) - { - services.AddHostedService(); - } + if (environment.IsDevelopment()) services.AddHostedService(); services.AddOptions() .Bind(configuration.GetSection(TenantProvisioningOptions.SectionName)) .Validate(options => !string.IsNullOrWhiteSpace(options.DefaultBaseOfferingCode) && @@ -57,17 +51,15 @@ internal static class NetworkConfigurationExtensions options.OwnerActivationMinutes is >= 5 and <= 1440 && options.OwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) && Uri.TryCreate( - options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com", StringComparison.Ordinal), + options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com", + StringComparison.Ordinal), UriKind.Absolute, out var activationOrigin) && activationOrigin.Scheme == Uri.UriSchemeHttps && ValidateDevelopmentActivationTemplate(options, environment.IsDevelopment()), "Tenant provisioning requires a default offering code, valid trial/activation durations, an HTTPS owner activation URL template, and permits an HTTP template only for Development .localhost sites.") .ValidateOnStart(); - if (environment.IsProduction()) - { - services.AddHostedService(); - } + if (environment.IsProduction()) services.AddHostedService(); services.AddOptions() .Bind(configuration.GetSection(CorsOptions.SectionName)) @@ -90,19 +82,13 @@ internal static class NetworkConfigurationExtensions .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - if (origins.Length > 0) - { - policy.WithOrigins(origins); - } + if (origins.Length > 0) policy.WithOrigins(origins); policy .WithHeaders(corsOptions.AllowedHeaders) .WithMethods(corsOptions.AllowedMethods); - if (corsOptions.AllowCredentials) - { - policy.AllowCredentials(); - } + if (corsOptions.AllowCredentials) policy.AllowCredentials(); }); }); @@ -113,10 +99,7 @@ internal static class NetworkConfigurationExtensions TenantProvisioningOptions options, bool isDevelopment) { - if (string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate)) - { - return true; - } + if (string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate)) return true; return isDevelopment && options.DevelopmentLocalhostOwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) && @@ -128,4 +111,4 @@ internal static class NetworkConfigurationExtensions (developmentOrigin.Scheme == Uri.UriSchemeHttp || developmentOrigin.Scheme == Uri.UriSchemeHttps); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs index f540393..3ccec6e 100644 --- a/Tiku.Api/Configuration/ObservabilityExtensions.cs +++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs @@ -18,31 +18,30 @@ internal static class ObservabilityExtensions services.AddOpenTelemetry() .ConfigureResource(resource => resource.AddService( - serviceName: environment.ApplicationName, + environment.ApplicationName, serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString())) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(options => options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health")) .AddHttpClientInstrumentation() .AddSource("Npgsql") - .ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!))) + .ApplyIf(hasOtlpEndpoint, + builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!))) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Learning", "Npgsql") - .ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!))); + .ApplyIf(hasOtlpEndpoint, + builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!))); return services; } private static TBuilder ApplyIf(this TBuilder builder, bool condition, Action configure) { - if (condition) - { - configure(builder); - } + if (condition) configure(builder); return builder; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/RateLimitingExtensions.cs b/Tiku.Api/Configuration/RateLimitingExtensions.cs index 767c1b4..e49df58 100644 --- a/Tiku.Api/Configuration/RateLimitingExtensions.cs +++ b/Tiku.Api/Configuration/RateLimitingExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Tiku.Api.Middleware; using Tiku.Api.Options; -using Tiku.Application.Auth; using Tiku.Application.Security; namespace Tiku.Api.Configuration; @@ -34,7 +33,6 @@ internal static class RateLimitingExtensions { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; if (rateLimitOptions.Enabled) - { options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { var partitionKey = @@ -49,7 +47,6 @@ internal static class RateLimitingExtensions rateLimitOptions.QueueLimit, rateLimitOptions.WindowSeconds)); }); - } options.AddPolicy( AuthRateLimitPolicies.Password, @@ -76,8 +73,9 @@ internal static class RateLimitingExtensions private static FixedWindowRateLimiterOptions CreateLimiterOptions( int permitLimit, int queueLimit, - int windowSeconds) => - new() + int windowSeconds) + { + return new FixedWindowRateLimiterOptions { AutoReplenishment = true, PermitLimit = permitLimit, @@ -85,15 +83,14 @@ internal static class RateLimitingExtensions QueueProcessingOrder = QueueProcessingOrder.OldestFirst, Window = TimeSpan.FromSeconds(windowSeconds) }; + } private static async ValueTask WriteRateLimitProblemAsync( OnRejectedContext context, CancellationToken cancellationToken) { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) - { context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); - } var problem = new ProblemDetails { @@ -107,4 +104,4 @@ internal static class RateLimitingExtensions context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs b/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs index 19b4c4e..4d5fe11 100644 --- a/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs +++ b/Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs @@ -41,13 +41,14 @@ internal sealed class TenantProvisioningStartupValidator( cancellationToken); if (!available) - { throw new InvalidOperationException( $"TenantProvisioning:DefaultBaseOfferingCode '{offeringCode}' does not resolve to an effective published base offering version."); - } logger.LogInformation("Validated default tenant provisioning offering {OfferingCode}", offeringCode); } - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/AssetAccessDtos.cs b/Tiku.Api/Contracts/AssetAccessDtos.cs index 880ec51..cc3ed66 100644 --- a/Tiku.Api/Contracts/AssetAccessDtos.cs +++ b/Tiku.Api/Contracts/AssetAccessDtos.cs @@ -7,25 +7,25 @@ using Tiku.Domain.Content; namespace Tiku.Api.Contracts; /// -/// 资产访问签名查询参数。 +/// 资产访问签名查询参数。 /// public sealed class AssetAccessQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 有效期秒数。 + /// 有效期秒数。 /// [Range(1, 7200)] public int? ExpiresInSeconds { get; set; } } /// -/// 资产访问签名响应。 +/// 资产访问签名响应。 /// public sealed record AssetAccessResponseDto( ContentAssetAccessSummaryDto Item, @@ -47,7 +47,7 @@ public sealed record AssetAccessResponseDto( } /// -/// 内容资产访问摘要。 +/// 内容资产访问摘要。 /// public sealed record ContentAssetAccessSummaryDto( Guid Id, @@ -76,7 +76,7 @@ public sealed record ContentAssetAccessSummaryDto( } /// -/// 资产访问Principal请求 DTO。 +/// 资产访问Principal请求 DTO。 /// public sealed record AssetAccessPrincipalDto( Guid? UserId, @@ -84,7 +84,7 @@ public sealed record AssetAccessPrincipalDto( bool HasSvip); /// -/// SignedStorageUrl请求 DTO。 +/// SignedStorageUrl请求 DTO。 /// public sealed record SignedStorageUrlDto( string Provider, @@ -110,4 +110,4 @@ public sealed record SignedStorageUrlDto( (int)signedUrl.ExpiresIn.TotalSeconds, signedUrl.SignatureMode); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/AssetDtos.cs b/Tiku.Api/Contracts/AssetDtos.cs index a1b7b3b..87790df 100644 --- a/Tiku.Api/Contracts/AssetDtos.cs +++ b/Tiku.Api/Contracts/AssetDtos.cs @@ -4,82 +4,82 @@ using Tiku.Application.Assets; namespace Tiku.Api.Contracts; /// -/// 资产查询参数。 +/// 资产查询参数。 /// public sealed class AssetQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } /// - /// 资产 ID。 + /// 资产 ID。 /// public Guid? AssetId { get; set; } /// - /// 资产类型。 + /// 资产类型。 /// [StringLength(50)] public string? AssetType { get; set; } /// - /// 分类。 + /// 分类。 /// [StringLength(100)] public string? Category { get; set; } /// - /// 资产键。 + /// 资产键。 /// [StringLength(200)] public string? AssetKey { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 是否包含锁定资源。 + /// 是否包含锁定资源。 /// public bool IncludeLocked { get; set; } /// - /// 是否包含停用数据。 + /// 是否包含停用数据。 /// public bool IncludeInactive { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -102,4 +102,4 @@ public sealed class AssetQueryDto IncludeInactive, Limit); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/AssetManagementDtos.cs b/Tiku.Api/Contracts/AssetManagementDtos.cs index 615bac8..58aa3f1 100644 --- a/Tiku.Api/Contracts/AssetManagementDtos.cs +++ b/Tiku.Api/Contracts/AssetManagementDtos.cs @@ -6,128 +6,128 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; /// -/// 创建资产上传签名请求。 +/// 创建资产上传签名请求。 /// public sealed class AssetUploadSignDto { /// - /// 资产 ID。 + /// 资产 ID。 /// public Guid? AssetId { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 资产键。 + /// 资产键。 /// [StringLength(200)] public string? AssetKey { get; set; } /// - /// 标题。 + /// 标题。 /// [StringLength(300)] public string? Title { get; set; } /// - /// 分类。 + /// 分类。 /// [StringLength(100)] public string? Category { get; set; } /// - /// 说明。 + /// 说明。 /// [StringLength(1000)] public string? Description { get; set; } /// - /// 文件名。 + /// 文件名。 /// [Required] [StringLength(500)] public string FileName { get; set; } = string.Empty; /// - /// MIME 类型。 + /// MIME 类型。 /// [Required] [StringLength(200)] public string MimeType { get; set; } = string.Empty; /// - /// 文件大小,单位为字节。 + /// 文件大小,单位为字节。 /// [Range(0, long.MaxValue)] public long? FileSizeBytes { get; set; } /// - /// SHA-256 校验值。 + /// SHA-256 校验值。 /// [RegularExpression("^[A-Fa-f0-9]{64}$")] public string? ChecksumSha256 { get; set; } /// - /// 资产类型。 + /// 资产类型。 /// [StringLength(50)] public string? AssetType { get; set; } /// - /// 可见性。 + /// 可见性。 /// [StringLength(50)] public string? Visibility { get; set; } /// - /// 是否公开。 + /// 是否公开。 /// public bool? IsPublic { get; set; } /// - /// 服务提供方。 + /// 服务提供方。 /// [StringLength(50)] public string? Provider { get; set; } /// - /// 存储桶。 + /// 存储桶。 /// [StringLength(200)] public string? Bucket { get; set; } /// - /// 对象存储键。 + /// 对象存储键。 /// [StringLength(1000)] public string? ObjectKey { get; set; } /// - /// 有效期秒数。 + /// 有效期秒数。 /// [Range(1, 3600)] public int? ExpiresInSeconds { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -159,30 +159,30 @@ public sealed class AssetUploadSignDto } /// -/// 确认资产上传请求。 +/// 确认资产上传请求。 /// public sealed class AssetUploadConfirmDto { /// - /// 资产 ID。 + /// 资产 ID。 /// [Required] public Guid AssetId { get; set; } /// - /// MIME 类型。 + /// MIME 类型。 /// [StringLength(200)] public string? MimeType { get; set; } /// - /// 文件大小,单位为字节。 + /// 文件大小,单位为字节。 /// [Range(0, long.MaxValue)] public long? FileSizeBytes { get; set; } /// - /// SHA-256 校验值。 + /// SHA-256 校验值。 /// [RegularExpression("^[A-Fa-f0-9]{64}$")] public string? ChecksumSha256 { get; set; } @@ -194,116 +194,142 @@ public sealed class AssetUploadConfirmDto } /// -/// 新增或更新内容资产请求。 +/// 新增或更新内容资产请求。 /// public sealed class UpsertAssetDto { /// - /// 资产 ID。 + /// 资产 ID。 /// public Guid? AssetId { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 资产键。 + /// 资产键。 /// public string? AssetKey { get; set; } + /// - /// 标题。 + /// 标题。 /// public string? Title { get; set; } + /// - /// 分类。 + /// 分类。 /// public string? Category { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 文件名。 + /// 文件名。 /// public string? FileName { get; set; } + /// - /// CDN 地址。 + /// CDN 地址。 /// public string? CdnUrl { get; set; } + /// - /// 是否公开。 + /// 是否公开。 /// public bool? IsPublic { get; set; } + /// - /// 资产类型。 + /// 资产类型。 /// public string? AssetType { get; set; } + /// - /// 可见性。 + /// 可见性。 /// public string? Visibility { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 服务提供方。 + /// 服务提供方。 /// public string? Provider { get; set; } + /// - /// 存储桶。 + /// 存储桶。 /// public string? Bucket { get; set; } + /// - /// 对象存储键。 + /// 对象存储键。 /// public string? ObjectKey { get; set; } + /// - /// MIME 类型。 + /// MIME 类型。 /// public string? MimeType { get; set; } + /// - /// 文件大小,单位为字节。 + /// 文件大小,单位为字节。 /// public long? FileSizeBytes { get; set; } + /// - /// SHA-256 校验值。 + /// SHA-256 校验值。 /// public string? ChecksumSha256 { get; set; } + /// - /// 预览地址。 + /// 预览地址。 /// public string? PreviewUrl { get; set; } + /// - /// 预览对象存储键。 + /// 预览对象存储键。 /// public string? PreviewObjectKey { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 访问规则。 + /// 访问规则。 /// public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -341,18 +367,18 @@ public sealed class UpsertAssetDto } /// -/// 创建资产访问签名请求。 +/// 创建资产访问签名请求。 /// public sealed class AssetAccessSignDto { /// - /// 资产 ID。 + /// 资产 ID。 /// [Required] public Guid AssetId { get; set; } /// - /// 有效期秒数。 + /// 有效期秒数。 /// [Range(60, 3600)] public int? ExpiresInSeconds { get; set; } @@ -364,62 +390,62 @@ public sealed class AssetAccessSignDto } /// -/// 资产管理查询参数。 +/// 资产管理查询参数。 /// public sealed class AssetManagementQueryDto { /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 资产类型。 + /// 资产类型。 /// [StringLength(50)] public string? AssetType { get; set; } /// - /// 分类。 + /// 分类。 /// [StringLength(100)] public string? Category { get; set; } /// - /// 上传状态。 + /// 上传状态。 /// [StringLength(50)] public string? UploadStatus { get; set; } /// - /// 安全扫描状态。 + /// 安全扫描状态。 /// [StringLength(50)] public string? SecurityScanStatus { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -441,21 +467,22 @@ public sealed class AssetManagementQueryDto } /// -/// 资产事件查询参数。 +/// 资产事件查询参数。 /// public sealed class AssetEventQueryDto { /// - /// 资产 ID。 + /// 资产 ID。 /// public Guid? AssetId { get; set; } + /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -467,30 +494,30 @@ public sealed class AssetEventQueryDto } /// -/// 导入任务查询参数。 +/// 导入任务查询参数。 /// public sealed class ImportJobQueryDto { /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 导入Type。 + /// 导入Type。 /// [StringLength(50)] public string? ImportType { get; set; } /// - /// 来源格式。 + /// 来源格式。 /// [StringLength(50)] public string? SourceFormat { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -499,4 +526,4 @@ public sealed class ImportJobQueryDto { return new ImportJobFilter(Status, ImportType, SourceFormat, Limit); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs index a790b5f..2bf65e8 100644 --- a/Tiku.Api/Contracts/AuthDtos.cs +++ b/Tiku.Api/Contracts/AuthDtos.cs @@ -6,38 +6,39 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; /// -/// PasswordLogin请求 DTO。 +/// PasswordLogin请求 DTO。 /// public sealed class PasswordLoginDto { /// - /// 认证域。 + /// 认证域。 /// [Required] public AuthRealm? Realm { get; set; } + /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] [Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")] public string? TenantCode { get; set; } /// - /// 账号标识。 + /// 账号标识。 /// [StringLength(320)] [Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")] public string? Identifier { get; set; } /// - /// 兼容手机号字段;新客户端应使用 identifier。 + /// 兼容手机号字段;新客户端应使用 identifier。 /// [StringLength(32)] [Description("兼容手机号字段;新客户端应使用 identifier。")] public string? Phone { get; set; } /// - /// 密码。 + /// 密码。 /// [Required] [StringLength(128, MinimumLength = 8)] @@ -46,24 +47,25 @@ public sealed class PasswordLoginDto } /// -/// SmsLogin请求 DTO。 +/// SmsLogin请求 DTO。 /// public sealed class SmsLoginDto { /// - /// 认证域。 + /// 认证域。 /// [Required] public AuthRealm? Realm { get; set; } + /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] [Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")] public string? TenantCode { get; set; } /// - /// 手机号。 + /// 手机号。 /// [Required] [StringLength(32)] @@ -71,7 +73,7 @@ public sealed class SmsLoginDto public string Phone { get; set; } = string.Empty; /// - /// 编码。 + /// 编码。 /// [Required] [StringLength(12, MinimumLength = 4)] @@ -80,55 +82,56 @@ public sealed class SmsLoginDto } /// -/// 发送短信验证码请求。 +/// 发送短信验证码请求。 /// public sealed class SendSmsCodeDto { /// - /// 认证域。 + /// 认证域。 /// [Required] public AuthRealm? Realm { get; set; } /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 手机号。 + /// 手机号。 /// [Required] [StringLength(32)] public string Phone { get; set; } = string.Empty; /// - /// 设备 ID。 + /// 设备 ID。 /// [StringLength(256)] public string? DeviceId { get; set; } } /// -/// O认证编码请求 DTO。 +/// O认证编码请求 DTO。 /// public sealed class OAuthCodeDto { /// - /// 认证域。 + /// 认证域。 /// [Required] public AuthRealm? Realm { get; set; } + /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] [Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")] public string? TenantCode { get; set; } /// - /// 编码。 + /// 编码。 /// [Required] [StringLength(512)] @@ -136,13 +139,13 @@ public sealed class OAuthCodeDto public string Code { get; set; } = string.Empty; /// - /// 公开用户资料。 + /// 公开用户资料。 /// [Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")] public Dictionary? Profile { get; set; } /// - /// 语言。 + /// 语言。 /// [StringLength(20)] [Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")] @@ -150,12 +153,12 @@ public sealed class OAuthCodeDto } /// -/// 刷新会话请求 DTO。 +/// 刷新会话请求 DTO。 /// public sealed class RefreshSessionDto { /// - /// 刷新令牌。 + /// 刷新令牌。 /// [Required] [StringLength(2048)] @@ -164,42 +167,42 @@ public sealed class RefreshSessionDto } /// -/// Authenticated用户请求 DTO。 +/// Authenticated用户请求 DTO。 /// public sealed class AuthenticatedUserDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid UserId { get; init; } /// - /// 手机号。 + /// 手机号。 /// public string? Phone { get; init; } /// - /// 邮箱。 + /// 邮箱。 /// public string? Email { get; init; } /// - /// 名称。 + /// 名称。 /// public string? Name { get; init; } /// - /// 认证域。 + /// 认证域。 /// public AuthRealm Realm { get; init; } /// - /// 租户成员摘要。 + /// 租户成员摘要。 /// public TenantMembershipSummary? Tenant { get; init; } /// - /// 访问令牌和刷新令牌。 + /// 访问令牌和刷新令牌。 /// public AuthTokenPair Tokens { get; init; } = default!; @@ -219,50 +222,56 @@ public sealed class AuthenticatedUserDto } /// -/// 登录认证结果。 +/// 登录认证结果。 /// public sealed class AuthenticationResultDto { /// - /// 状态。 + /// 状态。 /// public AuthenticationStatus Status { get; init; } + /// - /// 用户信息。 + /// 用户信息。 /// public AuthenticatedUserDto? User { get; init; } + /// - /// 挑战令牌。 + /// 挑战令牌。 /// public string? ChallengeToken { get; init; } + /// - /// 挑战令牌过期时间。 + /// 挑战令牌过期时间。 /// public DateTimeOffset? ChallengeExpiresAt { get; init; } - public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new() + public static AuthenticationResultDto FromApplication(AuthenticationResult result) { - Status = result.Status, - User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User), - ChallengeToken = result.ChallengeToken, - ChallengeExpiresAt = result.ChallengeExpiresAt - }; + return new AuthenticationResultDto + { + Status = result.Status, + User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User), + ChallengeToken = result.ChallengeToken, + ChallengeExpiresAt = result.ChallengeExpiresAt + }; + } } /// -/// 首次登录必改密码请求。 +/// 首次登录必改密码请求。 /// public sealed class RequiredPasswordChangeDto { /// - /// 挑战令牌。 + /// 挑战令牌。 /// [Required] [StringLength(2048)] public string ChallengeToken { get; set; } = string.Empty; /// - /// 新密码。 + /// 新密码。 /// [Required] [StringLength(128, MinimumLength = 8)] @@ -270,7 +279,7 @@ public sealed class RequiredPasswordChangeDto } /// -/// 请求租户短信密码重置验证码。 +/// 请求租户短信密码重置验证码。 /// public sealed class PasswordResetSmsSendDto { @@ -279,7 +288,8 @@ public sealed class PasswordResetSmsSendDto public string? TenantCode { get; set; } /// 绑定到账号的手机号。 - [Required, StringLength(32)] + [Required] + [StringLength(32)] public string Phone { get; set; } = string.Empty; /// 客户端设备标识,用于安全频控。 @@ -288,7 +298,7 @@ public sealed class PasswordResetSmsSendDto } /// -/// 使用短信验证码重置租户账号密码。 +/// 使用短信验证码重置租户账号密码。 /// public sealed class PasswordResetDto { @@ -297,48 +307,55 @@ public sealed class PasswordResetDto public string? TenantCode { get; set; } /// 绑定到账号的手机号。 - [Required, StringLength(32)] + [Required] + [StringLength(32)] public string Phone { get; set; } = string.Empty; /// 短信验证码。 - [Required, StringLength(12, MinimumLength = 4)] + [Required] + [StringLength(12, MinimumLength = 4)] public string Code { get; set; } = string.Empty; /// 符合当前密码策略的新密码。 - [Required, StringLength(128, MinimumLength = 8)] + [Required] + [StringLength(128, MinimumLength = 8)] public string NewPassword { get; set; } = string.Empty; } /// -/// 已登录用户修改密码。 +/// 已登录用户修改密码。 /// public sealed class AuthenticatedPasswordChangeDto { /// 当前密码。 - [Required, StringLength(128, MinimumLength = 1)] + [Required] + [StringLength(128, MinimumLength = 1)] public string CurrentPassword { get; set; } = string.Empty; /// 符合当前密码策略的新密码。 - [Required, StringLength(128, MinimumLength = 8)] + [Required] + [StringLength(128, MinimumLength = 8)] public string NewPassword { get; set; } = string.Empty; } /// -/// 管理员为用户设置一次性临时密码。 +/// 管理员为用户设置一次性临时密码。 /// public sealed class AdministrativePasswordResetDto { /// 符合当前密码策略的临时密码。 - [Required, StringLength(128, MinimumLength = 12)] + [Required] + [StringLength(128, MinimumLength = 12)] public string TemporaryPassword { get; set; } = string.Empty; /// 审计原因。 - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; } /// -/// 完成租户负责人一次性激活。 +/// 完成租户负责人一次性激活。 /// public sealed class CompleteOwnerActivationDto { @@ -347,12 +364,17 @@ public sealed class CompleteOwnerActivationDto public Guid ActivationId { get; set; } /// 只显示一次的激活令牌。 - [Required, StringLength(512, MinimumLength = 32)] + [Required] + [StringLength(512, MinimumLength = 32)] public string Token { get; set; } = string.Empty; /// 符合当前密码策略的新密码。 - [Required, StringLength(128, MinimumLength = 8)] + [Required] + [StringLength(128, MinimumLength = 8)] public string NewPassword { get; set; } = string.Empty; - public CompleteOwnerActivationRequest ToRequest() => new(ActivationId, Token, NewPassword); -} + public CompleteOwnerActivationRequest ToRequest() + { + return new CompleteOwnerActivationRequest(ActivationId, Token, NewPassword); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/BackgroundJobDtos.cs b/Tiku.Api/Contracts/BackgroundJobDtos.cs index 57864e8..55c2f4b 100644 --- a/Tiku.Api/Contracts/BackgroundJobDtos.cs +++ b/Tiku.Api/Contracts/BackgroundJobDtos.cs @@ -1,29 +1,34 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Tiku.Application.Jobs; namespace Tiku.Api.Contracts; /// -/// 创建租户后台任务请求。 +/// 创建租户后台任务请求。 /// public sealed class CreateBackgroundJobDto { /// - /// 任务类型。 + /// 任务类型。 /// public string JobType { get; set; } = string.Empty; + /// - /// 任务载荷。 + /// 任务载荷。 /// public JsonElement Payload { get; set; } + /// - /// 计划运行时间。 + /// 计划运行时间。 /// public DateTimeOffset? RunAfter { get; set; } + /// - /// 最大重试次数。 + /// 最大重试次数。 /// public int MaxRetries { get; set; } = 3; + /// 同租户同任务类型内的可选幂等键。 public string? IdempotencyKey { get; set; } @@ -43,7 +48,7 @@ public sealed class CreateBackgroundJobDto public sealed class CancelBackgroundJobDto { /// 取消原因。 - [System.ComponentModel.DataAnnotations.Required] - [System.ComponentModel.DataAnnotations.StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/BackofficeDtos.cs b/Tiku.Api/Contracts/BackofficeDtos.cs index 0b9641b..f62f045 100644 --- a/Tiku.Api/Contracts/BackofficeDtos.cs +++ b/Tiku.Api/Contracts/BackofficeDtos.cs @@ -5,32 +5,37 @@ using Tiku.Domain.Operations; namespace Tiku.Api.Contracts; /// -/// 创建或更新后台角色请求。 +/// 创建或更新后台角色请求。 /// public sealed class UpsertBackofficeRoleDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 编码。 + /// 编码。 /// public string Code { get; set; } = string.Empty; + /// - /// 名称。 + /// 名称。 /// public string Name { get; set; } = string.Empty; + /// - /// 状态。 + /// 状态。 /// public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active; + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 数据范围配置。 + /// 数据范围配置。 /// public JsonElement? DataScope { get; set; } @@ -41,16 +46,17 @@ public sealed class UpsertBackofficeRoleDto } /// -/// 替换角色权限绑定请求。 +/// 替换角色权限绑定请求。 /// public sealed class ReplaceRoleBindingsDto { /// - /// 权限编码列表。 + /// 权限编码列表。 /// public IReadOnlyCollection PermissionCodes { get; set; } = []; + /// - /// 菜单编码列表。 + /// 菜单编码列表。 /// public IReadOnlyCollection MenuCodes { get; set; } = []; @@ -61,12 +67,12 @@ public sealed class ReplaceRoleBindingsDto } /// -/// 替换用户角色请求。 +/// 替换用户角色请求。 /// public sealed class ReplaceUserRolesDto { /// - /// 角色 ID 列表。 + /// 角色 ID 列表。 /// public IReadOnlyCollection RoleIds { get; set; } = []; @@ -74,4 +80,4 @@ public sealed class ReplaceUserRolesDto { return new ReplaceUserRolesCommand(userId, RoleIds); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/CatalogDtos.cs b/Tiku.Api/Contracts/CatalogDtos.cs index b5338a8..96c3d5a 100644 --- a/Tiku.Api/Contracts/CatalogDtos.cs +++ b/Tiku.Api/Contracts/CatalogDtos.cs @@ -4,67 +4,67 @@ using Tiku.Application.Catalog; namespace Tiku.Api.Contracts; /// -/// 目录查询参数。 +/// 目录查询参数。 /// public sealed class CatalogQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// ModuleId。 + /// ModuleId。 /// public Guid? ModuleId { get; set; } /// - /// 父节点 ID;传 root 表示根节点。 + /// 父节点 ID;传 root 表示根节点。 /// [StringLength(64)] [RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")] public string? ParentId { get; set; } /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } /// - /// 专业 ID。 + /// 专业 ID。 /// public Guid? MajorId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 类型。 + /// 类型。 /// [StringLength(50)] public string? Type { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 2000)] public int? Limit { get; set; } @@ -90,4 +90,4 @@ public sealed class CatalogQueryDto Type, Limit); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/CommerceDtos.cs b/Tiku.Api/Contracts/CommerceDtos.cs index 359fb5a..48166fa 100644 --- a/Tiku.Api/Contracts/CommerceDtos.cs +++ b/Tiku.Api/Contracts/CommerceDtos.cs @@ -4,47 +4,47 @@ using Tiku.Application.Commerce; namespace Tiku.Api.Contracts; /// -/// 创建交易订单请求 DTO。 +/// 创建交易订单请求 DTO。 /// public sealed class CreateCommerceOrderDto { /// - /// 套餐 ID。 + /// 套餐 ID。 /// [Required] public Guid PlanId { get; set; } /// - /// 数量。 + /// 数量。 /// [Range(1, 99)] public int Quantity { get; set; } = 1; /// - /// 支付方式。 + /// 支付方式。 /// [StringLength(50)] public string? PayMethod { get; set; } /// - /// 支付渠道。 + /// 支付渠道。 /// [StringLength(50)] public string? PayProvider { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 优惠券码。 + /// 优惠券码。 /// [StringLength(100)] public string? CouponCode { get; set; } /// - /// 优惠券领取记录 ID。 + /// 优惠券领取记录 ID。 /// public Guid? CouponRedemptionId { get; set; } @@ -62,63 +62,63 @@ public sealed class CreateCommerceOrderDto } /// -/// 交易订单查询参数。 +/// 交易订单查询参数。 /// public sealed class CommerceOrderQueryDto { /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 100)] public int? Limit { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } } /// -/// 创建交易支付请求 DTO。 +/// 创建交易支付请求 DTO。 /// public sealed class CreateCommercePaymentDto { /// - /// 订单号。 + /// 订单号。 /// [Required] [StringLength(100)] public string OrderNo { get; set; } = string.Empty; /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 方式。 + /// 方式。 /// [Required] [StringLength(50)] public string Method { get; set; } = string.Empty; /// - /// 微信或支付渠道 openid。 + /// 微信或支付渠道 openid。 /// [StringLength(255)] public string? OpenId { get; set; } /// - /// 支付完成返回地址。 + /// 支付完成返回地址。 /// [StringLength(2048)] public string? ReturnUrl { get; set; } /// - /// 支付取消返回地址。 + /// 支付取消返回地址。 /// [StringLength(2048)] public string? QuitUrl { get; set; } @@ -136,18 +136,18 @@ public sealed class CreateCommercePaymentDto } /// -/// 交易优惠券查询参数。 +/// 交易优惠券查询参数。 /// public sealed class CommerceCouponQueryDto { /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 100)] public int? Limit { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } @@ -159,12 +159,12 @@ public sealed class CommerceCouponQueryDto } /// -/// 领取交易优惠券请求 DTO。 +/// 领取交易优惠券请求 DTO。 /// public sealed class ClaimCommerceCouponDto { /// - /// 优惠券码。 + /// 优惠券码。 /// [Required] [StringLength(100)] @@ -177,35 +177,35 @@ public sealed class ClaimCommerceCouponDto } /// -/// 校验交易优惠券请求 DTO。 +/// 校验交易优惠券请求 DTO。 /// public sealed class CheckCommerceCouponDto { /// - /// 优惠券码。 + /// 优惠券码。 /// [StringLength(100)] public string? CouponCode { get; set; } /// - /// 优惠券领取记录 ID。 + /// 优惠券领取记录 ID。 /// public Guid? CouponRedemptionId { get; set; } /// - /// 套餐 ID。 + /// 套餐 ID。 /// [Required] public Guid PlanId { get; set; } /// - /// 数量。 + /// 数量。 /// [Range(1, 99)] public int Quantity { get; set; } = 1; /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } @@ -213,4 +213,4 @@ public sealed class CheckCommerceCouponDto { return new CheckCommerceCouponCommand(CouponCode, CouponRedemptionId, PlanId, Quantity, RegionId); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/CommissionDtos.cs b/Tiku.Api/Contracts/CommissionDtos.cs index 44aba4e..c6ff62e 100644 --- a/Tiku.Api/Contracts/CommissionDtos.cs +++ b/Tiku.Api/Contracts/CommissionDtos.cs @@ -6,293 +6,373 @@ using Tiku.Application.Growth; namespace Tiku.Api.Contracts; /// -/// 保存佣金配置请求。 +/// 保存佣金配置请求。 /// public sealed class UpdateCommissionSettingsDto { /// - /// 默认佣金比例。 + /// 默认佣金比例。 /// [Range(0, 1)] public decimal? DefaultRate { get; set; } + /// - /// 最低结算金额,单位为分。 + /// 最低结算金额,单位为分。 /// [Range(0, int.MaxValue)] public int? MinSettlementCents { get; set; } + /// - /// 结算周期。 + /// 结算周期。 /// [StringLength(50)] public string? SettlementCycle { get; set; } + /// - /// 配置内容。 + /// 配置内容。 /// public JsonElement? Config { get; set; } - public UpdateCommissionSettingsCommand ToCommand() => new(DefaultRate, MinSettlementCents, SettlementCycle, Config); + + public UpdateCommissionSettingsCommand ToCommand() + { + return new UpdateCommissionSettingsCommand(DefaultRate, MinSettlementCents, SettlementCycle, Config); + } } /// -/// 调整成员佣金比例请求。 +/// 调整成员佣金比例请求。 /// public sealed class UpdateMemberCommissionRateDto { /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } + /// - /// 成员佣金比例。 + /// 成员佣金比例。 /// [Range(0, 1)] public decimal? CommissionRate { get; set; } + /// - /// 佣金扩展配置。 + /// 佣金扩展配置。 /// public JsonElement? CommissionConfig { get; set; } - public UpdateMemberCommissionRateCommand ToCommand() => new(UserId, CommissionRate, CommissionConfig); + + public UpdateMemberCommissionRateCommand ToCommand() + { + return new UpdateMemberCommissionRateCommand(UserId, CommissionRate, CommissionConfig); + } } /// -/// 佣金统计周期查询参数。 +/// 佣金统计周期查询参数。 /// public class CommissionPeriodQueryDto { /// - /// 开始日期,格式为 yyyy-MM-dd。 + /// 开始日期,格式为 yyyy-MM-dd。 /// [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string? StartDate { get; set; } + /// - /// 结束日期,格式为 yyyy-MM-dd。 + /// 结束日期,格式为 yyyy-MM-dd。 /// [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string? EndDate { get; set; } + /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// public Guid? ReferrerUserId { get; set; } + /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } - public CommissionPeriodQuery ToQuery() => new(ParseDate(StartDate), ParseDate(EndDate), ReferrerUserId, Limit); - protected static DateOnly? ParseDate(string? value) => string.IsNullOrWhiteSpace(value) ? null : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + + public CommissionPeriodQuery ToQuery() + { + return new CommissionPeriodQuery(ParseDate(StartDate), ParseDate(EndDate), ReferrerUserId, Limit); + } + + protected static DateOnly? ParseDate(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + } } /// -/// 佣金结算单查询参数。 +/// 佣金结算单查询参数。 /// public sealed class CommissionSettlementsQueryDto { /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } + /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// public Guid? ReferrerUserId { get; set; } + /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } - public CommissionSettlementQuery ToQuery() => new(Status, ReferrerUserId, Limit); + + public CommissionSettlementQuery ToQuery() + { + return new CommissionSettlementQuery(Status, ReferrerUserId, Limit); + } } /// -/// 生成佣金结算单请求 DTO。 +/// 生成佣金结算单请求 DTO。 /// public sealed class GenerateCommissionSettlementDto { /// - /// 开始日期,格式为 yyyy-MM-dd。 + /// 开始日期,格式为 yyyy-MM-dd。 /// [Required] [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string StartDate { get; set; } = string.Empty; + /// - /// 结束日期,格式为 yyyy-MM-dd。 + /// 结束日期,格式为 yyyy-MM-dd。 /// [Required] [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string EndDate { get; set; } = string.Empty; + /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// [Required] public Guid ReferrerUserId { get; set; } + /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } + /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Remark { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } - public GenerateCommissionSettlementCommand ToCommand() => new(Parse(StartDate), Parse(EndDate), ReferrerUserId, Status, Remark, Metadata); - private static DateOnly Parse(string value) => DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + + public GenerateCommissionSettlementCommand ToCommand() + { + return new GenerateCommissionSettlementCommand(Parse(StartDate), Parse(EndDate), ReferrerUserId, Status, Remark, + Metadata); + } + + private static DateOnly Parse(string value) + { + return DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture); + } } /// -/// 更新佣金结算单状态请求 DTO。 +/// 更新佣金结算单状态请求 DTO。 /// public sealed class UpdateCommissionSettlementStatusDto { /// - /// 结算单 ID。 + /// 结算单 ID。 /// [Required] public Guid SettlementId { get; set; } + /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } + /// - /// 审核备注。 + /// 审核备注。 /// [StringLength(1000)] public string? ReviewNote { get; set; } + /// - /// 支付方式。 + /// 支付方式。 /// [StringLength(100)] public string? PaymentMethod { get; set; } + /// - /// 收款账号。 + /// 收款账号。 /// [StringLength(200)] public string? PaymentAccount { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } - public UpdateCommissionSettlementStatusCommand ToCommand() => new(SettlementId, Status, ReviewNote, PaymentMethod, PaymentAccount, Metadata); + + public UpdateCommissionSettlementStatusCommand ToCommand() + { + return new UpdateCommissionSettlementStatusCommand(SettlementId, Status, ReviewNote, PaymentMethod, + PaymentAccount, Metadata); + } } /// -/// 佣金结算单导出查询参数。 +/// 佣金结算单导出查询参数。 /// public sealed class CommissionSettlementExportQueryDto { /// - /// 结算单 ID。 + /// 结算单 ID。 /// [Required] public Guid SettlementId { get; set; } + /// - /// 导出格式。 + /// 导出格式。 /// [StringLength(10)] public string? Format { get; set; } } /// -/// 佣金结算单凭证查询参数。 +/// 佣金结算单凭证查询参数。 /// public sealed class CommissionSettlementProofQueryDto { /// - /// 结算单 ID。 + /// 结算单 ID。 /// [Required] public Guid SettlementId { get; set; } } /// -/// 创建佣金结算凭证请求。 +/// 创建佣金结算凭证请求。 /// public sealed class CreateCommissionProofDto { /// - /// 结算单 ID。 + /// 结算单 ID。 /// [Required] public Guid SettlementId { get; set; } + /// - /// 凭证类型。 + /// 凭证类型。 /// [StringLength(50)] public string? ProofType { get; set; } + /// - /// 标题。 + /// 标题。 /// [StringLength(200)] public string? Title { get; set; } + /// - /// 说明。 + /// 说明。 /// [StringLength(2000)] public string? Description { get; set; } + /// - /// 资产 ID。 + /// 资产 ID。 /// public Guid? AssetId { get; set; } + /// - /// 外部链接。 + /// 外部链接。 /// [StringLength(2048)] public string? ExternalUrl { get; set; } + /// - /// 金额,单位为分。 + /// 金额,单位为分。 /// [Range(0, int.MaxValue)] public int? AmountCents { get; set; } + /// - /// 支付方式。 + /// 支付方式。 /// [StringLength(100)] public string? PaymentMethod { get; set; } + /// - /// 收款账号。 + /// 收款账号。 /// [StringLength(200)] public string? PaymentAccount { get; set; } + /// - /// 支付时间。 + /// 支付时间。 /// public DateTimeOffset? PaidAt { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } - public CreateCommissionProofCommand ToCommand() => new(SettlementId, ProofType, Title, Description, AssetId, ExternalUrl, AmountCents, PaymentMethod, PaymentAccount, PaidAt, Metadata); + + public CreateCommissionProofCommand ToCommand() + { + return new CreateCommissionProofCommand(SettlementId, ProofType, Title, Description, AssetId, ExternalUrl, + AmountCents, PaymentMethod, + PaymentAccount, PaidAt, Metadata); + } } /// -/// 更新佣金结算凭证状态请求。 +/// 更新佣金结算凭证状态请求。 /// public sealed class UpdateCommissionProofStatusDto { /// - /// 凭证 ID。 + /// 凭证 ID。 /// [Required] public Guid ProofId { get; set; } + /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } + /// - /// 审核备注。 + /// 审核备注。 /// [StringLength(1000)] public string? ReviewNote { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } - public UpdateCommissionProofStatusCommand ToCommand() => new(ProofId, Status, ReviewNote, Metadata); -} + + public UpdateCommissionProofStatusCommand ToCommand() + { + return new UpdateCommissionProofStatusCommand(ProofId, Status, ReviewNote, Metadata); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/ContentManagementDtos.cs b/Tiku.Api/Contracts/ContentManagementDtos.cs index c0e7174..73bbc35 100644 --- a/Tiku.Api/Contracts/ContentManagementDtos.cs +++ b/Tiku.Api/Contracts/ContentManagementDtos.cs @@ -8,74 +8,74 @@ using Tiku.Domain.Content; namespace Tiku.Api.Contracts; /// -/// 内容管理查询参数。 +/// 内容管理查询参数。 /// public sealed class ContentManagementQueryDto { /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } /// - /// 父节点 ID;传 root 表示根节点。 + /// 父节点 ID;传 root 表示根节点。 /// [StringLength(64)] [RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")] public string? ParentId { get; set; } /// - /// 入口类型。 + /// 入口类型。 /// [StringLength(50)] public string? EntryType { get; set; } /// - /// 题集类型。 + /// 题集类型。 /// [StringLength(50)] public string? CollectionType { get; set; } /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } /// - /// 标记类型。 + /// 标记类型。 /// [StringLength(50)] public string? MarkerType { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 是否包含停用数据。 + /// 是否包含停用数据。 /// public bool IncludeInactive { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 1000)] public int? Limit { get; set; } @@ -99,86 +99,86 @@ public sealed class ContentManagementQueryDto } /// -/// 新增或更新内容条目请求 DTO。 +/// 新增或更新内容条目请求 DTO。 /// public sealed class UpsertContentEntryDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// [StringLength(64)] public string? LegacyId { get; set; } /// - /// 入口键。 + /// 入口键。 /// [StringLength(100)] public string? EntryKey { get; set; } /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(300)] public string Name { get; set; } = string.Empty; /// - /// 入口类型。 + /// 入口类型。 /// [StringLength(50)] public string? EntryType { get; set; } /// - /// 图标。 + /// 图标。 /// [StringLength(100)] public string? Icon { get; set; } /// - /// 路由地址。 + /// 路由地址。 /// [StringLength(500)] public string? Route { get; set; } /// - /// 说明。 + /// 说明。 /// [StringLength(2000)] public string? Description { get; set; } /// - /// 可见性。 + /// 可见性。 /// [StringLength(50)] public string? Visibility { get; set; } /// - /// 访问规则。 + /// 访问规则。 /// public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); /// - /// 布局配置。 + /// 布局配置。 /// public JsonElement LayoutConfig { get; set; } = JsonDefaults.Object(); /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } @@ -203,94 +203,94 @@ public sealed class UpsertContentEntryDto } /// -/// 新增或更新内容节点请求 DTO。 +/// 新增或更新内容节点请求 DTO。 /// public sealed class UpsertContentNodeDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// [Required] public Guid EntryId { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 父节点 ID。 + /// 父节点 ID。 /// public Guid? ParentId { get; set; } /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// [StringLength(64)] public string? LegacyId { get; set; } /// - /// 节点键。 + /// 节点键。 /// [StringLength(100)] public string? NodeKey { get; set; } /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(300)] public string Name { get; set; } = string.Empty; /// - /// 节点Type。 + /// 节点Type。 /// [StringLength(50)] public string? NodeType { get; set; } /// - /// 标记类型。 + /// 标记类型。 /// [StringLength(50)] public string? MarkerType { get; set; } /// - /// 标记配置。 + /// 标记配置。 /// public JsonElement MarkerConfig { get; set; } = JsonDefaults.Object(); /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } /// - /// 是否可选择。 + /// 是否可选择。 /// public bool? IsSelectable { get; set; } /// - /// 是否叶子节点。 + /// 是否叶子节点。 /// public bool? IsLeaf { get; set; } /// - /// 访问规则。 + /// 访问规则。 /// public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -317,103 +317,103 @@ public sealed class UpsertContentNodeDto } /// -/// 新增或更新题目题集请求 DTO。 +/// 新增或更新题目题集请求 DTO。 /// public sealed class UpsertQuestionCollectionDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } /// - /// 题库 ID。 + /// 题库 ID。 /// public Guid? QuestionBankId { get; set; } /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// [StringLength(64)] public string? LegacyId { get; set; } /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(300)] public string Name { get; set; } = string.Empty; /// - /// 题集类型。 + /// 题集类型。 /// [StringLength(50)] public string? CollectionType { get; set; } /// - /// 来源类型。 + /// 来源类型。 /// [StringLength(50)] public string? SourceType { get; set; } /// - /// 筛选条件。 + /// 筛选条件。 /// public JsonElement Filters { get; set; } = JsonDefaults.Object(); /// - /// 总分。 + /// 总分。 /// public decimal? TotalScore { get; set; } /// - /// 时长,单位为分钟。 + /// 时长,单位为分钟。 /// public int? DurationMinutes { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } /// - /// 访问规则。 + /// 访问规则。 /// public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -442,45 +442,45 @@ public sealed class UpsertQuestionCollectionDto } /// -/// 题集题目请求 DTO。 +/// 题集题目请求 DTO。 /// public sealed class CollectionQuestionDto { /// - /// 题目 ID。 + /// 题目 ID。 /// [Required] public Guid QuestionId { get; set; } /// - /// 来源。 + /// 来源。 /// [Required] public QuestionSource Source { get; set; } = QuestionSource.Tenant; /// - /// 分段键。 + /// 分段键。 /// [StringLength(100)] public string? SectionKey { get; set; } /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } /// - /// 分数。 + /// 分数。 /// public decimal? Score { get; set; } /// - /// 是否必填。 + /// 是否必填。 /// public bool? Required { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -497,18 +497,18 @@ public sealed class CollectionQuestionDto } /// -/// 替换题集Items请求 DTO。 +/// 替换题集Items请求 DTO。 /// public sealed class ReplaceCollectionItemsDto { /// - /// 题集 ID。 + /// 题集 ID。 /// [Required] public Guid CollectionId { get; set; } /// - /// 题目列表。 + /// 题目列表。 /// public IReadOnlyCollection Questions { get; set; } = []; @@ -521,103 +521,103 @@ public sealed class ReplaceCollectionItemsDto } /// -/// 新增或更新练习Blueprint请求 DTO。 +/// 新增或更新练习Blueprint请求 DTO。 /// public sealed class UpsertPracticeBlueprintDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// [StringLength(64)] public string? LegacyId { get; set; } /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(300)] public string Name { get; set; } = string.Empty; /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } /// - /// 组卷方式。 + /// 组卷方式。 /// [StringLength(50)] public string? AssemblyType { get; set; } /// - /// 题目数量上限。 + /// 题目数量上限。 /// public int? QuestionLimit { get; set; } /// - /// 时长,单位为分钟。 + /// 时长,单位为分钟。 /// public int? DurationMinutes { get; set; } /// - /// 总分。 + /// 总分。 /// public decimal? TotalScore { get; set; } /// - /// 及格分。 + /// 及格分。 /// public decimal? PassScore { get; set; } /// - /// 分段配置。 + /// 分段配置。 /// public JsonElement Sections { get; set; } = JsonDefaults.Array(); /// - /// 规则配置。 + /// 规则配置。 /// public JsonElement Rules { get; set; } = JsonDefaults.Object(); /// - /// 访问规则。 + /// 访问规则。 /// public JsonElement AccessRules { get; set; } = JsonDefaults.Object(); /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } @@ -646,20 +646,20 @@ public sealed class UpsertPracticeBlueprintDto } /// -/// 导入Template查询参数。 +/// 导入Template查询参数。 /// public sealed class ImportTemplateQueryDto { /// - /// 导入Type。 + /// 导入Type。 /// [Required] [StringLength(50)] public string ImportType { get; set; } = string.Empty; /// - /// 导出格式。 + /// 导出格式。 /// [StringLength(10)] public string? Format { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/ContentNavigationDtos.cs b/Tiku.Api/Contracts/ContentNavigationDtos.cs index 6156165..e7aa9f2 100644 --- a/Tiku.Api/Contracts/ContentNavigationDtos.cs +++ b/Tiku.Api/Contracts/ContentNavigationDtos.cs @@ -4,85 +4,85 @@ using Tiku.Application.Content; namespace Tiku.Api.Contracts; /// -/// 内容Navigation查询参数。 +/// 内容Navigation查询参数。 /// public sealed class ContentNavigationQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } /// - /// 父节点 ID;传 root 表示根节点。 + /// 父节点 ID;传 root 表示根节点。 /// [StringLength(64)] [RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")] public string? ParentId { get; set; } /// - /// 入口类型。 + /// 入口类型。 /// [StringLength(50)] public string? EntryType { get; set; } /// - /// 题集类型。 + /// 题集类型。 /// [StringLength(50)] public string? CollectionType { get; set; } /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } /// - /// 标记类型。 + /// 标记类型。 /// [StringLength(50)] public string? MarkerType { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 是否包含隐藏数据。 + /// 是否包含隐藏数据。 /// public bool IncludeHidden { get; set; } /// - /// 是否包含停用数据。 + /// 是否包含停用数据。 /// public bool IncludeInactive { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 1000)] public int? Limit { get; set; } @@ -113,4 +113,4 @@ public sealed class ContentNavigationQueryDto IncludeInactive, Limit); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/CrmDtos.cs b/Tiku.Api/Contracts/CrmDtos.cs index 2c75056..62458d3 100644 --- a/Tiku.Api/Contracts/CrmDtos.cs +++ b/Tiku.Api/Contracts/CrmDtos.cs @@ -5,69 +5,69 @@ using Tiku.Application.Growth; namespace Tiku.Api.Contracts; /// -/// 新增或更新CRM配置请求 DTO。 +/// 新增或更新CRM配置请求 DTO。 /// public sealed class UpsertCrmConfigDto { /// - /// 是否启用。 + /// 是否启用。 /// public bool Enabled { get; set; } /// - /// 访问地址。 + /// 访问地址。 /// [StringLength(2048)] public string? Url { get; set; } /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } /// - /// 密钥内容。 + /// 密钥内容。 /// public string? Secret { get; set; } /// - /// 表单名称。 + /// 表单名称。 /// [StringLength(200)] public string? FormName { get; set; } /// - /// 考试类型。 + /// 考试类型。 /// [StringLength(100)] public string? ExamType { get; set; } /// - /// 超时时长,单位为秒。 + /// 超时时长,单位为秒。 /// [Range(1, 120)] public int? TimeoutSeconds { get; set; } /// - /// 延迟秒数。 + /// 延迟秒数。 /// [Range(0, 86400)] public int? DelaySeconds { get; set; } /// - /// 分配模式。 + /// 分配模式。 /// [StringLength(50)] public string? AssignmentMode { get; set; } /// - /// 分配池。 + /// 分配池。 /// public JsonElement? AssignmentPool { get; set; } /// - /// 分配配置。 + /// 分配配置。 /// public JsonElement? AssignmentConfig { get; set; } @@ -89,29 +89,29 @@ public sealed class UpsertCrmConfigDto } /// -/// CRM队列查询参数。 +/// CRM队列查询参数。 /// public sealed class CrmQueueQueryDto { /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 队列任务 ID。 + /// 队列任务 ID。 /// public Guid? QueueId { get; set; } /// - /// 来源。 + /// 来源。 /// [StringLength(200)] public string? Source { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -123,17 +123,17 @@ public sealed class CrmQueueQueryDto } /// -/// CRM队列日志查询参数。 +/// CRM队列日志查询参数。 /// public sealed class CrmQueueLogQueryDto { /// - /// 队列任务 ID。 + /// 队列任务 ID。 /// public Guid? QueueId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } @@ -145,30 +145,30 @@ public sealed class CrmQueueLogQueryDto } /// -/// CRM队列Action请求 DTO。 +/// CRM队列Action请求 DTO。 /// public sealed class CrmQueueActionDto { /// - /// 队列任务 ID。 + /// 队列任务 ID。 /// [Required] public Guid QueueId { get; set; } /// - /// 操作。 + /// 操作。 /// [StringLength(50)] public string? Action { get; set; } /// - /// 备注。 + /// 备注。 /// [StringLength(500)] public string? Note { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } @@ -176,4 +176,4 @@ public sealed class CrmQueueActionDto { return new CrmQueueActionCommand(QueueId, Action, Note, Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/DirectContentDtos.cs b/Tiku.Api/Contracts/DirectContentDtos.cs index 3263aad..1dd3ef5 100644 --- a/Tiku.Api/Contracts/DirectContentDtos.cs +++ b/Tiku.Api/Contracts/DirectContentDtos.cs @@ -6,71 +6,80 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; /// -/// 管理侧内容查询参数。 +/// 管理侧内容查询参数。 /// public sealed class DirectContentQueryDto { /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 父节点 ID。 + /// 父节点 ID。 /// public Guid? ParentId { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 章节 ID。 + /// 章节 ID。 /// public Guid? ChapterId { get; set; } + /// - /// 单元 ID。 + /// 单元 ID。 /// public Guid? UnitId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 专业 ID。 + /// 专业 ID。 /// public Guid? MajorId { get; set; } + /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(200)] public string? Keyword { get; set; } /// - /// 年份。 + /// 年份。 /// [Range(1900, 3000)] public int? Year { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 1000)] public int? Limit { get; set; } @@ -96,119 +105,143 @@ public sealed class DirectContentQueryDto } /// -/// 管理侧题目写入请求。 +/// 管理侧题目写入请求。 /// public sealed class DirectQuestionWriteDto { /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } + /// - /// 题库 ID。 + /// 题库 ID。 /// public Guid? QuestionBankId { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } + /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 主题集 ID。 + /// 主题集 ID。 /// public Guid? PrimaryCollectionId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 类型。 + /// 类型。 /// public string? Type { get; set; } + /// - /// 类型显示名。 + /// 类型显示名。 /// public string? TypeLabel { get; set; } /// - /// 难度。 + /// 难度。 /// [Range(1, 5)] public int? Difficulty { get; set; } /// - /// 标签列表。 + /// 标签列表。 /// public JsonElement Tags { get; set; } = JsonDefaults.Array(); + /// - /// 内容。 + /// 内容。 /// public string? Content { get; set; } + /// - /// 选项配置。 + /// 选项配置。 /// public JsonElement Options { get; set; } = JsonDefaults.Array(); + /// - /// 正确选项索引。 + /// 正确选项索引。 /// public int? CorrectOptionIndex { get; set; } + /// - /// 正确选项索引列表。 + /// 正确选项索引列表。 /// public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array(); + /// - /// 文字答案。 + /// 文字答案。 /// public string? AnswerText { get; set; } + /// - /// 解析。 + /// 解析。 /// public string? Explanation { get; set; } + /// - /// 子题列表。 + /// 子题列表。 /// public JsonElement SubQuestions { get; set; } = JsonDefaults.Array(); + /// - /// 代码语言。 + /// 代码语言。 /// public string? CodeLang { get; set; } + /// - /// 代码模板。 + /// 代码模板。 /// public string? CodeTemplate { get; set; } + /// - /// 媒体地址。 + /// 媒体地址。 /// public string? MediaUrl { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 考试标记配置。 + /// 考试标记配置。 /// public JsonElement ExamMarkers { get; set; } = JsonDefaults.Object(); + /// - /// 来源哈希。 + /// 来源哈希。 /// public string? SourceHash { get; set; } + /// - /// 是否创建题目版本。 + /// 是否创建题目版本。 /// public bool? CreateVersion { get; set; } @@ -246,124 +279,149 @@ public sealed class DirectQuestionWriteDto } /// -/// 管理侧词汇单元写入请求。 +/// 管理侧词汇单元写入请求。 /// public sealed class DirectVocabularyUnitDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 单词数量。 + /// 单词数量。 /// public int? WordCount { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public VocabularyUnitCommand ToCommand() { - return new VocabularyUnitCommand(Id, RegionId, EntryId, ContentNodeId, LegacyId, Name, Description, WordCount, Order, IsActive, Metadata); + return new VocabularyUnitCommand(Id, RegionId, EntryId, ContentNodeId, LegacyId, Name, Description, WordCount, + Order, IsActive, Metadata); } } /// -/// 管理侧词汇单词写入请求。 +/// 管理侧词汇单词写入请求。 /// public sealed class DirectVocabularyWordDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 单元 ID。 + /// 单元 ID。 /// public Guid? UnitId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 单词。 + /// 单词。 /// public required string Word { get; set; } + /// - /// 音标。 + /// 音标。 /// public string? Phonetic { get; set; } + /// - /// 释义。 + /// 释义。 /// public string? Meaning { get; set; } + /// - /// 例句。 + /// 例句。 /// public string? Example { get; set; } + /// - /// 例句翻译。 + /// 例句翻译。 /// public string? ExampleTranslation { get; set; } + /// - /// 难度。 + /// 难度。 /// public int? Difficulty { get; set; } + /// - /// 标签列表。 + /// 标签列表。 /// public JsonElement Tags { get; set; } = JsonDefaults.Array(); + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -389,68 +447,82 @@ public sealed class DirectVocabularyWordDto } /// -/// 管理侧知识手册科目写入请求。 +/// 管理侧知识手册科目写入请求。 /// public sealed class DirectHandbookSubjectDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 专业 ID。 + /// 专业 ID。 /// public Guid? MajorId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 类型。 + /// 类型。 /// public string? Type { get; set; } + /// - /// 图标。 + /// 图标。 /// public string? Icon { get; set; } + /// - /// 颜色。 + /// 颜色。 /// public string? Color { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -476,144 +548,171 @@ public sealed class DirectHandbookSubjectDto } /// -/// 管理侧知识手册章节写入请求。 +/// 管理侧知识手册章节写入请求。 /// public sealed class DirectHandbookChapterDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public HandbookChapterCommand ToCommand() { - return new HandbookChapterCommand(Id, SubjectId, EntryId, ContentNodeId, LegacyId, Name, Description, Order, IsActive, Metadata); + return new HandbookChapterCommand(Id, SubjectId, EntryId, ContentNodeId, LegacyId, Name, Description, Order, + IsActive, Metadata); } } /// -/// 管理侧知识手册条目写入请求。 +/// 管理侧知识手册条目写入请求。 /// public sealed class DirectHandbookEntryDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 章节 ID。 + /// 章节 ID。 /// public Guid? ChapterId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 标题。 + /// 标题。 /// public required string Title { get; set; } + /// - /// 摘要。 + /// 摘要。 /// public string? Summary { get; set; } + /// - /// 内容。 + /// 内容。 /// public string? Content { get; set; } + /// - /// 标签列表。 + /// 标签列表。 /// public JsonElement Tags { get; set; } = JsonDefaults.Array(); + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public HandbookEntryCommand ToCommand() { - return new HandbookEntryCommand(Id, ChapterId, EntryId, ContentNodeId, LegacyId, Title, Summary, Content, Tags, Order, IsActive, Metadata); + return new HandbookEntryCommand(Id, ChapterId, EntryId, ContentNodeId, LegacyId, Title, Summary, Content, Tags, + Order, IsActive, Metadata); } } /// -/// 管理侧院校写入请求。 +/// 管理侧院校写入请求。 /// public sealed class DirectSchoolDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 专业考试日期。 + /// 专业考试日期。 /// public string? ProfessionalExamDate { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -624,44 +723,52 @@ public sealed class DirectSchoolDto } /// -/// 管理侧专业写入请求。 +/// 管理侧专业写入请求。 /// public sealed class DirectMajorDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 备考建议。 + /// 备考建议。 /// public string? StudyTips { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } @@ -672,68 +779,82 @@ public sealed class DirectMajorDto } /// -/// 管理侧分数线字段写入请求。 +/// 管理侧分数线字段写入请求。 /// public sealed class DirectScorelineFieldDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 字段键。 + /// 字段键。 /// public required string FieldKey { get; set; } + /// - /// 字段名称。 + /// 字段名称。 /// public required string FieldName { get; set; } + /// - /// 字段类型。 + /// 字段类型。 /// public string? FieldType { get; set; } + /// - /// 单位。 + /// 单位。 /// public string? Unit { get; set; } + /// - /// 是否可筛选。 + /// 是否可筛选。 /// public bool? IsFilter { get; set; } + /// - /// 是否必填。 + /// 是否必填。 /// public bool? IsRequired { get; set; } + /// - /// 是否可见。 + /// 是否可见。 /// public bool? IsVisible { get; set; } + /// - /// 是否参与趋势展示。 + /// 是否参与趋势展示。 /// public bool? IsTrend { get; set; } + /// - /// 选项配置。 + /// 选项配置。 /// public JsonElement Options { get; set; } = JsonDefaults.Array(); + /// - /// 占位提示。 + /// 占位提示。 /// public string? Placeholder { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } @@ -759,47 +880,53 @@ public sealed class DirectScorelineFieldDto } /// -/// 管理侧分数线记录写入请求。 +/// 管理侧分数线记录写入请求。 /// public sealed class DirectScorelineRecordDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 专业 ID。 + /// 专业 ID。 /// public Guid? MajorId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } /// - /// 年份。 + /// 年份。 /// [Range(1900, 3000)] public int Year { get; set; } /// - /// 院校名称。 + /// 院校名称。 /// public string? SchoolName { get; set; } + /// - /// 专业名称。 + /// 专业名称。 /// public string? MajorName { get; set; } + /// - /// 动态字段值。 + /// 动态字段值。 /// public JsonElement FieldValues { get; set; } = JsonDefaults.Object(); @@ -819,64 +946,77 @@ public sealed class DirectScorelineRecordDto } /// -/// 管理侧视频写入请求。 +/// 管理侧视频写入请求。 /// public sealed class DirectVideoDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 标题。 + /// 标题。 /// public required string Title { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 视频地址。 + /// 视频地址。 /// public string? VideoUrl { get; set; } + /// - /// 缩略图地址。 + /// 缩略图地址。 /// public string? ThumbnailUrl { get; set; } + /// - /// 时长,单位为秒。 + /// 时长,单位为秒。 /// public int? DurationSeconds { get; set; } + /// - /// 知识标签。 + /// 知识标签。 /// public JsonElement KnowledgeTags { get; set; } = JsonDefaults.Array(); + /// - /// 是否通用视频。 + /// 是否通用视频。 /// public bool? IsGeneral { get; set; } + /// - /// 难度。 + /// 难度。 /// public int? Difficulty { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -901,32 +1041,37 @@ public sealed class DirectVideoDto } /// -/// 管理侧题目视频绑定请求。 +/// 管理侧题目视频绑定请求。 /// public sealed class DirectQuestionVideoDto { /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid QuestionId { get; set; } + /// - /// 视频 ID。 + /// 视频 ID。 /// public Guid VideoId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 视频类型。 + /// 视频类型。 /// public string? VideoType { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -937,92 +1082,112 @@ public sealed class DirectQuestionVideoDto } /// -/// 管理侧运营内容写入请求。 +/// 管理侧运营内容写入请求。 /// public sealed class DirectOperationContentDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 标题。 + /// 标题。 /// public string? Title { get; set; } + /// - /// 副标题。 + /// 副标题。 /// public string? Subtitle { get; set; } + /// - /// 内容。 + /// 内容。 /// public string? Content { get; set; } + /// - /// 问题。 + /// 问题。 /// public string? Question { get; set; } + /// - /// 答案。 + /// 答案。 /// public string? Answer { get; set; } + /// - /// 链接地址。 + /// 链接地址。 /// public string? Link { get; set; } + /// - /// 按钮文案。 + /// 按钮文案。 /// public string? ButtonText { get; set; } + /// - /// 按钮链接。 + /// 按钮链接。 /// public string? ButtonLink { get; set; } + /// - /// 背景颜色。 + /// 背景颜色。 /// public string? BackgroundColor { get; set; } + /// - /// 边框颜色。 + /// 边框颜色。 /// public string? BorderColor { get; set; } + /// - /// 考试名称。 + /// 考试名称。 /// public string? ExamName { get; set; } + /// - /// 考试时间。 + /// 考试时间。 /// public DateTimeOffset? ExamAt { get; set; } + /// - /// 考试类型。 + /// 考试类型。 /// public string? ExamType { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -1054,88 +1219,107 @@ public sealed class DirectOperationContentDto } /// -/// 管理侧内容导入请求。 +/// 管理侧内容导入请求。 /// public sealed class DirectImportDto { /// - /// 来源格式。 + /// 来源格式。 /// public string? SourceFormat { get; set; } + /// - /// 来源名称。 + /// 来源名称。 /// public string? SourceName { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } + /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } + /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } + /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } + /// - /// 题库 ID。 + /// 题库 ID。 /// public Guid? QuestionBankId { get; set; } + /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } + /// - /// 是否异步执行。 + /// 是否异步执行。 /// public bool? Async { get; set; } + /// - /// 数据项列表。 + /// 数据项列表。 /// public IReadOnlyCollection? Items { get; set; } + /// - /// 单元数据列表。 + /// 单元数据列表。 /// public IReadOnlyCollection? Units { get; set; } + /// - /// 单词数据列表。 + /// 单词数据列表。 /// public IReadOnlyCollection? Words { get; set; } + /// - /// 科目数据列表。 + /// 科目数据列表。 /// public IReadOnlyCollection? Subjects { get; set; } + /// - /// 条目数据列表。 + /// 条目数据列表。 /// public IReadOnlyCollection? Entries { get; set; } + /// - /// 字段数据列表。 + /// 字段数据列表。 /// public IReadOnlyCollection? Fields { get; set; } + /// - /// 院校数据列表。 + /// 院校数据列表。 /// public IReadOnlyCollection? Schools { get; set; } + /// - /// 专业数据列表。 + /// 专业数据列表。 /// public IReadOnlyCollection? Majors { get; set; } + /// - /// 记录数据列表。 + /// 记录数据列表。 /// public IReadOnlyCollection? Records { get; set; } + /// - /// 视频数据列表。 + /// 视频数据列表。 /// public IReadOnlyCollection? Videos { get; set; } @@ -1171,12 +1355,12 @@ public sealed class DirectImportDto } /// -/// 管理侧内容导入任务查询请求。 +/// 管理侧内容导入任务查询请求。 /// public sealed class DirectImportJobDto { /// - /// 任务 ID。 + /// 任务 ID。 /// public Guid JobId { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/HealthDtos.cs b/Tiku.Api/Contracts/HealthDtos.cs index d200c01..22cd94d 100644 --- a/Tiku.Api/Contracts/HealthDtos.cs +++ b/Tiku.Api/Contracts/HealthDtos.cs @@ -1,15 +1,15 @@ namespace Tiku.Api.Contracts; /// -/// 健康检查响应。 +/// 健康检查响应。 /// /// 服务状态,例如 ok。 /// 服务名称。 /// 检查时间。 /// -/// 健康检查Response请求 DTO。 +/// 健康检查Response请求 DTO。 /// public sealed record HealthResponseDto( string Status, string Service, - DateTimeOffset CheckedAt); + DateTimeOffset CheckedAt); \ No newline at end of file diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs index 2262137..0d6122a 100644 --- a/Tiku.Api/Contracts/LearningDtos.cs +++ b/Tiku.Api/Contracts/LearningDtos.cs @@ -8,24 +8,24 @@ using Tiku.Domain.Content; namespace Tiku.Api.Contracts; /// -/// 学习Limit查询参数。 +/// 学习Limit查询参数。 /// public sealed class LearningLimitQueryDto { /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 单元 ID。 + /// 单元 ID。 /// public Guid? UnitId { get; set; } @@ -36,34 +36,34 @@ public sealed class LearningLimitQueryDto } /// -/// 练习会话查询参数。 +/// 练习会话查询参数。 /// public sealed class PracticeSessionQueryDto { /// - /// 练习会话 ID。 + /// 练习会话 ID。 /// public Guid? PracticeSessionId { get; set; } /// - /// 练习蓝图 ID。 + /// 练习蓝图 ID。 /// public Guid? BlueprintId { get; set; } /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } @@ -80,67 +80,67 @@ public sealed class PracticeSessionQueryDto } /// -/// 创建练习会话请求 DTO。 +/// 创建练习会话请求 DTO。 /// public sealed class CreatePracticeSessionDto { /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } /// - /// 目标类型。 + /// 目标类型。 /// [StringLength(50)] public string? TargetType { get; set; } /// - /// 目标 ID。 + /// 目标 ID。 /// public Guid? TargetId { get; set; } /// - /// 练习蓝图 ID。 + /// 练习蓝图 ID。 /// public Guid? BlueprintId { get; set; } /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 题目数量上限。 + /// 题目数量上限。 /// [Range(1, 500)] public int? QuestionLimit { get; set; } /// - /// 时长,单位为分钟。 + /// 时长,单位为分钟。 /// [Range(1, 1440)] public int? DurationMinutes { get; set; } /// - /// 总分。 + /// 总分。 /// [Range(typeof(decimal), "0", "99999")] public decimal? TotalScore { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -162,62 +162,67 @@ public sealed class CreatePracticeSessionDto } /// -/// 提交练习会话请求 DTO。 +/// 提交练习会话请求 DTO。 /// public sealed class SubmitPracticeSessionDto { /// - /// 练习会话 ID。 + /// 练习会话 ID。 /// [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)] + [Required] + [StringLength(200, MinimumLength = 1)] public string IdempotencyKey { get; set; } = string.Empty; - public SubmitPracticeSessionCommand ToCommand() => - new(PracticeSessionId, ExpectedSessionVersion, IdempotencyKey); + public SubmitPracticeSessionCommand ToCommand() + { + return new SubmitPracticeSessionCommand(PracticeSessionId, ExpectedSessionVersion, IdempotencyKey); + } } /// -/// 提交答案请求 DTO。 +/// 提交答案请求 DTO。 /// public sealed class SubmitAnswerDto { /// - /// 会话题目 ID。 + /// 会话题目 ID。 /// [Required] public Guid SessionQuestionId { get; set; } /// - /// 客户端读取会话时获得的版本。 + /// 客户端读取会话时获得的版本。 /// - [Required, Range(1, long.MaxValue)] + [Required] + [Range(1, long.MaxValue)] public long ExpectedSessionVersion { get; set; } /// - /// 客户端在本会话内单调递增的序列。 + /// 客户端在本会话内单调递增的序列。 /// - [Required, Range(1, long.MaxValue)] + [Required] + [Range(1, long.MaxValue)] public long ClientSequence { get; set; } /// - /// 网络重试幂等键。 + /// 网络重试幂等键。 /// - [Required, StringLength(200, MinimumLength = 1)] + [Required] + [StringLength(200, MinimumLength = 1)] public string IdempotencyKey { get; set; } = string.Empty; /// - /// 已选选项的零基索引。 + /// 已选选项的零基索引。 /// public IReadOnlyCollection? SelectedOptionIndices { get; set; } /// - /// 文字答案。 + /// 文字答案。 /// [StringLength(10000)] public string? AnswerText { get; set; } @@ -235,24 +240,24 @@ public sealed class SubmitAnswerDto } /// -/// 题目Action请求 DTO。 +/// 题目Action请求 DTO。 /// public sealed class QuestionActionDto { /// - /// 题目 ID。 + /// 题目 ID。 /// [Required] public Guid QuestionId { get; set; } /// - /// 来源。 + /// 来源。 /// [Required] public QuestionSource Source { get; set; } = QuestionSource.Tenant; /// - /// 是否收藏。 + /// 是否收藏。 /// public bool? Favorite { get; set; } @@ -263,36 +268,36 @@ public sealed class QuestionActionDto } /// -/// 单词Progress请求 DTO。 +/// 单词Progress请求 DTO。 /// public sealed class WordProgressDto { /// - /// 单词 ID。 + /// 单词 ID。 /// [Required] public Guid WordId { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 答对数量变化。 + /// 答对数量变化。 /// [Range(0, 100)] public int? CorrectDelta { get; set; } /// - /// 答错数量变化。 + /// 答错数量变化。 /// [Range(0, 100)] public int? WrongDelta { get; set; } /// - /// 下次复习时间。 + /// 下次复习时间。 /// public DateTimeOffset? NextReviewAt { get; set; } @@ -308,23 +313,23 @@ public sealed class WordProgressDto } /// -/// 收藏单词请求 DTO。 +/// 收藏单词请求 DTO。 /// public sealed class FavoriteWordDto { /// - /// 单词 ID。 + /// 单词 ID。 /// [Required] public Guid WordId { get; set; } /// - /// 是否收藏。 + /// 是否收藏。 /// public bool? Favorite { get; set; } /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Note { get; set; } @@ -336,24 +341,24 @@ public sealed class FavoriteWordDto } /// -/// 单词Review请求 DTO。 +/// 单词Review请求 DTO。 /// public sealed class WordReviewDto { /// - /// 单词 ID。 + /// 单词 ID。 /// [Required] public Guid WordId { get; set; } /// - /// 结果。 + /// 结果。 /// [StringLength(50)] public string? Result { get; set; } /// - /// 下次复习时间。 + /// 下次复习时间。 /// public DateTimeOffset? NextReviewAt { get; set; } @@ -361,4 +366,4 @@ public sealed class WordReviewDto { return new WordReviewCommand(WordId, Result, NextReviewAt); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/PlatformAdminDtos.cs b/Tiku.Api/Contracts/PlatformAdminDtos.cs index 836169e..edf5f25 100644 --- a/Tiku.Api/Contracts/PlatformAdminDtos.cs +++ b/Tiku.Api/Contracts/PlatformAdminDtos.cs @@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Tiku.Application.PlatformAdmin; using Tiku.Domain.Common; -using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; @@ -10,89 +9,98 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; /// -/// 平台管理端查询参数。 +/// 平台管理端查询参数。 /// public sealed class PlatformAdminQueryDto { /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } /// - /// 搜索关键字。 + /// 搜索关键字。 /// [StringLength(200)] public string? Search { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } - public PlatformAdminQuery ToQuery() => new(Status, Search, Limit); + public PlatformAdminQuery ToQuery() + { + return new PlatformAdminQuery(Status, Search, Limit); + } } /// -/// 创建平台租户请求 DTO。 +/// 创建平台租户请求 DTO。 /// public sealed class CreatePlatformTenantDto { /// - /// 短编码。 + /// 短编码。 /// [Required] [StringLength(100)] public string Slug { get; set; } = string.Empty; /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(200)] public string Name { get; set; } = string.Empty; /// - /// 法定名称。 + /// 法定名称。 /// [StringLength(300)] public string? LegalName { get; set; } /// - /// 状态。 + /// 状态。 /// public TenantStatus Status { get; set; } = TenantStatus.Active; + /// - /// 账务状态。 + /// 账务状态。 /// public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial; + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); /// 租户首次开通使用的自定义主域名。 - [Required, StringLength(253, MinimumLength = 4)] + [Required] + [StringLength(253, MinimumLength = 4)] public string PrimaryDomainHost { get; set; } = string.Empty; /// - /// 租户负责人邮箱。 + /// 租户负责人邮箱。 /// - [EmailAddress, StringLength(320)] + [EmailAddress] + [StringLength(320)] public string? OwnerEmail { get; set; } /// - /// 租户负责人手机号。 + /// 租户负责人手机号。 /// - [Phone, StringLength(32)] + [Phone] + [StringLength(32)] public string? OwnerPhone { get; set; } /// - /// 租户负责人姓名。 + /// 租户负责人姓名。 /// - [Required, StringLength(100)] + [Required] + [StringLength(100)] public string OwnerName { get; set; } = string.Empty; /// 初始试用套餐版本;为空时使用平台配置的默认基础套餐。 @@ -106,7 +114,8 @@ public sealed class CreatePlatformTenantDto public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online; /// 默认支付 Provider。 - [Required, StringLength(50)] + [Required] + [StringLength(50)] public string DefaultPaymentProvider { get; set; } = "manual"; /// 是否自动生成续费应收。 @@ -116,408 +125,456 @@ public sealed class CreatePlatformTenantDto [Range(1, 90)] public int RenewalLeadDays { get; set; } = 14; - public CreatePlatformTenantCommand ToCommand(string idempotencyKey) => new( - Slug, Name, LegalName, Status, BillingStatus, Metadata, PrimaryDomainHost, - OwnerEmail, OwnerPhone, OwnerName, - InitialOfferingVersionId, TrialDays, CollectionMode, DefaultPaymentProvider, - AutoGenerateRenewal, RenewalLeadDays, idempotencyKey); + public CreatePlatformTenantCommand ToCommand(string idempotencyKey) + { + return new CreatePlatformTenantCommand( + Slug, Name, LegalName, Status, BillingStatus, Metadata, PrimaryDomainHost, + OwnerEmail, OwnerPhone, OwnerName, + InitialOfferingVersionId, TrialDays, CollectionMode, DefaultPaymentProvider, + AutoGenerateRenewal, RenewalLeadDays, idempotencyKey); + } } public sealed class ReplacePlatformPrimaryDomainDto { - [Required, StringLength(253, MinimumLength = 4)] + [Required] + [StringLength(253, MinimumLength = 4)] public string Host { get; set; } = string.Empty; - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; - public ReplacePlatformPrimaryDomainCommand ToCommand(Guid tenantId) => new(tenantId, Host, Reason); + public ReplacePlatformPrimaryDomainCommand ToCommand(Guid tenantId) + { + return new ReplacePlatformPrimaryDomainCommand(tenantId, Host, Reason); + } } public sealed class IssuePlatformOwnerActivationLinkDto { - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; public bool ReplaceExisting { get; set; } - public IssuePlatformOwnerActivationLinkCommand ToCommand(Guid tenantId, string idempotencyKey) => - new(tenantId, idempotencyKey, Reason, ReplaceExisting); + public IssuePlatformOwnerActivationLinkCommand ToCommand(Guid tenantId, string idempotencyKey) + { + return new IssuePlatformOwnerActivationLinkCommand(tenantId, idempotencyKey, Reason, ReplaceExisting); + } } /// 更新租户收款策略。 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; - [Range(1, 90)] - public int RenewalLeadDays { get; set; } = 14; - [Required, StringLength(1000, MinimumLength = 3)] + + [Range(1, 90)] public int RenewalLeadDays { get; set; } = 14; + + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; - public UpsertTenantBillingPolicyCommand ToCommand(Guid tenantId) => new( - tenantId, - CollectionMode, - DefaultPaymentProvider, - AutoGenerateRenewal, - RenewalLeadDays, - Reason); + public UpsertTenantBillingPolicyCommand ToCommand(Guid tenantId) + { + return new UpsertTenantBillingPolicyCommand( + tenantId, + CollectionMode, + DefaultPaymentProvider, + AutoGenerateRenewal, + RenewalLeadDays, + Reason); + } } /// -/// 更新平台租户状态请求 DTO。 +/// 更新平台租户状态请求 DTO。 /// public sealed class UpdatePlatformTenantStatusDto { /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } /// - /// 状态。 + /// 状态。 /// public TenantStatus Status { get; set; } = TenantStatus.Active; + /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } - public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, Reason); + public UpdatePlatformTenantStatusCommand ToCommand() + { + return new UpdatePlatformTenantStatusCommand(TenantId, Status, Reason); + } } /// -/// 新增或更新平台租户账务资料请求 DTO。 +/// 新增或更新平台租户账务资料请求 DTO。 /// public sealed class UpsertPlatformTenantBillingProfileDto { /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } /// - /// 账务名称。 + /// 账务名称。 /// [StringLength(300)] public string? BillingName { get; set; } /// - /// 税号。 + /// 税号。 /// [StringLength(100)] public string? TaxId { get; set; } /// - /// 联系人姓名。 + /// 联系人姓名。 /// [StringLength(100)] public string? ContactName { get; set; } /// - /// 联系人手机号。 + /// 联系人手机号。 /// [StringLength(32)] public string? ContactPhone { get; set; } /// - /// 联系人邮箱。 + /// 联系人邮箱。 /// [StringLength(320)] public string? ContactEmail { get; set; } /// - /// 账务地址。 + /// 账务地址。 /// [StringLength(1000)] public string? BillingAddress { get; set; } /// - /// 发票抬头。 + /// 发票抬头。 /// [StringLength(300)] public string? InvoiceTitle { get; set; } /// - /// 发票类型。 + /// 发票类型。 /// public TenantBillingInvoiceTitleType? InvoiceType { get; set; } /// - /// 开户银行。 + /// 开户银行。 /// [StringLength(300)] public string? BankName { get; set; } /// - /// 脱敏银行账号。 + /// 脱敏银行账号。 /// [StringLength(100)] public string? BankAccountMasked { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformTenantBillingProfileCommand ToCommand() => new( - TenantId, - BillingName, - TaxId, - ContactName, - ContactPhone, - ContactEmail, - BillingAddress, - InvoiceTitle, - InvoiceType, - BankName, - BankAccountMasked, - Metadata); + public UpsertPlatformTenantBillingProfileCommand ToCommand() + { + return new UpsertPlatformTenantBillingProfileCommand( + TenantId, + BillingName, + TaxId, + ContactName, + ContactPhone, + ContactEmail, + BillingAddress, + InvoiceTitle, + InvoiceType, + BankName, + BankAccountMasked, + Metadata); + } } /// -/// 新增或更新平台Staff请求 DTO。 +/// 新增或更新平台Staff请求 DTO。 /// public sealed class UpsertPlatformStaffDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } /// - /// 邮箱。 + /// 邮箱。 /// [StringLength(320)] public string? Email { get; set; } /// - /// 手机号。 + /// 手机号。 /// [StringLength(32)] public string? Phone { get; set; } /// - /// 名称。 + /// 名称。 /// [StringLength(200)] public string? Name { get; set; } /// - /// 状态。 + /// 状态。 /// public UserStatus Status { get; set; } = UserStatus.Active; + /// - /// 角色 ID 列表。 + /// 角色 ID 列表。 /// public IReadOnlyCollection RoleIds { get; set; } = []; - public UpsertPlatformStaffCommand ToCommand() => new(UserId, Email, Phone, Name, Status, RoleIds); + public UpsertPlatformStaffCommand ToCommand() + { + return new UpsertPlatformStaffCommand(UserId, Email, Phone, Name, Status, RoleIds); + } } /// -/// 更新平台Staff状态请求 DTO。 +/// 更新平台Staff状态请求 DTO。 /// public sealed class UpdatePlatformStaffStatusDto { /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } /// - /// 状态。 + /// 状态。 /// public UserStatus Status { get; set; } = UserStatus.Active; /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } - public UpdatePlatformStaffStatusCommand ToCommand() => new(UserId, Status, Reason); + public UpdatePlatformStaffStatusCommand ToCommand() + { + return new UpdatePlatformStaffStatusCommand(UserId, Status, Reason); + } } /// -/// 更新平台审计Alert状态请求 DTO。 +/// 更新平台审计Alert状态请求 DTO。 /// public sealed class UpdatePlatformAuditAlertStatusDto { /// - /// 告警 ID。 + /// 告警 ID。 /// [Required] public Guid AlertId { get; set; } /// - /// 状态。 + /// 状态。 /// public PlatformAuditAlertStatus Status { get; set; } = PlatformAuditAlertStatus.Acknowledged; /// - /// 处理备注。 + /// 处理备注。 /// [StringLength(1000)] public string? ResolutionNote { get; set; } - public UpdatePlatformAuditAlertStatusCommand ToCommand() => new(AlertId, Status, ResolutionNote); + public UpdatePlatformAuditAlertStatusCommand ToCommand() + { + return new UpdatePlatformAuditAlertStatusCommand(AlertId, Status, ResolutionNote); + } } /// -/// 新增或更新平台DunningChannel请求 DTO。 +/// 新增或更新平台DunningChannel请求 DTO。 /// public sealed class UpsertPlatformBillingDunningChannelDto { /// - /// 通知渠道 ID。 + /// 通知渠道 ID。 /// public Guid? ChannelId { get; set; } /// - /// 渠道编码。 + /// 渠道编码。 /// [Required] [StringLength(100)] public string ChannelCode { get; set; } = string.Empty; /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(200)] public string Name { get; set; } = string.Empty; /// - /// 说明。 + /// 说明。 /// [StringLength(1000)] public string? Description { get; set; } /// - /// 是否启用。 + /// 是否启用。 /// public bool Enabled { get; set; } = true; + /// - /// 服务提供方。 + /// 服务提供方。 /// public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic; /// - /// Webhook 地址。 + /// Webhook 地址。 /// [Required] [StringLength(2048)] public string WebhookUrl { get; set; } = string.Empty; /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } /// - /// 提醒类型列表。 + /// 提醒类型列表。 /// public IReadOnlyCollection ReminderTypes { get; set; } = ["overdue", "final_notice"]; + /// - /// 提醒渠道列表。 + /// 提醒渠道列表。 /// public IReadOnlyCollection ReminderChannels { get; set; } = ["internal"]; /// - /// 最低提醒级别。 + /// 最低提醒级别。 /// [Range(1, 20)] public int MinReminderLevel { get; set; } = 1; /// - /// 租户 ID 列表。 + /// 租户 ID 列表。 /// public IReadOnlyCollection TenantIds { get; set; } = []; /// - /// 超时时长,单位为秒。 + /// 超时时长,单位为秒。 /// [Range(1, 60)] public int TimeoutSeconds { get; set; } = 10; /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformBillingDunningChannelCommand ToCommand() => new( - ChannelId, - ChannelCode, - Name, - Description, - Enabled, - Provider, - WebhookUrl, - SecretRef, - ReminderTypes, - ReminderChannels, - MinReminderLevel, - TenantIds, - TimeoutSeconds, - Metadata); + public UpsertPlatformBillingDunningChannelCommand ToCommand() + { + return new UpsertPlatformBillingDunningChannelCommand( + ChannelId, + ChannelCode, + Name, + Description, + Enabled, + Provider, + WebhookUrl, + SecretRef, + ReminderTypes, + ReminderChannels, + MinReminderLevel, + TenantIds, + TimeoutSeconds, + Metadata); + } } /// -/// 禁用平台DunningChannel请求 DTO。 +/// 禁用平台DunningChannel请求 DTO。 /// public sealed class DisablePlatformBillingDunningChannelDto { /// - /// 通知渠道 ID。 + /// 通知渠道 ID。 /// [Required] public Guid ChannelId { get; set; } /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } - public DisablePlatformBillingDunningChannelCommand ToCommand() => new(ChannelId, Reason); + public DisablePlatformBillingDunningChannelCommand ToCommand() + { + return new DisablePlatformBillingDunningChannelCommand(ChannelId, Reason); + } } /// -/// 重试平台Dunning事件请求 DTO。 +/// 重试平台Dunning事件请求 DTO。 /// public sealed class RetryPlatformBillingDunningEventDto { /// - /// 事件 ID。 + /// 事件 ID。 /// [Required] public Guid EventId { get; set; } /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } - public RetryPlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason); + public RetryPlatformBillingDunningEventCommand ToCommand() + { + return new RetryPlatformBillingDunningEventCommand(EventId, Reason); + } } /// 人工确认或忽略催缴投递事件。 public sealed class ResolvePlatformBillingDunningEventDto { - [Required] - public Guid EventId { get; set; } + [Required] public Guid EventId { get; set; } - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; - public ResolvePlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason); -} + public ResolvePlatformBillingDunningEventCommand ToCommand() + { + return new ResolvePlatformBillingDunningEventCommand(EventId, Reason); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/PlatformApprovalDtos.cs b/Tiku.Api/Contracts/PlatformApprovalDtos.cs index b5ff866..ecdf42e 100644 --- a/Tiku.Api/Contracts/PlatformApprovalDtos.cs +++ b/Tiku.Api/Contracts/PlatformApprovalDtos.cs @@ -5,7 +5,7 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; -public sealed record PlatformApprovalDecisionDto([Required, MaxLength(1000)] string Reason); +public sealed record PlatformApprovalDecisionDto([Required] [MaxLength(1000)] string Reason); public sealed record UpdatePlatformApprovalPolicyDto( bool Enabled, @@ -14,11 +14,14 @@ public sealed record UpdatePlatformApprovalPolicyDto( [Range(1, 720)] int ExpiresAfterHours, JsonElement? Conditions) { - public UpdatePlatformApprovalPolicyCommand ToCommand(string code) => new( - code, - Enabled, - AlwaysRequireApproval, - AmountThresholdCents, - ExpiresAfterHours, - Conditions?.Clone() ?? JsonDefaults.Object()); -} + public UpdatePlatformApprovalPolicyCommand ToCommand(string code) + { + return new UpdatePlatformApprovalPolicyCommand( + code, + Enabled, + AlwaysRequireApproval, + AmountThresholdCents, + ExpiresAfterHours, + Conditions?.Clone() ?? JsonDefaults.Object()); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/PlatformGovernanceDtos.cs b/Tiku.Api/Contracts/PlatformGovernanceDtos.cs index 00b941c..e47183c 100644 --- a/Tiku.Api/Contracts/PlatformGovernanceDtos.cs +++ b/Tiku.Api/Contracts/PlatformGovernanceDtos.cs @@ -7,25 +7,47 @@ 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() => new(DefinitionCode, Environment, Value, SecretRef, Reason); + public SavePlatformConfigurationDraftCommand ToCommand() + { + return new SavePlatformConfigurationDraftCommand(DefinitionCode, Environment, Value, SecretRef, 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, PlatformNotificationChannel Channel, - [Required, MaxLength(500)] string SubjectTemplate, [Required, MaxLength(8000)] string BodyTemplate, - bool Enabled, JsonElement? Variables) + +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, + PlatformNotificationChannel Channel, + [Required] [MaxLength(500)] string SubjectTemplate, + [Required] [MaxLength(8000)] string BodyTemplate, + bool Enabled, + JsonElement? Variables) { - public UpsertPlatformNotificationTemplateCommand ToCommand() => new(Id, Code, Name, Channel, SubjectTemplate, BodyTemplate, Enabled, Variables?.Clone() ?? JsonDefaults.Array()); + public UpsertPlatformNotificationTemplateCommand ToCommand() + { + return new UpsertPlatformNotificationTemplateCommand(Id, Code, Name, Channel, SubjectTemplate, BodyTemplate, + Enabled, + Variables?.Clone() ?? JsonDefaults.Array()); + } } -public sealed record SendPlatformNotificationDto(Guid TemplateId, [MinLength(1)] IReadOnlyCollection RoleCodes, + +public sealed record SendPlatformNotificationDto( + Guid TemplateId, + [MinLength(1)] IReadOnlyCollection RoleCodes, IReadOnlyDictionary? Variables, - [Required, MaxLength(200)] string IdempotencyKey) + [Required] [MaxLength(200)] string IdempotencyKey) { - public SendPlatformNotificationCommand ToCommand() => new(TemplateId, RoleCodes, Variables ?? new Dictionary(), IdempotencyKey); -} + public SendPlatformNotificationCommand ToCommand() + { + return new SendPlatformNotificationCommand(TemplateId, RoleCodes, Variables ?? new Dictionary(), + IdempotencyKey); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs b/Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs index 4e77481..44df51a 100644 --- a/Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs +++ b/Tiku.Api/Contracts/PlatformTenantCapabilitiesDtos.cs @@ -9,398 +9,497 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; /// -/// 平台租户能力通用查询参数。 +/// 平台租户能力通用查询参数。 /// public class PlatformCapabilityQueryDto { /// - /// 租户 ID。 + /// 租户 ID。 /// public Guid? TenantId { get; set; } + /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } + /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int Limit { get; set; } = 100; - public PlatformCapabilityQuery ToQuery() => new(TenantId, Status, Limit); + public PlatformCapabilityQuery ToQuery() + { + return new PlatformCapabilityQuery(TenantId, Status, Limit); + } } /// -/// 新增或更新租户 CRM 配置请求。 +/// 新增或更新租户 CRM 配置请求。 /// public sealed class UpsertPlatformCrmConfigDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool Enabled { get; set; } + /// - /// 回调地址。 + /// 回调地址。 /// [StringLength(2048)] public string? Url { get; set; } + /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } + /// - /// 表单名称。 + /// 表单名称。 /// [StringLength(200)] public string? FormName { get; set; } + /// - /// 考试类型。 + /// 考试类型。 /// [StringLength(100)] public string? ExamType { get; set; } + /// - /// 超时时长,单位为秒。 + /// 超时时长,单位为秒。 /// [Range(1, 120)] public int? TimeoutSeconds { get; set; } + /// - /// 延迟秒数。 + /// 延迟秒数。 /// [Range(0, 86400)] public int? DelaySeconds { get; set; } + /// - /// 分配模式。 + /// 分配模式。 /// [StringLength(50)] public string? AssignmentMode { get; set; } + /// - /// 分配池。 + /// 分配池。 /// public JsonElement? AssignmentPool { get; set; } + /// - /// 分配配置。 + /// 分配配置。 /// public JsonElement? AssignmentConfig { get; set; } - public UpsertPlatformCrmConfigCommand ToCommand() => - new(Id, TenantId, Enabled, Url, SecretRef, FormName, ExamType, TimeoutSeconds, DelaySeconds, AssignmentMode, AssignmentPool, AssignmentConfig); + public UpsertPlatformCrmConfigCommand ToCommand() + { + return new UpsertPlatformCrmConfigCommand(Id, TenantId, Enabled, Url, SecretRef, FormName, ExamType, + TimeoutSeconds, DelaySeconds, + AssignmentMode, AssignmentPool, AssignmentConfig); + } } /// -/// 重试租户 CRM 线索推送请求。 +/// 重试租户 CRM 线索推送请求。 /// public sealed class RetryPlatformCrmLeadDto { /// - /// 队列任务 ID。 + /// 队列任务 ID。 /// [Required] public Guid QueueId { get; set; } + /// - /// 备注。 + /// 备注。 /// [StringLength(500)] public string? Note { get; set; } - public PlatformCrmLeadRetryCommand ToCommand() => new(QueueId, Note); + public PlatformCrmLeadRetryCommand ToCommand() + { + return new PlatformCrmLeadRetryCommand(QueueId, Note); + } } /// -/// 平台租户 CRM 日志查询参数。 +/// 平台租户 CRM 日志查询参数。 /// public sealed class PlatformCrmLogQueryDto : PlatformCapabilityQueryDto { /// - /// 队列任务 ID。 + /// 队列任务 ID。 /// public Guid? QueueId { get; set; } } /// -/// 新增或更新租户短信渠道请求。 +/// 新增或更新租户短信渠道请求。 /// public sealed class UpsertPlatformSmsChannelDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } + /// - /// 服务提供方。 + /// 服务提供方。 /// - [Required, StringLength(80)] + [Required] + [StringLength(80)] public string Provider { get; set; } = "generic"; + /// - /// 名称。 + /// 名称。 /// - [Required, StringLength(200)] + [Required] + [StringLength(200)] public string Name { get; set; } = string.Empty; + /// - /// 短信签名。 + /// 短信签名。 /// - [Required, StringLength(100)] + [Required] + [StringLength(100)] public string Signature { get; set; } = string.Empty; + /// - /// 使用场景。 + /// 使用场景。 /// - [Required, StringLength(100)] + [Required] + [StringLength(100)] public string Scene { get; set; } = "login"; + /// - /// 状态。 + /// 状态。 /// public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled; + /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public int? Priority { get; set; } + /// - /// 月度配额。 + /// 月度配额。 /// [Range(0, int.MaxValue)] public int? MonthlyQuota { get; set; } + /// - /// 公开配置内容。 + /// 公开配置内容。 /// public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformSmsChannelCommand ToCommand() => - new(Id, TenantId, Provider, Name, Signature, Scene, Status, SecretRef, Priority, MonthlyQuota, ConfigPublic, Metadata); + public UpsertPlatformSmsChannelCommand ToCommand() + { + return new UpsertPlatformSmsChannelCommand(Id, TenantId, Provider, Name, Signature, Scene, Status, SecretRef, + Priority, MonthlyQuota, + ConfigPublic, Metadata); + } } /// -/// 新增或更新租户短信模板请求。 +/// 新增或更新租户短信模板请求。 /// public sealed class UpsertPlatformSmsTemplateDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } + /// - /// 短信渠道 ID。 + /// 短信渠道 ID。 /// [Required] public Guid ChannelId { get; set; } + /// - /// 编码。 + /// 编码。 /// - [Required, StringLength(120)] + [Required] + [StringLength(120)] public string Code { get; set; } = string.Empty; + /// - /// 名称。 + /// 名称。 /// - [Required, StringLength(200)] + [Required] + [StringLength(200)] public string Name { get; set; } = string.Empty; + /// - /// 类型。 + /// 类型。 /// public SmsTemplateType Type { get; set; } = SmsTemplateType.Notification; + /// - /// 审核状态。 + /// 审核状态。 /// public SmsTemplateAuditStatus AuditStatus { get; set; } = SmsTemplateAuditStatus.Draft; + /// - /// 状态。 + /// 状态。 /// public SmsTemplateStatus Status { get; set; } = SmsTemplateStatus.Active; + /// - /// 渠道模板编码。 + /// 渠道模板编码。 /// [StringLength(120)] public string? ProviderTemplateCode { get; set; } + /// - /// 模板内容。 + /// 模板内容。 /// - [Required, StringLength(1000)] + [Required] + [StringLength(1000)] public string Content { get; set; } = string.Empty; + /// - /// Remark。 + /// Remark。 /// [StringLength(500)] public string? Remark { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformSmsTemplateCommand ToCommand() => - new(Id, TenantId, ChannelId, Code, Name, Type, AuditStatus, Status, ProviderTemplateCode, Content, Remark, Metadata); + public UpsertPlatformSmsTemplateCommand ToCommand() + { + return new UpsertPlatformSmsTemplateCommand(Id, TenantId, ChannelId, Code, Name, Type, AuditStatus, Status, + ProviderTemplateCode, Content, + Remark, Metadata); + } } /// -/// 新增或更新平台支付应用请求。 +/// 新增或更新平台支付应用请求。 /// public sealed class UpsertPlatformPaymentAppDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 支付应用编码。 + /// 支付应用编码。 /// - [Required, StringLength(100)] + [Required] + [StringLength(100)] public string AppCode { get; set; } = string.Empty; + /// - /// 支付应用名称。 + /// 支付应用名称。 /// - [Required, StringLength(200)] + [Required] + [StringLength(200)] public string AppName { get; set; } = string.Empty; + /// - /// 状态。 + /// 状态。 /// public PlatformPaymentAppStatus Status { get; set; } = PlatformPaymentAppStatus.Disabled; + /// - /// 结算模式。 + /// 结算模式。 /// [StringLength(100)] public string SettlementMode { get; set; } = "PlatformCollect"; + /// - /// 说明。 + /// 说明。 /// [StringLength(500)] public string? Description { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformPaymentAppCommand ToCommand() => - new(Id, AppCode, AppName, Status, SettlementMode, Description, Metadata); + public UpsertPlatformPaymentAppCommand ToCommand() + { + return new UpsertPlatformPaymentAppCommand(Id, AppCode, AppName, Status, SettlementMode, Description, Metadata); + } } /// -/// 新增或更新平台支付渠道请求。 +/// 新增或更新平台支付渠道请求。 /// public sealed class UpsertPlatformPaymentChannelDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 支付应用 ID。 + /// 支付应用 ID。 /// [Required] public Guid AppId { get; set; } + /// - /// 服务提供方。 + /// 服务提供方。 /// - [Required, StringLength(80)] + [Required] + [StringLength(80)] public string Provider { get; set; } = "manual"; + /// - /// 模式。 + /// 模式。 /// [StringLength(100)] public string Mode { get; set; } = "PlatformCollect"; + /// - /// 状态。 + /// 状态。 /// public PlatformPaymentChannelStatus Status { get; set; } = PlatformPaymentChannelStatus.Disabled; + /// - /// 显示名称。 + /// 显示名称。 /// - [Required, StringLength(200)] + [Required] + [StringLength(200)] public string DisplayName { get; set; } = string.Empty; + /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } + /// - /// 回调路径。 + /// 回调路径。 /// [StringLength(500)] public string? CallbackPath { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public int? Priority { get; set; } + /// - /// 公开配置内容。 + /// 公开配置内容。 /// public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertPlatformPaymentChannelCommand ToCommand() => - new(Id, AppId, Provider, Mode, Status, DisplayName, SecretRef, CallbackPath, Priority, ConfigPublic, Metadata); + public UpsertPlatformPaymentChannelCommand ToCommand() + { + return new UpsertPlatformPaymentChannelCommand(Id, AppId, Provider, Mode, Status, DisplayName, SecretRef, + CallbackPath, Priority, ConfigPublic, + Metadata); + } } /// -/// 新增或更新租户支付应用请求。 +/// 新增或更新租户支付应用请求。 /// public sealed class UpsertPlatformTenantPaymentAppDto { /// - /// 租户 ID。 + /// 租户 ID。 /// [Required] public Guid TenantId { get; set; } + /// - /// 服务提供方。 + /// 服务提供方。 /// - [Required, StringLength(80)] + [Required] + [StringLength(80)] public string Provider { get; set; } = "manual"; + /// - /// 状态。 + /// 状态。 /// public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled; + /// - /// 显示名称。 + /// 显示名称。 /// [StringLength(200)] public string? DisplayName { get; set; } + /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public int? Priority { get; set; } + /// - /// 公开配置内容。 + /// 公开配置内容。 /// public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public UpsertTenantExternalProviderCommand ToCommand() => - new(TenantExternalProviderCapability.Payment, Provider, Status, DisplayName, SecretRef, Priority, ConfigPublic, Metadata); -} + public UpsertTenantExternalProviderCommand ToCommand() + { + return new UpsertTenantExternalProviderCommand(TenantExternalProviderCapability.Payment, Provider, Status, + DisplayName, SecretRef, Priority, + ConfigPublic, Metadata); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/PointDtos.cs b/Tiku.Api/Contracts/PointDtos.cs index 5a1ddd1..7aef5fd 100644 --- a/Tiku.Api/Contracts/PointDtos.cs +++ b/Tiku.Api/Contracts/PointDtos.cs @@ -4,24 +4,24 @@ using Tiku.Application.Points; namespace Tiku.Api.Contracts; /// -/// 积分查询参数。 +/// 积分查询参数。 /// public sealed class PointQueryDto { /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } @@ -32,25 +32,25 @@ public sealed class PointQueryDto } /// -/// 领取积分任务请求 DTO。 +/// 领取积分任务请求 DTO。 /// public sealed class ClaimPointTaskDto { /// - /// 任务键。 + /// 任务键。 /// [Required] [StringLength(100)] public string TaskKey { get; set; } = string.Empty; /// - /// 来源类型。 + /// 来源类型。 /// [StringLength(100)] public string? SourceType { get; set; } /// - /// 来源 ID。 + /// 来源 ID。 /// public Guid? SourceId { get; set; } @@ -61,12 +61,12 @@ public sealed class ClaimPointTaskDto } /// -/// 创建积分兑换订单请求 DTO。 +/// 创建积分兑换订单请求 DTO。 /// public sealed class CreatePointExchangeOrderDto { /// - /// 兑换项 ID。 + /// 兑换项 ID。 /// [Required] public Guid ItemId { get; set; } @@ -75,4 +75,4 @@ public sealed class CreatePointExchangeOrderDto { return new CreatePointExchangeOrderCommand(ItemId); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/ProfileDtos.cs b/Tiku.Api/Contracts/ProfileDtos.cs index 45c3c9c..f0a620f 100644 --- a/Tiku.Api/Contracts/ProfileDtos.cs +++ b/Tiku.Api/Contracts/ProfileDtos.cs @@ -5,105 +5,111 @@ using Tiku.Application.Profile; namespace Tiku.Api.Contracts; /// -/// 资料查询参数。 +/// 资料查询参数。 /// public sealed class ProfileQueryDto { /// - /// 最近记录数量上限。 + /// 最近记录数量上限。 /// [Range(1, 50)] public int? RecentLimit { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 20)] public int? Limit { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } /// - /// 类型。 + /// 类型。 /// [StringLength(50)] public string? Type { get; set; } /// - /// 分类。 + /// 分类。 /// [StringLength(50)] public string? Category { get; set; } /// - /// 是否包含锁定资源。 + /// 是否包含锁定资源。 /// public bool IncludeLocked { get; set; } /// - /// 来源类型。 + /// 来源类型。 /// [StringLength(100)] public string? SourceType { get; set; } /// - /// 开始时间。 + /// 开始时间。 /// public DateTimeOffset? From { get; set; } /// - /// 结束时间。 + /// 结束时间。 /// public DateTimeOffset? To { get; set; } } /// -/// 更新资料请求 DTO。 +/// 更新资料请求 DTO。 /// public sealed class UpdateProfileDto { /// - /// 名称。 + /// 名称。 /// [StringLength(100)] public string? Name { get; set; } /// - /// 头像预设。 + /// 头像预设。 /// [StringLength(32)] public string? AvatarPreset { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 已选择院校 ID。 + /// 已选择院校 ID。 /// public Guid? SelectedSchoolId { get; set; } + /// - /// 已选择专业 ID。 + /// 已选择专业 ID。 /// public Guid? SelectedMajorId { get; set; } + /// - /// 统计数据。 + /// 统计数据。 /// public JsonElement? Stats { get; set; } + /// - /// 进度数据。 + /// 进度数据。 /// public JsonElement? Progress { get; set; } + /// - /// 模块选择数据。 + /// 模块选择数据。 /// public JsonElement? ModuleSelections { get; set; } + /// - /// 最近活动数据。 + /// 最近活动数据。 /// public JsonElement? RecentActivities { get; set; } @@ -123,12 +129,12 @@ public sealed class UpdateProfileDto } /// -/// 通知状态请求 DTO。 +/// 通知状态请求 DTO。 /// public sealed class NotificationStatusDto { /// - /// 通知 ID 列表。 + /// 通知 ID 列表。 /// [Required] [MinLength(1)] @@ -136,7 +142,7 @@ public sealed class NotificationStatusDto public IReadOnlyCollection NotificationIds { get; set; } = []; /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } @@ -148,58 +154,59 @@ public sealed class NotificationStatusDto } /// -/// 提交反馈请求 DTO。 +/// 提交反馈请求 DTO。 /// public sealed class SubmitFeedbackDto { /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } /// - /// 类型。 + /// 类型。 /// [StringLength(50)] public string? Type { get; set; } /// - /// 分类。 + /// 分类。 /// [StringLength(100)] public string? Category { get; set; } /// - /// 标题。 + /// 标题。 /// [StringLength(200)] public string? Title { get; set; } /// - /// 说明。 + /// 说明。 /// [Required] [StringLength(5000)] public string Description { get; set; } = string.Empty; /// - /// 优先级。 + /// 优先级。 /// [StringLength(32)] public string? Priority { get; set; } /// - /// 联系方式。 + /// 联系方式。 /// [StringLength(200)] public string? Contact { get; set; } /// - /// 附件列表。 + /// 附件列表。 /// public JsonElement Attachments { get; set; } = JsonSerializer.SerializeToElement(Array.Empty()); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { }); @@ -216,4 +223,4 @@ public sealed class SubmitFeedbackDto Attachments, Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/QuestionBankDtos.cs b/Tiku.Api/Contracts/QuestionBankDtos.cs index 2378888..15f53a9 100644 --- a/Tiku.Api/Contracts/QuestionBankDtos.cs +++ b/Tiku.Api/Contracts/QuestionBankDtos.cs @@ -5,80 +5,80 @@ using Tiku.Domain.Content; namespace Tiku.Api.Contracts; /// -/// 题目题库查询参数。 +/// 题目题库查询参数。 /// public sealed class QuestionBankQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 题库 ID。 + /// 题库 ID。 /// public Guid? QuestionBankId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 分类 ID。 + /// 分类 ID。 /// public Guid? CategoryId { get; set; } /// - /// 节点 ID。 + /// 节点 ID。 /// public Guid? NodeId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 题集 ID。 + /// 题集 ID。 /// public Guid? CollectionId { get; set; } /// - /// 来源。 + /// 来源。 /// public QuestionSource? Source { get; set; } /// - /// 类型。 + /// 类型。 /// [StringLength(50)] public string? Type { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 题目 ID 列表。 + /// 题目 ID 列表。 /// public string? QuestionIds { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -105,10 +105,7 @@ public sealed class QuestionBankQueryDto private Guid[] ParseQuestionIds() { - if (string.IsNullOrWhiteSpace(QuestionIds)) - { - return []; - } + if (string.IsNullOrWhiteSpace(QuestionIds)) return []; return QuestionIds .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) @@ -119,4 +116,4 @@ public sealed class QuestionBankQueryDto .Take(300) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/ReferralDtos.cs b/Tiku.Api/Contracts/ReferralDtos.cs index c85e248..5ca6c96 100644 --- a/Tiku.Api/Contracts/ReferralDtos.cs +++ b/Tiku.Api/Contracts/ReferralDtos.cs @@ -6,30 +6,30 @@ using Tiku.Application.Growth; namespace Tiku.Api.Contracts; /// -/// 推荐租户查询参数。 +/// 推荐租户查询参数。 /// public sealed class ReferralTenantQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } } /// -/// 推荐邀请码请求 DTO。 +/// 推荐邀请码请求 DTO。 /// public sealed class ReferralInviteDto { /// - /// 渠道。 + /// 渠道。 /// [StringLength(100)] public string? Channel { get; set; } /// - /// 落地页路径。 + /// 落地页路径。 /// [StringLength(2048)] public string? LandingPath { get; set; } @@ -41,18 +41,18 @@ public sealed class ReferralInviteDto } /// -/// 解析推荐请求 DTO。 +/// 解析推荐请求 DTO。 /// public sealed class ResolveReferralDto { /// - /// 编码。 + /// 编码。 /// [StringLength(100)] public string? Code { get; set; } /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } @@ -64,41 +64,41 @@ public sealed class ResolveReferralDto } /// -/// 记录推荐事件请求 DTO。 +/// 记录推荐事件请求 DTO。 /// public sealed class TrackReferralEventDto { /// - /// 推荐码。 + /// 推荐码。 /// [Required] [StringLength(100)] public string RefCode { get; set; } = string.Empty; /// - /// 事件类型。 + /// 事件类型。 /// [StringLength(100)] public string? EventType { get; set; } /// - /// 来源。 + /// 来源。 /// [StringLength(100)] public string? Source { get; set; } /// - /// 目标用户 ID。 + /// 目标用户 ID。 /// public Guid? TargetUserId { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } @@ -110,25 +110,25 @@ public sealed class TrackReferralEventDto } /// -/// 绑定推荐请求 DTO。 +/// 绑定推荐请求 DTO。 /// public sealed class BindReferralDto { /// - /// 推荐码。 + /// 推荐码。 /// [Required] [StringLength(100)] public string RefCode { get; set; } = string.Empty; /// - /// 来源。 + /// 来源。 /// [StringLength(100)] public string? Source { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } @@ -139,36 +139,36 @@ public sealed class BindReferralDto } /// -/// 推荐二维码请求 DTO。 +/// 推荐二维码请求 DTO。 /// public sealed class ReferralQrcodeDto { /// - /// 页码。 + /// 页码。 /// [StringLength(500)] public string? Page { get; set; } /// - /// 场景参数。 + /// 场景参数。 /// [StringLength(128)] public string? Scene { get; set; } /// - /// 服务提供方。 + /// 服务提供方。 /// [StringLength(100)] public string? Provider { get; set; } /// - /// 二维码地址。 + /// 二维码地址。 /// [StringLength(2048)] public string? QrcodeUrl { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } @@ -179,17 +179,17 @@ public sealed class ReferralQrcodeDto } /// -/// 推荐统计查询参数。 +/// 推荐统计查询参数。 /// public sealed class ReferralStatsQueryDto { /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// public Guid? ReferrerUserId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -201,35 +201,35 @@ public sealed class ReferralStatsQueryDto } /// -/// 推荐Conversion查询参数。 +/// 推荐Conversion查询参数。 /// public sealed class ReferralConversionQueryDto { /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// public Guid? ReferrerUserId { get; set; } /// - /// 开始日期,格式为 yyyy-MM-dd。 + /// 开始日期,格式为 yyyy-MM-dd。 /// [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string? StartDate { get; set; } /// - /// 结束日期,格式为 yyyy-MM-dd。 + /// 结束日期,格式为 yyyy-MM-dd。 /// [RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")] public string? EndDate { get; set; } /// - /// 天数。 + /// 天数。 /// [Range(1, 365)] public int? Days { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 100)] public int? Limit { get; set; } @@ -253,35 +253,35 @@ public sealed class ReferralConversionQueryDto } /// -/// 人工绑定推荐请求 DTO。 +/// 人工绑定推荐请求 DTO。 /// public sealed class ManualBindReferralDto { /// - /// 学生用户 ID。 + /// 学生用户 ID。 /// [Required] public Guid StudentUserId { get; set; } /// - /// 推荐人用户 ID。 + /// 推荐人用户 ID。 /// [Required] public Guid ReferrerUserId { get; set; } /// - /// 来源。 + /// 来源。 /// [StringLength(100)] public string? Source { get; set; } /// - /// 是否强制执行。 + /// 是否强制执行。 /// public bool Force { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } @@ -292,12 +292,12 @@ public sealed class ManualBindReferralDto } /// -/// 推荐团队查询参数。 +/// 推荐团队查询参数。 /// public sealed class ReferralTeamQueryDto { /// - /// 上级成员用户 ID。 + /// 上级成员用户 ID。 /// public Guid? LeaderUserId { get; set; } @@ -308,35 +308,35 @@ public sealed class ReferralTeamQueryDto } /// -/// 新增或更新推荐团队请求 DTO。 +/// 新增或更新推荐团队请求 DTO。 /// public sealed class UpsertReferralTeamDto { /// - /// 成员用户 ID。 + /// 成员用户 ID。 /// [Required] public Guid MemberUserId { get; set; } /// - /// 上级成员用户 ID。 + /// 上级成员用户 ID。 /// public Guid? LeaderUserId { get; set; } /// - /// 关系类型。 + /// 关系类型。 /// [StringLength(50)] public string? RelationType { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(50)] public string? Status { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement? Metadata { get; set; } @@ -344,4 +344,4 @@ public sealed class UpsertReferralTeamDto { return new UpsertReferralTeamCommand(MemberUserId, LeaderUserId, RelationType, Status, Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/SaasBillingDtos.cs b/Tiku.Api/Contracts/SaasBillingDtos.cs index 61c2238..f6f89d7 100644 --- a/Tiku.Api/Contracts/SaasBillingDtos.cs +++ b/Tiku.Api/Contracts/SaasBillingDtos.cs @@ -6,56 +6,66 @@ using Tiku.Domain.Platform; namespace Tiku.Api.Contracts; /// -/// 新增或更新SaaS功能请求 DTO。 +/// 新增或更新SaaS功能请求 DTO。 /// 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) { - public UpsertSaasFeatureCommand ToCommand() => new(Id, Code, Name, Category, Description, ReferencePriceCents, Currency, Status, SortOrder); + public UpsertSaasFeatureCommand ToCommand() + { + return new UpsertSaasFeatureCommand(Id, Code, Name, Category, Description, ReferencePriceCents, Currency, + Status, SortOrder); + } } /// -/// 新增或更新SaaS套餐请求 DTO。 +/// 新增或更新SaaS套餐请求 DTO。 /// 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, int SortOrder) { - public UpsertSaasOfferingCommand ToCommand() => new(Id, Code, Name, Type, Status, Description, SortOrder); + public UpsertSaasOfferingCommand ToCommand() + { + return new UpsertSaasOfferingCommand(Id, Code, Name, Type, Status, Description, SortOrder); + } } /// -/// 新增或更新 SaaS 功能额度定义。 +/// 新增或更新 SaaS 功能额度定义。 /// 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) { - public UpsertSaasFeatureLimitCommand ToCommand() => new( - Id, MetricCode, FeatureCode, Name, Unit, Kind, WarningPercent, IsHardLimit); + public UpsertSaasFeatureLimitCommand ToCommand() + { + return new UpsertSaasFeatureLimitCommand( + Id, MetricCode, FeatureCode, Name, Unit, Kind, WarningPercent, IsHardLimit); + } } /// -/// 新增或更新SaaS套餐版本请求 DTO。 +/// 新增或更新SaaS套餐版本请求 DTO。 /// public sealed record UpsertSaasOfferingVersionDto( Guid? Id, @@ -63,91 +73,113 @@ public sealed record UpsertSaasOfferingVersionDto( PlatformBillingCycle BillingCycle, [Range(0, int.MaxValue)] int OriginalAmountCents, [Range(0, int.MaxValue)] int AmountCents, - [Required, MaxLength(10)] string Currency, + [Required] [MaxLength(10)] string Currency, DateTimeOffset? EffectiveAt, IReadOnlyCollection? FeatureCodes, IReadOnlyDictionary? Limits, JsonElement Metadata) { - public UpsertSaasOfferingVersionCommand ToCommand() => new( - Id, OfferingId, BillingCycle, OriginalAmountCents, AmountCents, Currency, EffectiveAt, - FeatureCodes ?? [], Limits ?? new Dictionary(), Metadata); + public UpsertSaasOfferingVersionCommand ToCommand() + { + return new UpsertSaasOfferingVersionCommand( + Id, OfferingId, BillingCycle, OriginalAmountCents, AmountCents, Currency, EffectiveAt, + FeatureCodes ?? [], Limits ?? new Dictionary(), Metadata); + } } /// -/// 创建平台账务报价请求 DTO。 +/// 创建平台账务报价请求 DTO。 /// public sealed record CreatePlatformBillingQuoteDto( Guid BaseOfferingVersionId, IReadOnlyCollection? AddOnOfferingVersionIds, PlatformBillingOrderPurpose Purpose, - [Required, MaxLength(200)] string IdempotencyKey) + [Required] [MaxLength(200)] string IdempotencyKey) { - public CreatePlatformBillingQuoteCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? [], Purpose, IdempotencyKey); + public CreatePlatformBillingQuoteCommand ToCommand() + { + return new CreatePlatformBillingQuoteCommand(BaseOfferingVersionId, AddOnOfferingVersionIds ?? [], Purpose, + IdempotencyKey); + } } /// -/// 创建平台账务订单请求 DTO。 +/// 创建平台账务订单请求 DTO。 /// -public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required, MaxLength(200)] string IdempotencyKey) +public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required] [MaxLength(200)] string IdempotencyKey) { - public CreatePlatformBillingOrderCommand ToCommand() => new(QuoteId, IdempotencyKey); + public CreatePlatformBillingOrderCommand ToCommand() + { + return new CreatePlatformBillingOrderCommand(QuoteId, IdempotencyKey); + } } /// -/// 创建平台账务支付请求 DTO。 +/// 创建平台账务支付请求 DTO。 /// public sealed record CreatePlatformBillingPaymentDto( - [Required, MaxLength(50)] string Provider, - [Required, MaxLength(50)] string Method, - [Required, MaxLength(200)] string IdempotencyKey, + [Required] [MaxLength(50)] string Provider, + [Required] [MaxLength(50)] string Method, + [Required] [MaxLength(200)] string IdempotencyKey, string? OpenId, string? ReturnUrl, string? QuitUrl) { - public CreatePlatformBillingPaymentCommand ToCommand(string orderNo) => - new(orderNo, Provider, Method, IdempotencyKey, OpenId, ReturnUrl, QuitUrl); + public CreatePlatformBillingPaymentCommand ToCommand(string orderNo) + { + return new CreatePlatformBillingPaymentCommand(orderNo, Provider, Method, IdempotencyKey, OpenId, ReturnUrl, + QuitUrl); + } } /// -/// 变更租户订阅请求 DTO。 +/// 变更租户订阅请求 DTO。 /// public sealed record ChangeTenantSubscriptionDto( Guid BaseOfferingVersionId, IReadOnlyCollection? AddOnOfferingVersionIds, - [Required, MaxLength(200)] string IdempotencyKey) + [Required] [MaxLength(200)] string IdempotencyKey) { - public ChangeTenantSubscriptionCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? []); + public ChangeTenantSubscriptionCommand ToCommand() + { + return new ChangeTenantSubscriptionCommand(BaseOfferingVersionId, AddOnOfferingVersionIds ?? []); + } } /// -/// Idempotent租户账务请求 DTO。 +/// Idempotent租户账务请求 DTO。 /// -public sealed record IdempotentTenantBillingDto([Required, MaxLength(200)] string IdempotencyKey); +public sealed record IdempotentTenantBillingDto([Required] [MaxLength(200)] string IdempotencyKey); /// -/// 确认人工平台支付请求 DTO。 +/// 确认人工平台支付请求 DTO。 /// public sealed record ConfirmManualPlatformPaymentDto( Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, - [Required, MaxLength(1000)] string Reason) + [Required] [MaxLength(1000)] string Reason) { - public ConfirmManualPaymentCommand ToCommand() => new(PaymentId, ProviderTradeNo, PaidAt, Reason); + public ConfirmManualPaymentCommand ToCommand() + { + return new ConfirmManualPaymentCommand(PaymentId, ProviderTradeNo, PaidAt, Reason); + } } /// -/// 新增或更新租户功能覆盖规则请求 DTO。 +/// 新增或更新租户功能覆盖规则请求 DTO。 /// 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() => new(TenantId, FeatureCode, Mode, ExpiresAt, Reason); + public UpsertTenantFeatureOverrideCommand ToCommand() + { + return new UpsertTenantFeatureOverrideCommand(TenantId, FeatureCode, Mode, ExpiresAt, Reason); + } } /// 为已有租户补录试用订阅。 @@ -155,38 +187,50 @@ 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() => new(TenantId, BaseOfferingVersionId, TrialDays, IdempotencyKey, Reason); + public GrantTenantTrialCommand ToCommand() + { + return new GrantTenantTrialCommand(TenantId, BaseOfferingVersionId, TrialDays, IdempotencyKey, Reason); + } } /// 平台修改订阅状态。 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) => new(subscriptionId, Reason, ExtendDays); + public ChangePlatformSubscriptionCommand ToCommand(Guid subscriptionId) + { + return new ChangePlatformSubscriptionCommand(subscriptionId, Reason, ExtendDays); + } } /// 申请 SaaS 退款。 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() => new( - PaymentId, - AmountCents, - Reason, - IdempotencyKey, - SubscriptionEffect); + public RequestPlatformRefundCommand ToCommand() + { + return new RequestPlatformRefundCommand( + PaymentId, + AmountCents, + Reason, + IdempotencyKey, + SubscriptionEffect); + } } /// 审核或重试 SaaS 退款。 -public sealed record ReviewPlatformRefundDto([Required, MaxLength(1000)] string Reason) +public sealed record ReviewPlatformRefundDto([Required] [MaxLength(1000)] string Reason) { - public ReviewPlatformRefundCommand ToCommand(Guid refundId) => new(refundId, Reason); -} + public ReviewPlatformRefundCommand ToCommand(Guid refundId) + { + return new ReviewPlatformRefundCommand(refundId, Reason); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/ScorelineDtos.cs b/Tiku.Api/Contracts/ScorelineDtos.cs index 195b1ab..905385f 100644 --- a/Tiku.Api/Contracts/ScorelineDtos.cs +++ b/Tiku.Api/Contracts/ScorelineDtos.cs @@ -4,61 +4,63 @@ using Tiku.Application.Scoreline; namespace Tiku.Api.Contracts; /// -/// 分数线查询参数。 +/// 分数线查询参数。 /// public sealed class ScorelineQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 院校 ID。 + /// 院校 ID。 /// public Guid? SchoolId { get; set; } + /// - /// 专业 ID。 + /// 专业 ID。 /// public Guid? MajorId { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 年份。 + /// 年份。 /// [Range(1900, 3000)] public int? Year { get; set; } /// - /// 页码。 + /// 页码。 /// [Range(1, 10000)] public int? Page { get; set; } /// - /// 每页数量。 + /// 每页数量。 /// [Range(1, 200)] public int? PageSize { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 2000)] public int? Limit { get; set; } /// - /// 版本化的不透明游标,仅用于游标分页接口。 + /// 版本化的不透明游标,仅用于游标分页接口。 /// [StringLength(2000)] public string? Cursor { get; set; } @@ -79,4 +81,4 @@ public sealed class ScorelineQueryDto Limit, dynamicFilters); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/StudyContentDtos.cs b/Tiku.Api/Contracts/StudyContentDtos.cs index e79ff79..617b4fc 100644 --- a/Tiku.Api/Contracts/StudyContentDtos.cs +++ b/Tiku.Api/Contracts/StudyContentDtos.cs @@ -4,59 +4,59 @@ using Tiku.Application.StudyContent; namespace Tiku.Api.Contracts; /// -/// Study内容查询参数。 +/// Study内容查询参数。 /// public sealed class StudyContentQueryDto { /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] public string? TenantCode { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 单元 ID。 + /// 单元 ID。 /// public Guid? UnitId { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 章节 ID。 + /// 章节 ID。 /// public Guid? ChapterId { get; set; } /// - /// 内容入口 ID。 + /// 内容入口 ID。 /// public Guid? EntryId { get; set; } /// - /// 内容节点 ID。 + /// 内容节点 ID。 /// public Guid? ContentNodeId { get; set; } /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 是否包含正文内容。 + /// 是否包含正文内容。 /// public bool IncludeContent { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 2000)] public int? Limit { get; set; } @@ -75,4 +75,4 @@ public sealed class StudyContentQueryDto IncludeContent, Limit); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TaxonomyDtos.cs b/Tiku.Api/Contracts/TaxonomyDtos.cs index 37dcd2f..76053ad 100644 --- a/Tiku.Api/Contracts/TaxonomyDtos.cs +++ b/Tiku.Api/Contracts/TaxonomyDtos.cs @@ -8,48 +8,59 @@ using Tiku.Domain.Content; namespace Tiku.Api.Contracts; /// -/// 创建分类节点请求 DTO。 +/// 创建分类节点请求 DTO。 /// public sealed class CreateTaxonomyNodeDto { /// - /// 父节点 ID。 + /// 父节点 ID。 /// public Guid? ParentId { get; set; } + /// - /// 父级来源。 + /// 父级来源。 /// public QuestionSource? ParentSource { get; set; } + /// - /// 节点Type。 + /// 节点Type。 /// [Required] public TaxonomyNodeType NodeType { get; set; } + /// - /// 编码。 + /// 编码。 /// - [Required, StringLength(100)] + [Required] + [StringLength(100)] public string Code { get; set; } = string.Empty; + /// - /// 名称。 + /// 名称。 /// - [Required, StringLength(300)] + [Required] + [StringLength(300)] public string Name { get; set; } = string.Empty; + /// - /// 排序值。 + /// 排序值。 /// public int SortOrder { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public CreateTaxonomyNodeCommand ToCommand() => new( - ParentId, - ParentSource, - NodeType, - Code, - Name, - SortOrder, - Metadata); -} + public CreateTaxonomyNodeCommand ToCommand() + { + return new CreateTaxonomyNodeCommand( + ParentId, + ParentSource, + NodeType, + Code, + Name, + SortOrder, + Metadata); + } +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index 4b20b7b..8267a71 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -6,25 +6,27 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; /// -/// 租户管理端班级查询参数。 +/// 租户管理端班级查询参数。 /// public sealed class TenantAdminClassQueryDto { /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 关键字。 + /// 关键字。 /// public string? Keyword { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -36,27 +38,28 @@ public sealed class TenantAdminClassQueryDto } /// -/// 租户管理端班级成员查询参数。 +/// 租户管理端班级成员查询参数。 /// public sealed class TenantAdminClassMemberQueryDto { /// - /// 班级 ID。 + /// 班级 ID。 /// [Required] public Guid ClassId { get; set; } /// - /// 成员类型。 + /// 成员类型。 /// public string? MemberType { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -68,29 +71,32 @@ public sealed class TenantAdminClassMemberQueryDto } /// -/// 租户管理端学生查询参数。 +/// 租户管理端学生查询参数。 /// public sealed class TenantAdminStudentQueryDto { /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 班级 ID。 + /// 班级 ID。 /// public Guid? ClassId { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 关键字。 + /// 关键字。 /// public string? Keyword { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -102,21 +108,22 @@ public sealed class TenantAdminStudentQueryDto } /// -/// 租户管理端学生活动查询参数。 +/// 租户管理端学生活动查询参数。 /// public sealed class TenantAdminStudentActivityQueryDto { /// - /// 学生用户 ID。 + /// 学生用户 ID。 /// public Guid? StudentUserId { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -128,25 +135,27 @@ public sealed class TenantAdminStudentActivityQueryDto } /// -/// 租户管理端成员查询参数。 +/// 租户管理端成员查询参数。 /// public sealed class TenantAdminMemberQueryDto { /// - /// 角色。 + /// 角色。 /// public string? Role { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 关键字。 + /// 关键字。 /// public string? Keyword { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -158,25 +167,27 @@ public sealed class TenantAdminMemberQueryDto } /// -/// 租户管理端审计日志查询参数。 +/// 租户管理端审计日志查询参数。 /// public sealed class TenantAdminAuditLogQueryDto { /// - /// 操作。 + /// 操作。 /// public string? Action { get; set; } + /// - /// 目标类型。 + /// 目标类型。 /// public string? TargetType { get; set; } + /// - /// 操作人用户 ID。 + /// 操作人用户 ID。 /// public Guid? ActorUserId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -188,21 +199,22 @@ public sealed class TenantAdminAuditLogQueryDto } /// -/// 租户管理端徽章查询参数。 +/// 租户管理端徽章查询参数。 /// public sealed class TenantAdminBadgeQueryDto { /// - /// 分类。 + /// 分类。 /// public string? Category { get; set; } + /// - /// 是否包含停用数据。 + /// 是否包含停用数据。 /// public bool IncludeInactive { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -214,21 +226,22 @@ public sealed class TenantAdminBadgeQueryDto } /// -/// 租户管理端徽章授予查询参数。 +/// 租户管理端徽章授予查询参数。 /// public sealed class TenantAdminBadgeGrantQueryDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } + /// - /// 徽章 ID。 + /// 徽章 ID。 /// public Guid? BadgeId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -240,25 +253,27 @@ public sealed class TenantAdminBadgeGrantQueryDto } /// -/// 租户管理端通知查询参数。 +/// 租户管理端通知查询参数。 /// public sealed class TenantAdminNotificationQueryDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 通知类型。 + /// 通知类型。 /// public string? NotificationType { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -270,29 +285,32 @@ public sealed class TenantAdminNotificationQueryDto } /// -/// 租户管理端反馈查询参数。 +/// 租户管理端反馈查询参数。 /// public sealed class TenantAdminFeedbackQueryDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 类型。 + /// 类型。 /// public string? Type { get; set; } + /// - /// 关键字。 + /// 关键字。 /// public string? Keyword { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } @@ -304,80 +322,94 @@ public sealed class TenantAdminFeedbackQueryDto } /// -/// 新增或更新租户管理端班级请求 DTO。 +/// 新增或更新租户管理端班级请求 DTO。 /// public sealed class UpsertTenantAdminClassDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 编码。 + /// 编码。 /// public string? Code { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public UpsertTenantAdminClassCommand ToCommand() { - return new UpsertTenantAdminClassCommand(Id, RegionId, LegacyId, Code, Name, Description, Status, Order, Metadata); + return new UpsertTenantAdminClassCommand(Id, RegionId, LegacyId, Code, Name, Description, Status, Order, + Metadata); } } /// -/// 租户管理端用户查找请求 DTO。 +/// 租户管理端用户查找请求 DTO。 /// public sealed class TenantAdminUserLookupDto { /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } + /// - /// 用户名。 + /// 用户名。 /// public string? Username { get; set; } + /// - /// 邮箱。 + /// 邮箱。 /// public string? Email { get; set; } + /// - /// 手机号。 + /// 手机号。 /// public string? Phone { get; set; } + /// - /// 名称。 + /// 名称。 /// public string? Name { get; set; } + /// - /// 头像地址。 + /// 头像地址。 /// public string? AvatarUrl { get; set; } @@ -388,30 +420,33 @@ public sealed class TenantAdminUserLookupDto } /// -/// 新增或更新租户管理端班级成员请求 DTO。 +/// 新增或更新租户管理端班级成员请求 DTO。 /// public sealed class UpsertTenantAdminClassMemberDto { /// - /// 班级 ID。 + /// 班级 ID。 /// [Required] public Guid ClassId { get; set; } /// - /// 用户信息。 + /// 用户信息。 /// public TenantAdminUserLookupDto User { get; set; } = new(); + /// - /// 成员类型。 + /// 成员类型。 /// public string? MemberType { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -422,56 +457,64 @@ public sealed class UpsertTenantAdminClassMemberDto } /// -/// 移除租户管理端班级成员请求 DTO。 +/// 移除租户管理端班级成员请求 DTO。 /// public sealed class RemoveTenantAdminClassMemberDto { /// - /// 班级成员 ID。 + /// 班级成员 ID。 /// [Required] public Guid ClassMemberId { get; set; } } /// -/// 新增或更新租户管理端学生请求 DTO。 +/// 新增或更新租户管理端学生请求 DTO。 /// public sealed class UpsertTenantAdminStudentDto { /// - /// 用户信息。 + /// 用户信息。 /// public TenantAdminUserLookupDto User { get; set; } = new(); + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 已选择院校 ID。 + /// 已选择院校 ID。 /// public Guid? SelectedSchoolId { get; set; } + /// - /// 已选择专业 ID。 + /// 已选择专业 ID。 /// public Guid? SelectedMajorId { get; set; } + /// - /// 头像预设。 + /// 头像预设。 /// public string? AvatarPreset { get; set; } + /// - /// 统计数据。 + /// 统计数据。 /// public JsonElement Stats { get; set; } = JsonDefaults.Object(); + /// - /// 进度数据。 + /// 进度数据。 /// public JsonElement Progress { get; set; } = JsonDefaults.Object(); + /// - /// 模块选择数据。 + /// 模块选择数据。 /// public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object(); + /// - /// 原始用户资料。 + /// 原始用户资料。 /// public JsonElement RawProfile { get; set; } = JsonDefaults.Object(); @@ -491,22 +534,23 @@ public sealed class UpsertTenantAdminStudentDto } /// -/// 更新租户管理端学生状态请求 DTO。 +/// 更新租户管理端学生状态请求 DTO。 /// public sealed class UpdateTenantAdminStudentStatusDto { /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 原因。 + /// 原因。 /// public string? Reason { get; set; } @@ -517,28 +561,32 @@ public sealed class UpdateTenantAdminStudentStatusDto } /// -/// 租户管理端学生导入行请求 DTO。 +/// 租户管理端学生导入行请求 DTO。 /// public sealed class TenantAdminStudentImportRowDto { /// - /// 用户信息。 + /// 用户信息。 /// public TenantAdminUserLookupDto User { get; set; } = new(); + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } + /// - /// 班级 ID。 + /// 班级 ID。 /// public Guid? ClassId { get; set; } + /// - /// 头像预设。 + /// 头像预设。 /// public string? AvatarPreset { get; set; } + /// - /// 原始用户资料。 + /// 原始用户资料。 /// public JsonElement RawProfile { get; set; } = JsonDefaults.Object(); @@ -549,12 +597,12 @@ public sealed class TenantAdminStudentImportRowDto } /// -/// 租户管理端学生导入请求 DTO。 +/// 租户管理端学生导入请求 DTO。 /// public sealed class TenantAdminStudentImportDto { /// - /// 数据行列表。 + /// 数据行列表。 /// [Required] public IReadOnlyCollection Rows { get; set; } = []; @@ -566,18 +614,18 @@ public sealed class TenantAdminStudentImportDto } /// -/// 租户管理端批量分配班级请求 DTO。 +/// 租户管理端批量分配班级请求 DTO。 /// public sealed class TenantAdminBulkAssignClassDto { /// - /// 班级 ID。 + /// 班级 ID。 /// [Required] public Guid ClassId { get; set; } /// - /// 用户 ID 列表。 + /// 用户 ID 列表。 /// [Required] public IReadOnlyCollection UserIds { get; set; } = []; @@ -589,23 +637,23 @@ public sealed class TenantAdminBulkAssignClassDto } /// -/// 租户管理端批量状态请求 DTO。 +/// 租户管理端批量状态请求 DTO。 /// public sealed class TenantAdminBulkStatusDto { /// - /// 用户 ID 列表。 + /// 用户 ID 列表。 /// [Required] public IReadOnlyCollection UserIds { get; set; } = []; /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } @@ -617,55 +665,55 @@ public sealed class TenantAdminBulkStatusDto } /// -/// 新增或更新租户督导规则请求 DTO。 +/// 新增或更新租户督导规则请求 DTO。 /// public sealed class UpsertTenantSupervisionRuleDto { /// - /// 编码。 + /// 编码。 /// [Required] [StringLength(100)] public string Code { get; set; } = string.Empty; /// - /// 标题。 + /// 标题。 /// [Required] [StringLength(200)] public string Title { get; set; } = string.Empty; /// - /// 是否启用。 + /// 是否启用。 /// public bool Enabled { get; set; } = true; /// - /// 未签到天数。 + /// 未签到天数。 /// [Range(0, 3650)] public int? DaysWithoutCheckIn { get; set; } /// - /// 当天最大答题数。 + /// 当天最大答题数。 /// [Range(0, int.MaxValue)] public int? MaxQuestionsAnsweredToday { get; set; } /// - /// 跟进类型。 + /// 跟进类型。 /// [StringLength(50)] public string? FollowupType { get; set; } /// - /// 优先级。 + /// 优先级。 /// [StringLength(50)] public string? Priority { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -684,20 +732,22 @@ public sealed class UpsertTenantSupervisionRuleDto } /// -/// 租户督导生成请求 DTO。 +/// 租户督导生成请求 DTO。 /// public sealed class TenantSupervisionGenerateDto { /// - /// 用户 ID 列表。 + /// 用户 ID 列表。 /// public IReadOnlyCollection? UserIds { get; set; } + /// - /// 分配处理人用户 ID。 + /// 分配处理人用户 ID。 /// public Guid? AssignedToUserId { get; set; } + /// - /// 到期时间。 + /// 到期时间。 /// public DateTimeOffset? DueAt { get; set; } @@ -708,98 +758,111 @@ public sealed class TenantSupervisionGenerateDto } /// -/// 新增或更新租户管理端学生Note请求 DTO。 +/// 新增或更新租户管理端学生Note请求 DTO。 /// public sealed class UpsertTenantAdminStudentNoteDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 学生用户 ID。 + /// 学生用户 ID。 /// [Required] public Guid StudentUserId { get; set; } /// - /// 备注类型。 + /// 备注类型。 /// public string? NoteType { get; set; } + /// - /// 内容。 + /// 内容。 /// public required string Content { get; set; } + /// - /// 可见性。 + /// 可见性。 /// public string? Visibility { get; set; } + /// - /// 是否置顶。 + /// 是否置顶。 /// public bool? IsPinned { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public UpsertTenantAdminStudentNoteCommand ToCommand() { - return new UpsertTenantAdminStudentNoteCommand(Id, StudentUserId, NoteType, Content, Visibility, IsPinned, Metadata); + return new UpsertTenantAdminStudentNoteCommand(Id, StudentUserId, NoteType, Content, Visibility, IsPinned, + Metadata); } } /// -/// 新增或更新租户管理端学生跟进请求 DTO。 +/// 新增或更新租户管理端学生跟进请求 DTO。 /// public sealed class UpsertTenantAdminStudentFollowupDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 学生用户 ID。 + /// 学生用户 ID。 /// [Required] public Guid StudentUserId { get; set; } /// - /// 分配处理人用户 ID。 + /// 分配处理人用户 ID。 /// public Guid? AssignedToUserId { get; set; } + /// - /// 班级 ID。 + /// 班级 ID。 /// public Guid? ClassId { get; set; } + /// - /// 标题。 + /// 标题。 /// public required string Title { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 跟进类型。 + /// 跟进类型。 /// public string? FollowupType { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public string? Priority { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 到期时间。 + /// 到期时间。 /// public DateTimeOffset? DueAt { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -821,28 +884,32 @@ public sealed class UpsertTenantAdminStudentFollowupDto } /// -/// 新增或更新租户管理端成员请求 DTO。 +/// 新增或更新租户管理端成员请求 DTO。 /// public sealed class UpsertTenantAdminMemberDto { /// - /// 成员关系 ID。 + /// 成员关系 ID。 /// public Guid? MembershipId { get; set; } + /// - /// 用户信息。 + /// 用户信息。 /// public TenantAdminUserLookupDto User { get; set; } = new(); + /// - /// 角色。 + /// 角色。 /// public string? Role { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 主要角色。 + /// 主要角色。 /// public string? PrimaryRole { get; set; } @@ -853,52 +920,59 @@ public sealed class UpsertTenantAdminMemberDto } /// -/// 禁用租户管理端成员请求 DTO。 +/// 禁用租户管理端成员请求 DTO。 /// public sealed class DisableTenantAdminMemberDto { /// - /// 成员关系 ID。 + /// 成员关系 ID。 /// [Required] public Guid MembershipId { get; set; } } /// -/// 新增或更新租户品牌请求 DTO。 +/// 新增或更新租户品牌请求 DTO。 /// public sealed class UpsertTenantBrandingDto { /// - /// 品牌名称。 + /// 品牌名称。 /// public required string BrandName { get; set; } + /// - /// 品牌简称。 + /// 品牌简称。 /// public string? ShortName { get; set; } + /// - /// 品牌标语。 + /// 品牌标语。 /// public string? Slogan { get; set; } + /// - /// 机构名称。 + /// 机构名称。 /// public string? OrganizationName { get; set; } + /// - /// Logo 地址。 + /// Logo 地址。 /// public string? LogoUrl { get; set; } + /// - /// 站点图标地址。 + /// 站点图标地址。 /// public string? FaviconUrl { get; set; } + /// - /// 客服微信。 + /// 客服微信。 /// public string? ServiceWechat { get; set; } + /// - /// 客服账号名称。 + /// 客服账号名称。 /// public string? ServiceAccountName { get; set; } @@ -917,20 +991,22 @@ public sealed class UpsertTenantBrandingDto } /// -/// 新增或更新租户设置请求 DTO。 +/// 新增或更新租户设置请求 DTO。 /// public sealed class UpsertTenantSettingsDto { /// - /// 前端功能开关。 + /// 前端功能开关。 /// public JsonElement FeatureFlags { get; set; } = JsonDefaults.Object(); + /// - /// 管理端功能开关。 + /// 管理端功能开关。 /// public JsonElement AdminFeatureFlags { get; set; } = JsonDefaults.Object(); + /// - /// 公开配置。 + /// 公开配置。 /// public JsonElement PublicConfig { get; set; } = JsonDefaults.Object(); @@ -941,20 +1017,22 @@ public sealed class UpsertTenantSettingsDto } /// -/// 预览租户主题请求 DTO。 +/// 预览租户主题请求 DTO。 /// public sealed class PreviewTenantThemeDto { /// - /// 模板编码。 + /// 模板编码。 /// public required string TemplateCode { get; set; } + /// - /// 主题配置。 + /// 主题配置。 /// public JsonElement Theme { get; set; } = JsonDefaults.Object(); + /// - /// 公开资源配置。 + /// 公开资源配置。 /// public JsonElement PublicAssets { get; set; } = JsonDefaults.Object(); @@ -965,24 +1043,27 @@ public sealed class PreviewTenantThemeDto } /// -/// 发布租户主题请求 DTO。 +/// 发布租户主题请求 DTO。 /// public sealed class PublishTenantThemeDto { /// - /// 是否使用草稿配置。 + /// 是否使用草稿配置。 /// public bool? UseDraft { get; set; } + /// - /// 模板编码。 + /// 模板编码。 /// public string? TemplateCode { get; set; } + /// - /// 主题配置。 + /// 主题配置。 /// public JsonElement Theme { get; set; } = JsonDefaults.Object(); + /// - /// 公开资源配置。 + /// 公开资源配置。 /// public JsonElement PublicAssets { get; set; } = JsonDefaults.Object(); @@ -993,20 +1074,22 @@ public sealed class PublishTenantThemeDto } /// -/// 创建租户域名请求 DTO。 +/// 创建租户域名请求 DTO。 /// public sealed class CreateTenantDomainDto { /// - /// 访问域名。 + /// 访问域名。 /// public required string Host { get; set; } + /// - /// 域名类型。 + /// 域名类型。 /// public string? DomainType { get; set; } + /// - /// 是否主域名。 + /// 是否主域名。 /// public bool IsPrimary { get; set; } @@ -1017,100 +1100,119 @@ public sealed class CreateTenantDomainDto } /// -/// 新增或更新租户身份源Provider请求 DTO。 +/// 新增或更新租户身份源Provider请求 DTO。 /// public sealed class UpsertTenantIdentityProviderDto { /// - /// 服务提供方。 + /// 服务提供方。 /// public required string Provider { get; set; } + /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 显示名称。 + /// 显示名称。 /// public string? DisplayName { get; set; } + /// - /// 密钥引用。 + /// 密钥引用。 /// public string? SecretRef { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public int? Priority { get; set; } + /// - /// 公开配置内容。 + /// 公开配置内容。 /// public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); public UpsertTenantIdentityProviderCommand ToCommand() { - return new UpsertTenantIdentityProviderCommand(Provider, Status, DisplayName, SecretRef, Priority, ConfigPublic); + return new UpsertTenantIdentityProviderCommand(Provider, Status, DisplayName, SecretRef, Priority, + ConfigPublic); } } /// -/// 新增或更新租户管理端徽章请求 DTO。 +/// 新增或更新租户管理端徽章请求 DTO。 /// public sealed class UpsertTenantAdminBadgeDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 名称。 + /// 名称。 /// public required string Name { get; set; } + /// - /// 说明。 + /// 说明。 /// public string? Description { get; set; } + /// - /// 分类。 + /// 分类。 /// public string? Category { get; set; } + /// - /// 图标地址。 + /// 图标地址。 /// public string? IconUrl { get; set; } + /// - /// 级别。 + /// 级别。 /// public int? Level { get; set; } + /// - /// 解锁类型。 + /// 解锁类型。 /// public string? UnlockType { get; set; } + /// - /// 条件字段。 + /// 条件字段。 /// public string? ConditionField { get; set; } + /// - /// 条件运算符。 + /// 条件运算符。 /// public string? ConditionOperator { get; set; } + /// - /// 条件值。 + /// 条件值。 /// public decimal? ConditionValue { get; set; } + /// - /// 条件扩展配置。 + /// 条件扩展配置。 /// public JsonElement ConditionExtra { get; set; } = JsonDefaults.Object(); + /// - /// 显示顺序。 + /// 显示顺序。 /// public int? Order { get; set; } + /// - /// 是否启用。 + /// 是否启用。 /// public bool? IsActive { get; set; } @@ -1135,36 +1237,39 @@ public sealed class UpsertTenantAdminBadgeDto } /// -/// 授予租户管理端徽章请求 DTO。 +/// 授予租户管理端徽章请求 DTO。 /// public sealed class GrantTenantAdminBadgeDto { /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } /// - /// 徽章 ID。 + /// 徽章 ID。 /// [Required] public Guid BadgeId { get; set; } /// - /// 历史系统 ID。 + /// 历史系统 ID。 /// public string? LegacyId { get; set; } + /// - /// 备注。 + /// 备注。 /// public string? Note { get; set; } + /// - /// 授予时间。 + /// 授予时间。 /// public DateTimeOffset? GrantedAt { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -1175,54 +1280,63 @@ public sealed class GrantTenantAdminBadgeDto } /// -/// 新增或更新租户管理端通知请求 DTO。 +/// 新增或更新租户管理端通知请求 DTO。 /// public sealed class UpsertTenantAdminNotificationDto { /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } /// - /// 通知类型。 + /// 通知类型。 /// public required string NotificationType { get; set; } + /// - /// 严重级别。 + /// 严重级别。 /// public string? Severity { get; set; } + /// - /// 标题。 + /// 标题。 /// public required string Title { get; set; } + /// - /// 消息内容。 + /// 消息内容。 /// public required string Message { get; set; } + /// - /// 操作按钮文案。 + /// 操作按钮文案。 /// public string? ActionLabel { get; set; } + /// - /// 操作路径。 + /// 操作路径。 /// public string? ActionPath { get; set; } + /// - /// 来源类型。 + /// 来源类型。 /// public string? SourceType { get; set; } + /// - /// 来源 ID。 + /// 来源 ID。 /// public Guid? SourceId { get; set; } + /// - /// 去重键。 + /// 去重键。 /// public string? DedupeKey { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -1244,34 +1358,38 @@ public sealed class UpsertTenantAdminNotificationDto } /// -/// 更新租户管理端反馈请求 DTO。 +/// 更新租户管理端反馈请求 DTO。 /// public sealed class UpdateTenantAdminFeedbackDto { /// - /// 反馈 ID。 + /// 反馈 ID。 /// [Required] public Guid FeedbackId { get; set; } /// - /// 状态。 + /// 状态。 /// public string? Status { get; set; } + /// - /// 优先级。 + /// 优先级。 /// public string? Priority { get; set; } + /// - /// 处理结果。 + /// 处理结果。 /// public string? Resolution { get; set; } + /// - /// 备注。 + /// 备注。 /// public string? Note { get; set; } + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -1279,4 +1397,4 @@ public sealed class UpdateTenantAdminFeedbackDto { return new UpdateTenantAdminFeedbackCommand(FeedbackId, Status, Priority, Resolution, Note, Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs index 1c94cee..39b52b4 100644 --- a/Tiku.Api/Contracts/TenantCommerceDtos.cs +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -8,81 +8,81 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; /// -/// 租户交易查询参数。 +/// 租户交易查询参数。 /// public sealed class TenantCommerceQueryDto { /// - /// 服务提供方。 + /// 服务提供方。 /// [StringLength(50)] public string? Provider { get; set; } /// - /// 状态。 + /// 状态。 /// [StringLength(32)] public string? Status { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } /// - /// 用户 ID。 + /// 用户 ID。 /// public Guid? UserId { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } } /// -/// 新增或更新支付账号请求 DTO。 +/// 新增或更新支付账号请求 DTO。 /// public sealed class UpsertPaymentAccountDto { /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 模式。 + /// 模式。 /// [StringLength(50)] public string? Mode { get; set; } = "TenantCollect"; /// - /// 显示名称。 + /// 显示名称。 /// [StringLength(200)] public string? DisplayName { get; set; } /// - /// 状态。 + /// 状态。 /// public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled; /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string? SecretRef { get; set; } /// - /// 优先级。 + /// 优先级。 /// public int? Priority { get; set; } /// - /// 公开配置内容。 + /// 公开配置内容。 /// public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); @@ -93,47 +93,49 @@ public sealed class UpsertPaymentAccountDto } /// -/// 新增或更新租户Secret请求 DTO。 +/// 新增或更新租户Secret请求 DTO。 /// public sealed class UpsertTenantSecretDto { /// - /// 用途。 + /// 用途。 /// [Required] [StringLength(80)] public string Purpose { get; set; } = "payment"; /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 密钥键。 + /// 密钥键。 /// [Required] [StringLength(120)] public string SecretKey { get; set; } = string.Empty; /// - /// 密钥引用。 + /// 密钥引用。 /// [StringLength(300)] public string SecretRef { get; set; } = string.Empty; /// - /// 状态。 + /// 状态。 /// public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active; + /// - /// 密钥载荷。 + /// 密钥载荷。 /// public JsonElement SecretPayload { get; set; } = JsonDefaults.Object(); + /// - /// 过期时间。 + /// 过期时间。 /// public DateTimeOffset? ExpiresAt { get; set; } @@ -151,60 +153,60 @@ public sealed class UpsertTenantSecretDto } /// -/// 创建编码批次请求 DTO。 +/// 创建编码批次请求 DTO。 /// public sealed class CreateCodeBatchDto { /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(200)] public string Name { get; set; } = string.Empty; /// - /// 总数量。 + /// 总数量。 /// [Range(1, 1000)] public int TotalCount { get; set; } /// - /// 天数。 + /// 天数。 /// [Range(1, 3650)] public int Days { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 售卖类型。 + /// 售卖类型。 /// [StringLength(50)] public string? SaleType { get; set; } /// - /// 渠道。 + /// 渠道。 /// [StringLength(100)] public string? Channel { get; set; } /// - /// 默认单价,单位为分。 + /// 默认单价,单位为分。 /// [Range(0, int.MaxValue)] public int? DefaultUnitPriceCents { get; set; } /// - /// 成本价,单位为分。 + /// 成本价,单位为分。 /// [Range(0, int.MaxValue)] public int? CostPriceCents { get; set; } /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Remark { get; set; } @@ -225,25 +227,25 @@ public sealed class CreateCodeBatchDto } /// -/// 核销激活码编码请求 DTO。 +/// 核销激活码编码请求 DTO。 /// public sealed class RedeemActivationCodeDto { /// - /// 编码。 + /// 编码。 /// [Required] [StringLength(100)] public string Code { get; set; } = string.Empty; /// - /// 用户 ID。 + /// 用户 ID。 /// [Required] public Guid UserId { get; set; } /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } @@ -254,74 +256,79 @@ public sealed class RedeemActivationCodeDto } /// -/// 新增或更新积分任务请求 DTO。 +/// 新增或更新积分任务请求 DTO。 /// public sealed class UpsertPointTaskDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 任务键。 + /// 任务键。 /// [Required] [StringLength(100)] public string TaskKey { get; set; } = string.Empty; /// - /// 标题。 + /// 标题。 /// [Required] [StringLength(200)] public string Title { get; set; } = string.Empty; /// - /// 说明。 + /// 说明。 /// [StringLength(1000)] public string? Description { get; set; } /// - /// 任务类型。 + /// 任务类型。 /// public PointActivityTaskType TaskType { get; set; } = PointActivityTaskType.Manual; + /// - /// 状态。 + /// 状态。 /// public PointActivityTaskStatus Status { get; set; } = PointActivityTaskStatus.Active; /// - /// 积分数量。 + /// 积分数量。 /// [Range(1, int.MaxValue)] public int Points { get; set; } /// - /// 每用户最多领取次数。 + /// 每用户最多领取次数。 /// [Range(1, 1000)] public int MaxClaimsPerUser { get; set; } = 1; /// - /// 开始时间。 + /// 开始时间。 /// public DateTimeOffset? StartsAt { get; set; } + /// - /// 结束时间。 + /// 结束时间。 /// public DateTimeOffset? EndsAt { get; set; } + /// - /// 排序值。 + /// 排序值。 /// public int SortOrder { get; set; } + /// - /// 规则配置。 + /// 规则配置。 /// public JsonElement Rules { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -345,84 +352,90 @@ public sealed class UpsertPointTaskDto } /// -/// 新增或更新积分兑换Item请求 DTO。 +/// 新增或更新积分兑换Item请求 DTO。 /// public sealed class UpsertPointExchangeItemDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } + /// - /// 地区 ID。 + /// 地区 ID。 /// public Guid? RegionId { get; set; } /// - /// 兑换项键。 + /// 兑换项键。 /// [Required] [StringLength(100)] public string ItemKey { get; set; } = string.Empty; /// - /// 名称。 + /// 名称。 /// [Required] [StringLength(200)] public string Name { get; set; } = string.Empty; /// - /// 说明。 + /// 说明。 /// [StringLength(1000)] public string? Description { get; set; } /// - /// 兑换项类型。 + /// 兑换项类型。 /// public PointExchangeItemType ItemType { get; set; } = PointExchangeItemType.Entitlement; + /// - /// 状态。 + /// 状态。 /// public PointExchangeItemStatus Status { get; set; } = PointExchangeItemStatus.Active; /// - /// 所需积分。 + /// 所需积分。 /// [Range(1, int.MaxValue)] public int PointsCost { get; set; } /// - /// 库存数量。 + /// 库存数量。 /// [Range(0, int.MaxValue)] public int? Stock { get; set; } /// - /// 天数。 + /// 天数。 /// [Range(0, 3650)] public int? Days { get; set; } /// - /// 排序值。 + /// 排序值。 /// public int SortOrder { get; set; } + /// - /// 开始时间。 + /// 开始时间。 /// public DateTimeOffset? StartsAt { get; set; } + /// - /// 结束时间。 + /// 结束时间。 /// public DateTimeOffset? EndsAt { get; set; } + /// - /// 履约载荷。 + /// 履约载荷。 /// public JsonElement FulfillmentPayload { get; set; } = JsonDefaults.Object(); + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -448,39 +461,40 @@ public sealed class UpsertPointExchangeItemDto } /// -/// 创建退款Request请求 DTO。 +/// 创建退款Request请求 DTO。 /// public sealed class CreateRefundRequestDto { /// - /// 订单 ID。 + /// 订单 ID。 /// [Required] public Guid OrderId { get; set; } /// - /// 支付记录 ID。 + /// 支付记录 ID。 /// public Guid? PaymentId { get; set; } /// - /// 金额,单位为分。 + /// 金额,单位为分。 /// [Range(1, int.MaxValue)] public int AmountCents { get; set; } /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } /// - /// 权益动作。 + /// 权益动作。 /// public RefundEntitlementAction EntitlementAction { get; set; } = RefundEntitlementAction.RevokeOnSuccess; + /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); @@ -491,29 +505,29 @@ public sealed class CreateRefundRequestDto } /// -/// 更新退款状态请求 DTO。 +/// 更新退款状态请求 DTO。 /// public sealed class UpdateRefundStatusDto { /// - /// 退款申请 ID。 + /// 退款申请 ID。 /// [Required] public Guid RefundRequestId { get; set; } /// - /// 状态。 + /// 状态。 /// public CommerceRefundStatus Status { get; set; } /// - /// 原因。 + /// 原因。 /// [StringLength(1000)] public string? Reason { get; set; } /// - /// 渠道退款单号。 + /// 渠道退款单号。 /// [StringLength(200)] public string? ProviderRefundNo { get; set; } @@ -525,82 +539,86 @@ public sealed class UpdateRefundStatusDto } /// -/// 创建对账批次请求 DTO。 +/// 创建对账批次请求 DTO。 /// public sealed class CreateReconciliationBatchDto { /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 账单日期。 + /// 账单日期。 /// public DateOnly BillDate { get; set; } + /// - /// 账单类型。 + /// 账单类型。 /// public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; + /// - /// 来源。 + /// 来源。 /// public ReconciliationSource Source { get; set; } = ReconciliationSource.ManualUpload; /// - /// 来源名称。 + /// 来源名称。 /// [StringLength(200)] public string? SourceName { get; set; } /// - /// 来源哈希。 + /// 来源哈希。 /// [Required] [StringLength(200)] public string SourceHash { get; set; } = string.Empty; /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public CreateReconciliationBatchCommand ToCommand() { - return new CreateReconciliationBatchCommand(Provider, BillDate, BillType, Source, SourceName, SourceHash, Metadata); + return new CreateReconciliationBatchCommand(Provider, BillDate, BillType, Source, SourceName, SourceHash, + Metadata); } } /// -/// 更新对账Issue请求 DTO。 +/// 更新对账Issue请求 DTO。 /// public sealed class UpdateReconciliationIssueDto { /// - /// 工单 ID。 + /// 工单 ID。 /// [Required] public Guid IssueId { get; set; } /// - /// 状态。 + /// 状态。 /// public ReconciliationIssueStatus Status { get; set; } = ReconciliationIssueStatus.Investigating; + /// - /// 处理类型。 + /// 处理类型。 /// public ReconciliationResolutionType ResolutionType { get; set; } = ReconciliationResolutionType.None; /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Note { get; set; } /// - /// 分配处理人。 + /// 分配处理人。 /// public Guid? AssignedTo { get; set; } @@ -611,225 +629,252 @@ public sealed class UpdateReconciliationIssueDto } /// -/// 预览对账导入请求 DTO。 +/// 预览对账导入请求 DTO。 /// public sealed class PreviewReconciliationImportDto { /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 账单日期。 + /// 账单日期。 /// public DateOnly BillDate { get; set; } + /// - /// 账单类型。 + /// 账单类型。 /// public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; /// - /// 来源名称。 + /// 来源名称。 /// [Required] [StringLength(300)] public string SourceName { get; set; } = string.Empty; /// - /// 数据行列表。 + /// 数据行列表。 /// public JsonElement Rows { get; set; } = JsonDefaults.Array(); - public PreviewReconciliationImportCommand ToPreviewCommand() => new(Provider, BillDate, BillType, SourceName, Rows); + public PreviewReconciliationImportCommand ToPreviewCommand() + { + return new PreviewReconciliationImportCommand(Provider, BillDate, BillType, SourceName, Rows); + } - public ImportReconciliationCommand ToImportCommand() => new(Provider, BillDate, BillType, SourceName, Rows); + public ImportReconciliationCommand ToImportCommand() + { + return new ImportReconciliationCommand(Provider, BillDate, BillType, SourceName, Rows); + } } /// -/// 创建调账凭证请求 DTO。 +/// 创建调账凭证请求 DTO。 /// public sealed class CreateAdjustmentVoucherDto { /// - /// 工单 ID。 + /// 工单 ID。 /// public Guid? IssueId { get; set; } + /// - /// 批次 ID。 + /// 批次 ID。 /// public Guid? BatchId { get; set; } + /// - /// 兑换项 ID。 + /// 兑换项 ID。 /// public Guid? ItemId { get; set; } + /// - /// 订单 ID。 + /// 订单 ID。 /// public Guid? OrderId { get; set; } + /// - /// 支付记录 ID。 + /// 支付记录 ID。 /// public Guid? PaymentId { get; set; } + /// - /// 退款申请 ID。 + /// 退款申请 ID。 /// public Guid? RefundRequestId { get; set; } + /// - /// 方向。 + /// 方向。 /// public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue; /// - /// 金额,单位为分。 + /// 金额,单位为分。 /// [Range(1, int.MaxValue)] public int AmountCents { get; set; } /// - /// 币种。 + /// 币种。 /// [StringLength(10)] public string? Currency { get; set; } /// - /// 原因。 + /// 原因。 /// [Required] [StringLength(1000)] public string Reason { get; set; } = string.Empty; /// - /// 证明资产键。 + /// 证明资产键。 /// [StringLength(500)] public string? ProofAssetKey { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonDefaults.Object(); - public CreateAdjustmentVoucherCommand ToCommand() => new( - IssueId, - BatchId, - ItemId, - OrderId, - PaymentId, - RefundRequestId, - Direction, - AmountCents, - Currency, - Reason, - ProofAssetKey, - Metadata); + public CreateAdjustmentVoucherCommand ToCommand() + { + return new CreateAdjustmentVoucherCommand( + IssueId, + BatchId, + ItemId, + OrderId, + PaymentId, + RefundRequestId, + Direction, + AmountCents, + Currency, + Reason, + ProofAssetKey, + Metadata); + } } /// -/// 更新调账凭证状态请求 DTO。 +/// 更新调账凭证状态请求 DTO。 /// public sealed class UpdateAdjustmentVoucherStatusDto { /// - /// 凭证 ID。 + /// 凭证 ID。 /// [Required] public Guid VoucherId { get; set; } /// - /// 状态。 + /// 状态。 /// public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.PendingReview; /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Note { get; set; } - public UpdateAdjustmentVoucherStatusCommand ToCommand() => new(VoucherId, Status, Note); + public UpdateAdjustmentVoucherStatusCommand ToCommand() + { + return new UpdateAdjustmentVoucherStatusCommand(VoucherId, Status, Note); + } } /// -/// RequestProviderBill任务请求 DTO。 +/// RequestProviderBill任务请求 DTO。 /// public sealed class RequestProviderBillJobDto { /// - /// 服务提供方。 + /// 服务提供方。 /// [Required] [StringLength(50)] public string Provider { get; set; } = string.Empty; /// - /// 账单日期。 + /// 账单日期。 /// public DateOnly BillDate { get; set; } + /// - /// 账单类型。 + /// 账单类型。 /// public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; + /// - /// 计划运行时间。 + /// 计划运行时间。 /// public DateTimeOffset? RunAfter { get; set; } - public RequestProviderBillJobCommand ToCommand() => new(Provider, BillDate, BillType, RunAfter); + public RequestProviderBillJobCommand ToCommand() + { + return new RequestProviderBillJobCommand(Provider, BillDate, BillType, RunAfter); + } } /// -/// 退款通知请求 DTO。 +/// 退款通知请求 DTO。 /// public sealed class RefundNotificationDto { /// - /// 退款单号。 + /// 退款单号。 /// [Required] [StringLength(100)] public string RefundNo { get; set; } = string.Empty; /// - /// 渠道退款单号。 + /// 渠道退款单号。 /// [StringLength(200)] public string? ProviderRefundNo { get; set; } /// - /// 状态。 + /// 状态。 /// public CommerceRefundStatus Status { get; set; } = CommerceRefundStatus.Succeeded; /// - /// 事件 ID。 + /// 事件 ID。 /// [StringLength(200)] public string? EventId { get; set; } /// - /// 任务载荷。 + /// 任务载荷。 /// public JsonElement Payload { get; set; } = JsonDefaults.Object(); - public RefundNotificationCommand ToCommand(string provider) => new(provider, RefundNo, ProviderRefundNo, Status, EventId, Payload); + public RefundNotificationCommand ToCommand(string provider) + { + return new RefundNotificationCommand(provider, RefundNo, ProviderRefundNo, Status, EventId, Payload); + } } /// -/// 更新积分兑换订单状态请求 DTO。 +/// 更新积分兑换订单状态请求 DTO。 /// public sealed class UpdatePointExchangeOrderStatusDto { /// - /// 订单 ID。 + /// 订单 ID。 /// [Required] public Guid OrderId { get; set; } /// - /// 状态。 + /// 状态。 /// public PointExchangeOrderStatus Status { get; set; } @@ -840,60 +885,62 @@ public sealed class UpdatePointExchangeOrderStatusDto } /// -/// 新增或更新租户优惠券请求 DTO。 +/// 新增或更新租户优惠券请求 DTO。 /// public sealed class UpsertTenantCouponDto { /// - /// ID。 + /// ID。 /// public Guid? Id { get; set; } /// - /// 编码。 + /// 编码。 /// [Required] [StringLength(100)] public string Code { get; set; } = string.Empty; /// - /// 套餐 ID。 + /// 套餐 ID。 /// public Guid? PlanId { get; set; } + /// - /// 优惠类型。 + /// 优惠类型。 /// public DiscountType DiscountType { get; set; } = DiscountType.Fixed; /// - /// 优惠值。 + /// 优惠值。 /// [Range(typeof(decimal), "0.01", "999999")] public decimal DiscountValue { get; set; } /// - /// 有效期开始时间。 + /// 有效期开始时间。 /// public DateTimeOffset? ValidFrom { get; set; } + /// - /// 有效期结束时间。 + /// 有效期结束时间。 /// public DateTimeOffset? ValidTo { get; set; } /// - /// 最大使用次数。 + /// 最大使用次数。 /// [Range(1, int.MaxValue)] public int? MaxUses { get; set; } /// - /// 来源。 + /// 来源。 /// [StringLength(50)] public string? Source { get; set; } /// - /// 备注。 + /// 备注。 /// [StringLength(1000)] public string? Remark { get; set; } @@ -912,4 +959,4 @@ public sealed class UpsertTenantCouponDto Source, Remark); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TenantDtos.cs b/Tiku.Api/Contracts/TenantDtos.cs index 7e1e540..289cf0e 100644 --- a/Tiku.Api/Contracts/TenantDtos.cs +++ b/Tiku.Api/Contracts/TenantDtos.cs @@ -7,19 +7,19 @@ using Tiku.Domain.Tenancy; namespace Tiku.Api.Contracts; /// -/// 租户解析查询参数。 +/// 租户解析查询参数。 /// public sealed class TenantResolveQueryDto { /// - /// 访问域名。 + /// 访问域名。 /// [StringLength(253)] [Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")] public string? Host { get; set; } /// - /// 租户编码。 + /// 租户编码。 /// [StringLength(100)] [Description("租户编码;本地开发或无独立域名时使用,例如 master。")] @@ -27,7 +27,7 @@ public sealed class TenantResolveQueryDto } /// -/// 租户解析响应。 +/// 租户解析响应。 /// /// 公开租户基础信息。 /// 公开品牌配置。 @@ -35,7 +35,7 @@ public sealed class TenantResolveQueryDto /// 面向管理端公开的功能开关。 /// 可公开的租户配置,不包含密钥或内部配置。 /// -/// 租户解析Response请求 DTO。 +/// 租户解析Response请求 DTO。 /// public sealed record TenantResolveResponseDto( PublicTenantDto Tenant, @@ -45,7 +45,7 @@ public sealed record TenantResolveResponseDto( JsonElement PublicConfig); /// -/// 可公开给客户端的租户基础信息。 +/// 可公开给客户端的租户基础信息。 /// /// 租户 ID。 /// 租户编码。 @@ -54,7 +54,7 @@ public sealed record TenantResolveResponseDto( /// 租户模式。 /// 当前匹配到的访问域名。 /// -/// 公开租户请求 DTO。 +/// 公开租户请求 DTO。 /// public sealed record PublicTenantDto( Guid Id, @@ -65,7 +65,7 @@ public sealed record PublicTenantDto( string? Host); /// -/// 可公开给客户端的租户品牌信息。 +/// 可公开给客户端的租户品牌信息。 /// /// 品牌名称。 /// 品牌短名称。 @@ -77,7 +77,7 @@ public sealed record PublicTenantDto( /// 公开主题配置。 /// 公开资源配置。 /// -/// 公开租户品牌请求 DTO。 +/// 公开租户品牌请求 DTO。 /// public sealed record PublicTenantBrandingDto( string? BrandName, @@ -100,4 +100,4 @@ public sealed record PublicTenantBrandingDto( null, JsonDefaults.Object(), JsonDefaults.Object()); -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TenantFrontendConfigDtos.cs b/Tiku.Api/Contracts/TenantFrontendConfigDtos.cs index 2f5803c..a6d8b59 100644 --- a/Tiku.Api/Contracts/TenantFrontendConfigDtos.cs +++ b/Tiku.Api/Contracts/TenantFrontendConfigDtos.cs @@ -6,47 +6,54 @@ using Tiku.Domain.Common; namespace Tiku.Api.Contracts; /// -/// 保存租户前端配置草稿请求。 +/// 保存租户前端配置草稿请求。 /// public sealed class SaveTenantFrontendConfigDraftDto { /// - /// 品牌配置。 + /// 品牌配置。 /// public JsonElement Branding { get; set; } = JsonDefaults.Object(); + /// - /// 主题配置。 + /// 主题配置。 /// public JsonElement Theme { get; set; } = JsonDefaults.Object(); + /// - /// 功能配置。 + /// 功能配置。 /// public JsonElement Features { get; set; } = JsonDefaults.Object(); + /// - /// 导航配置。 + /// 导航配置。 /// public JsonElement Navigation { get; set; } = JsonDefaults.Array(); + /// - /// 首页模块配置。 + /// 首页模块配置。 /// public JsonElement HomeModules { get; set; } = JsonDefaults.Array(); - public TenantFrontendConfigDraft ToDraft() => new( - Branding, - Theme, - Features, - Navigation, - HomeModules); + public TenantFrontendConfigDraft ToDraft() + { + return new TenantFrontendConfigDraft( + Branding, + Theme, + Features, + Navigation, + HomeModules); + } } /// -/// 发布租户前端配置请求。 +/// 发布租户前端配置请求。 /// public sealed class PublishTenantFrontendConfigDto { /// - /// 期望配置版本。 + /// 期望配置版本。 /// [Range(1, int.MaxValue)] public int ExpectedVersion { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/TenantLifecycleDtos.cs b/Tiku.Api/Contracts/TenantLifecycleDtos.cs index d9a3503..6daf570 100644 --- a/Tiku.Api/Contracts/TenantLifecycleDtos.cs +++ b/Tiku.Api/Contracts/TenantLifecycleDtos.cs @@ -6,7 +6,8 @@ namespace Tiku.Api.Contracts; public sealed class TenantLifecycleReasonDto { /// 审计原因。 - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; } @@ -18,6 +19,7 @@ public sealed class TenantOwnerTransferDto public Guid TargetUserId { get; set; } /// 审计原因。 - [Required, StringLength(1000, MinimumLength = 3)] + [Required] + [StringLength(1000, MinimumLength = 3)] public string Reason { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/Tiku.Api/Contracts/VideoDtos.cs b/Tiku.Api/Contracts/VideoDtos.cs index dbe57fe..b2c77a9 100644 --- a/Tiku.Api/Contracts/VideoDtos.cs +++ b/Tiku.Api/Contracts/VideoDtos.cs @@ -5,90 +5,96 @@ using Tiku.Application.Assets; namespace Tiku.Api.Contracts; /// -/// 视频Search查询参数。 +/// 视频Search查询参数。 /// public sealed class VideoSearchQueryDto { /// - /// 关键字。 + /// 关键字。 /// [StringLength(100)] public string? Keyword { get; set; } /// - /// 科目 ID。 + /// 科目 ID。 /// public Guid? SubjectId { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 200)] public int? Limit { get; set; } - public VideoSearchQuery ToQuery() => new(Keyword, SubjectId, Limit); + public VideoSearchQuery ToQuery() + { + return new VideoSearchQuery(Keyword, SubjectId, Limit); + } } /// -/// 视频Play请求 DTO。 +/// 视频Play请求 DTO。 /// public sealed class VideoPlayDto { /// - /// 视频 ID。 + /// 视频 ID。 /// [Required] public Guid VideoId { get; set; } /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } - public VideoPlayCommand ToCommand() => new(VideoId, QuestionId); + public VideoPlayCommand ToCommand() + { + return new VideoPlayCommand(VideoId, QuestionId); + } } /// -/// 视频Progress请求 DTO。 +/// 视频Progress请求 DTO。 /// public sealed class VideoProgressDto { /// - /// 视频 ID。 + /// 视频 ID。 /// [Required] public Guid VideoId { get; set; } /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } /// - /// 播放位置,单位为秒。 + /// 播放位置,单位为秒。 /// [Range(0, int.MaxValue)] public int PositionSeconds { get; set; } /// - /// 时长,单位为秒。 + /// 时长,单位为秒。 /// [Range(0, int.MaxValue)] public int? DurationSeconds { get; set; } /// - /// 已观看时长,单位为秒。 + /// 已观看时长,单位为秒。 /// [Range(0, int.MaxValue)] public int? WatchedSeconds { get; set; } /// - /// 是否已完成。 + /// 是否已完成。 /// public bool? IsCompleted { get; set; } /// - /// 扩展元数据。 + /// 扩展元数据。 /// public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { }); @@ -106,26 +112,29 @@ public sealed class VideoProgressDto } /// -/// 题目视频查询参数。 +/// 题目视频查询参数。 /// public sealed class QuestionVideoQueryDto { /// - /// 题目 ID。 + /// 题目 ID。 /// public Guid? QuestionId { get; set; } /// - /// 题目 ID 列表。 + /// 题目 ID 列表。 /// [MaxLength(100)] public IReadOnlyCollection? QuestionIds { get; set; } /// - /// 返回数量上限。 + /// 返回数量上限。 /// [Range(1, 500)] public int? Limit { get; set; } - public QuestionVideoQuery ToQuery() => new(QuestionId, QuestionIds, Limit); -} + public QuestionVideoQuery ToQuery() + { + return new QuestionVideoQuery(QuestionId, QuestionIds, Limit); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/AssetsController.cs b/Tiku.Api/Controllers/AssetsController.cs index df845cb..5522f4d 100644 --- a/Tiku.Api/Controllers/AssetsController.cs +++ b/Tiku.Api/Controllers/AssetsController.cs @@ -4,7 +4,6 @@ using Tiku.Api.Contracts; using Tiku.Application.Assets; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -74,18 +73,12 @@ public sealed class AssetsController( private async Task ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken) { - if (currentTenant.TenantId.HasValue) - { - return currentTenant.TenantId.Value; - } + if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; var resolvedTenantCode = tenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(resolvedTenantCode)) - { - throw new TenantNotFoundException(); - } + if (string.IsNullOrWhiteSpace(resolvedTenantCode)) throw new TenantNotFoundException(); var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode, cancellationToken); return tenant?.TenantId ?? throw new TenantNotFoundException(); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index 9e14437..cad0059 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -2,10 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; -using Tiku.Application.Auth; -using Tiku.Application.Content; using Tiku.Api.Contracts; using Tiku.Api.Options; +using Tiku.Application.Auth; +using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; @@ -59,12 +59,10 @@ public sealed class AuthController( { var realm = request.Realm!.Value; if (realm != AuthRealm.Tenant) - { throw new RequiredFieldException("SMS authentication is only available in the tenant realm."); - } var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); + ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); var result = await smsVerificationService.CreateCodeAsync( new SendSmsCodeRequest( tenantId, @@ -90,10 +88,7 @@ public sealed class AuthController( { var realm = request.Realm!.Value; var identifier = request.Identifier ?? request.Phone; - if (string.IsNullOrWhiteSpace(identifier)) - { - throw new RequiredFieldException("identifier is required."); - } + if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required."); var result = await authService.LoginWithPasswordAsync( new PasswordLoginRequest( realm, @@ -223,10 +218,7 @@ public sealed class AuthController( [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task LogoutAll(CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId) - { - return Unauthorized(); - } + if (currentUser.UserId is not { } userId) return Unauthorized(); await authService.LogoutAllAsync(userId, cancellationToken); return NoContent(); @@ -260,7 +252,7 @@ public sealed class AuthController( CancellationToken cancellationToken) { var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for password reset."); + ?? throw new RequiredFieldException("tenantCode is required for password reset."); var result = await authService.RequestPasswordResetAsync( new PasswordResetCodeRequest( tenantId, @@ -282,7 +274,7 @@ public sealed class AuthController( CancellationToken cancellationToken) { var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for password reset."); + ?? throw new RequiredFieldException("tenantCode is required for password reset."); await authService.ResetPasswordAsync( new PasswordResetRequest( tenantId, @@ -304,10 +296,7 @@ public sealed class AuthController( AuthenticatedPasswordChangeDto request, CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) - { - return Unauthorized(); - } + if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); var result = await authService.ChangePasswordAsync( new AuthenticatedPasswordChangeRequest( @@ -323,53 +312,39 @@ public sealed class AuthController( private void ResolveRefreshTokenTenant(string refreshToken) { - if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) - { - return; - } + if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) return; if (locator.Realm == AuthRealm.Platform) { EnsurePlatformHost(); if (tenantContext.IsResolved) - { throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty); - } return; } if (!tenantContext.IsResolved) - { throw new RequiredFieldException( "tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token."); - } tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken); } private void ResolveAuthChallengeTenant(string challengeToken) { - var parts = challengeToken.Split('.', 4, StringSplitOptions.None); - if (parts.Length != 4 || parts[0] != "c1") - { - return; - } + var parts = challengeToken.Split('.', 4); + if (parts.Length != 4 || parts[0] != "c1") return; if (parts[1] == "p" && parts[2] == "-") { EnsurePlatformHost(); if (tenantContext.IsResolved) - { throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty); - } return; } if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved) - { throw new RequiredFieldException( "tenant authentication challenge requires a tenant host or x-tenant-code."); - } tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken); } @@ -388,9 +363,8 @@ public sealed class AuthController( { EnsurePlatformHost(); if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode)) - { - throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host."); - } + throw new RequiredFieldException( + "platform realm does not accept tenantCode and must use a platform host."); return null; } @@ -402,23 +376,19 @@ public sealed class AuthController( { var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken); if (supplied?.TenantId != tenantContext.TenantId.Value) - { throw new TenantContextConflictException( tenantContext.TenantId.Value, supplied?.TenantId ?? Guid.Empty); - } } return tenantContext.TenantId.Value; } if (string.IsNullOrWhiteSpace(tenantCode)) - { throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant."); - } var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken) - ?? throw new TenantNotFoundException(); + ?? throw new TenantNotFoundException(); tenantContextInitializer.Initialize( tenant.TenantId, tenant.TenantCode, @@ -434,8 +404,6 @@ public sealed class AuthController( host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase))) - { throw new RequiredFieldException("platform realm is only available on a configured platform host."); - } } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs index 4574887..b5c19a2 100644 --- a/Tiku.Api/Controllers/BackgroundJobsController.cs +++ b/Tiku.Api/Controllers/BackgroundJobsController.cs @@ -71,6 +71,8 @@ public sealed class BackgroundJobsController( return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved."); } - private Guid ResolveUserId() => - currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved."); -} + private Guid ResolveUserId() + { + return currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved."); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs index caa14e4..749c1a0 100644 --- a/Tiku.Api/Controllers/BrowserAuthController.cs +++ b/Tiku.Api/Controllers/BrowserAuthController.cs @@ -58,11 +58,9 @@ public sealed class BrowserAuthController( EnsureTrustedOrigin(); var realm = request.Realm!.Value; if (realm != AuthRealm.Tenant) - { throw new RequiredFieldException("SMS authentication is only available in the tenant realm."); - } var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); + ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); var result = await smsVerificationService.CreateCodeAsync(new SendSmsCodeRequest( tenantId, request.Phone, @@ -174,9 +172,7 @@ public sealed class BrowserAuthController( { var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie]; if (!string.IsNullOrWhiteSpace(refreshToken)) - { await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken); - } ClearCookies(); return NoContent(); } @@ -202,7 +198,7 @@ public sealed class BrowserAuthController( { EnsureTrustedOrigin(); var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for password reset."); + ?? throw new RequiredFieldException("tenantCode is required for password reset."); var result = await authService.RequestPasswordResetAsync( new PasswordResetCodeRequest( tenantId, @@ -225,7 +221,7 @@ public sealed class BrowserAuthController( { EnsureTrustedOrigin(); var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken) - ?? throw new RequiredFieldException("tenantCode is required for password reset."); + ?? throw new RequiredFieldException("tenantCode is required for password reset."); await authService.ResetPasswordAsync( new PasswordResetRequest( tenantId, @@ -248,10 +244,7 @@ public sealed class BrowserAuthController( CancellationToken cancellationToken) { EnsureTrustedOrigin(); - if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) - { - return Unauthorized(); - } + if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); var result = await authService.ChangePasswordAsync( new AuthenticatedPasswordChangeRequest( @@ -267,22 +260,21 @@ public sealed class BrowserAuthController( private object WriteResult(AuthenticationResult result) { - if (result.User?.Tokens is { } tokens) - { - WriteCookies(tokens); - } + if (result.User?.Tokens is { } tokens) WriteCookies(tokens); return new { status = result.Status.ToString(), - user = result.User is null ? null : new - { - result.User.UserId, - result.User.Phone, - result.User.Email, - result.User.Name, - result.User.Realm, - result.User.Tenant - }, + user = result.User is null + ? null + : new + { + result.User.UserId, + result.User.Phone, + result.User.Email, + result.User.Name, + result.User.Realm, + result.User.Tenant + }, result.ChallengeToken, result.ChallengeExpiresAt }; @@ -319,7 +311,8 @@ public sealed class BrowserAuthController( private void ClearCookies() { Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" }); - Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" }); + Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, + new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" }); Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" }); } @@ -329,12 +322,11 @@ public sealed class BrowserAuthController( if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) || !string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase)) - { throw new BrowserOriginException(); - } } - private async Task ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken) + private async Task ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, + CancellationToken cancellationToken) { if (realm == AuthRealm.Platform) { @@ -344,13 +336,14 @@ public sealed class BrowserAuthController( throw new RequiredFieldException("platform realm is only available on a configured platform host."); return null; } + if (tenantContext.TenantId is { } resolved) return resolved; if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required."); var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken) - ?? throw new TenantNotFoundException(); + ?? throw new TenantNotFoundException(); tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); return tenant.TenantId; } } -public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request."); +public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request."); \ No newline at end of file diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index 5b89d39..b970cb0 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -1,15 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; -using Tiku.Application.Assets; using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Application.StudyContent; using Tiku.Application.Tenancy; -using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -148,7 +148,7 @@ public sealed class CatalogController( } [HttpGet("question-collections")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询可用题集")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -162,7 +162,7 @@ public sealed class CatalogController( } [HttpGet("question-collections/questions")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询题集内题目")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -177,7 +177,7 @@ public sealed class CatalogController( } [HttpGet("practice-blueprints")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询练习蓝图")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -191,7 +191,7 @@ public sealed class CatalogController( } [HttpGet("question-banks")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询题库列表")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -205,7 +205,7 @@ public sealed class CatalogController( } [HttpGet("questions")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询已发布题目")] [EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")] [ProducesResponseType>(StatusCodes.Status200OK)] @@ -220,7 +220,7 @@ public sealed class CatalogController( } [HttpGet("questions/{questionId:guid}")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询题目详情")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -235,7 +235,7 @@ public sealed class CatalogController( } [HttpGet("questions/{questionId:guid}/versions")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询题目版本")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -250,7 +250,7 @@ public sealed class CatalogController( } [HttpGet("vocabulary-units")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("查询词汇单元")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -264,7 +264,7 @@ public sealed class CatalogController( } [HttpGet("vocabulary-words")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("查询词汇单词")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -278,7 +278,7 @@ public sealed class CatalogController( } [HttpGet("handbook-subjects")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询知识手册科目")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -292,7 +292,7 @@ public sealed class CatalogController( } [HttpGet("handbook-chapters")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询知识手册章节")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -306,7 +306,7 @@ public sealed class CatalogController( } [HttpGet("handbook-entries")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询知识手册条目")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -359,7 +359,7 @@ public sealed class CatalogController( } [HttpGet("video-explanations")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] [EndpointSummary("查询视频讲解")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -373,7 +373,7 @@ public sealed class CatalogController( } [HttpGet("question-videos")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] [EndpointSummary("查询题目关联视频")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -387,7 +387,7 @@ public sealed class CatalogController( } [HttpGet("banners")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("查询首页横幅")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -401,7 +401,7 @@ public sealed class CatalogController( } [HttpGet("faqs")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("查询常见问题")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -415,7 +415,7 @@ public sealed class CatalogController( } [HttpGet("announcements")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("查询公告")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -429,7 +429,7 @@ public sealed class CatalogController( } [HttpGet("exam-dates")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("查询考试日期")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -443,7 +443,7 @@ public sealed class CatalogController( } [HttpGet("products")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] + [RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [EndpointSummary("查询可购买产品")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -457,7 +457,7 @@ public sealed class CatalogController( } [HttpGet("svip-plans")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] + [RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [EndpointSummary("查询 SVIP 套餐")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -474,16 +474,10 @@ public sealed class CatalogController( CatalogQueryDto query, CancellationToken cancellationToken) { - if (currentTenant.TenantId.HasValue) - { - return currentTenant.TenantId.Value; - } + if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(tenantCode)) - { - throw new TenantNotFoundException(); - } + if (string.IsNullOrWhiteSpace(tenantCode)) throw new TenantNotFoundException(); var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken); return tenant?.TenantId ?? throw new TenantNotFoundException(); @@ -544,4 +538,4 @@ public sealed class TenantNotFoundException : Exception : base("Tenant was not found.") { } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index eb555e5..adda838 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -1,9 +1,10 @@ +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; -using System.Text; -using System.Text.Json; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Commerce; using Tiku.Application.Security; using Tiku.Application.Tenancy; @@ -14,7 +15,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-交易")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] +[RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] [Route("api/student/commerce")] public sealed class CommerceController( @@ -189,18 +190,13 @@ public sealed class CommerceController( string? tenantCode, CancellationToken cancellationToken) { - if (currentTenant.TenantId.HasValue) - { - return currentTenant.TenantId.Value; - } + 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"); + ?? throw new CommerceException("Tenant was not found.", "tenant_not_found"); tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); return tenant.TenantId; } @@ -208,9 +204,7 @@ public sealed class CommerceController( 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); } @@ -221,9 +215,7 @@ public sealed class CommerceController( CancellationToken cancellationToken) { if (tenantId == Guid.Empty) - { throw new CommerceException("Tenant id is required for payment notification.", "tenant_required"); - } var rawBody = await ReadRawBodyAsync(Request, cancellationToken); using var body = ParseNotificationBody(Request, rawBody, cancellationToken); @@ -246,7 +238,7 @@ public sealed class CommerceController( using var reader = new StreamReader( request.Body, Encoding.UTF8, - detectEncodingFromByteOrderMarks: false, + false, leaveOpen: false); return await reader.ReadToEndAsync(cancellationToken); } @@ -267,11 +259,8 @@ public sealed class CommerceController( return JsonDocument.Parse(JsonSerializer.Serialize(values)); } - if (string.IsNullOrWhiteSpace(rawBody)) - { - return JsonDocument.Parse("{}"); - } + if (string.IsNullOrWhiteSpace(rawBody)) return JsonDocument.Parse("{}"); return JsonDocument.Parse(rawBody); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/CommissionController.cs b/Tiku.Api/Controllers/CommissionController.cs index f2b200c..6dea2b4 100644 --- a/Tiku.Api/Controllers/CommissionController.cs +++ b/Tiku.Api/Controllers/CommissionController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Growth; using Tiku.Application.Security; @@ -9,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-佣金")] [Authorize(Policy = BackendPermissions.TenantCommissionManage)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] +[RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] [Produces("application/json")] [Route("api/tenant/commission")] public sealed class CommissionController( @@ -19,71 +20,109 @@ public sealed class CommissionController( { [HttpGet("settings")] [EndpointSummary("查询佣金配置")] - public async Task> Settings(CancellationToken cancellationToken) => - Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken)); + public async Task> Settings(CancellationToken cancellationToken) + { + return Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken)); + } [HttpPut("settings")] [EndpointSummary("保存佣金配置")] - public async Task> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) => - Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> UpdateSettings(UpdateCommissionSettingsDto request, + CancellationToken cancellationToken) + { + return Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } [HttpPut("member-rate")] [EndpointSummary("调整成员佣金比例")] - public async Task> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) => - Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> MemberRate(UpdateMemberCommissionRateDto request, + CancellationToken cancellationToken) + { + return Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); + } [HttpGet("summary")] [EndpointSummary("查询佣金统计摘要")] - public async Task> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) => - Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + public async Task> Summary([FromQuery] CommissionPeriodQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } [HttpGet("orders")] [EndpointSummary("查询佣金来源订单")] - public async Task>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) => - Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + public async Task>> Orders( + [FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) + { + return Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } [HttpGet("settlements")] [EndpointSummary("查询佣金结算单")] - public async Task>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) => - Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + public async Task>> Settlements( + [FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) + { + return Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } [HttpGet("settlements/export")] [EndpointSummary("导出佣金结算单")] - public async Task> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) => - Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken)); + public async Task> Export([FromQuery] CommissionSettlementExportQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, + cancellationToken)); + } [HttpPost("settlements/generate")] [EndpointSummary("生成佣金结算单")] - public async Task> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) => - Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> Generate(GenerateCommissionSettlementDto request, + CancellationToken cancellationToken) + { + return Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); + } [HttpPost("settlements/status")] [EndpointSummary("更新佣金结算单状态")] - public async Task> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) => - Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> UpdateStatus( + UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) + { + return Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); + } [HttpGet("settlements/proofs")] [EndpointSummary("查询佣金结算凭证")] - public async Task>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) => - Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken)); + public async Task>> Proofs( + [FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) + { + return Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken)); + } [HttpPost("settlements/proofs")] [EndpointSummary("创建佣金结算凭证")] - public async Task> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) => - Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> CreateProof(CreateCommissionProofDto request, + CancellationToken cancellationToken) + { + return Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } [HttpPost("settlements/proofs/status")] [EndpointSummary("更新佣金结算凭证状态")] - public async Task> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) => - Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + public async Task> UpdateProofStatus(UpdateCommissionProofStatusDto request, + CancellationToken cancellationToken) + { + return Ok( + await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } private CommissionAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new CommissionException("Commission admin actor was not resolved.", "commission_access_denied"); - } return new CommissionAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/CrmController.cs b/Tiku.Api/Controllers/CrmController.cs index 944898a..a8cbc96 100644 --- a/Tiku.Api/Controllers/CrmController.cs +++ b/Tiku.Api/Controllers/CrmController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Growth; using Tiku.Application.Security; @@ -9,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-CRM")] [Authorize(Policy = BackendPermissions.TenantCrmManage)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Crm)] +[RequireSaasFeature(SaasFeatureCatalog.Crm)] [Produces("application/json")] [Route("api/tenant/crm")] public sealed class CrmController( @@ -78,10 +79,8 @@ public sealed class CrmController( private CrmAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new CrmException("CRM admin actor was not resolved.", "crm_access_denied"); - } return new CrmAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs index 2b3f7db..f1df1fa 100644 --- a/Tiku.Api/Controllers/HealthController.cs +++ b/Tiku.Api/Controllers/HealthController.cs @@ -32,4 +32,4 @@ public sealed class HealthController(IDependencyReadinessProbe readinessProbe) : var response = new { status = readiness.Ready ? "ready" : "not_ready", readiness.CheckedAt }; return readiness.Ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs index 37232a8..a9e1edb 100644 --- a/Tiku.Api/Controllers/LearningController.cs +++ b/Tiku.Api/Controllers/LearningController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Learning; using Tiku.Application.Security; @@ -9,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-学习")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] [Produces("application/json")] [Route("api/student/learning")] public sealed class LearningController( @@ -42,7 +43,8 @@ public sealed class LearningController( [FromQuery] LearningLimitQueryDto query, CancellationToken cancellationToken) { - return Ok(await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok( + await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken)); } [HttpPost("practice-sessions")] @@ -306,14 +308,11 @@ public sealed class LearningController( private LearningActor ResolveActor() { - if (currentTenant.TenantId is null || currentUser.UserId is null) - { - throw new LearningAccessDeniedException(); - } + if (currentTenant.TenantId is null || currentUser.UserId is null) throw new LearningAccessDeniedException(); return new LearningActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } } public sealed class LearningAccessDeniedException() - : Exception("Learning actor was not resolved."); + : Exception("Learning actor was not resolved."); \ No newline at end of file diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs index 4d3b683..e25872f 100644 --- a/Tiku.Api/Controllers/MeController.cs +++ b/Tiku.Api/Controllers/MeController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Tiku.Application.Security; using Tiku.Application.Auth; +using Tiku.Application.Security; using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -20,16 +20,10 @@ public sealed class MeController( [EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")] public async Task> Get(CancellationToken cancellationToken) { - if (currentUser.UserId is null) - { - return Unauthorized(); - } + if (currentUser.UserId is null) return Unauthorized(); var user = await identityQueries.GetUserAsync(currentUser.UserId.Value, cancellationToken); - if (user is null) - { - return Unauthorized(); - } + if (user is null) return Unauthorized(); return Ok(new MeResponse( user.UserId, @@ -50,10 +44,7 @@ public sealed class MeController( public async Task>> Sessions( CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) - { - return Unauthorized(); - } + if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); return Ok(await sessionStore.ListActiveAsync(userId, sessionId, cancellationToken)); } @@ -65,10 +56,7 @@ public sealed class MeController( Guid sessionFamilyId, CancellationToken cancellationToken) { - if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) - { - return Unauthorized(); - } + if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); await sessionStore.RevokeOwnedFamilyAsync(userId, sessionId, sessionFamilyId, cancellationToken); return NoContent(); @@ -76,7 +64,7 @@ public sealed class MeController( } /// -/// 当前用户基础信息和租户成员摘要。 +/// 当前用户基础信息和租户成员摘要。 /// /// 用户 ID。 /// 手机号。 @@ -91,7 +79,7 @@ public sealed record MeResponse( IReadOnlyCollection Tenants); /// -/// 用户在某个租户内的成员摘要。 +/// 用户在某个租户内的成员摘要。 /// /// 租户 ID。 /// 租户名称。 @@ -103,4 +91,4 @@ public sealed record TenantMembershipResponse( string TenantName, string TenantSlug, TenantRole Role, - MembershipStatus Status); + MembershipStatus Status); \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformAdminController.cs b/Tiku.Api/Controllers/PlatformAdminController.cs index f1cc17d..ff734c4 100644 --- a/Tiku.Api/Controllers/PlatformAdminController.cs +++ b/Tiku.Api/Controllers/PlatformAdminController.cs @@ -2,11 +2,11 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; -using Tiku.Application.PlatformAdmin; +using Tiku.Api.OpenApi; using Tiku.Application.Auth; +using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Platform; -using Tiku.Api.OpenApi; namespace Tiku.Api.Controllers; @@ -46,7 +46,8 @@ public sealed class PlatformAdminController( [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreateTenant( CreatePlatformTenantDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, CancellationToken cancellationToken) { return Ok(await platformAdminService.CreateTenantAsync( @@ -61,8 +62,11 @@ public sealed class PlatformAdminController( public Task ReplacePrimaryDomain( Guid tenantId, ReplacePlatformPrimaryDomainDto request, - CancellationToken cancellationToken) => - platformAdminService.ReplacePrimaryDomainAsync(ResolveActor(), request.ToCommand(tenantId), cancellationToken); + CancellationToken cancellationToken) + { + return platformAdminService.ReplacePrimaryDomainAsync(ResolveActor(), request.ToCommand(tenantId), + cancellationToken); + } [HttpPost("tenants/{tenantId:guid}/owner-activation-links")] [Authorize(Policy = BackendPermissions.PlatformTenantManage)] @@ -71,16 +75,21 @@ public sealed class PlatformAdminController( public Task IssueOwnerActivationLink( Guid tenantId, IssuePlatformOwnerActivationLinkDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, - CancellationToken cancellationToken) => - platformAdminService.IssueOwnerActivationLinkAsync( + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, + CancellationToken cancellationToken) + { + return platformAdminService.IssueOwnerActivationLinkAsync( ResolveActor(), request.ToCommand(tenantId, idempotencyKey), cancellationToken); + } [HttpGet("tenants/{tenantId:guid}/billing-policy")] [Authorize(Policy = BackendPermissions.PlatformTenantManage)] [EndpointSummary("查询租户收款策略")] - public Task GetBillingPolicy(Guid tenantId, CancellationToken cancellationToken) => - platformAdminService.GetTenantBillingPolicyAsync(ResolveActor(), tenantId, cancellationToken); + public Task GetBillingPolicy(Guid tenantId, CancellationToken cancellationToken) + { + return platformAdminService.GetTenantBillingPolicyAsync(ResolveActor(), tenantId, cancellationToken); + } [HttpPut("tenants/{tenantId:guid}/billing-policy")] [Authorize(Policy = BackendPermissions.PlatformTenantManage)] @@ -88,8 +97,11 @@ public sealed class PlatformAdminController( public Task UpsertBillingPolicy( Guid tenantId, UpsertTenantBillingPolicyDto request, - CancellationToken cancellationToken) => - platformAdminService.UpsertTenantBillingPolicyAsync(ResolveActor(), request.ToCommand(tenantId), cancellationToken); + CancellationToken cancellationToken) + { + return platformAdminService.UpsertTenantBillingPolicyAsync(ResolveActor(), request.ToCommand(tenantId), + cancellationToken); + } [HttpGet("tenants/detail")] [Authorize(Policy = BackendPermissions.PlatformTenantManage)] @@ -110,10 +122,12 @@ public sealed class PlatformAdminController( [ProducesResponseType(StatusCodes.Status202Accepted)] public async Task> TenantStatus( UpdatePlatformTenantStatusDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, CancellationToken cancellationToken) { - var result = await approvalService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), idempotencyKey, cancellationToken); + var result = await approvalService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), idempotencyKey, + cancellationToken); return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result); } @@ -125,7 +139,8 @@ public sealed class PlatformAdminController( UpsertPlatformTenantBillingProfileDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("domains")] @@ -180,7 +195,8 @@ public sealed class PlatformAdminController( UpdatePlatformStaffStatusDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("staff/{userId:guid}/password-reset")] @@ -234,7 +250,8 @@ public sealed class PlatformAdminController( UpdatePlatformAuditAlertStatusDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("saas/dunning/channels")] @@ -245,7 +262,8 @@ public sealed class PlatformAdminController( [FromQuery] PlatformAdminQueryDto query, CancellationToken cancellationToken) { - return Ok(await platformAdminService.GetBillingDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + return Ok(await platformAdminService.GetBillingDunningChannelsAsync(ResolveActor(), query.ToQuery(), + cancellationToken)); } [HttpPut("saas/dunning/channels")] @@ -256,7 +274,8 @@ public sealed class PlatformAdminController( UpsertPlatformBillingDunningChannelDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.UpsertBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.UpsertBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("saas/dunning/channels/disable")] @@ -267,7 +286,8 @@ public sealed class PlatformAdminController( DisablePlatformBillingDunningChannelDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.DisableBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.DisableBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("saas/dunning/events")] @@ -278,7 +298,8 @@ public sealed class PlatformAdminController( [FromQuery] PlatformAdminQueryDto query, CancellationToken cancellationToken) { - return Ok(await platformAdminService.GetBillingDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + return Ok(await platformAdminService.GetBillingDunningEventsAsync(ResolveActor(), query.ToQuery(), + cancellationToken)); } [HttpGet("saas/dunning/events/detail")] @@ -289,7 +310,8 @@ public sealed class PlatformAdminController( [FromQuery] Guid eventId, CancellationToken cancellationToken) { - return Ok(await platformAdminService.GetBillingDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken)); + return Ok(await platformAdminService.GetBillingDunningEventDetailAsync(ResolveActor(), eventId, + cancellationToken)); } [HttpPost("saas/dunning/events/retry")] @@ -300,7 +322,8 @@ public sealed class PlatformAdminController( RetryPlatformBillingDunningEventDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.RetryBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.RetryBillingDunningEventAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("saas/dunning/events/acknowledge")] @@ -310,7 +333,8 @@ public sealed class PlatformAdminController( ResolvePlatformBillingDunningEventDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.AcknowledgeBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.AcknowledgeBillingDunningEventAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("saas/dunning/events/ignore")] @@ -320,16 +344,15 @@ public sealed class PlatformAdminController( ResolvePlatformBillingDunningEventDto request, CancellationToken cancellationToken) { - return Ok(await platformAdminService.IgnoreBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await platformAdminService.IgnoreBillingDunningEventAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } private PlatformAdminActor ResolveActor() { if (currentUser.UserId is not { } userId) - { throw new PlatformAdminException("Platform admin actor was not resolved.", "platform_access_denied"); - } return new PlatformAdminActor(userId); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformApprovalsController.cs b/Tiku.Api/Controllers/PlatformApprovalsController.cs index c0c7da5..cf05664 100644 --- a/Tiku.Api/Controllers/PlatformApprovalsController.cs +++ b/Tiku.Api/Controllers/PlatformApprovalsController.cs @@ -21,18 +21,24 @@ public sealed class PlatformApprovalsController( public async Task> Requests( PlatformApprovalRequestStatus? status, int limit = 100, - CancellationToken cancellationToken = default) => - await approvalService.ListAsync(await ActorAsync(cancellationToken), status, limit, cancellationToken); + CancellationToken cancellationToken = default) + { + return await approvalService.ListAsync(await ActorAsync(cancellationToken), status, limit, cancellationToken); + } [HttpGet("{requestId:guid}")] [EndpointSummary("查询平台审批详情")] - public async Task Details(Guid requestId, CancellationToken cancellationToken) => - await approvalService.GetAsync(await ActorAsync(cancellationToken), requestId, cancellationToken); + public async Task Details(Guid requestId, CancellationToken cancellationToken) + { + return await approvalService.GetAsync(await ActorAsync(cancellationToken), requestId, cancellationToken); + } [HttpGet("policies")] [EndpointSummary("查询平台审批策略")] - public async Task> Policies(CancellationToken cancellationToken) => - await approvalService.ListPoliciesAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task> Policies(CancellationToken cancellationToken) + { + return await approvalService.ListPoliciesAsync(await ActorAsync(cancellationToken), cancellationToken); + } [HttpPut("policies/{code}")] [Authorize(Policy = BackendPermissions.PlatformApprovalPolicyManage)] @@ -41,27 +47,42 @@ public sealed class PlatformApprovalsController( public async Task UpdatePolicy( string code, UpdatePlatformApprovalPolicyDto request, - CancellationToken cancellationToken) => - await approvalService.UpdatePolicyAsync(await ActorAsync(cancellationToken), request.ToCommand(code), cancellationToken); + CancellationToken cancellationToken) + { + return await approvalService.UpdatePolicyAsync(await ActorAsync(cancellationToken), request.ToCommand(code), + cancellationToken); + } [HttpPost("{requestId:guid}/approve")] [Authorize(Policy = BackendPermissions.PlatformApprovalDecide)] [EndpointSummary("批准并执行平台审批任务")] [PlatformOperationRisk("high")] - public async Task Approve(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) => - await approvalService.ApproveAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken); + public async Task Approve(Guid requestId, PlatformApprovalDecisionDto request, + CancellationToken cancellationToken) + { + return await approvalService.ApproveAsync(await ActorAsync(cancellationToken), requestId, request.Reason, + cancellationToken); + } [HttpPost("{requestId:guid}/reject")] [Authorize(Policy = BackendPermissions.PlatformApprovalDecide)] [EndpointSummary("拒绝平台审批任务")] [PlatformOperationRisk("high")] - public async Task Reject(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) => - await approvalService.RejectAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken); + public async Task Reject(Guid requestId, PlatformApprovalDecisionDto request, + CancellationToken cancellationToken) + { + return await approvalService.RejectAsync(await ActorAsync(cancellationToken), requestId, request.Reason, + cancellationToken); + } [HttpPost("{requestId:guid}/cancel")] [EndpointSummary("撤销本人提交的平台审批任务")] - public async Task Cancel(Guid requestId, PlatformApprovalDecisionDto request, CancellationToken cancellationToken) => - await approvalService.CancelAsync(await ActorAsync(cancellationToken), requestId, request.Reason, cancellationToken); + public async Task Cancel(Guid requestId, PlatformApprovalDecisionDto request, + CancellationToken cancellationToken) + { + return await approvalService.CancelAsync(await ActorAsync(cancellationToken), requestId, request.Reason, + cancellationToken); + } private async Task ActorAsync(CancellationToken cancellationToken) { @@ -70,4 +91,4 @@ public sealed class PlatformApprovalsController( ? new PlatformApprovalActor(userId, access.PlatformPermissions) : throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied"); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformBackofficeController.cs b/Tiku.Api/Controllers/PlatformBackofficeController.cs index 41903a1..1d69fb8 100644 --- a/Tiku.Api/Controllers/PlatformBackofficeController.cs +++ b/Tiku.Api/Controllers/PlatformBackofficeController.cs @@ -1,11 +1,11 @@ +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; -using Tiku.Application.Backoffice; -using Tiku.Application.Security; -using System.ComponentModel.DataAnnotations; using Tiku.Api.OpenApi; +using Tiku.Application.Backoffice; using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; namespace Tiku.Api.Controllers; @@ -35,7 +35,8 @@ public sealed class PlatformBackofficeController( [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetBootstrap(CancellationToken cancellationToken) { - return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolveActorAsync(cancellationToken), cancellationToken)); + return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolveActorAsync(cancellationToken), + cancellationToken)); } [HttpPost("roles")] @@ -46,7 +47,8 @@ public sealed class PlatformBackofficeController( UpsertBackofficeRoleDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(), cancellationToken)); + return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(), cancellationToken)); } [HttpPut("roles/{roleId:guid}/bindings")] @@ -58,10 +60,12 @@ public sealed class PlatformBackofficeController( public async Task> ReplaceRoleBindings( Guid roleId, ReplaceRoleBindingsDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, CancellationToken cancellationToken) { - var result = await approvalService.ReplaceRoleBindingsAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(roleId), idempotencyKey, cancellationToken); + var result = await approvalService.ReplaceRoleBindingsAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(roleId), idempotencyKey, cancellationToken); return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result); } @@ -74,7 +78,8 @@ public sealed class PlatformBackofficeController( ReplaceUserRolesDto request, CancellationToken cancellationToken) { - await backofficeService.ReplacePlatformUserRolesAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken); + await backofficeService.ReplacePlatformUserRolesAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(userId), cancellationToken); return NoContent(); } @@ -82,4 +87,4 @@ public sealed class PlatformBackofficeController( { return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken)); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformBillingCallbackController.cs b/Tiku.Api/Controllers/PlatformBillingCallbackController.cs index c12890b..8adf1dd 100644 --- a/Tiku.Api/Controllers/PlatformBillingCallbackController.cs +++ b/Tiku.Api/Controllers/PlatformBillingCallbackController.cs @@ -33,16 +33,15 @@ public sealed class PlatformBillingCallbackController( { body = JsonDocument.Parse("{}").RootElement.Clone(); } + var normalizedProvider = provider.Trim().ToLowerInvariant().Replace('-', '_'); await notificationService.ProcessAsync(new PlatformBillingNotification( normalizedProvider, - Request.Headers.ToDictionary(value => value.Key, value => value.Value.ToString(), StringComparer.OrdinalIgnoreCase), + Request.Headers.ToDictionary(value => value.Key, value => value.Value.ToString(), + StringComparer.OrdinalIgnoreCase), rawBody, body), cancellationToken); - if (normalizedProvider is "alipay" or "ali_pay") - { - return Content("success", "text/plain", Encoding.UTF8); - } + if (normalizedProvider is "alipay" or "ali_pay") return Content("success", "text/plain", Encoding.UTF8); return Ok(new { code = "SUCCESS", message = "成功" }); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformGovernanceController.cs b/Tiku.Api/Controllers/PlatformGovernanceController.cs index c5b6679..8c3b2ec 100644 --- a/Tiku.Api/Controllers/PlatformGovernanceController.cs +++ b/Tiku.Api/Controllers/PlatformGovernanceController.cs @@ -19,65 +19,105 @@ public sealed class PlatformGovernanceController( [HttpGet("configuration/definitions")] [Authorize(Policy = BackendPermissions.PlatformConfigurationManage)] [EndpointSummary("查询类型化平台配置定义")] - public async Task> ConfigurationDefinitions(CancellationToken cancellationToken) => - await governanceService.GetConfigurationDefinitionsAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task> ConfigurationDefinitions( + CancellationToken cancellationToken) + { + return await governanceService.GetConfigurationDefinitionsAsync(await ActorAsync(cancellationToken), + cancellationToken); + } [HttpGet("configuration/versions")] [Authorize(Policy = BackendPermissions.PlatformConfigurationManage)] [EndpointSummary("查询平台配置版本")] - public async Task> ConfigurationVersions(string definitionCode, string? environment, CancellationToken cancellationToken) => - await governanceService.GetConfigurationVersionsAsync(await ActorAsync(cancellationToken), definitionCode, environment, cancellationToken); + public async Task> ConfigurationVersions( + string definitionCode, string? environment, CancellationToken cancellationToken) + { + return await governanceService.GetConfigurationVersionsAsync(await ActorAsync(cancellationToken), + definitionCode, environment, cancellationToken); + } [HttpPost("configuration/drafts")] [Authorize(Policy = BackendPermissions.PlatformConfigurationManage)] [EndpointSummary("保存平台配置草稿")] - public async Task SaveConfigurationDraft(SavePlatformConfigurationDraftDto request, CancellationToken cancellationToken) => - await governanceService.SaveConfigurationDraftAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken); + public async Task SaveConfigurationDraft( + SavePlatformConfigurationDraftDto request, CancellationToken cancellationToken) + { + return await governanceService.SaveConfigurationDraftAsync(await ActorAsync(cancellationToken), + request.ToCommand(), cancellationToken); + } [HttpPost("configuration/versions/{versionId:guid}/publish")] [Authorize(Policy = BackendPermissions.PlatformConfigurationManage)] [EndpointSummary("发布平台配置版本")] [PlatformOperationRisk("high")] - public async Task PublishConfiguration(Guid versionId, CancellationToken cancellationToken) => - await governanceService.PublishConfigurationAsync(await ActorAsync(cancellationToken), versionId, cancellationToken); + public async Task PublishConfiguration(Guid versionId, + CancellationToken cancellationToken) + { + return await governanceService.PublishConfigurationAsync(await ActorAsync(cancellationToken), versionId, + cancellationToken); + } [HttpPost("configuration/versions/{versionId:guid}/rollback")] [Authorize(Policy = BackendPermissions.PlatformConfigurationManage)] [EndpointSummary("回滚平台配置版本")] [PlatformOperationRisk("high")] - public async Task RollbackConfiguration(Guid versionId, PlatformRollbackDto request, CancellationToken cancellationToken) => - await governanceService.RollbackConfigurationAsync(await ActorAsync(cancellationToken), versionId, request.Reason, cancellationToken); + public async Task RollbackConfiguration(Guid versionId, + PlatformRollbackDto request, CancellationToken cancellationToken) + { + return await governanceService.RollbackConfigurationAsync(await ActorAsync(cancellationToken), versionId, + request.Reason, cancellationToken); + } [HttpGet("notifications/templates")] [Authorize(Policy = BackendPermissions.PlatformNotificationManage)] [EndpointSummary("查询平台通知模板")] - public async Task> NotificationTemplates(CancellationToken cancellationToken) => - await governanceService.GetNotificationTemplatesAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task> NotificationTemplates( + CancellationToken cancellationToken) + { + return await governanceService.GetNotificationTemplatesAsync(await ActorAsync(cancellationToken), + cancellationToken); + } [HttpPut("notifications/templates")] [Authorize(Policy = BackendPermissions.PlatformNotificationManage)] [EndpointSummary("新增或更新平台通知模板")] - public async Task UpsertNotificationTemplate(UpsertPlatformNotificationTemplateDto request, CancellationToken cancellationToken) => - await governanceService.UpsertNotificationTemplateAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken); + public async Task UpsertNotificationTemplate( + UpsertPlatformNotificationTemplateDto request, CancellationToken cancellationToken) + { + return await governanceService.UpsertNotificationTemplateAsync(await ActorAsync(cancellationToken), + request.ToCommand(), cancellationToken); + } [HttpPost("notifications/send")] [Authorize(Policy = BackendPermissions.PlatformNotificationManage)] [EndpointSummary("按平台岗位发送通知")] - public async Task> SendNotification(SendPlatformNotificationDto request, CancellationToken cancellationToken) => - await governanceService.SendNotificationAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken); + public async Task> SendNotification( + SendPlatformNotificationDto request, CancellationToken cancellationToken) + { + return await governanceService.SendNotificationAsync(await ActorAsync(cancellationToken), request.ToCommand(), + cancellationToken); + } [HttpGet("notifications/deliveries")] [Authorize(Policy = BackendPermissions.PlatformNotificationManage)] [EndpointSummary("分页查询平台通知投递")] public async Task> NotificationDeliveries( - int page = 1, int pageSize = 50, string? search = null, PlatformNotificationDeliveryStatus? status = null, CancellationToken cancellationToken = default) => - await governanceService.GetNotificationDeliveriesAsync(await ActorAsync(cancellationToken), new PagedQuery(page, pageSize, search), status, cancellationToken); + int page = 1, int pageSize = 50, string? search = null, PlatformNotificationDeliveryStatus? status = null, + CancellationToken cancellationToken = default) + { + return await governanceService.GetNotificationDeliveriesAsync(await ActorAsync(cancellationToken), + new PagedQuery(page, pageSize, search), status, cancellationToken); + } [HttpPost("notifications/deliveries/{deliveryId:guid}/retry")] [Authorize(Policy = BackendPermissions.PlatformNotificationManage)] [EndpointSummary("重试平台通知投递")] - public async Task RetryNotification(Guid deliveryId, CancellationToken cancellationToken) => - await governanceService.RetryNotificationAsync(await ActorAsync(cancellationToken), deliveryId, cancellationToken); + public async Task RetryNotification(Guid deliveryId, + CancellationToken cancellationToken) + { + return await governanceService.RetryNotificationAsync(await ActorAsync(cancellationToken), deliveryId, + cancellationToken); + } private async Task ActorAsync(CancellationToken cancellationToken) { @@ -86,4 +126,4 @@ public sealed class PlatformGovernanceController( ? new PlatformApprovalActor(userId, access.PlatformPermissions) : throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied"); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformOperationsController.cs b/Tiku.Api/Controllers/PlatformOperationsController.cs index 7190f0e..c1aca54 100644 --- a/Tiku.Api/Controllers/PlatformOperationsController.cs +++ b/Tiku.Api/Controllers/PlatformOperationsController.cs @@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Jobs; -using Tiku.Application.Security; using Tiku.Application.PlatformAdmin.Operations; +using Tiku.Application.Security; using Tiku.Domain.Operations; namespace Tiku.Api.Controllers; @@ -95,9 +95,11 @@ public sealed class PlatformOperationsController( [FromQuery] string? jobType, [FromQuery] BackgroundJobStatus? status, [FromQuery] int? limit, - CancellationToken cancellationToken) => - Ok(await backgroundJobService.ListPlatformAsync( + CancellationToken cancellationToken) + { + return Ok(await backgroundJobService.ListPlatformAsync( tenantId, jobType, status, limit ?? 100, cancellationToken)); + } [HttpGet("jobs/{jobId:guid}")] [EndpointSummary("查询平台后台任务详情")] @@ -113,16 +115,22 @@ public sealed class PlatformOperationsController( public async Task> CancelJob( Guid jobId, CancelBackgroundJobDto request, - CancellationToken cancellationToken) => - Ok(await backgroundJobService.RequestCancellationAsync( + CancellationToken cancellationToken) + { + return Ok(await backgroundJobService.RequestCancellationAsync( jobId, null, ResolveUserId(), request.Reason, cancellationToken)); + } [HttpPost("jobs/{jobId:guid}/retry")] [Authorize(Policy = BackendPermissions.PlatformOperationsManage)] [EndpointSummary("重试平台后台任务")] - public async Task> RetryJob(Guid jobId, CancellationToken cancellationToken) => - Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken)); + public async Task> RetryJob(Guid jobId, CancellationToken cancellationToken) + { + return Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken)); + } - private Guid ResolveUserId() => - currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved."); -} + private Guid ResolveUserId() + { + return currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved."); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs b/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs index fbb22f1..917d6c2 100644 --- a/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs +++ b/Tiku.Api/Controllers/PlatformPaymentSettingsController.cs @@ -1,11 +1,11 @@ +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.OpenApi; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Platform; -using System.ComponentModel.DataAnnotations; -using Tiku.Api.OpenApi; namespace Tiku.Api.Controllers; @@ -22,20 +22,28 @@ public sealed class PlatformPaymentSettingsController( [HttpGet("apps")] [EndpointSummary("查询平台支付应用")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task> Apps(string? status, int limit = 100, CancellationToken cancellationToken = default) => - paymentSettingsService.GetAppsAsync(Actor(), status, limit, cancellationToken); + public Task> Apps(string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return paymentSettingsService.GetAppsAsync(Actor(), status, limit, cancellationToken); + } [HttpPut("apps")] [EndpointSummary("新增或更新平台支付应用")] [Authorize(Policy = BackendPermissions.PlatformPaymentWrite)] - public Task UpsertApp(UpsertPlatformPaymentAppDto request, CancellationToken cancellationToken) => - paymentSettingsService.UpsertAppAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertApp(UpsertPlatformPaymentAppDto request, CancellationToken cancellationToken) + { + return paymentSettingsService.UpsertAppAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpGet("channels")] [EndpointSummary("查询平台支付渠道")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task> Channels(Guid? appId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - paymentSettingsService.GetChannelsAsync(Actor(), appId, status, limit, cancellationToken); + public Task> Channels(Guid? appId, string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return paymentSettingsService.GetChannelsAsync(Actor(), appId, status, limit, cancellationToken); + } [HttpPut("channels")] [EndpointSummary("新增或更新平台支付渠道")] @@ -45,32 +53,45 @@ public sealed class PlatformPaymentSettingsController( [ProducesResponseType(StatusCodes.Status202Accepted)] public async Task> UpsertChannel( UpsertPlatformPaymentChannelDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, CancellationToken cancellationToken) { - var result = await approvalService.UpsertPaymentChannelAsync(Actor(), request.ToCommand(), idempotencyKey, cancellationToken); + var result = + await approvalService.UpsertPaymentChannelAsync(Actor(), request.ToCommand(), idempotencyKey, + cancellationToken); return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result); } [HttpPost("channels/{id:guid}/disable")] [EndpointSummary("禁用平台支付渠道")] [Authorize(Policy = BackendPermissions.PlatformPaymentWrite)] - public Task DisableChannel(Guid id, CancellationToken cancellationToken) => - paymentSettingsService.DisableChannelAsync(Actor(), id, cancellationToken); + public Task DisableChannel(Guid id, CancellationToken cancellationToken) + { + return paymentSettingsService.DisableChannelAsync(Actor(), id, cancellationToken); + } [HttpGet("events")] [EndpointSummary("查询平台支付事件")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task> Events(string? status, int limit = 100, CancellationToken cancellationToken = default) => - paymentSettingsService.GetEventsAsync(Actor(), status, limit, cancellationToken); + public Task> Events(string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return paymentSettingsService.GetEventsAsync(Actor(), status, limit, cancellationToken); + } [HttpGet("rebates/summary")] [EndpointSummary("查询平台返佣汇总")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task RebateSummary(CancellationToken cancellationToken) => - paymentSettingsService.GetRebateSummaryAsync(Actor(), cancellationToken); + public Task RebateSummary(CancellationToken cancellationToken) + { + return paymentSettingsService.GetRebateSummaryAsync(Actor(), cancellationToken); + } - private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId - ? new PlatformCapabilityActor(userId) - : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); -} + private PlatformCapabilityActor Actor() + { + return currentUser.UserId is { } userId + ? new PlatformCapabilityActor(userId) + : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs index 83a15e2..6c67a1d 100644 --- a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs +++ b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs @@ -23,7 +23,8 @@ public sealed class PlatformQuestionBanksController( [FromQuery] string? status, CancellationToken cancellationToken) { - return Ok(await service.GetBanksAsync(ResolveActor(), new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken)); + return Ok(await service.GetBanksAsync(ResolveActor(), + new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken)); } [HttpPut] @@ -37,14 +38,16 @@ public sealed class PlatformQuestionBanksController( [HttpPost("{bankId:guid}/archive")] [EndpointSummary("归档平台公共题库")] - public async Task> ArchiveBank(Guid bankId, CancellationToken cancellationToken) + public async Task> ArchiveBank(Guid bankId, + CancellationToken cancellationToken) { return Ok(await service.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken)); } [HttpGet("{bankId:guid}/nodes")] [EndpointSummary("查询公共题库内容结构")] - public async Task>> GetNodes(Guid bankId, CancellationToken cancellationToken) + public async Task>> GetNodes(Guid bankId, + CancellationToken cancellationToken) { return Ok(await service.GetNodesAsync(ResolveActor(), bankId, cancellationToken)); } @@ -69,7 +72,8 @@ public sealed class PlatformQuestionBanksController( [HttpPost("nodes/{nodeId:guid}/archive")] [EndpointSummary("归档公共题库内容节点")] - public async Task> ArchiveNode(Guid nodeId, CancellationToken cancellationToken) + public async Task> ArchiveNode(Guid nodeId, + CancellationToken cancellationToken) { return Ok(await service.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken)); } @@ -149,7 +153,8 @@ public sealed class PlatformQuestionBanksController( AssetUploadConfirmDto request, CancellationToken cancellationToken) { - return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } private PlatformAdminActor ResolveActor() @@ -158,4 +163,4 @@ public sealed class PlatformQuestionBanksController( ? new PlatformAdminActor(userId) : throw new PlatformAdminException("无法识别当前平台员工。", "platform_access_denied"); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformSaasController.cs b/Tiku.Api/Controllers/PlatformSaasController.cs index 4a99a2d..193f783 100644 --- a/Tiku.Api/Controllers/PlatformSaasController.cs +++ b/Tiku.Api/Controllers/PlatformSaasController.cs @@ -2,11 +2,11 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; -using Tiku.Application.PlatformBilling; +using Tiku.Api.OpenApi; using Tiku.Application.PlatformAdmin; +using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Platform; -using Tiku.Api.OpenApi; namespace Tiku.Api.Controllers; @@ -23,94 +23,139 @@ public sealed class PlatformSaasController( [HttpGet("catalog")] [EndpointSummary("查询平台 SaaS 商品目录")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task Catalog(CancellationToken cancellationToken) => - catalogService.GetCatalogAsync(Actor(), cancellationToken); + public Task Catalog(CancellationToken cancellationToken) + { + return catalogService.GetCatalogAsync(Actor(), cancellationToken); + } [HttpPut("features")] [EndpointSummary("新增或更新 SaaS 功能")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task UpsertFeature(UpsertSaasFeatureDto request, CancellationToken cancellationToken) => - catalogService.UpsertFeatureAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertFeature(UpsertSaasFeatureDto request, CancellationToken cancellationToken) + { + return catalogService.UpsertFeatureAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPut("feature-limits")] [EndpointSummary("新增或更新 SaaS 功能限额")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] public Task UpsertFeatureLimit( UpsertSaasFeatureLimitDto request, - CancellationToken cancellationToken) => - catalogService.UpsertLimitDefinitionAsync(Actor(), request.ToCommand(), cancellationToken); + CancellationToken cancellationToken) + { + return catalogService.UpsertLimitDefinitionAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPut("offerings")] [EndpointSummary("新增或更新 SaaS 套餐")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task UpsertOffering(UpsertSaasOfferingDto request, CancellationToken cancellationToken) => - catalogService.UpsertOfferingAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertOffering(UpsertSaasOfferingDto request, CancellationToken cancellationToken) + { + return catalogService.UpsertOfferingAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPut("offering-versions")] [EndpointSummary("新增或更新 SaaS 套餐版本草稿")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task UpsertVersion(UpsertSaasOfferingVersionDto request, CancellationToken cancellationToken) => - catalogService.UpsertDraftVersionAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertVersion(UpsertSaasOfferingVersionDto request, + CancellationToken cancellationToken) + { + return catalogService.UpsertDraftVersionAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPost("offering-versions/{versionId:guid}/publish")] [EndpointSummary("发布 SaaS 套餐版本")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task PublishVersion(Guid versionId, CancellationToken cancellationToken) => - catalogService.PublishVersionAsync(Actor(), versionId, cancellationToken); + public Task PublishVersion(Guid versionId, CancellationToken cancellationToken) + { + return catalogService.PublishVersionAsync(Actor(), versionId, cancellationToken); + } [HttpPost("offering-versions/{versionId:guid}/clone")] [EndpointSummary("克隆 SaaS 套餐版本")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task CloneVersion(Guid versionId, CancellationToken cancellationToken) => - catalogService.CloneVersionAsync(Actor(), versionId, cancellationToken); + public Task CloneVersion(Guid versionId, CancellationToken cancellationToken) + { + return catalogService.CloneVersionAsync(Actor(), versionId, cancellationToken); + } [HttpPost("offering-versions/{versionId:guid}/retire")] [EndpointSummary("下架 SaaS 套餐版本")] [Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)] - public Task RetireVersion(Guid versionId, CancellationToken cancellationToken) => - catalogService.RetireVersionAsync(Actor(), versionId, cancellationToken); + public Task RetireVersion(Guid versionId, CancellationToken cancellationToken) + { + return catalogService.RetireVersionAsync(Actor(), versionId, cancellationToken); + } [HttpGet("orders")] [EndpointSummary("查询平台 SaaS 订单")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Orders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetOrdersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> Orders(Guid? tenantId, string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return billingService.GetOrdersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpGet("payments")] [EndpointSummary("查询平台 SaaS 支付记录")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Payments(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetPaymentsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> Payments(Guid? tenantId, string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return billingService.GetPaymentsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpGet("refunds")] [EndpointSummary("查询平台 SaaS 退款记录")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Refunds(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetRefundsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> Refunds(Guid? tenantId, string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return billingService.GetRefundsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpGet("invoices")] [EndpointSummary("查询平台 SaaS 发票")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> Invoices(Guid? tenantId, string? status, int limit = 100, + CancellationToken cancellationToken = default) + { + return billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpGet("usage")] [EndpointSummary("查询租户 SaaS 用量")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Usage(Guid? tenantId, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetUsageAsync(Actor(), new PlatformBillingAdminQuery(tenantId, null, limit), cancellationToken); + public Task> Usage(Guid? tenantId, int limit = 100, + CancellationToken cancellationToken = default) + { + return billingService.GetUsageAsync(Actor(), new PlatformBillingAdminQuery(tenantId, null, limit), + cancellationToken); + } [HttpGet("invoices/reminders")] [EndpointSummary("查询平台 SaaS 账单提醒")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> InvoiceReminders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> InvoiceReminders(Guid? tenantId, string? status, + int limit = 100, CancellationToken cancellationToken = default) + { + return billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpGet("subscriptions")] [EndpointSummary("查询租户 SaaS 订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task> Subscriptions(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) => - billingService.GetSubscriptionsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken); + public Task> Subscriptions(Guid? tenantId, string? status, + int limit = 100, CancellationToken cancellationToken = default) + { + return billingService.GetSubscriptionsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), + cancellationToken); + } [HttpPost("payments/manual/confirm")] [EndpointSummary("确认平台手工支付")] @@ -120,51 +165,74 @@ public sealed class PlatformSaasController( [ProducesResponseType(StatusCodes.Status202Accepted)] public async Task> ConfirmManualPayment( ConfirmManualPlatformPaymentDto request, - [FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey, - CancellationToken cancellationToken) => - CommandResult(await approvalService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), idempotencyKey, cancellationToken)); + [FromHeader(Name = "Idempotency-Key")] [Required] + string idempotencyKey, + CancellationToken cancellationToken) + { + return CommandResult(await approvalService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), + idempotencyKey, cancellationToken)); + } [HttpPut("tenant-feature-overrides")] [EndpointSummary("新增或更新租户功能覆盖规则")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request, CancellationToken cancellationToken) => - billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request, + CancellationToken cancellationToken) + { + return billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpGet("metrics")] [EndpointSummary("查询 SaaS 商业经营指标")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task Metrics(CancellationToken cancellationToken) => - billingService.GetCommercialMetricsAsync(Actor(), cancellationToken); + public Task Metrics(CancellationToken cancellationToken) + { + return billingService.GetCommercialMetricsAsync(Actor(), cancellationToken); + } [HttpPost("subscriptions/trial")] [EndpointSummary("为已有租户补录试用订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task GrantTrial(GrantTenantTrialDto request, CancellationToken cancellationToken) => - billingService.GrantTrialAsync(Actor(), request.ToCommand(), cancellationToken); + public Task GrantTrial(GrantTenantTrialDto request, CancellationToken cancellationToken) + { + return billingService.GrantTrialAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPost("subscriptions/{subscriptionId:guid}/suspend")] [EndpointSummary("暂停租户 SaaS 订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task SuspendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) => - billingService.SuspendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + public Task SuspendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, + CancellationToken cancellationToken) + { + return billingService.SuspendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + } [HttpPost("subscriptions/{subscriptionId:guid}/resume")] [EndpointSummary("恢复租户 SaaS 订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task ResumeSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) => - billingService.ResumeSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + public Task ResumeSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, + CancellationToken cancellationToken) + { + return billingService.ResumeSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + } [HttpPost("subscriptions/{subscriptionId:guid}/cancel")] [EndpointSummary("立即取消租户 SaaS 订阅")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task CancelSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) => - billingService.CancelSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + public Task CancelSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, + CancellationToken cancellationToken) + { + return billingService.CancelSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + } [HttpPost("subscriptions/{subscriptionId:guid}/extend")] [EndpointSummary("延长租户 SaaS 订阅账期")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task ExtendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, CancellationToken cancellationToken) => - billingService.ExtendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + public Task ExtendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request, + CancellationToken cancellationToken) + { + return billingService.ExtendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken); + } [HttpPost("refunds")] [EndpointSummary("申请平台 SaaS 退款")] @@ -172,31 +240,48 @@ public sealed class PlatformSaasController( [PlatformOperationRisk("high", PlatformApprovalPolicyCodes.FinancialAdjustment)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task> RequestRefund(RequestPlatformRefundDto request, CancellationToken cancellationToken) => - CommandResult(await approvalService.SubmitRefundAsync(Actor(), request.ToCommand(), cancellationToken)); + public async Task> RequestRefund(RequestPlatformRefundDto request, + CancellationToken cancellationToken) + { + return CommandResult(await approvalService.SubmitRefundAsync(Actor(), request.ToCommand(), cancellationToken)); + } [HttpPost("refunds/{refundId:guid}/approve")] [EndpointSummary("批准平台 SaaS 退款")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task ApproveRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) => - billingService.ApproveRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + public Task ApproveRefund(Guid refundId, ReviewPlatformRefundDto request, + CancellationToken cancellationToken) + { + return billingService.ApproveRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + } [HttpPost("refunds/{refundId:guid}/reject")] [EndpointSummary("拒绝平台 SaaS 退款")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task RejectRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) => - billingService.RejectRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + public Task RejectRefund(Guid refundId, ReviewPlatformRefundDto request, + CancellationToken cancellationToken) + { + return billingService.RejectRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + } [HttpPost("refunds/{refundId:guid}/retry")] [EndpointSummary("重试失败的平台 SaaS 退款")] [Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)] - public Task RetryRefund(Guid refundId, ReviewPlatformRefundDto request, CancellationToken cancellationToken) => - billingService.RetryRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + public Task RetryRefund(Guid refundId, ReviewPlatformRefundDto request, + CancellationToken cancellationToken) + { + return billingService.RetryRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken); + } - private SaasCatalogActor Actor() => currentUser.UserId is { } userId - ? new SaasCatalogActor(userId) - : throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied"); + private SaasCatalogActor Actor() + { + return currentUser.UserId is { } userId + ? new SaasCatalogActor(userId) + : throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied"); + } - private ActionResult CommandResult(PlatformCommandSubmission result) => - result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result); -} + private ActionResult CommandResult(PlatformCommandSubmission result) + { + return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs b/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs index 0b244d9..a02c340 100644 --- a/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs +++ b/Tiku.Api/Controllers/PlatformTenantCapabilitiesController.cs @@ -21,36 +21,53 @@ public sealed class PlatformAdminCrmController( [HttpGet("configs")] [EndpointSummary("查询租户 CRM 配置")] [Authorize(Policy = BackendPermissions.PlatformCrmRead)] - public Task> Configs([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - crmService.GetConfigsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Configs( + [FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) + { + return crmService.GetConfigsAsync(Actor(), query.ToQuery(), cancellationToken); + } [HttpPut("configs")] [EndpointSummary("新增或更新租户 CRM 配置")] [Authorize(Policy = BackendPermissions.PlatformCrmWrite)] - public Task UpsertConfig(UpsertPlatformCrmConfigDto request, CancellationToken cancellationToken) => - crmService.UpsertConfigAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertConfig(UpsertPlatformCrmConfigDto request, + CancellationToken cancellationToken) + { + return crmService.UpsertConfigAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpGet("leads")] [EndpointSummary("查询租户 CRM 线索队列")] [Authorize(Policy = BackendPermissions.PlatformCrmRead)] - public Task> Leads([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - crmService.GetLeadsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Leads([FromQuery] PlatformCapabilityQueryDto query, + CancellationToken cancellationToken) + { + return crmService.GetLeadsAsync(Actor(), query.ToQuery(), cancellationToken); + } [HttpPost("leads/retry")] [EndpointSummary("重试租户 CRM 线索推送")] [Authorize(Policy = BackendPermissions.PlatformCrmWrite)] - public Task RetryLead(RetryPlatformCrmLeadDto request, CancellationToken cancellationToken) => - crmService.RetryLeadAsync(Actor(), request.ToCommand(), cancellationToken); + public Task RetryLead(RetryPlatformCrmLeadDto request, CancellationToken cancellationToken) + { + return crmService.RetryLeadAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpGet("logs")] [EndpointSummary("查询租户 CRM 推送日志")] [Authorize(Policy = BackendPermissions.PlatformCrmRead)] - public Task> Logs([FromQuery] PlatformCrmLogQueryDto query, CancellationToken cancellationToken) => - crmService.GetLogsAsync(Actor(), query.TenantId, query.QueueId, query.Limit, cancellationToken); + public Task> Logs([FromQuery] PlatformCrmLogQueryDto query, + CancellationToken cancellationToken) + { + return crmService.GetLogsAsync(Actor(), query.TenantId, query.QueueId, query.Limit, cancellationToken); + } - private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId - ? new PlatformCapabilityActor(userId) - : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + private PlatformCapabilityActor Actor() + { + return currentUser.UserId is { } userId + ? new PlatformCapabilityActor(userId) + : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + } } [ApiController] @@ -65,54 +82,78 @@ public sealed class PlatformAdminSmsController( [HttpGet("channels")] [EndpointSummary("查询租户短信渠道")] [Authorize(Policy = BackendPermissions.PlatformSmsRead)] - public Task> Channels([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - smsService.GetChannelsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Channels( + [FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) + { + return smsService.GetChannelsAsync(Actor(), query.ToQuery(), cancellationToken); + } [HttpPut("channels")] [EndpointSummary("新增或更新租户短信渠道")] [Authorize(Policy = BackendPermissions.PlatformSmsWrite)] - public Task UpsertChannel(UpsertPlatformSmsChannelDto request, CancellationToken cancellationToken) => - smsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertChannel(UpsertPlatformSmsChannelDto request, + CancellationToken cancellationToken) + { + return smsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPost("channels/{id:guid}/disable")] [EndpointSummary("禁用租户短信渠道")] [Authorize(Policy = BackendPermissions.PlatformSmsWrite)] - public Task DisableChannel(Guid id, CancellationToken cancellationToken) => - smsService.DisableChannelAsync(Actor(), id, cancellationToken); + public Task DisableChannel(Guid id, CancellationToken cancellationToken) + { + return smsService.DisableChannelAsync(Actor(), id, cancellationToken); + } [HttpGet("templates")] [EndpointSummary("查询租户短信模板")] [Authorize(Policy = BackendPermissions.PlatformSmsRead)] - public Task> Templates([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - smsService.GetTemplatesAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Templates( + [FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) + { + return smsService.GetTemplatesAsync(Actor(), query.ToQuery(), cancellationToken); + } [HttpPut("templates")] [EndpointSummary("新增或更新租户短信模板")] [Authorize(Policy = BackendPermissions.PlatformSmsWrite)] - public Task UpsertTemplate(UpsertPlatformSmsTemplateDto request, CancellationToken cancellationToken) => - smsService.UpsertTemplateAsync(Actor(), request.ToCommand(), cancellationToken); + public Task UpsertTemplate(UpsertPlatformSmsTemplateDto request, + CancellationToken cancellationToken) + { + return smsService.UpsertTemplateAsync(Actor(), request.ToCommand(), cancellationToken); + } [HttpPost("templates/{id:guid}/submit-review")] [EndpointSummary("提交租户短信模板审核")] [Authorize(Policy = BackendPermissions.PlatformSmsWrite)] - public Task SubmitTemplateReview(Guid id, CancellationToken cancellationToken) => - smsService.SubmitTemplateReviewAsync(Actor(), id, cancellationToken); + public Task SubmitTemplateReview(Guid id, CancellationToken cancellationToken) + { + return smsService.SubmitTemplateReviewAsync(Actor(), id, cancellationToken); + } [HttpPost("templates/{id:guid}/disable")] [EndpointSummary("禁用租户短信模板")] [Authorize(Policy = BackendPermissions.PlatformSmsWrite)] - public Task DisableTemplate(Guid id, CancellationToken cancellationToken) => - smsService.DisableTemplateAsync(Actor(), id, cancellationToken); + public Task DisableTemplate(Guid id, CancellationToken cancellationToken) + { + return smsService.DisableTemplateAsync(Actor(), id, cancellationToken); + } [HttpGet("logs")] [EndpointSummary("查询租户短信发送日志")] [Authorize(Policy = BackendPermissions.PlatformSmsRead)] - public Task> Logs([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - smsService.GetLogsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Logs([FromQuery] PlatformCapabilityQueryDto query, + CancellationToken cancellationToken) + { + return smsService.GetLogsAsync(Actor(), query.ToQuery(), cancellationToken); + } - private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId - ? new PlatformCapabilityActor(userId) - : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + private PlatformCapabilityActor Actor() + { + return currentUser.UserId is { } userId + ? new PlatformCapabilityActor(userId) + : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + } } [ApiController] @@ -127,22 +168,34 @@ public sealed class PlatformAdminTenantPaymentSettingsController( [HttpGet("apps")] [EndpointSummary("查询租户支付应用")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task> Apps([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - paymentService.GetAppsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Apps( + [FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) + { + return paymentService.GetAppsAsync(Actor(), query.ToQuery(), cancellationToken); + } [HttpPut("apps")] [EndpointSummary("新增或更新租户支付应用")] [Authorize(Policy = BackendPermissions.PlatformPaymentWrite)] - public Task UpsertApp(UpsertPlatformTenantPaymentAppDto request, CancellationToken cancellationToken) => - paymentService.UpsertAppAsync(Actor(), request.TenantId, request.ToCommand(), cancellationToken); + public Task UpsertApp(UpsertPlatformTenantPaymentAppDto request, + CancellationToken cancellationToken) + { + return paymentService.UpsertAppAsync(Actor(), request.TenantId, request.ToCommand(), cancellationToken); + } [HttpGet("events")] [EndpointSummary("查询租户支付事件")] [Authorize(Policy = BackendPermissions.PlatformPaymentRead)] - public Task> Events([FromQuery] PlatformCapabilityQueryDto query, CancellationToken cancellationToken) => - paymentService.GetEventsAsync(Actor(), query.ToQuery(), cancellationToken); + public Task> Events([FromQuery] PlatformCapabilityQueryDto query, + CancellationToken cancellationToken) + { + return paymentService.GetEventsAsync(Actor(), query.ToQuery(), cancellationToken); + } - private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId - ? new PlatformCapabilityActor(userId) - : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); -} + private PlatformCapabilityActor Actor() + { + return currentUser.UserId is { } userId + ? new PlatformCapabilityActor(userId) + : throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied"); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs index 7ba7b59..fd0b626 100644 --- a/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs +++ b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs @@ -17,15 +17,19 @@ public sealed class PlatformTenantLifecycleController( { [HttpGet("archive-preview")] [EndpointSummary("预检租户归档条件")] - public async Task> Preview(Guid tenantId, CancellationToken cancellationToken) => - Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken)); + public async Task> Preview(Guid tenantId, CancellationToken cancellationToken) + { + return Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken)); + } [HttpPost("exports")] [EndpointSummary("创建租户数据导出")] public async Task> CreateExport( Guid tenantId, - CancellationToken cancellationToken) => - Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken)); + CancellationToken cancellationToken) + { + return Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken)); + } [HttpGet("exports/{operationId:guid}")] [EndpointSummary("查询租户数据导出状态")] @@ -43,34 +47,44 @@ public sealed class PlatformTenantLifecycleController( public async Task> DownloadExport( Guid tenantId, Guid operationId, - CancellationToken cancellationToken) => - Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken)); + CancellationToken cancellationToken) + { + return Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken)); + } [HttpPost("archive")] [EndpointSummary("逻辑归档租户")] public async Task> Archive( Guid tenantId, TenantLifecycleReasonDto request, - CancellationToken cancellationToken) => - Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken)); + CancellationToken cancellationToken) + { + return Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken)); + } [HttpPost("restore")] [EndpointSummary("恢复租户到暂停状态")] public async Task> Restore( Guid tenantId, TenantLifecycleReasonDto request, - CancellationToken cancellationToken) => - Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken)); + CancellationToken cancellationToken) + { + return Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken)); + } [HttpPost("owner-transfer")] [EndpointSummary("转移租户所有者")] public async Task> TransferOwner( Guid tenantId, TenantOwnerTransferDto request, - CancellationToken cancellationToken) => - Ok(await lifecycleService.TransferOwnerAsync( + CancellationToken cancellationToken) + { + return Ok(await lifecycleService.TransferOwnerAsync( tenantId, ResolveUserId(), request.TargetUserId, request.Reason, cancellationToken)); + } - private Guid ResolveUserId() => - currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved."); -} + private Guid ResolveUserId() + { + return currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved."); + } +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/PointsController.cs b/Tiku.Api/Controllers/PointsController.cs index fb70a12..f74df01 100644 --- a/Tiku.Api/Controllers/PointsController.cs +++ b/Tiku.Api/Controllers/PointsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Points; using Tiku.Application.Security; @@ -9,7 +10,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-积分")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] +[RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] [Route("api/student/points")] public sealed class PointsController( @@ -93,10 +94,8 @@ public sealed class PointsController( private PointActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new PointException("Current point actor was not resolved.", "point_access_denied"); - } return new PointActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/ProfileController.cs b/Tiku.Api/Controllers/ProfileController.cs index 83e0a98..58d17c9 100644 --- a/Tiku.Api/Controllers/ProfileController.cs +++ b/Tiku.Api/Controllers/ProfileController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Profile; using Tiku.Application.Security; @@ -108,7 +109,7 @@ public sealed class ProfileController( } [HttpPost("check-in")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] + [RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [EndpointSummary("每日签到")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CheckIn(CancellationToken cancellationToken) @@ -117,7 +118,7 @@ public sealed class ProfileController( } [HttpGet("score-events")] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] + [RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [EndpointSummary("查询积分流水")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ScoreEvents( @@ -146,10 +147,8 @@ public sealed class ProfileController( private ProfileActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new ProfileException("Current profile actor was not resolved.", "profile_access_denied"); - } return new ProfileActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/QuestionVideosController.cs b/Tiku.Api/Controllers/QuestionVideosController.cs index 8192ef2..fc52ddc 100644 --- a/Tiku.Api/Controllers/QuestionVideosController.cs +++ b/Tiku.Api/Controllers/QuestionVideosController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Security; @@ -10,7 +11,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-题目视频")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] +[RequireSaasFeature(SaasFeatureCatalog.Video)] [Produces("application/json")] [Route("api/student/questions/videos")] public sealed class QuestionVideosController( @@ -25,7 +26,8 @@ public sealed class QuestionVideosController( [FromQuery] QuestionVideoQueryDto query, CancellationToken cancellationToken) { - return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), query.ToQuery(), + cancellationToken)); } [HttpPost("batch")] @@ -35,16 +37,15 @@ public sealed class QuestionVideosController( QuestionVideoQueryDto request, CancellationToken cancellationToken) { - return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), request.ToQuery(), cancellationToken)); + return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), request.ToQuery(), + cancellationToken)); } private VideoPlaybackActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied"); - } return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/ReferralController.cs b/Tiku.Api/Controllers/ReferralController.cs index 38aa1a2..71e9c8a 100644 --- a/Tiku.Api/Controllers/ReferralController.cs +++ b/Tiku.Api/Controllers/ReferralController.cs @@ -1,16 +1,16 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Growth; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-推荐增长")] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] +[RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)] [Produces("application/json")] [Route("api/student/referral")] public sealed class ReferralController( @@ -202,9 +202,7 @@ public sealed class ReferralController( private ReferralActor ResolveUserActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); - } return new ReferralActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } @@ -212,29 +210,21 @@ public sealed class ReferralController( private ReferralAdminActor ResolveAdminActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new ReferralException("Referral admin actor was not resolved.", "referral_access_denied"); - } return new ReferralAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } private async Task ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken) { - if (currentTenant.TenantId.HasValue) - { - return currentTenant.TenantId.Value; - } + if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; var resolvedTenantCode = tenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(resolvedTenantCode)) - { - throw new TenantNotFoundException(); - } + if (string.IsNullOrWhiteSpace(resolvedTenantCode)) throw new TenantNotFoundException(); var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode.Trim(), cancellationToken) - ?? throw new TenantNotFoundException(); + ?? throw new TenantNotFoundException(); tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); return tenant.TenantId; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/RuntimeController.cs b/Tiku.Api/Controllers/RuntimeController.cs index 943a407..f507cc0 100644 --- a/Tiku.Api/Controllers/RuntimeController.cs +++ b/Tiku.Api/Controllers/RuntimeController.cs @@ -24,25 +24,21 @@ public sealed class RuntimeController( public async Task> Bootstrap(CancellationToken cancellationToken) { if (!tenantContext.TenantId.HasValue) - { return NotFound(new ProblemDetails { Title = "Tenant was not found.", Status = StatusCodes.Status404NotFound }); - } var runtime = await frontendConfigService.GetRuntimeAsync( tenantContext.TenantId.Value, cancellationToken); var etag = $"\"{runtime.TenantCode}-{runtime.ConfigVersion}\""; if (Request.Headers.IfNoneMatch.Any(value => string.Equals(value, etag, StringComparison.Ordinal))) - { return StatusCode(StatusCodes.Status304NotModified); - } Response.Headers.ETag = etag; Response.Headers.CacheControl = "public,max-age=60,must-revalidate"; return Ok(runtime); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/ScorelineController.cs b/Tiku.Api/Controllers/ScorelineController.cs index 6e5541d..dd43491 100644 --- a/Tiku.Api/Controllers/ScorelineController.cs +++ b/Tiku.Api/Controllers/ScorelineController.cs @@ -2,18 +2,18 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Catalog; using Tiku.Application.Scoreline; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-分数线")] [AllowAnonymous] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] +[RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [Produces("application/json")] [Route("api/public/scoreline")] public sealed class ScorelineController( @@ -97,16 +97,10 @@ public sealed class ScorelineController( foreach (var (key, value) in Request.Query) { var prefix = DynamicPrefixes.FirstOrDefault(key.StartsWith); - if (prefix is null) - { - continue; - } + if (prefix is null) continue; var raw = value.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(raw)) - { - continue; - } + if (string.IsNullOrWhiteSpace(raw)) continue; filters.Add(new ScorelineDynamicFilter( prefix.TrimEnd('.'), @@ -121,18 +115,12 @@ public sealed class ScorelineController( ScorelineQueryDto query, CancellationToken cancellationToken) { - if (currentTenant.TenantId.HasValue) - { - return currentTenant.TenantId.Value; - } + if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value; var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(tenantCode)) - { - throw new TenantNotFoundException(); - } + if (string.IsNullOrWhiteSpace(tenantCode)) throw new TenantNotFoundException(); var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken); return tenant?.TenantId ?? throw new TenantNotFoundException(); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs index 80dbb77..38396dc 100644 --- a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs +++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs @@ -44,4 +44,4 @@ public sealed class SecurityDiagnosticsController( currentTenant.TenantId }); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs index a8e1bd5..0befad4 100644 --- a/Tiku.Api/Controllers/TaxonomyController.cs +++ b/Tiku.Api/Controllers/TaxonomyController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Catalog; using Tiku.Application.Security; @@ -16,7 +17,7 @@ public sealed class TaxonomyController( ITaxonomyService taxonomyService) : ControllerBase { [HttpGet] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)] + [RequireSaasFeature(SaasFeatureCatalog.Practice)] [EndpointSummary("查询租户分类节点")] public Task> List(CancellationToken cancellationToken) { @@ -24,7 +25,7 @@ public sealed class TaxonomyController( } [HttpPost] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] + [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] [Authorize(Policy = BackendPermissions.TenantContentManage)] [EndpointSummary("创建租户分类节点")] public Task Create( @@ -38,4 +39,4 @@ public sealed class TaxonomyController( { return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant was not resolved."); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index cf98510..2e5d2dd 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; -using Tiku.Application.Catalog; using Tiku.Application.Auth; +using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -81,7 +80,8 @@ public sealed class TenantAdminDirectController( UpsertTenantAdminClassMemberDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertClassMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertClassMemberAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("classes/members/remove")] @@ -92,7 +92,8 @@ public sealed class TenantAdminDirectController( RemoveTenantAdminClassMemberDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.RemoveClassMemberAsync(ResolveActor(), request.ClassMemberId, cancellationToken)); + return Ok(await tenantAdminService.RemoveClassMemberAsync(ResolveActor(), request.ClassMemberId, + cancellationToken)); } [HttpGet("students")] @@ -125,7 +126,8 @@ public sealed class TenantAdminDirectController( UpdateTenantAdminStudentStatusDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("students/import/preview")] @@ -136,7 +138,8 @@ public sealed class TenantAdminDirectController( TenantAdminStudentImportDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.PreviewStudentImportAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.PreviewStudentImportAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("students/import")] @@ -158,7 +161,8 @@ public sealed class TenantAdminDirectController( TenantAdminBulkAssignClassDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.BulkAssignClassAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.BulkAssignClassAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpPost("students/bulk-status")] @@ -169,14 +173,16 @@ public sealed class TenantAdminDirectController( TenantAdminBulkStatusDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.BulkUpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.BulkUpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("students/supervision/rules")] [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询学习督导规则")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> SupervisionRules(CancellationToken cancellationToken) + public async Task>> SupervisionRules( + CancellationToken cancellationToken) { return Ok(await tenantAdminService.GetSupervisionRulesAsync(ResolveActor(), cancellationToken)); } @@ -189,7 +195,8 @@ public sealed class TenantAdminDirectController( UpsertTenantSupervisionRuleDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertSupervisionRuleAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertSupervisionRuleAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("students/supervision/preview")] @@ -209,7 +216,8 @@ public sealed class TenantAdminDirectController( TenantSupervisionGenerateDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.GenerateSupervisionFollowupsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.GenerateSupervisionFollowupsAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("student-followups/report")] @@ -258,7 +266,8 @@ public sealed class TenantAdminDirectController( UpsertTenantAdminStudentNoteDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertStudentNoteAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertStudentNoteAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("student-followups")] @@ -269,7 +278,8 @@ public sealed class TenantAdminDirectController( [FromQuery] TenantAdminStudentActivityQueryDto query, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.GetStudentFollowupsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok( + await tenantAdminService.GetStudentFollowupsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); } [HttpPut("student-followups")] @@ -280,7 +290,8 @@ public sealed class TenantAdminDirectController( UpsertTenantAdminStudentFollowupDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertStudentFollowupAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertStudentFollowupAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("members")] @@ -374,7 +385,8 @@ public sealed class TenantAdminDirectController( [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("查询可用租户主题模板")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetThemeTemplates(CancellationToken cancellationToken) + public async Task>> GetThemeTemplates( + CancellationToken cancellationToken) { return Ok(await tenantAdminService.GetThemeTemplatesAsync(ResolveActor(), cancellationToken)); } @@ -383,7 +395,8 @@ public sealed class TenantAdminDirectController( [Authorize(Policy = BackendPermissions.TenantSettingsManage)] [EndpointSummary("查询租户当前主题与草稿")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetTheme(CancellationToken cancellationToken) + public async Task>> GetTheme( + CancellationToken cancellationToken) { return Ok(await tenantAdminService.GetThemeAsync(ResolveActor(), cancellationToken)); } @@ -434,7 +447,8 @@ public sealed class TenantAdminDirectController( [Authorize(Policy = BackendPermissions.TenantProviderManage)] [EndpointSummary("查询租户登录 Provider 公开配置")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetAuthProviders(CancellationToken cancellationToken) + public async Task>> GetAuthProviders( + CancellationToken cancellationToken) { return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken)); } @@ -447,7 +461,8 @@ public sealed class TenantAdminDirectController( UpsertTenantIdentityProviderDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("badges")] @@ -513,7 +528,8 @@ public sealed class TenantAdminDirectController( UpsertTenantAdminNotificationDto request, CancellationToken cancellationToken) { - return Ok(await tenantAdminService.UpsertNotificationAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await tenantAdminService.UpsertNotificationAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("feedbacks")] @@ -549,4 +565,4 @@ public sealed class TenantAdminDirectController( throw new TenantAdminDirectException(exception.Message, "tenant_admin_access_denied"); } } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantBackofficeController.cs b/Tiku.Api/Controllers/TenantBackofficeController.cs index e61a20f..8626157 100644 --- a/Tiku.Api/Controllers/TenantBackofficeController.cs +++ b/Tiku.Api/Controllers/TenantBackofficeController.cs @@ -31,7 +31,8 @@ public sealed class TenantBackofficeController( [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetBootstrap(CancellationToken cancellationToken) { - return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveActorAsync(cancellationToken), cancellationToken)); + return Ok(await backofficeService.GetTenantBootstrapAsync(await ResolveActorAsync(cancellationToken), + cancellationToken)); } [HttpPost("roles")] @@ -42,7 +43,8 @@ public sealed class TenantBackofficeController( UpsertBackofficeRoleDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(), cancellationToken)); + return Ok(await backofficeService.UpsertTenantRoleAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(), cancellationToken)); } [HttpPut("roles/{roleId:guid}/bindings")] @@ -54,7 +56,8 @@ public sealed class TenantBackofficeController( ReplaceRoleBindingsDto request, CancellationToken cancellationToken) { - return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken)); + return Ok(await backofficeService.ReplaceTenantRoleBindingsAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(roleId), cancellationToken)); } [HttpPut("users/{userId:guid}/roles")] @@ -66,7 +69,8 @@ public sealed class TenantBackofficeController( ReplaceUserRolesDto request, CancellationToken cancellationToken) { - await backofficeService.ReplaceTenantUserRolesAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken); + await backofficeService.ReplaceTenantUserRolesAsync(await ResolveActorAsync(cancellationToken), + request.ToCommand(userId), cancellationToken); return NoContent(); } @@ -74,4 +78,4 @@ public sealed class TenantBackofficeController( { return BackofficeActor.FromTenantAccess(await currentAccessContext.GetAsync(cancellationToken)); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantBillingController.cs b/Tiku.Api/Controllers/TenantBillingController.cs index 410e748..fe67cd8 100644 --- a/Tiku.Api/Controllers/TenantBillingController.cs +++ b/Tiku.Api/Controllers/TenantBillingController.cs @@ -17,78 +17,122 @@ public sealed class TenantBillingController( { [HttpGet("catalog")] [EndpointSummary("查询租户可购买 SaaS 目录")] - public async Task Catalog(CancellationToken cancellationToken) => - await billingService.GetCatalogAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task Catalog(CancellationToken cancellationToken) + { + return await billingService.GetCatalogAsync(await ActorAsync(cancellationToken), cancellationToken); + } [HttpPost("quotes")] [EndpointSummary("创建租户账务报价")] - public async Task Quote(CreatePlatformBillingQuoteDto request, CancellationToken cancellationToken) => - await billingService.CreateQuoteAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken); + public async Task Quote(CreatePlatformBillingQuoteDto request, + CancellationToken cancellationToken) + { + return await billingService.CreateQuoteAsync(await ActorAsync(cancellationToken), request.ToCommand(), + cancellationToken); + } [HttpPost("orders")] [EndpointSummary("创建租户账务订单")] - public async Task CreateOrder(CreatePlatformBillingOrderDto request, CancellationToken cancellationToken) => - await billingService.CreateOrderAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken); + public async Task CreateOrder(CreatePlatformBillingOrderDto request, + CancellationToken cancellationToken) + { + return await billingService.CreateOrderAsync(await ActorAsync(cancellationToken), request.ToCommand(), + cancellationToken); + } [HttpPost("orders/{orderNo}/payments")] [EndpointSummary("创建租户账务订单支付")] - public async Task CreatePayment(string orderNo, CreatePlatformBillingPaymentDto request, CancellationToken cancellationToken) => - await billingService.CreatePaymentAsync(await ActorAsync(cancellationToken), request.ToCommand(orderNo), cancellationToken); + public async Task CreatePayment(string orderNo, CreatePlatformBillingPaymentDto request, + CancellationToken cancellationToken) + { + return await billingService.CreatePaymentAsync(await ActorAsync(cancellationToken), request.ToCommand(orderNo), + cancellationToken); + } [HttpGet("orders")] [EndpointSummary("查询租户账务订单")] - public async Task> Orders(int limit = 100, CancellationToken cancellationToken = default) => - await billingService.GetOrdersAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + public async Task> Orders(int limit = 100, + CancellationToken cancellationToken = default) + { + return await billingService.GetOrdersAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + } [HttpPost("orders/{orderNo}/cancel")] [EndpointSummary("取消待支付租户账务订单")] - public async Task CancelOrder(string orderNo, CancellationToken cancellationToken) => - await billingService.CancelOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken); + public async Task CancelOrder(string orderNo, CancellationToken cancellationToken) + { + return await billingService.CancelOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken); + } [HttpGet("orders/{orderNo}")] [EndpointSummary("查询租户账务订单详情")] - public async Task Order(string orderNo, CancellationToken cancellationToken) => - await billingService.GetOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken); + public async Task Order(string orderNo, CancellationToken cancellationToken) + { + return await billingService.GetOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken); + } [HttpGet("subscription")] [EndpointSummary("查询当前租户 SaaS 订阅")] - public async Task Subscription(CancellationToken cancellationToken) => - await billingService.GetSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task Subscription(CancellationToken cancellationToken) + { + return await billingService.GetSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken); + } [HttpPost("subscription/change")] [EndpointSummary("变更当前租户 SaaS 订阅")] - public async Task Change(ChangeTenantSubscriptionDto request, CancellationToken cancellationToken) => - await billingService.ChangeSubscriptionAsync(await ActorAsync(cancellationToken), request.ToCommand(), request.IdempotencyKey, cancellationToken); + public async Task Change(ChangeTenantSubscriptionDto request, + CancellationToken cancellationToken) + { + return await billingService.ChangeSubscriptionAsync(await ActorAsync(cancellationToken), request.ToCommand(), + request.IdempotencyKey, cancellationToken); + } [HttpPost("subscription/renew")] [EndpointSummary("续费当前租户 SaaS 订阅")] - public async Task Renew(IdempotentTenantBillingDto request, CancellationToken cancellationToken) => - await billingService.RenewSubscriptionAsync(await ActorAsync(cancellationToken), request.IdempotencyKey, cancellationToken); + public async Task Renew(IdempotentTenantBillingDto request, + CancellationToken cancellationToken) + { + return await billingService.RenewSubscriptionAsync(await ActorAsync(cancellationToken), request.IdempotencyKey, + cancellationToken); + } [HttpPost("subscription/cancel")] [EndpointSummary("取消当前租户 SaaS 订阅")] - public async Task Cancel(CancellationToken cancellationToken) => - await billingService.CancelSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task Cancel(CancellationToken cancellationToken) + { + return await billingService.CancelSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken); + } [HttpGet("usage")] [EndpointSummary("查询当前租户功能用量")] - public async Task> Usage(CancellationToken cancellationToken) => - await billingService.GetUsageAsync(await ActorAsync(cancellationToken), cancellationToken); + public async Task> Usage(CancellationToken cancellationToken) + { + return await billingService.GetUsageAsync(await ActorAsync(cancellationToken), cancellationToken); + } [HttpGet("invoices")] [EndpointSummary("查询当前租户发票")] - public async Task> Invoices(int limit = 100, CancellationToken cancellationToken = default) => - await billingService.GetInvoicesAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + public async Task> Invoices(int limit = 100, + CancellationToken cancellationToken = default) + { + return await billingService.GetInvoicesAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + } [HttpGet("receivables")] [EndpointSummary("查询当前租户应收账单")] - public async Task> Receivables(int limit = 100, CancellationToken cancellationToken = default) => - await billingService.GetReceivablesAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + public async Task> Receivables(int limit = 100, + CancellationToken cancellationToken = default) + { + return await billingService.GetReceivablesAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + } [HttpGet("refunds")] [EndpointSummary("查询当前租户 SaaS 退款")] - public async Task> Refunds(int limit = 100, CancellationToken cancellationToken = default) => - await billingService.GetRefundsAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + public async Task> Refunds(int limit = 100, + CancellationToken cancellationToken = default) + { + return await billingService.GetRefundsAsync(await ActorAsync(cancellationToken), limit, cancellationToken); + } private async Task ActorAsync(CancellationToken cancellationToken) { @@ -97,4 +141,4 @@ public sealed class TenantBillingController( ? new TenantBillingActor(userId, tenantId) : throw new PlatformBillingException("Tenant billing actor was not resolved.", "tenant_access_denied"); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index 11f2815..14a88ec 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Commerce; using Tiku.Application.Jobs; using Tiku.Application.Security; @@ -11,7 +12,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-交易运营")] [Authorize(Policy = BackendPermissions.TenantCommerceOperate)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)] +[RequireSaasFeature(SaasFeatureCatalog.StudentStore)] [Produces("application/json")] [Route("api/tenant/commerce")] public sealed class TenantCommerceController( @@ -504,7 +505,8 @@ public sealed class TenantCommerceController( [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("查询对账异常汇总")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> ReconciliationAnomalies(CancellationToken cancellationToken) + public async Task> ReconciliationAnomalies( + CancellationToken cancellationToken) { return Ok(await commerceAdminService.GetAnomalySummaryAsync( ResolveActor(), @@ -570,9 +572,7 @@ public sealed class TenantCommerceController( private CommerceAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new CommerceException("Tenant commerce actor was not resolved.", "tenant_admin_access_denied"); - } return new CommerceAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } @@ -586,4 +586,4 @@ public sealed class TenantCommerceController( { return new TenantPointQuery(query.Status, query.Limit, query.UserId, query.RegionId); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index fae3bda..8094b4b 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -306,9 +306,8 @@ public sealed class TenantContentController( private AssetManagementActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { - throw new AssetManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied"); - } + throw new AssetManagementException("Tenant content actor was not resolved.", + "tenant_content_access_denied"); return new AssetManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } @@ -316,10 +315,9 @@ public sealed class TenantContentController( private ContentManagementActor ResolveContentActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { - throw new ContentManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied"); - } + throw new ContentManagementException("Tenant content actor was not resolved.", + "tenant_content_access_denied"); return new ContentManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantContentDirectController.cs b/Tiku.Api/Controllers/TenantContentDirectController.cs index e89e674..972a7e9 100644 --- a/Tiku.Api/Controllers/TenantContentDirectController.cs +++ b/Tiku.Api/Controllers/TenantContentDirectController.cs @@ -1,6 +1,8 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; @@ -24,158 +26,170 @@ public sealed class TenantContentDirectController( [HttpPost("questions")] [Authorize(Policy = BackendPermissions.TenantContentManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] + [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] [EndpointSummary("创建题目及首个版本")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> CreateQuestion( DirectQuestionWriteDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.CreateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: true), cancellationToken)); + return Ok(await directContentService.CreateQuestionAsync(ResolveActor(), request.ToCommand(true), + cancellationToken)); } [HttpPatch("questions")] [Authorize(Policy = BackendPermissions.TenantContentManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] + [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)] [EndpointSummary("更新题目并可选择创建新版本")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpdateQuestion( DirectQuestionWriteDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpdateQuestionAsync(ResolveActor(), request.ToCommand(createVersionDefault: false), cancellationToken)); + return Ok(await directContentService.UpdateQuestionAsync(ResolveActor(), request.ToCommand(false), + cancellationToken)); } [HttpGet("vocabulary-units")] [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("查询管理侧词汇单元")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetVocabularyUnits( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetVocabularyUnitsAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("vocabulary-units")] [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("新增或更新词汇单元")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertVocabularyUnit( DirectVocabularyUnitDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertVocabularyUnitAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("vocabulary-words")] [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("查询管理侧词汇")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetVocabularyWords( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetVocabularyWordsAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("vocabulary-words")] [Authorize(Policy = BackendPermissions.TenantVocabularyManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] + [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)] [EndpointSummary("新增或更新词汇")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertVocabularyWord( DirectVocabularyWordDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertVocabularyWordAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("handbook-subjects")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询管理侧知识手册科目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetHandbookSubjects( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetHandbookSubjectsAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("handbook-subjects")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("新增或更新知识手册科目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertHandbookSubject( DirectHandbookSubjectDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertHandbookSubjectAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("handbook-chapters")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询管理侧知识手册章节")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetHandbookChapters( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetHandbookChaptersAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("handbook-chapters")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("新增或更新知识手册章节")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertHandbookChapter( DirectHandbookChapterDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertHandbookChapterAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("handbook-entries")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("查询管理侧知识手册条目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetHandbookEntries( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetHandbookEntriesAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("handbook-entries")] [Authorize(Policy = BackendPermissions.TenantHandbookManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)] + [RequireSaasFeature(SaasFeatureCatalog.Handbook)] [EndpointSummary("新增或更新知识手册条目")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertHandbookEntry( DirectHandbookEntryDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertHandbookEntryAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("scoreline/schools")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询管理侧分数线院校")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetSchools( @@ -187,7 +201,7 @@ public sealed class TenantContentDirectController( [HttpPut("scoreline/schools")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("新增或更新分数线院校")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertSchool( @@ -199,7 +213,7 @@ public sealed class TenantContentDirectController( [HttpGet("scoreline/majors")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询管理侧分数线专业")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetMajors( @@ -211,7 +225,7 @@ public sealed class TenantContentDirectController( [HttpPut("scoreline/majors")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("新增或更新分数线专业")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertMajor( @@ -223,80 +237,86 @@ public sealed class TenantContentDirectController( [HttpGet("scoreline/fields")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询管理侧分数线字段")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetScorelineFields( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetScorelineFieldsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetScorelineFieldsAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("scoreline/fields")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("新增或更新动态分数线字段")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertScorelineField( DirectScorelineFieldDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertScorelineFieldAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertScorelineFieldAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("scoreline/records")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询管理侧分数线记录")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetScorelineRecords( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetScorelineRecordsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetScorelineRecordsAsync(ResolveActor(), query.ToFilter(), + cancellationToken)); } [HttpPut("scoreline/records")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("新增或更新分数线记录")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertScorelineRecord( DirectScorelineRecordDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertScorelineRecordAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertScorelineRecordAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("scoreline/years")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询分数线年份")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetScorelineYears( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok( + await directContentService.GetScorelineYearsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); } [HttpGet("scoreline/trend")] [Authorize(Policy = BackendPermissions.TenantScorelineManage)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)] + [RequireSaasFeature(SaasFeatureCatalog.Scoreline)] [EndpointSummary("查询分数线趋势摘要")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetScorelineTrend( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + return Ok( + await directContentService.GetScorelineTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); } [HttpGet("videos")] [Authorize(Policy = BackendPermissions.TenantVideoManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] [EndpointSummary("查询租户视频解析")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetVideos( @@ -309,7 +329,7 @@ public sealed class TenantContentDirectController( [HttpPut("videos")] [Authorize(Policy = BackendPermissions.TenantVideoManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] [EndpointSummary("新增或更新视频解析")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertVideo( @@ -322,20 +342,21 @@ public sealed class TenantContentDirectController( [HttpPost("question-videos")] [Authorize(Policy = BackendPermissions.TenantVideoManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] + [RequireSaasFeature(SaasFeatureCatalog.Video)] [EndpointSummary("绑定题目与解析视频")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> BindQuestionVideo( DirectQuestionVideoDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok(await directContentService.BindQuestionVideoAsync(ResolveActor(), request.ToCommand(), + cancellationToken)); } [HttpGet("operations/{kind}")] [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("查询运营内容")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> GetOperationContent( @@ -343,13 +364,14 @@ public sealed class TenantContentDirectController( [FromQuery] DirectContentQueryDto query, CancellationToken cancellationToken) { - return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(), cancellationToken)); + return Ok(await directContentService.GetOperationContentAsync(ResolveActor(), kind, query.ToFilter(), + cancellationToken)); } [HttpPut("operations/{kind}")] [Authorize(Policy = BackendPermissions.TenantSiteContentManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)] + [RequireSaasFeature(SaasFeatureCatalog.SiteContent)] [EndpointSummary("新增或更新运营内容")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> UpsertOperationContent( @@ -357,13 +379,14 @@ public sealed class TenantContentDirectController( DirectOperationContentDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(), cancellationToken)); + return Ok(await directContentService.UpsertOperationContentAsync(ResolveActor(), kind, request.ToCommand(), + cancellationToken)); } [HttpPost("imports/preview/{importType}")] [Authorize(Policy = BackendPermissions.TenantJobManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeatureFromRoute("importType")] + [RequireSaasFeatureFromRoute("importType")] [EndpointSummary("预览内容导入数据")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> PreviewImport( @@ -371,13 +394,14 @@ public sealed class TenantContentDirectController( DirectImportDto request, CancellationToken cancellationToken) { - return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: true), cancellationToken)); + return Ok(await directContentService.PreviewImportAsync(ResolveActor(), request.ToCommand(importType, true), + cancellationToken)); } [HttpPost("imports/{importType}")] [Authorize(Policy = BackendPermissions.TenantJobManage)] [Authorize(Policy = TikuPolicies.TenantAllDataScope)] - [Tiku.Api.Security.RequireSaasFeatureFromRoute("importType")] + [RequireSaasFeatureFromRoute("importType")] [EndpointSummary("执行或排队内容导入")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status202Accepted)] @@ -387,14 +411,14 @@ public sealed class TenantContentDirectController( CancellationToken cancellationToken) { var actor = ResolveActor(); - var command = request.ToCommand(importType, dryRun: false); + var command = request.ToCommand(importType, false); if (request.Async == true || command.Items.Count > 100) { var job = await backgroundJobService.EnqueueAsync( new CreateBackgroundJobCommand( actor.TenantId, "content_import", - System.Text.Json.JsonSerializer.SerializeToElement(new + JsonSerializer.SerializeToElement(new { createdBy = actor.UserId, importType = command.ImportType, @@ -467,10 +491,9 @@ public sealed class TenantContentDirectController( private DirectContentActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { - throw new ContentManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied"); - } + throw new ContentManagementException("Tenant content actor was not resolved.", + "tenant_content_access_denied"); return new DirectContentActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantFrontendConfigController.cs b/Tiku.Api/Controllers/TenantFrontendConfigController.cs index 4213340..f2fc3a6 100644 --- a/Tiku.Api/Controllers/TenantFrontendConfigController.cs +++ b/Tiku.Api/Controllers/TenantFrontendConfigController.cs @@ -52,4 +52,4 @@ public sealed class TenantFrontendConfigController( "tenant_not_resolved", "Tenant was not resolved."); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantOnboardingController.cs b/Tiku.Api/Controllers/TenantOnboardingController.cs index 9aab9f4..26192a4 100644 --- a/Tiku.Api/Controllers/TenantOnboardingController.cs +++ b/Tiku.Api/Controllers/TenantOnboardingController.cs @@ -19,9 +19,7 @@ public sealed class TenantOnboardingController( { var access = await accessContext.GetAsync(cancellationToken); if (access.TenantId is not { } tenantId || !access.IsCurrentTenantMember) - { throw new TenantExternalProviderException("Tenant onboarding access is denied.", "tenant_access_denied"); - } return await onboardingService.GetStatusAsync(tenantId, cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantPublicController.cs b/Tiku.Api/Controllers/TenantPublicController.cs index 74d185f..1a9f3e6 100644 --- a/Tiku.Api/Controllers/TenantPublicController.cs +++ b/Tiku.Api/Controllers/TenantPublicController.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; -using Tiku.Domain.Tenancy; using Tiku.Application.Security; using Tiku.Application.Tenancy; +using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -38,7 +38,7 @@ public sealed class TenantPublicController( ? null : await tenantDirectory.FindByHostAsync(host, cancellationToken); - TenantLookupResult? tenant = directoryEntry is null + var tenant = directoryEntry is null ? null : new TenantLookupResult( directoryEntry.TenantId, @@ -49,14 +49,12 @@ public sealed class TenantPublicController( directoryEntry.Host); if (tenant is null) - { return NotFound(new ProblemDetails { Title = "Tenant was not found.", Status = StatusCodes.Status404NotFound, Detail = "No active tenant matches the supplied host or tenantCode." }); - } tenantContextInitializer.Initialize( tenant.Id, @@ -115,10 +113,7 @@ public sealed class TenantPublicController( private static string? NormalizeHost(string? value) { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } + if (string.IsNullOrWhiteSpace(value)) return null; var host = value.Trim().ToLowerInvariant(); return host.Split(':', 2)[0]; @@ -131,4 +126,4 @@ public sealed class TenantPublicController( TenantStatus Status, TenantMode Mode, string? Host); -} +} \ No newline at end of file diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs index c302f1f..a217a1d 100644 --- a/Tiku.Api/Controllers/TenantsController.cs +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -20,20 +20,14 @@ public sealed class TenantsController( [EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")] public async Task> GetCurrent(CancellationToken cancellationToken) { - if (currentUser.UserId is null || currentTenant.TenantId is null) - { - throw new TenantAccessDeniedException(); - } + if (currentUser.UserId is null || currentTenant.TenantId is null) throw new TenantAccessDeniedException(); var membership = await identityQueries.GetTenantMembershipAsync( currentUser.UserId.Value, currentTenant.TenantId.Value, cancellationToken); - if (membership is null) - { - throw new TenantAccessDeniedException(); - } + if (membership is null) throw new TenantAccessDeniedException(); return Ok(new CurrentTenantResponse( membership.TenantId, @@ -45,7 +39,7 @@ public sealed class TenantsController( } /// -/// 当前请求租户和当前用户在该租户内的权限摘要。 +/// 当前请求租户和当前用户在该租户内的权限摘要。 /// /// 租户 ID。 /// 租户名称。 @@ -57,4 +51,4 @@ public sealed record CurrentTenantResponse( string TenantName, string TenantSlug, TenantStatus Status, - TenantRole Role); + TenantRole Role); \ No newline at end of file diff --git a/Tiku.Api/Controllers/VideosController.cs b/Tiku.Api/Controllers/VideosController.cs index a9143ab..0e641f4 100644 --- a/Tiku.Api/Controllers/VideosController.cs +++ b/Tiku.Api/Controllers/VideosController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Api.Security; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Security; @@ -10,7 +11,7 @@ namespace Tiku.Api.Controllers; [ApiController] [Tags("学生端-视频")] [Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)] +[RequireSaasFeature(SaasFeatureCatalog.Video)] [Produces("application/json")] [Route("api/student/videos")] public sealed class VideosController( @@ -45,16 +46,15 @@ public sealed class VideosController( VideoProgressDto request, CancellationToken cancellationToken) { - return Ok(await videoPlaybackService.ReportProgressAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + return Ok( + await videoPlaybackService.ReportProgressAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } private VideoPlaybackActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) - { throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied"); - } return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Logging/SerilogRequestLogging.cs b/Tiku.Api/Logging/SerilogRequestLogging.cs index 43796df..405598e 100644 --- a/Tiku.Api/Logging/SerilogRequestLogging.cs +++ b/Tiku.Api/Logging/SerilogRequestLogging.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Serilog; using Serilog.AspNetCore; using Serilog.Events; @@ -33,13 +34,10 @@ public static class SerilogRequestLogging private static void SetClaim( IDiagnosticContext diagnosticContext, string propertyName, - System.Security.Claims.ClaimsPrincipal principal, + ClaimsPrincipal principal, string claimType) { var value = principal.FindFirst(claimType)?.Value; - if (!string.IsNullOrWhiteSpace(value)) - { - diagnosticContext.Set(propertyName, value); - } + if (!string.IsNullOrWhiteSpace(value)) diagnosticContext.Set(propertyName, value); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs index 320b566..09c52cf 100644 --- a/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs +++ b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Infrastructure.Security; @@ -11,7 +12,7 @@ namespace Tiku.Api.Middleware; public sealed class AuthRateLimitPartitionMiddleware( RequestDelegate next, IRedisSecurityStore redisSecurityStore, - Microsoft.Extensions.Options.IOptions options) + IOptions options) { public AuthRateLimitPartitionMiddleware(RequestDelegate next) : this( @@ -40,10 +41,7 @@ public sealed class AuthRateLimitPartitionMiddleware( if (redisSecurityStore.IsConfigured) { await ConsumeDistributedLimitAsync(context, policy!); - if (context.Response.HasStarted) - { - return; - } + if (context.Response.HasStarted) return; } } @@ -87,9 +85,8 @@ public sealed class AuthRateLimitPartitionMiddleware( if (!result.Allowed) { if (result.RetryAfter is { } retryAfter) - { - context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(); - } + context.Response.Headers.RetryAfter = + Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(); context.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.Response.WriteAsJsonAsync(new { @@ -103,7 +100,7 @@ public sealed class AuthRateLimitPartitionMiddleware( private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName) { - context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384); + context.Request.EnableBuffering(4096, 16_384); try { using var document = await JsonDocument.ParseAsync( @@ -113,9 +110,7 @@ public sealed class AuthRateLimitPartitionMiddleware( (propertyName == "identifier" ? TryGetStringProperty(document.RootElement, "phone") : null); if (captured is { } value && !string.IsNullOrWhiteSpace(value)) - { context.Items[AuthRateLimitPartitionKey.AccountHashItemKey] = Hash(value.Trim()); - } } catch (JsonException) { @@ -127,28 +122,18 @@ public sealed class AuthRateLimitPartitionMiddleware( } finally { - if (context.Request.Body.CanSeek) - { - context.Request.Body.Position = 0; - } + if (context.Request.Body.CanSeek) context.Request.Body.Position = 0; } } private static string? TryGetStringProperty(JsonElement element, string propertyName) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var property in element.EnumerateObject()) - { if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String) - { return property.Value.GetString(); - } - } return null; } @@ -174,4 +159,4 @@ public static class AuthRateLimitPartitionKey : "unknown-account"; return $"{policyName}:{ipAddress}:{accountHash}"; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs b/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs index 80ab541..9e70995 100644 --- a/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs +++ b/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text; using Microsoft.Extensions.Options; using Tiku.Api.Options; @@ -32,20 +33,21 @@ public sealed class BrowserCsrfMiddleware( await next(context); } - private static bool IsUnsafe(string method) => - !HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method); + private static bool IsUnsafe(string method) + { + return !HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method); + } - private static bool IsBrowserCookieRequest(HttpRequest request) => - request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) || - request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie); + private static bool IsBrowserCookieRequest(HttpRequest request) + { + return request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) || + request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie); + } private bool IsTrustedOrigin(HttpRequest request) { var origin = request.Headers.Origin.ToString().Trim().TrimEnd('/'); - if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri)) - { - return false; - } + if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false; var sameOrigin = string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); @@ -57,12 +59,9 @@ public sealed class BrowserCsrfMiddleware( { var cookie = request.Cookies[BrowserAuthOptions.CsrfCookie]; var header = request.Headers[BrowserAuthOptions.CsrfHeader].ToString(); - if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header)) - { - return false; - } - var left = System.Text.Encoding.UTF8.GetBytes(cookie); - var right = System.Text.Encoding.UTF8.GetBytes(header); + if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header)) return false; + var left = Encoding.UTF8.GetBytes(cookie); + var right = Encoding.UTF8.GetBytes(header); return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs b/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs index 4f327e7..dd128dd 100644 --- a/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs +++ b/Tiku.Api/Middleware/CurrentPrincipalMiddleware.cs @@ -11,4 +11,4 @@ public sealed class CurrentPrincipalMiddleware(RequestDelegate next) currentUser.Load(context.User); await next(context); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs b/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs index 41b18f7..31dfc30 100644 --- a/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs +++ b/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs @@ -9,4 +9,4 @@ public sealed class DatabaseRequestMetricsMiddleware(RequestDelegate next) using var metrics = DatabaseRequestMetrics.Begin(); await next(context); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 9ba0dd1..2895015 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -3,21 +3,21 @@ using Tiku.Api.Controllers; using Tiku.Application.Assets; using Tiku.Application.Auth; using Tiku.Application.Backoffice; -using Tiku.Application.Security; using Tiku.Application.Commerce; using Tiku.Application.Content; using Tiku.Application.Growth; using Tiku.Application.Jobs; -using Tiku.Application.Points; -using Tiku.Application.Profile; +using Tiku.Application.Learning; using Tiku.Application.PlatformAdmin; using Tiku.Application.PlatformBilling; +using Tiku.Application.Points; +using Tiku.Application.Profile; using Tiku.Application.QuestionBanks; using Tiku.Application.Scoreline; +using Tiku.Application.Security; using Tiku.Application.Storage; -using Tiku.Application.TenantAdmin; using Tiku.Application.Tenancy; -using Tiku.Application.Learning; +using Tiku.Application.TenantAdmin; namespace Tiku.Api.Middleware; @@ -39,13 +39,16 @@ public sealed class ExceptionHandlingMiddleware( var status = approvalException.Code switch { "approval_request_not_found" or "approval_policy_not_found" => StatusCodes.Status404NotFound, - "platform_access_denied" or "approval_business_permission_required" or "approval_cancel_denied" => StatusCodes.Status403Forbidden, - "approval_request_not_pending" or "approval_request_expired" or "approval_maker_checker_required" or "idempotency_conflict" => StatusCodes.Status409Conflict, + "platform_access_denied" or "approval_business_permission_required" or "approval_cancel_denied" => + StatusCodes.Status403Forbidden, + "approval_request_not_pending" or "approval_request_expired" or "approval_maker_checker_required" + or "idempotency_conflict" => StatusCodes.Status409Conflict, _ => StatusCodes.Status400BadRequest }; await WriteProblemAsync(context, approvalException.Message, status, approvalException.Code); return; } + if (exception is AuthorizationSecurityUnavailableException) { await WriteProblemAsync( @@ -67,7 +70,8 @@ public sealed class ExceptionHandlingMiddleware( var status = ownerActivationException.Code is "owner_activation_consumed" ? StatusCodes.Status409Conflict : StatusCodes.Status400BadRequest; - await WriteProblemAsync(context, ownerActivationException.Message, status, ownerActivationException.Code); + await WriteProblemAsync(context, ownerActivationException.Message, status, + ownerActivationException.Code); return; } @@ -88,7 +92,8 @@ public sealed class ExceptionHandlingMiddleware( "tenant_not_found" => StatusCodes.Status404NotFound, "tenant_export_not_ready" => StatusCodes.Status409Conflict, "tenant_archive_blocked" or "tenant_not_archived" or - "tenant_owner_target_not_active_member" or "tenant_owner_unchanged" => StatusCodes.Status409Conflict, + "tenant_owner_target_not_active_member" + or "tenant_owner_unchanged" => StatusCodes.Status409Conflict, _ => StatusCodes.Status400BadRequest }; await WriteProblemAsync(context, lifecycleException.Message, status, lifecycleException.Code); @@ -97,7 +102,8 @@ public sealed class ExceptionHandlingMiddleware( if (exception is BrowserOriginException) { - await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected"); + await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, + "browser_origin_rejected"); return; } @@ -573,7 +579,8 @@ public sealed class ExceptionHandlingMiddleware( return code switch { "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, - "class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound, + "class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes + .Status404NotFound, "tenant_member_not_found" => StatusCodes.Status404NotFound, _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest @@ -588,7 +595,8 @@ public sealed class ExceptionHandlingMiddleware( "tenant_admin_access_denied" => StatusCodes.Status403Forbidden, "order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" or "coupon_not_found" or "coupon_redemption_not_found" => StatusCodes.Status404NotFound, - "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable, + "payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes + .Status503ServiceUnavailable, "order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or "coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict, _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, @@ -600,7 +608,8 @@ public sealed class ExceptionHandlingMiddleware( { return code switch { - "platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes.Status403Forbidden, + "platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes + .Status403Forbidden, _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest }; @@ -684,4 +693,4 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Middleware/TenantResolutionMiddleware.cs b/Tiku.Api/Middleware/TenantResolutionMiddleware.cs index 1c99100..0b48e09 100644 --- a/Tiku.Api/Middleware/TenantResolutionMiddleware.cs +++ b/Tiku.Api/Middleware/TenantResolutionMiddleware.cs @@ -1,5 +1,5 @@ -using Microsoft.Extensions.Options; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Application.Tenancy; @@ -41,18 +41,14 @@ public sealed class TenantResolutionMiddleware(RequestDelegate next) var tenantCode = context.Request.Headers["x-tenant-code"].FirstOrDefault() ?? context.Request.Query["tenantCode"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(tenantCode)) - { tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), context.RequestAborted); - } } if (tenant is not null) - { tenantInitializer.Initialize( tenant.TenantId, tenant.TenantCode, tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host); - } await next(context); } @@ -71,4 +67,4 @@ public sealed class TenantResolutionMiddleware(RequestDelegate next) { return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); } -} +} \ No newline at end of file diff --git a/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs b/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs index 3da201b..1b7360c 100644 --- a/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs +++ b/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs @@ -14,10 +14,7 @@ internal sealed class BearerSecuritySchemeTransformer( CancellationToken cancellationToken) { var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); - if (authenticationSchemes.All(scheme => scheme.Name != JwtBearerDefaults.AuthenticationScheme)) - { - return; - } + if (authenticationSchemes.All(scheme => scheme.Name != JwtBearerDefaults.AuthenticationScheme)) return; document.Components ??= new OpenApiComponents(); document.Components.SecuritySchemes ??= new Dictionary(); @@ -30,4 +27,4 @@ internal sealed class BearerSecuritySchemeTransformer( Description = "输入 JWT access token。不要带 Bearer 前缀,Scalar 会自动补。" }; } -} +} \ No newline at end of file diff --git a/Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs b/Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs index 9346d96..ea93547 100644 --- a/Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs +++ b/Tiku.Api/OpenApi/PlatformOperationMetadataTransformer.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; using Tiku.Api.Security; +using Tiku.Application.Security; namespace Tiku.Api.OpenApi; @@ -24,29 +25,22 @@ internal sealed class PlatformOperationMetadataTransformer : IOpenApiOperationTr var authorization = context.Description.ActionDescriptor.EndpointMetadata .OfType() .LastOrDefault(); - if (authorization?.Realm != "platform") - { - return Task.CompletedTask; - } + if (authorization?.Realm != "platform") return Task.CompletedTask; if (authorization.Permission is not null) - { operation.AddExtension("x-tiku-required-permission", new JsonNodeExtension(JsonValue.Create(authorization.Permission))); - } var declaredRisk = context.Description.ActionDescriptor.EndpointMetadata .OfType() .LastOrDefault(); var riskLevel = declaredRisk?.RiskLevel ?? - (authorization.Operation == Tiku.Application.Security.CapabilityOperation.Read ? "low" : "medium"); + (authorization.Operation == CapabilityOperation.Read ? "low" : "medium"); operation.AddExtension("x-tiku-risk-level", new JsonNodeExtension(JsonValue.Create(riskLevel))); if (declaredRisk?.ApprovalPolicyCode is not null) - { operation.AddExtension("x-tiku-approval-policy-code", new JsonNodeExtension(JsonValue.Create(declaredRisk.ApprovalPolicyCode))); - } return Task.CompletedTask; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Options/ApiRateLimitOptions.cs b/Tiku.Api/Options/ApiRateLimitOptions.cs index 3330337..8be610b 100644 --- a/Tiku.Api/Options/ApiRateLimitOptions.cs +++ b/Tiku.Api/Options/ApiRateLimitOptions.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Tiku.Api.Options; public sealed class ApiRateLimitOptions @@ -6,12 +8,9 @@ public sealed class ApiRateLimitOptions public bool Enabled { get; set; } = true; - [System.ComponentModel.DataAnnotations.Range(1, 100_000)] - public int PermitLimit { get; set; } = 600; + [Range(1, 100_000)] public int PermitLimit { get; set; } = 600; - [System.ComponentModel.DataAnnotations.Range(1, 86_400)] - public int WindowSeconds { get; set; } = 60; + [Range(1, 86_400)] public int WindowSeconds { get; set; } = 60; - [System.ComponentModel.DataAnnotations.Range(0, 10_000)] - public int QueueLimit { get; set; } -} + [Range(0, 10_000)] public int QueueLimit { get; set; } +} \ No newline at end of file diff --git a/Tiku.Api/Options/AuthRateLimitOptions.cs b/Tiku.Api/Options/AuthRateLimitOptions.cs index 4445227..32ce7b0 100644 --- a/Tiku.Api/Options/AuthRateLimitOptions.cs +++ b/Tiku.Api/Options/AuthRateLimitOptions.cs @@ -6,22 +6,17 @@ public sealed class AuthRateLimitOptions { public const string SectionName = "RateLimiting:Authentication"; - [Range(1, 100)] - public int PasswordPermitLimit { get; set; } = 5; + [Range(1, 100)] public int PasswordPermitLimit { get; set; } = 5; - [Range(1, 86_400)] - public int PasswordWindowSeconds { get; set; } = 900; + [Range(1, 86_400)] public int PasswordWindowSeconds { get; set; } = 900; - [Range(1, 100)] - public int SmsPermitLimit { get; set; } = 5; - - [Range(1, 86_400)] - public int SmsWindowSeconds { get; set; } = 300; + [Range(1, 100)] public int SmsPermitLimit { get; set; } = 5; + [Range(1, 86_400)] public int SmsWindowSeconds { get; set; } = 300; } public static class AuthRateLimitPolicies { public const string Password = "auth-password"; public const string Sms = "auth-sms"; -} +} \ No newline at end of file diff --git a/Tiku.Api/Options/BrowserAuthOptions.cs b/Tiku.Api/Options/BrowserAuthOptions.cs index 87f976d..80fbd11 100644 --- a/Tiku.Api/Options/BrowserAuthOptions.cs +++ b/Tiku.Api/Options/BrowserAuthOptions.cs @@ -9,4 +9,4 @@ public sealed class BrowserAuthOptions public const string CsrfHeader = "X-CSRF-Token"; public string[] AllowedOrigins { get; set; } = []; -} +} \ No newline at end of file diff --git a/Tiku.Api/Options/CorsOptions.cs b/Tiku.Api/Options/CorsOptions.cs index d8fbaae..09c9d9a 100644 --- a/Tiku.Api/Options/CorsOptions.cs +++ b/Tiku.Api/Options/CorsOptions.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Tiku.Api.Options; public sealed class CorsOptions @@ -5,16 +7,15 @@ public sealed class CorsOptions public const string SectionName = "Cors"; public const string PolicyName = "TikuCors"; - [System.ComponentModel.DataAnnotations.Required] - public string[] AllowedOrigins { get; set; } = []; + [Required] public string[] AllowedOrigins { get; set; } = []; - [System.ComponentModel.DataAnnotations.Required] - [System.ComponentModel.DataAnnotations.MinLength(1)] + [Required] + [MinLength(1)] public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"]; - [System.ComponentModel.DataAnnotations.Required] - [System.ComponentModel.DataAnnotations.MinLength(1)] + [Required] + [MinLength(1)] public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; public bool AllowCredentials { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Api/Options/OptionsValidation.cs b/Tiku.Api/Options/OptionsValidation.cs index 0a69930..dd1033e 100644 --- a/Tiku.Api/Options/OptionsValidation.cs +++ b/Tiku.Api/Options/OptionsValidation.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Configuration; using System.Net; using Tiku.Application.Security; @@ -13,15 +12,9 @@ public static class OptionsValidation var connectionString = configuration.GetConnectionString("Database") ?? configuration["DATABASE_URL"]; - if (!string.IsNullOrWhiteSpace(connectionString)) - { - return connectionString; - } + if (!string.IsNullOrWhiteSpace(connectionString)) return connectionString; - if (isDevelopment) - { - return $"Host=localhost;Database=tiku;Username={Environment.UserName}"; - } + if (isDevelopment) return $"Host=localhost;Database=tiku;Username={Environment.UserName}"; throw new InvalidOperationException( "Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."); @@ -34,10 +27,7 @@ public static class OptionsValidation public static bool BeValidCorsOptions(CorsOptions options) { - if (options.AllowCredentials && options.AllowedOrigins.Length == 0) - { - return false; - } + if (options.AllowCredentials && options.AllowedOrigins.Length == 0) return false; return options.AllowedOrigins.All(IsHttpOrigin); } @@ -47,10 +37,7 @@ public static class OptionsValidation IConfiguration configuration, bool isProduction) { - if (!isProduction) - { - return true; - } + if (!isProduction) return true; var platformHosts = options.PlatformHosts .Where(host => !string.IsNullOrWhiteSpace(host)) @@ -74,4 +61,4 @@ public static class OptionsValidation (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && string.IsNullOrEmpty(uri.PathAndQuery.Trim('/')); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Options/TenantResolutionOptions.cs b/Tiku.Api/Options/TenantResolutionOptions.cs index d31a744..1d0b2ce 100644 --- a/Tiku.Api/Options/TenantResolutionOptions.cs +++ b/Tiku.Api/Options/TenantResolutionOptions.cs @@ -6,7 +6,12 @@ public sealed class TenantResolutionOptions public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"]; public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"]; + public string[] TenantCodePathPrefixes { get; set; } = - ["/api/tenant/auth", "/api/tenant", "/api/public/catalog", "/api/student/assets", "/api/public/scoreline", "/api/student/referral", "/api/student/commerce/payments/notify"]; + [ + "/api/tenant/auth", "/api/tenant", "/api/public/catalog", "/api/student/assets", "/api/public/scoreline", + "/api/student/referral", "/api/student/commerce/payments/notify" + ]; + public string[] TrustedProxyAddresses { get; set; } = []; -} +} \ No newline at end of file diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index 2904780..a5fcd70 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -29,4 +29,4 @@ finally Log.CloseAndFlush(); } -public partial class Program; +public partial class Program; \ No newline at end of file diff --git a/Tiku.Api/Security/AccessAuthorizationRequirements.cs b/Tiku.Api/Security/AccessAuthorizationRequirements.cs index be707de..10662d2 100644 --- a/Tiku.Api/Security/AccessAuthorizationRequirements.cs +++ b/Tiku.Api/Security/AccessAuthorizationRequirements.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Tiku.Application.Security; @@ -49,29 +48,30 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac AuthorizationHandlerContext context, CurrentTenantMemberRequirement requirement) { - if (!IsTenantRealm(context.User)) - { - return; - } + if (!IsTenantRealm(context.User)) return; var access = await accessContext.GetAsync(); if (access.IsCurrentTenantMember && access.TenantId is { } tenantId && FindTenantId(context.User) == tenantId) - { context.Succeed(requirement); - } } - internal static bool IsTenantRealm(ClaimsPrincipal principal) => - string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "tenant", StringComparison.Ordinal); + internal static bool IsTenantRealm(ClaimsPrincipal principal) + { + return string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "tenant", StringComparison.Ordinal); + } - internal static bool IsPlatformRealm(ClaimsPrincipal principal) => - string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "platform", StringComparison.Ordinal) && - principal.FindFirst(TikuClaimTypes.TenantId) is null; + internal static bool IsPlatformRealm(ClaimsPrincipal principal) + { + return string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "platform", StringComparison.Ordinal) && + principal.FindFirst(TikuClaimTypes.TenantId) is null; + } - private static Guid? FindTenantId(ClaimsPrincipal principal) => - Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null; + private static Guid? FindTenantId(ClaimsPrincipal principal) + { + return Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null; + } } internal sealed class TenantPermissionAuthorizationHandler( @@ -84,10 +84,7 @@ internal sealed class TenantPermissionAuthorizationHandler( AuthorizationHandlerContext context, TenantPermissionRequirement requirement) { - if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) - { - return; - } + if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) return; var access = await accessContext.GetAsync(); var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method) @@ -97,14 +94,14 @@ internal sealed class TenantPermissionAuthorizationHandler( access.HasTenantPermission(requirement.PermissionCode) && (await featureAccessService.FilterPermissionCodesAsync( tenantId, [requirement.PermissionCode], operation)).Contains(requirement.PermissionCode)) - { context.Succeed(requirement); - } } - private static bool IsSafeMethod(string? method) => - method is not null && - (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method)); + private static bool IsSafeMethod(string? method) + { + return method is not null && + (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method)); + } } internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) : @@ -114,16 +111,10 @@ internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessCo AuthorizationHandlerContext context, CurrentPlatformAccessRequirement requirement) { - if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) - { - return; - } + if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) return; var access = await accessContext.GetAsync(); - if (access.IsUserActive && access.PlatformPermissions.Count > 0) - { - context.Succeed(requirement); - } + if (access.IsUserActive && access.PlatformPermissions.Count > 0) context.Succeed(requirement); } } @@ -135,19 +126,14 @@ internal sealed class TenantResourceAccessAuthorizationHandler(ICurrentAccessCon TenantResourceAccessRequirement requirement, TenantResourceAuthorizationResource resource) { - if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) - { - return; - } + if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User)) return; var access = await accessContext.GetAsync(); if (access.UserId is { } userId && access.IsCurrentTenantMember && access.TenantId == resource.TenantId && access.DataScope.AllowsResource(userId, resource.OwnerUserId, resource.RegionId, resource.ClassId)) - { context.Succeed(requirement); - } } } @@ -158,16 +144,10 @@ internal sealed class PlatformPermissionAuthorizationHandler(ICurrentAccessConte AuthorizationHandlerContext context, PlatformPermissionRequirement requirement) { - if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) - { - return; - } + if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User)) return; var access = await accessContext.GetAsync(); - if (access.HasPlatformPermission(requirement.PermissionCode)) - { - context.Succeed(requirement); - } + if (access.HasPlatformPermission(requirement.PermissionCode)) context.Succeed(requirement); } } @@ -179,10 +159,7 @@ internal sealed class AllDataScopeAuthorizationHandler(ICurrentAccessContext acc AllDataScopeRequirement requirement) { var access = await accessContext.GetAsync(); - if (access.IsCurrentTenantMember && access.DataScope.Mode == DataScopeMode.All) - { - context.Succeed(requirement); - } + if (access.IsCurrentTenantMember && access.DataScope.Mode == DataScopeMode.All) context.Succeed(requirement); } } @@ -252,7 +229,6 @@ public static class AccessAuthorizationServiceCollectionExtensions new AllDataScopeRequirement())); foreach (var permissionCode in BackendPermissions.Tenant) - { options.AddPolicy( permissionCode, policy => policy @@ -260,16 +236,13 @@ public static class AccessAuthorizationServiceCollectionExtensions .AddRequirements( new CurrentTenantMemberRequirement(), new TenantPermissionRequirement(permissionCode))); - } foreach (var permissionCode in BackendPermissions.Platform) - { options.AddPolicy( permissionCode, policy => policy .RequireAuthenticatedUser() .AddRequirements(new PlatformPermissionRequirement(permissionCode))); - } }); return services; @@ -278,19 +251,40 @@ public static class AccessAuthorizationServiceCollectionExtensions internal sealed class CompatibilityFeatureAccessService : IFeatureAccessService { - public Task EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) => - Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation)); + public Task EvaluateAsync(Guid tenantId, string featureCode, + FeatureAccessOperation operation, CancellationToken cancellationToken = default) + { + return Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation)); + } - public Task> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => - Task.FromResult>(new HashSet(SaasFeatureCatalog.All, StringComparer.Ordinal)); + public Task> GetEnabledFeaturesAsync(Guid tenantId, + FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) + { + return Task.FromResult>( + new HashSet(SaasFeatureCatalog.All, StringComparer.Ordinal)); + } - public Task> FilterPermissionCodesAsync(Guid tenantId, IEnumerable permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => - Task.FromResult>(permissionCodes.ToHashSet(StringComparer.Ordinal)); + public Task> FilterPermissionCodesAsync(Guid tenantId, IEnumerable permissionCodes, + FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) + { + return Task.FromResult>(permissionCodes.ToHashSet(StringComparer.Ordinal)); + } - public Task> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) => - Task.FromResult>([]); + public Task> GetQuotaSummaryAsync(Guid tenantId, + CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } - public Task TryConsumeQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.FromResult(true); + public Task TryConsumeQuotaAsync(Guid tenantId, string metricCode, long amount, + CancellationToken cancellationToken = default) + { + return Task.FromResult(true); + } - public Task ReleaseQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.CompletedTask; -} + public Task ReleaseQuotaAsync(Guid tenantId, string metricCode, long amount, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs index 5dc3950..df7cee9 100644 --- a/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs +++ b/Tiku.Api/Security/AuditingAuthorizationMiddlewareResultHandler.cs @@ -20,7 +20,6 @@ internal sealed class AuditingAuthorizationMiddlewareResultHandler( PolicyAuthorizationResult authorizeResult) { if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true) - { try { await using var scope = scopeFactory.CreateAsyncScope(); @@ -40,10 +39,11 @@ internal sealed class AuditingAuthorizationMiddlewareResultHandler( UserAgent = context.Request.Headers.UserAgent.ToString(), Details = JsonSerializer.SerializeToElement(new { - Method = context.Request.Method, + context.Request.Method, Path = context.Request.Path.Value, Realm = context.User.FindFirst(TikuClaimTypes.Realm)?.Value, - Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message).ToArray() + Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message) + .ToArray() }) }); await dbContext.SaveChangesAsync(context.RequestAborted); @@ -52,8 +52,7 @@ internal sealed class AuditingAuthorizationMiddlewareResultHandler( { logger.LogWarning(exception, "Failed to persist authorization denial audit event."); } - } await fallback.HandleAsync(next, context, policy, authorizeResult); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Security/EndpointAuthorizationMetadata.cs b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs index e0f2e0f..400ebc9 100644 --- a/Tiku.Api/Security/EndpointAuthorizationMetadata.cs +++ b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs @@ -19,71 +19,65 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode public void Apply(ApplicationModel application) { foreach (var controller in application.Controllers) + foreach (var action in controller.Actions) { - foreach (var action in controller.Actions) - { - var anonymous = controller.Attributes.OfType().Any() || - action.Attributes.OfType().Any(); - if (anonymous) - { - continue; - } + var anonymous = controller.Attributes.OfType().Any() || + action.Attributes.OfType().Any(); + if (anonymous) continue; - var policies = controller.Attributes.OfType() - .Concat(action.Attributes.OfType()) - .Select(attribute => attribute.Policy) - .Where(policy => !string.IsNullOrWhiteSpace(policy)) - .Cast() - .ToArray(); - var permission = policies.LastOrDefault(policy => - BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy)); - var realm = permission is not null && BackendPermissions.Platform.Contains(permission) || - policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal)) - ? "platform" - : permission is not null && BackendPermissions.Tenant.Contains(permission) || - policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal)) - ? "tenant" - : "authenticated"; - var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission); - var requiredFeatures = controller.Attributes.OfType() - .Concat(action.Attributes.OfType()) - .Select(attribute => attribute.FeatureCode) - .Concat(module is not null && - PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) && - moduleFeature is not null - ? [moduleFeature] - : []) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .Concat(action.Attributes.OfType() - .Select(attribute => $"route:{attribute.RouteValueName}")) - .ToArray(); - var httpMethods = action.Attributes.OfType() - .SelectMany(attribute => attribute.HttpMethods) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var operation = httpMethods.All(IsSafeMethod) - ? CapabilityOperation.Read - : CapabilityOperation.Write; - var route = $"{controller.ControllerName}.{action.ActionName}"; - var metadata = new EndpointAuthorizationMetadata( - realm, - module, - requiredFeatures, - permission, - operation, - policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) || - policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) || - policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal), - $"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}"); - foreach (var selector in action.Selectors) - { - selector.EndpointMetadata.Add(metadata); - } - } + var policies = controller.Attributes.OfType() + .Concat(action.Attributes.OfType()) + .Select(attribute => attribute.Policy) + .Where(policy => !string.IsNullOrWhiteSpace(policy)) + .Cast() + .ToArray(); + var permission = policies.LastOrDefault(policy => + BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy)); + var realm = (permission is not null && BackendPermissions.Platform.Contains(permission)) || + policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal)) + ? "platform" + : (permission is not null && BackendPermissions.Tenant.Contains(permission)) || + policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal)) + ? "tenant" + : "authenticated"; + var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission); + var requiredFeatures = controller.Attributes.OfType() + .Concat(action.Attributes.OfType()) + .Select(attribute => attribute.FeatureCode) + .Concat(module is not null && + PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) && + moduleFeature is not null + ? [moduleFeature] + : []) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Concat(action.Attributes.OfType() + .Select(attribute => $"route:{attribute.RouteValueName}")) + .ToArray(); + var httpMethods = action.Attributes.OfType() + .SelectMany(attribute => attribute.HttpMethods) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var operation = httpMethods.All(IsSafeMethod) + ? CapabilityOperation.Read + : CapabilityOperation.Write; + var route = $"{controller.ControllerName}.{action.ActionName}"; + var metadata = new EndpointAuthorizationMetadata( + realm, + module, + requiredFeatures, + permission, + operation, + policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) || + policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) || + policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal), + $"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}"); + foreach (var selector in action.Selectors) selector.EndpointMetadata.Add(metadata); } } - private static bool IsSafeMethod(string method) => - HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); -} + private static bool IsSafeMethod(string method) + { + return HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); + } +} \ No newline at end of file diff --git a/Tiku.Api/Security/SaasFeatureEndpointMetadata.cs b/Tiku.Api/Security/SaasFeatureEndpointMetadata.cs index 703c8b4..64c3302 100644 --- a/Tiku.Api/Security/SaasFeatureEndpointMetadata.cs +++ b/Tiku.Api/Security/SaasFeatureEndpointMetadata.cs @@ -3,13 +3,13 @@ using Tiku.Application.Security; namespace Tiku.Api.Security; -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] public sealed class RequireSaasFeatureAttribute(string featureCode) : Attribute { public string FeatureCode { get; } = featureCode; } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class RequireSaasFeatureFromRouteAttribute(string routeValueName) : Attribute { public string RouteValueName { get; } = routeValueName; @@ -23,7 +23,8 @@ public sealed class SaasFeatureAccessMiddleware(RequestDelegate next) IFeatureAccessService featureAccessService) { var requirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata(); - var routeRequirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata(); + var routeRequirements = + context.GetEndpoint()?.Metadata.GetOrderedMetadata(); if ((requirements is null || requirements.Count == 0) && (routeRequirements is null || routeRequirements.Count == 0)) { @@ -69,9 +70,11 @@ public sealed class SaasFeatureAccessMiddleware(RequestDelegate next) var featureCode = ResolveRouteFeature(routeValue); if (featureCode is null) { - await WriteForbiddenAsync(context, "feature_route_value_invalid", "The requested content module is not available."); + await WriteForbiddenAsync(context, "feature_route_value_invalid", + "The requested content module is not available."); return; } + var decision = await featureAccessService.EvaluateAsync( tenantId, featureCode, @@ -110,4 +113,4 @@ public sealed class SaasFeatureAccessMiddleware(RequestDelegate next) { return SaasFeatureCatalog.ResolveContentImportFeature(routeValue); } -} +} \ No newline at end of file diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj index edbb1c5..bfe9423 100644 --- a/Tiku.Api/Tiku.Api.csproj +++ b/Tiku.Api/Tiku.Api.csproj @@ -1,39 +1,39 @@ - - net10.0 - enable - enable - true - $(NoWarn);1591 - ..\Tiku.PlatformAdmin.Web\ - http://localhost:5173 - npm run dev - + + net10.0 + enable + enable + true + $(NoWarn);1591 + ..\Tiku.PlatformAdmin.Web\ + http://localhost:5173 + npm run dev + - - - - + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/Tiku.Application/Assets/AssetAccessModels.cs b/Tiku.Application/Assets/AssetAccessModels.cs index a05e8ef..131f557 100644 --- a/Tiku.Application/Assets/AssetAccessModels.cs +++ b/Tiku.Application/Assets/AssetAccessModels.cs @@ -37,4 +37,4 @@ public sealed record AssetAccessPrincipal( public sealed class AssetAccessException(string message, string code) : InvalidOperationException(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/AssetManagementModels.cs b/Tiku.Application/Assets/AssetManagementModels.cs index 7a3a08b..e550edb 100644 --- a/Tiku.Application/Assets/AssetManagementModels.cs +++ b/Tiku.Application/Assets/AssetManagementModels.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Tiku.Application.Catalog; using Tiku.Application.Storage; using Tiku.Domain.Content; @@ -221,4 +220,4 @@ public sealed record ContentImportJobDetail( public sealed class AssetManagementException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/AssetQueryModels.cs b/Tiku.Application/Assets/AssetQueryModels.cs index 3dd58e0..969be8c 100644 --- a/Tiku.Application/Assets/AssetQueryModels.cs +++ b/Tiku.Application/Assets/AssetQueryModels.cs @@ -92,4 +92,4 @@ public sealed record QuestionVideoCatalogItem( QuestionVideoType VideoType, int Order, JsonElement Metadata, - VideoExplanationCatalogItem? Video); + VideoExplanationCatalogItem? Video); \ No newline at end of file diff --git a/Tiku.Application/Assets/IAssetAccessService.cs b/Tiku.Application/Assets/IAssetAccessService.cs index 06ec58f..dc91295 100644 --- a/Tiku.Application/Assets/IAssetAccessService.cs +++ b/Tiku.Application/Assets/IAssetAccessService.cs @@ -9,4 +9,4 @@ public interface IAssetAccessService Task PreviewAsync( AssetAccessRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/IAssetManagementService.cs b/Tiku.Application/Assets/IAssetManagementService.cs index 11cd068..5552872 100644 --- a/Tiku.Application/Assets/IAssetManagementService.cs +++ b/Tiku.Application/Assets/IAssetManagementService.cs @@ -59,4 +59,4 @@ public interface IAssetManagementService AssetManagementActor actor, Guid jobId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/IAssetQueryService.cs b/Tiku.Application/Assets/IAssetQueryService.cs index 52c73d9..ab18124 100644 --- a/Tiku.Application/Assets/IAssetQueryService.cs +++ b/Tiku.Application/Assets/IAssetQueryService.cs @@ -23,4 +23,4 @@ public interface IAssetQueryService Task> GetQuestionVideosAsync( AssetFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/IAssetSecurityScanner.cs b/Tiku.Application/Assets/IAssetSecurityScanner.cs index 3eed0c2..92a5773 100644 --- a/Tiku.Application/Assets/IAssetSecurityScanner.cs +++ b/Tiku.Application/Assets/IAssetSecurityScanner.cs @@ -27,4 +27,4 @@ public sealed class AssetSecurityScannerException(string code, string message, E : Exception(message, innerException) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Assets/VideoPlaybackModels.cs b/Tiku.Application/Assets/VideoPlaybackModels.cs index 77d536c..19f2074 100644 --- a/Tiku.Application/Assets/VideoPlaybackModels.cs +++ b/Tiku.Application/Assets/VideoPlaybackModels.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Tiku.Application.Catalog; -using Tiku.Domain.Content; namespace Tiku.Application.Assets; @@ -77,4 +76,4 @@ public interface IVideoPlaybackService public sealed class VideoPlaybackException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs index 7b91a67..c5e44f5 100644 --- a/Tiku.Application/Auth/AuthContracts.cs +++ b/Tiku.Application/Auth/AuthContracts.cs @@ -4,7 +4,7 @@ using Tiku.Domain.Tenancy; namespace Tiku.Application.Auth; /// -/// 认证令牌对。 +/// 认证令牌对。 /// /// 用于访问 API 的 JWT access token。 /// 用于刷新会话的 refresh token,服务端只保存哈希。 @@ -17,7 +17,7 @@ public sealed record AuthTokenPair( DateTimeOffset RefreshTokenExpiresAt); /// -/// 当前登录租户成员摘要。 +/// 当前登录租户成员摘要。 /// /// 租户 ID。 /// 租户名称。 @@ -30,7 +30,7 @@ public sealed record TenantMembershipSummary( MembershipStatus Status); /// -/// 登录成功后的应用层用户信息。 +/// 登录成功后的应用层用户信息。 /// /// 用户 ID。 /// 手机号。 @@ -52,6 +52,7 @@ public enum AuthenticationStatus { [JsonStringEnumMemberName("authenticated")] Authenticated, + [JsonStringEnumMemberName("password_change_required")] PasswordChangeRequired } @@ -144,4 +145,4 @@ public sealed record SendSmsCodeRequest( SmsPurpose Purpose, string? IpAddress, string? UserAgent, - string? DeviceId = null); + string? DeviceId = null); \ No newline at end of file diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs index 71717df..5a1e428 100644 --- a/Tiku.Application/Auth/AuthExceptions.cs +++ b/Tiku.Application/Auth/AuthExceptions.cs @@ -30,4 +30,5 @@ public sealed class AuthSessionNotFoundException() : AuthException("auth_session_not_found", "The requested authentication session was not found."); public sealed class CurrentAuthSessionCannotBeRevokedException() - : AuthException("current_auth_session_cannot_be_revoked", "Use logout to revoke the current authentication session."); + : AuthException("current_auth_session_cannot_be_revoked", + "Use logout to revoke the current authentication session."); \ No newline at end of file diff --git a/Tiku.Application/Auth/CurrentIdentityQueries.cs b/Tiku.Application/Auth/CurrentIdentityQueries.cs index b85cd75..bcaa5ee 100644 --- a/Tiku.Application/Auth/CurrentIdentityQueries.cs +++ b/Tiku.Application/Auth/CurrentIdentityQueries.cs @@ -31,4 +31,4 @@ public interface ICurrentIdentityQueryService Guid userId, Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IAuthAdministrationService.cs b/Tiku.Application/Auth/IAuthAdministrationService.cs index 488db7c..0aee758 100644 --- a/Tiku.Application/Auth/IAuthAdministrationService.cs +++ b/Tiku.Application/Auth/IAuthAdministrationService.cs @@ -12,4 +12,4 @@ public interface IAuthAdministrationService Task ResetPasswordAsync( AdministrativePasswordResetRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs index e0cd437..0914df7 100644 --- a/Tiku.Application/Auth/IAuthService.cs +++ b/Tiku.Application/Auth/IAuthService.cs @@ -43,4 +43,4 @@ public interface IAuthService Task ChangePasswordAsync( AuthenticatedPasswordChangeRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IAuthSessionStore.cs b/Tiku.Application/Auth/IAuthSessionStore.cs index 4efb150..75d7199 100644 --- a/Tiku.Application/Auth/IAuthSessionStore.cs +++ b/Tiku.Application/Auth/IAuthSessionStore.cs @@ -31,7 +31,10 @@ public interface IAuthSessionStore CancellationToken cancellationToken = default); Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default); - Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default); + + Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, + CancellationToken cancellationToken = default); + Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default); Task> ListActiveAsync( @@ -59,6 +62,10 @@ public sealed record AuthSessionIssueRequest( Guid? TokenFamilyId = null, Guid? ParentSessionId = null); -public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, long AuthorizationVersion = 1); +public sealed record AuthSessionValidationResult( + Guid UserId, + AuthRealm Realm, + Guid? TenantId, + long AuthorizationVersion = 1); -public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId); +public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId); \ No newline at end of file diff --git a/Tiku.Application/Auth/IIdentityProvider.cs b/Tiku.Application/Auth/IIdentityProvider.cs index 41c4eba..3c31237 100644 --- a/Tiku.Application/Auth/IIdentityProvider.cs +++ b/Tiku.Application/Auth/IIdentityProvider.cs @@ -20,4 +20,4 @@ public interface IIdentityProvider Task AuthenticateAsync( IdentityProviderRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IOwnerActivationService.cs b/Tiku.Application/Auth/IOwnerActivationService.cs index f758238..c5d673a 100644 --- a/Tiku.Application/Auth/IOwnerActivationService.cs +++ b/Tiku.Application/Auth/IOwnerActivationService.cs @@ -23,4 +23,4 @@ public interface IOwnerActivationService public sealed class OwnerActivationException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IRequestSecurityState.cs b/Tiku.Application/Auth/IRequestSecurityState.cs index 0aacdd7..7749008 100644 --- a/Tiku.Application/Auth/IRequestSecurityState.cs +++ b/Tiku.Application/Auth/IRequestSecurityState.cs @@ -15,10 +15,8 @@ internal sealed class RequestSecurityState : IRequestSecurityState { ArgumentNullException.ThrowIfNull(session); if (ValidatedSession is not null && ValidatedSession != session) - { throw new InvalidOperationException("The request security session was already initialized."); - } ValidatedSession = session; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/ISmsProvider.cs b/Tiku.Application/Auth/ISmsProvider.cs index 5b0e1b0..a916081 100644 --- a/Tiku.Application/Auth/ISmsProvider.cs +++ b/Tiku.Application/Auth/ISmsProvider.cs @@ -26,4 +26,4 @@ public interface ISmsProvider Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/ISmsVerificationService.cs b/Tiku.Application/Auth/ISmsVerificationService.cs index abcb331..5a53ed0 100644 --- a/Tiku.Application/Auth/ISmsVerificationService.cs +++ b/Tiku.Application/Auth/ISmsVerificationService.cs @@ -14,4 +14,4 @@ public interface ISmsVerificationService SmsPurpose purpose, string code, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/ITokenService.cs b/Tiku.Application/Auth/ITokenService.cs index be36027..d2fa968 100644 --- a/Tiku.Application/Auth/ITokenService.cs +++ b/Tiku.Application/Auth/ITokenService.cs @@ -11,4 +11,4 @@ public interface ITokenService string? email, AuthRealm realm, Guid? tenantId); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/IWechatOAuthClient.cs b/Tiku.Application/Auth/IWechatOAuthClient.cs index 35d5901..8c666f3 100644 --- a/Tiku.Application/Auth/IWechatOAuthClient.cs +++ b/Tiku.Application/Auth/IWechatOAuthClient.cs @@ -23,4 +23,4 @@ public interface IWechatOAuthClient WechatProviderOptions options, string code, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Auth/SmsSecurityOptions.cs b/Tiku.Application/Auth/SmsSecurityOptions.cs index 823e4fd..8cc79a3 100644 --- a/Tiku.Application/Auth/SmsSecurityOptions.cs +++ b/Tiku.Application/Auth/SmsSecurityOptions.cs @@ -20,4 +20,4 @@ public sealed class SmsSecurityOptions options.IpRequestsPerHour > 0 && options.DeviceRequestsPerHour > 0; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Backoffice/BackofficeException.cs b/Tiku.Application/Backoffice/BackofficeException.cs index 0e9a996..3d4bba5 100644 --- a/Tiku.Application/Backoffice/BackofficeException.cs +++ b/Tiku.Application/Backoffice/BackofficeException.cs @@ -3,4 +3,4 @@ namespace Tiku.Application.Backoffice; public sealed class BackofficeException(string message, string code) : InvalidOperationException(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Backoffice/BackofficeModels.cs b/Tiku.Application/Backoffice/BackofficeModels.cs index f84827c..d6e26d6 100644 --- a/Tiku.Application/Backoffice/BackofficeModels.cs +++ b/Tiku.Application/Backoffice/BackofficeModels.cs @@ -11,9 +11,7 @@ public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatfor if (access.UserId is not { } userId || access.TenantId is not { } tenantId || !access.IsCurrentTenantMember) - { throw new InvalidOperationException("Tenant backoffice actor was not resolved."); - } return new BackofficeActor(userId, tenantId, false); } @@ -21,9 +19,7 @@ public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatfor public static BackofficeActor FromPlatformAccess(CurrentAccessSnapshot access) { if (access.UserId is not { } userId || !access.IsUserActive) - { throw new InvalidOperationException("Platform backoffice actor was not resolved."); - } return new BackofficeActor(userId, null, true); } @@ -97,4 +93,4 @@ public sealed record BackofficeOperationAuditCommand( string? TargetId, JsonElement Details, string? IpAddress = null, - string? UserAgent = null); + string? UserAgent = null); \ No newline at end of file diff --git a/Tiku.Application/Backoffice/IBackofficeService.cs b/Tiku.Application/Backoffice/IBackofficeService.cs index 7c7c3d5..e51a76e 100644 --- a/Tiku.Application/Backoffice/IBackofficeService.cs +++ b/Tiku.Application/Backoffice/IBackofficeService.cs @@ -56,4 +56,4 @@ public interface IOperationAuditService Task WriteAsync( BackofficeOperationAuditCommand command, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Catalog/CatalogQueryModels.cs b/Tiku.Application/Catalog/CatalogQueryModels.cs index f4700ba..7d6471d 100644 --- a/Tiku.Application/Catalog/CatalogQueryModels.cs +++ b/Tiku.Application/Catalog/CatalogQueryModels.cs @@ -183,4 +183,4 @@ public sealed record SvipPlanCatalogItem( string? VpProductId, bool VpEnabled, int Order, - bool IsActive); + bool IsActive); \ No newline at end of file diff --git a/Tiku.Application/Catalog/ICatalogQueryService.cs b/Tiku.Application/Catalog/ICatalogQueryService.cs index bd84b81..cdc8bc2 100644 --- a/Tiku.Application/Catalog/ICatalogQueryService.cs +++ b/Tiku.Application/Catalog/ICatalogQueryService.cs @@ -53,4 +53,4 @@ public interface ICatalogQueryService Task> GetSvipPlansAsync( CatalogFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Catalog/TaxonomyModels.cs b/Tiku.Application/Catalog/TaxonomyModels.cs index 756ef3f..0274014 100644 --- a/Tiku.Application/Catalog/TaxonomyModels.cs +++ b/Tiku.Application/Catalog/TaxonomyModels.cs @@ -29,8 +29,9 @@ public sealed record CreateTaxonomyNodeCommand( public interface ITaxonomyService { Task> ListAsync(Guid tenantId, CancellationToken cancellationToken = default); + Task CreateAsync( Guid tenantId, CreateTaxonomyNodeCommand command, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs index e5a9fa0..cf6668c 100644 --- a/Tiku.Application/Commerce/CommerceAdminModels.cs +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -9,7 +9,11 @@ public sealed record CommerceAdminActor(Guid TenantId, Guid UserId); public sealed record CommerceAdminQuery(string? Provider = null, string? Status = null, int? Limit = null); -public sealed record TenantPointQuery(string? Status = null, int? Limit = null, Guid? UserId = null, Guid? RegionId = null); +public sealed record TenantPointQuery( + string? Status = null, + int? Limit = null, + Guid? UserId = null, + Guid? RegionId = null); public sealed record UpsertPaymentAccountCommand( string Provider, @@ -133,8 +137,11 @@ public sealed record UpsertPointExchangeItemCommand( public sealed record UpdatePointExchangeOrderStatusCommand(Guid OrderId, PointExchangeOrderStatus Status); public sealed record TenantPointTaskList(IReadOnlyCollection Items); + public sealed record TenantPointClaimList(IReadOnlyCollection Items); + public sealed record TenantPointExchangeItemList(IReadOnlyCollection Items); + public sealed record TenantPointExchangeOrderList(IReadOnlyCollection Items); public sealed record UpsertCouponCommand( @@ -150,7 +157,9 @@ public sealed record UpsertCouponCommand( string? Remark); public sealed record TenantCouponList(IReadOnlyCollection Items); + public sealed record TenantCouponRedemptionList(IReadOnlyCollection Items); + public sealed record TenantCouponReport(int CouponCount, int ClaimedCount, int UsedCount, int DiscountAppliedCents); public sealed record CreateRefundRequestCommand( @@ -168,6 +177,7 @@ public sealed record UpdateRefundStatusCommand( string? ProviderRefundNo = null); public sealed record TenantRefundList(IReadOnlyCollection Items); + public sealed record TenantRefundEventList(IReadOnlyCollection Items); public sealed record CreateReconciliationBatchCommand( @@ -180,9 +190,13 @@ public sealed record CreateReconciliationBatchCommand( JsonElement Metadata); public sealed record TenantReconciliationBatchList(IReadOnlyCollection Items); + public sealed record TenantReconciliationIssueList(IReadOnlyCollection Items); + public sealed record TenantReconciliationItemList(IReadOnlyCollection Items); + public sealed record TenantReconciliationIssueEventList(IReadOnlyCollection Items); + public sealed record TenantCommerceAnomalySummary( int OpenRefundCount, int ProcessingRefundCount, @@ -211,7 +225,9 @@ public sealed record UpdateAdjustmentVoucherStatusCommand( string? Note); public sealed record TenantAdjustmentVoucherList(IReadOnlyCollection Items); + public sealed record TenantAdjustmentVoucherEventList(IReadOnlyCollection Items); + public sealed record TenantAdjustmentReport( int DraftCount, int PendingReviewCount, @@ -468,4 +484,4 @@ public interface ICommerceAdminService Guid tenantId, RefundNotificationCommand command, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Commerce/CommerceModels.cs b/Tiku.Application/Commerce/CommerceModels.cs index fb597b8..9593caf 100644 --- a/Tiku.Application/Commerce/CommerceModels.cs +++ b/Tiku.Application/Commerce/CommerceModels.cs @@ -69,7 +69,12 @@ public sealed record CommerceCouponQuery(int? Limit = null, string? Status = nul public sealed record ClaimCommerceCouponCommand(string CouponCode); -public sealed record CheckCommerceCouponCommand(string? CouponCode, Guid? CouponRedemptionId, Guid PlanId, int Quantity, Guid? RegionId); +public sealed record CheckCommerceCouponCommand( + string? CouponCode, + Guid? CouponRedemptionId, + Guid PlanId, + int Quantity, + Guid? RegionId); public sealed record CommerceCouponItem( Guid Id, @@ -160,4 +165,4 @@ public interface ICommerceService public sealed class CommerceException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Commerce/PaymentProviderContracts.cs b/Tiku.Application/Commerce/PaymentProviderContracts.cs index dae1590..e51773c 100644 --- a/Tiku.Application/Commerce/PaymentProviderContracts.cs +++ b/Tiku.Application/Commerce/PaymentProviderContracts.cs @@ -87,10 +87,12 @@ public interface IPaymentProvider Task CreateRefundAsync( PaymentProviderAccount account, CreateRefundProviderRequest request, - CancellationToken cancellationToken = default) => - Task.FromException(new PaymentProviderException( + CancellationToken cancellationToken = default) + { + return Task.FromException(new PaymentProviderException( "Payment provider refund is not configured.", "payment_provider_refund_not_supported")); + } } public interface IPaymentProviderGateway @@ -125,4 +127,4 @@ public interface ITenantSecretService public sealed class PaymentProviderException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Content/ContentExceptions.cs b/Tiku.Application/Content/ContentExceptions.cs index e3bad81..1f88c42 100644 --- a/Tiku.Application/Content/ContentExceptions.cs +++ b/Tiku.Application/Content/ContentExceptions.cs @@ -2,4 +2,4 @@ namespace Tiku.Application.Content; public sealed class RequiredFieldException(string message) : Exception(message); -public sealed class ContentNavigationNotFoundException(string message) : Exception(message); +public sealed class ContentNavigationNotFoundException(string message) : Exception(message); \ No newline at end of file diff --git a/Tiku.Application/Content/ContentManagementModels.cs b/Tiku.Application/Content/ContentManagementModels.cs index 7ffc933..11b99c2 100644 --- a/Tiku.Application/Content/ContentManagementModels.cs +++ b/Tiku.Application/Content/ContentManagementModels.cs @@ -1,7 +1,6 @@ using System.Text.Json; -using Tiku.Application.Catalog; -using Tiku.Domain.Content; using Tiku.Application.QuestionBanks; +using Tiku.Domain.Content; namespace Tiku.Application.Content; @@ -237,4 +236,4 @@ public sealed record ImportTemplateItem( public sealed class ContentManagementException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Content/ContentNavigationQueryModels.cs b/Tiku.Application/Content/ContentNavigationQueryModels.cs index c436aa0..9e6d173 100644 --- a/Tiku.Application/Content/ContentNavigationQueryModels.cs +++ b/Tiku.Application/Content/ContentNavigationQueryModels.cs @@ -124,4 +124,4 @@ public sealed record CollectionQuestionCatalogItem( string? Explanation, JsonElement SubQuestions, string? CodeLang, - string? CodeTemplate); + string? CodeTemplate); \ No newline at end of file diff --git a/Tiku.Application/Content/DirectContentModels.cs b/Tiku.Application/Content/DirectContentModels.cs index f360d1d..aa7036b 100644 --- a/Tiku.Application/Content/DirectContentModels.cs +++ b/Tiku.Application/Content/DirectContentModels.cs @@ -3,8 +3,6 @@ using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Domain.Catalog; using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; namespace Tiku.Application.Content; @@ -270,7 +268,11 @@ public sealed record SimpleImportResult( IReadOnlyCollection Items, IReadOnlyCollection Issues); -public sealed record ImportPostCheckResult(Guid JobId, string Status, JsonElement Counts, IReadOnlyCollection Issues); +public sealed record ImportPostCheckResult( + Guid JobId, + string Status, + JsonElement Counts, + IReadOnlyCollection Issues); public sealed record VideoManagementItem( Guid Id, @@ -317,37 +319,102 @@ public sealed record ScorelineTrendItem(int Year, int SchoolCount, int MajorCoun public interface IDirectContentService { - Task> CreateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); - Task> UpdateQuestionAsync(DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default); - Task> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertVocabularyUnitAsync(DirectContentActor actor, VocabularyUnitCommand command, CancellationToken cancellationToken = default); - Task> GetVocabularyWordsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertVocabularyWordAsync(DirectContentActor actor, VocabularyWordCommand command, CancellationToken cancellationToken = default); - Task> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertHandbookSubjectAsync(DirectContentActor actor, HandbookSubjectCommand command, CancellationToken cancellationToken = default); - Task> GetHandbookChaptersAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertHandbookChapterAsync(DirectContentActor actor, HandbookChapterCommand command, CancellationToken cancellationToken = default); - Task> GetHandbookEntriesAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertHandbookEntryAsync(DirectContentActor actor, HandbookEntryCommand command, CancellationToken cancellationToken = default); - Task> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertSchoolAsync(DirectContentActor actor, SchoolCommand command, CancellationToken cancellationToken = default); - Task> GetMajorsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default); - Task> GetScorelineFieldsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertScorelineFieldAsync(DirectContentActor actor, ScorelineFieldCommand command, CancellationToken cancellationToken = default); - Task> GetScorelineRecordsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertScorelineRecordAsync(DirectContentActor actor, ScorelineRecordCommand command, CancellationToken cancellationToken = default); - Task> GetScorelineYearsAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertVideoAsync(DirectContentActor actor, VideoExplanationCommand command, CancellationToken cancellationToken = default); - Task> BindQuestionVideoAsync(DirectContentActor actor, QuestionVideoCommand command, CancellationToken cancellationToken = default); - Task> GetOperationContentAsync(DirectContentActor actor, string kind, AdminLimitFilter filter, CancellationToken cancellationToken = default); - Task> UpsertOperationContentAsync(DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default); - Task PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default); - Task ExecuteImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default); - Task GetImportJobAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); - Task> GetImportIssuesAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); - Task RunImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); - Task GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default); -} + Task> CreateQuestionAsync(DirectContentActor actor, + QuestionWriteCommand command, CancellationToken cancellationToken = default); + + Task> UpdateQuestionAsync(DirectContentActor actor, + QuestionWriteCommand command, CancellationToken cancellationToken = default); + + Task> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertVocabularyUnitAsync(DirectContentActor actor, + VocabularyUnitCommand command, CancellationToken cancellationToken = default); + + Task> GetVocabularyWordsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertVocabularyWordAsync(DirectContentActor actor, + VocabularyWordCommand command, CancellationToken cancellationToken = default); + + Task> GetHandbookSubjectsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertHandbookSubjectAsync(DirectContentActor actor, + HandbookSubjectCommand command, CancellationToken cancellationToken = default); + + Task> GetHandbookChaptersAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertHandbookChapterAsync(DirectContentActor actor, + HandbookChapterCommand command, CancellationToken cancellationToken = default); + + Task> GetHandbookEntriesAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertHandbookEntryAsync(DirectContentActor actor, + HandbookEntryCommand command, CancellationToken cancellationToken = default); + + Task> GetSchoolsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertSchoolAsync(DirectContentActor actor, SchoolCommand command, + CancellationToken cancellationToken = default); + + Task> GetMajorsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertMajorAsync(DirectContentActor actor, MajorCommand command, + CancellationToken cancellationToken = default); + + Task> GetScorelineFieldsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertScorelineFieldAsync(DirectContentActor actor, + ScorelineFieldCommand command, CancellationToken cancellationToken = default); + + Task> GetScorelineRecordsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertScorelineRecordAsync(DirectContentActor actor, + ScorelineRecordCommand command, CancellationToken cancellationToken = default); + + Task> GetScorelineYearsAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> GetScorelineTrendAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> GetVideosAsync(DirectContentActor actor, AdminLimitFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertVideoAsync(DirectContentActor actor, + VideoExplanationCommand command, CancellationToken cancellationToken = default); + + Task> BindQuestionVideoAsync(DirectContentActor actor, + QuestionVideoCommand command, CancellationToken cancellationToken = default); + + Task> GetOperationContentAsync(DirectContentActor actor, string kind, + AdminLimitFilter filter, CancellationToken cancellationToken = default); + + Task> UpsertOperationContentAsync(DirectContentActor actor, + string kind, OperationContentCommand command, CancellationToken cancellationToken = default); + + Task PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, + CancellationToken cancellationToken = default); + + Task ExecuteImportAsync(DirectContentActor actor, SimpleImportCommand command, + CancellationToken cancellationToken = default); + + Task GetImportJobAsync(DirectContentActor actor, Guid jobId, + CancellationToken cancellationToken = default); + + Task> GetImportIssuesAsync(DirectContentActor actor, Guid jobId, + CancellationToken cancellationToken = default); + + Task RunImportPostCheckAsync(DirectContentActor actor, Guid jobId, + CancellationToken cancellationToken = default); + + Task GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Tiku.Application/Content/IContentManagementService.cs b/Tiku.Application/Content/IContentManagementService.cs index 6080d44..0da729d 100644 --- a/Tiku.Application/Content/IContentManagementService.cs +++ b/Tiku.Application/Content/IContentManagementService.cs @@ -52,4 +52,4 @@ public interface IContentManagementService ImportFieldMappingItem GetImportFieldMapping(string importType); ImportTemplateItem GetImportTemplate(string importType, string? format); -} +} \ No newline at end of file diff --git a/Tiku.Application/Content/IContentNavigationQueryService.cs b/Tiku.Application/Content/IContentNavigationQueryService.cs index c3446c7..fd193e5 100644 --- a/Tiku.Application/Content/IContentNavigationQueryService.cs +++ b/Tiku.Application/Content/IContentNavigationQueryService.cs @@ -23,4 +23,4 @@ public interface IContentNavigationQueryService Task> GetCollectionQuestionsAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/DependencyInjection.cs b/Tiku.Application/DependencyInjection.cs index 268acdf..a65e5f6 100644 --- a/Tiku.Application/DependencyInjection.cs +++ b/Tiku.Application/DependencyInjection.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -using Tiku.Application.Security; using Tiku.Application.Auth; +using Tiku.Application.Security; namespace Tiku.Application; @@ -16,4 +16,4 @@ public static class DependencyInjection return services; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Growth/CommissionModels.cs b/Tiku.Application/Growth/CommissionModels.cs index cd14db4..279c992 100644 --- a/Tiku.Application/Growth/CommissionModels.cs +++ b/Tiku.Application/Growth/CommissionModels.cs @@ -4,23 +4,65 @@ namespace Tiku.Application.Growth; public sealed record CommissionAdminActor(Guid TenantId, Guid UserId); -public sealed record UpdateCommissionSettingsCommand(decimal? DefaultRate, int? MinSettlementCents, string? SettlementCycle, JsonElement? Config); +public sealed record UpdateCommissionSettingsCommand( + decimal? DefaultRate, + int? MinSettlementCents, + string? SettlementCycle, + JsonElement? Config); -public sealed record UpdateMemberCommissionRateCommand(Guid UserId, decimal? CommissionRate, JsonElement? CommissionConfig); +public sealed record UpdateMemberCommissionRateCommand( + Guid UserId, + decimal? CommissionRate, + JsonElement? CommissionConfig); -public sealed record CommissionPeriodQuery(DateOnly? StartDate = null, DateOnly? EndDate = null, Guid? ReferrerUserId = null, int? Limit = null); +public sealed record CommissionPeriodQuery( + DateOnly? StartDate = null, + DateOnly? EndDate = null, + Guid? ReferrerUserId = null, + int? Limit = null); public sealed record CommissionSettlementQuery(string? Status = null, Guid? ReferrerUserId = null, int? Limit = null); -public sealed record GenerateCommissionSettlementCommand(DateOnly StartDate, DateOnly EndDate, Guid ReferrerUserId, string? Status, string? Remark, JsonElement? Metadata); +public sealed record GenerateCommissionSettlementCommand( + DateOnly StartDate, + DateOnly EndDate, + Guid ReferrerUserId, + string? Status, + string? Remark, + JsonElement? Metadata); -public sealed record UpdateCommissionSettlementStatusCommand(Guid SettlementId, string? Status, string? ReviewNote, string? PaymentMethod, string? PaymentAccount, JsonElement? Metadata); +public sealed record UpdateCommissionSettlementStatusCommand( + Guid SettlementId, + string? Status, + string? ReviewNote, + string? PaymentMethod, + string? PaymentAccount, + JsonElement? Metadata); -public sealed record CreateCommissionProofCommand(Guid SettlementId, string? ProofType, string? Title, string? Description, Guid? AssetId, string? ExternalUrl, int? AmountCents, string? PaymentMethod, string? PaymentAccount, DateTimeOffset? PaidAt, JsonElement? Metadata); +public sealed record CreateCommissionProofCommand( + Guid SettlementId, + string? ProofType, + string? Title, + string? Description, + Guid? AssetId, + string? ExternalUrl, + int? AmountCents, + string? PaymentMethod, + string? PaymentAccount, + DateTimeOffset? PaidAt, + JsonElement? Metadata); -public sealed record UpdateCommissionProofStatusCommand(Guid ProofId, string? Status, string? ReviewNote, JsonElement? Metadata); +public sealed record UpdateCommissionProofStatusCommand( + Guid ProofId, + string? Status, + string? ReviewNote, + JsonElement? Metadata); -public sealed record CommissionSettingsItem(decimal DefaultRate, int MinSettlementCents, string SettlementCycle, JsonElement Config); +public sealed record CommissionSettingsItem( + decimal DefaultRate, + int MinSettlementCents, + string SettlementCycle, + JsonElement Config); public sealed record CommissionSourceItem( string SourceType, @@ -34,7 +76,11 @@ public sealed record CommissionSourceItem( string RateSource, Guid? SettlementId); -public sealed record CommissionSummaryItem(int SourceCount, int PaidUserCount, int GrossAmountCents, int CommissionAmountCents); +public sealed record CommissionSummaryItem( + int SourceCount, + int PaidUserCount, + int GrossAmountCents, + int CommissionAmountCents); public sealed record CommissionSettlementItemDto( Guid Id, @@ -64,27 +110,58 @@ public sealed record CommissionProofItem( DateTimeOffset? PaidAt, DateTimeOffset? ReviewedAt); -public sealed record CommissionExportItem(Guid SettlementId, string Filename, string Format, string MimeType, int RowCount, string ContentBase64, string Sha256, int SizeBytes); +public sealed record CommissionExportItem( + Guid SettlementId, + string Filename, + string Format, + string MimeType, + int RowCount, + string ContentBase64, + string Sha256, + int SizeBytes); public sealed record CommissionList(IReadOnlyCollection Items); public interface ICommissionService { - Task GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default); - Task UpdateSettingsAsync(CommissionAdminActor actor, UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default); - Task UpdateMemberRateAsync(CommissionAdminActor actor, UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default); - Task GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default); - Task> GetOrdersAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default); - Task> GetSettlementsAsync(CommissionAdminActor actor, CommissionSettlementQuery query, CancellationToken cancellationToken = default); - Task ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId, string? format, CancellationToken cancellationToken = default); - Task GenerateSettlementAsync(CommissionAdminActor actor, GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default); - Task UpdateSettlementStatusAsync(CommissionAdminActor actor, UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default); - Task> GetProofsAsync(CommissionAdminActor actor, Guid settlementId, CancellationToken cancellationToken = default); - Task CreateProofAsync(CommissionAdminActor actor, CreateCommissionProofCommand command, CancellationToken cancellationToken = default); - Task UpdateProofStatusAsync(CommissionAdminActor actor, UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default); + Task GetSettingsAsync(CommissionAdminActor actor, + CancellationToken cancellationToken = default); + + Task UpdateSettingsAsync(CommissionAdminActor actor, + UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default); + + Task UpdateMemberRateAsync(CommissionAdminActor actor, UpdateMemberCommissionRateCommand command, + CancellationToken cancellationToken = default); + + Task GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query, + CancellationToken cancellationToken = default); + + Task> GetOrdersAsync(CommissionAdminActor actor, CommissionPeriodQuery query, + CancellationToken cancellationToken = default); + + Task> GetSettlementsAsync(CommissionAdminActor actor, + CommissionSettlementQuery query, CancellationToken cancellationToken = default); + + Task ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId, string? format, + CancellationToken cancellationToken = default); + + Task GenerateSettlementAsync(CommissionAdminActor actor, + GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default); + + Task UpdateSettlementStatusAsync(CommissionAdminActor actor, + UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default); + + Task> GetProofsAsync(CommissionAdminActor actor, Guid settlementId, + CancellationToken cancellationToken = default); + + Task CreateProofAsync(CommissionAdminActor actor, CreateCommissionProofCommand command, + CancellationToken cancellationToken = default); + + Task UpdateProofStatusAsync(CommissionAdminActor actor, + UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default); } public sealed class CommissionException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Growth/CrmModels.cs b/Tiku.Application/Growth/CrmModels.cs index 7430539..9dc0c39 100644 --- a/Tiku.Application/Growth/CrmModels.cs +++ b/Tiku.Application/Growth/CrmModels.cs @@ -17,7 +17,11 @@ public sealed record UpsertCrmConfigCommand( JsonElement? AssignmentPool, JsonElement? AssignmentConfig); -public sealed record CrmQueueQuery(string? Status = null, Guid? QueueId = null, string? Source = null, int? Limit = null); +public sealed record CrmQueueQuery( + string? Status = null, + Guid? QueueId = null, + string? Source = null, + int? Limit = null); public sealed record CrmQueueLogQuery(Guid? QueueId = null, int? Limit = null); @@ -101,4 +105,4 @@ public interface ICrmService public sealed class CrmException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Growth/ReferralModels.cs b/Tiku.Application/Growth/ReferralModels.cs index 0d92e46..a0afb0c 100644 --- a/Tiku.Application/Growth/ReferralModels.cs +++ b/Tiku.Application/Growth/ReferralModels.cs @@ -44,7 +44,12 @@ public sealed record ReferralQrcodeGenerateResult( public sealed record ReferralStatsQuery(Guid? ReferrerUserId = null, int? Limit = null); -public sealed record ReferralConversionQuery(Guid? ReferrerUserId = null, DateOnly? StartDate = null, DateOnly? EndDate = null, int? Days = null, int? Limit = null); +public sealed record ReferralConversionQuery( + Guid? ReferrerUserId = null, + DateOnly? StartDate = null, + DateOnly? EndDate = null, + int? Days = null, + int? Limit = null); public sealed record ManualBindReferralCommand( Guid StudentUserId, @@ -211,4 +216,4 @@ public interface IReferralQrcodeGenerator public sealed class ReferralException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs index f4f3bb2..43ff1b9 100644 --- a/Tiku.Application/Jobs/BackgroundJobModels.cs +++ b/Tiku.Application/Jobs/BackgroundJobModels.cs @@ -40,7 +40,6 @@ public interface IBackgroundJobQueue Task EnqueueAsync( CreateBackgroundJobCommand command, CancellationToken cancellationToken = default); - } public interface IBackgroundJobProcessor @@ -57,7 +56,6 @@ public interface IBackgroundJobProcessor string jobType, string workerId, CancellationToken cancellationToken = default); - } public interface IBackgroundJobOperations @@ -112,4 +110,4 @@ public interface IBackgroundJobHandler Task HandleAsync( BackgroundJobExecutionContext context, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Learning/ILearningActivityService.cs b/Tiku.Application/Learning/ILearningActivityService.cs index cde565a..ed6c980 100644 --- a/Tiku.Application/Learning/ILearningActivityService.cs +++ b/Tiku.Application/Learning/ILearningActivityService.cs @@ -110,4 +110,4 @@ public interface ILearningActivityService LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Learning/LearningActivityModels.cs b/Tiku.Application/Learning/LearningActivityModels.cs index f24d39e..50a9641 100644 --- a/Tiku.Application/Learning/LearningActivityModels.cs +++ b/Tiku.Application/Learning/LearningActivityModels.cs @@ -1,7 +1,7 @@ using System.Text.Json; +using Tiku.Application.QuestionBanks; using Tiku.Domain.Content; using Tiku.Domain.Learning; -using Tiku.Application.QuestionBanks; namespace Tiku.Application.Learning; @@ -248,4 +248,4 @@ public sealed record PracticeHistoryItem( Guid? ReportId, decimal? Score, decimal? ReportTotalScore, - decimal? Accuracy); + decimal? Accuracy); \ No newline at end of file diff --git a/Tiku.Application/Learning/LearningExceptions.cs b/Tiku.Application/Learning/LearningExceptions.cs index 37b720f..314874d 100644 --- a/Tiku.Application/Learning/LearningExceptions.cs +++ b/Tiku.Application/Learning/LearningExceptions.cs @@ -9,4 +9,4 @@ public sealed class LearningResourceNotFoundException(string code, string messag : LearningException(code, message); public sealed class LearningValidationException(string code, string message) - : LearningException(code, message); + : LearningException(code, message); \ No newline at end of file diff --git a/Tiku.Application/Learning/QuestionGrading.cs b/Tiku.Application/Learning/QuestionGrading.cs index 9272e59..9beb674 100644 --- a/Tiku.Application/Learning/QuestionGrading.cs +++ b/Tiku.Application/Learning/QuestionGrading.cs @@ -31,9 +31,7 @@ public static class QuestionGrader { var type = input.QuestionType.Trim().ToLowerInvariant(); if (SubjectiveTypes.Contains(type)) - { return new QuestionGradingResult(AnswerGradingStatus.PendingReview, null, null); - } var correct = type switch { @@ -95,25 +93,16 @@ public static class QuestionGrader if (input.GradingRules.ValueKind == JsonValueKind.Object && input.GradingRules.TryGetProperty("acceptedAnswers", out var alternatives) && alternatives.ValueKind == JsonValueKind.Array) - { foreach (var alternative in alternatives.EnumerateArray()) - { if (alternative.ValueKind == JsonValueKind.String) - { AddNormalized(accepted, alternative.GetString()); - } - } - } return accepted.Count > 0 && accepted.Contains(NormalizeText(input.AnswerText)); } private static IReadOnlyList ReadIndices(JsonElement value) { - if (value.ValueKind != JsonValueKind.Array) - { - return []; - } + if (value.ValueKind != JsonValueKind.Array) return []; return value.EnumerateArray() .Where(item => item.TryGetInt32(out _)) @@ -142,24 +131,17 @@ public static class QuestionGrader private static void AddNormalized(ISet values, string? value) { var normalized = NormalizeText(value); - if (normalized.Length > 0) - { - values.Add(normalized); - } + if (normalized.Length > 0) values.Add(normalized); } private static string NormalizeText(string? value) { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } + if (string.IsNullOrWhiteSpace(value)) return string.Empty; var normalized = value.Normalize(NormalizationForm.FormKC).Trim().ToLower(CultureInfo.InvariantCulture); var builder = new StringBuilder(normalized.Length); var previousWhitespace = false; foreach (var character in normalized) - { if (char.IsWhiteSpace(character)) { if (!previousWhitespace) @@ -173,8 +155,7 @@ public static class QuestionGrader builder.Append(character); previousWhitespace = false; } - } return builder.ToString(); } -} +} \ No newline at end of file diff --git a/Tiku.Application/Notifications/INotificationProvider.cs b/Tiku.Application/Notifications/INotificationProvider.cs index f88a1e4..82d59bf 100644 --- a/Tiku.Application/Notifications/INotificationProvider.cs +++ b/Tiku.Application/Notifications/INotificationProvider.cs @@ -23,4 +23,4 @@ public interface INotificationProvider Task UpsertInAppAsync( InAppNotificationRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs b/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs index 9e8b70b..2692334 100644 --- a/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs +++ b/Tiku.Application/PlatformAdmin/Operations/PlatformOperationsQueries.cs @@ -46,4 +46,4 @@ public interface IPlatformOperationsQueryService Task> GetWorkersAsync(CancellationToken cancellationToken = default); Task GetJobMetricsAsync(CancellationToken cancellationToken = default); Task GetGovernanceMetricsAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs index e4e91f1..5ccf781 100644 --- a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs @@ -1,8 +1,7 @@ using System.Text.Json; -using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; -using Tiku.Domain.Identity; namespace Tiku.Application.PlatformAdmin; @@ -156,7 +155,8 @@ public static class TenantOwnerActivationUrlPolicy var normalizedHost = host.Trim().TrimEnd('.').ToLowerInvariant(); var useDevelopmentLocalhost = normalizedHost.EndsWith(".localhost", StringComparison.Ordinal) && - !string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate); + !string.IsNullOrWhiteSpace(options + .DevelopmentLocalhostOwnerActivationUrlTemplate); var template = useDevelopmentLocalhost ? options.DevelopmentLocalhostOwnerActivationUrlTemplate! : options.OwnerActivationUrlTemplate; @@ -166,10 +166,8 @@ public static class TenantOwnerActivationUrlPolicy (useDevelopmentLocalhost && uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) - { throw new InvalidOperationException( "Owner activation URLs must use HTTPS except for an explicitly configured Development .localhost origin."); - } return $"{origin}/activate/{activationId}#token={token}"; } @@ -213,7 +211,10 @@ public sealed record UpsertPlatformTenantBillingProfileCommand( public sealed record PlatformDomainList(IReadOnlyCollection Items); -public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt); +public sealed record PlatformDomainRecheckResult( + Guid DomainId, + TenantDomainStatus Status, + DateTimeOffset LastCheckedAt); public sealed record PlatformStaffList(IReadOnlyCollection Items); @@ -320,39 +321,90 @@ public sealed record PlatformBillingDunningEventItem( DateTimeOffset UpdatedAt); public sealed record RetryPlatformBillingDunningEventCommand(Guid EventId, string? Reason); + public sealed record ResolvePlatformBillingDunningEventCommand(Guid EventId, string Reason); public interface IPlatformAdminService { Task GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default); - Task GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default); - Task CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default); - Task ReplacePrimaryDomainAsync(PlatformAdminActor actor, ReplacePlatformPrimaryDomainCommand command, CancellationToken cancellationToken = default); - Task IssueOwnerActivationLinkAsync(PlatformAdminActor actor, IssuePlatformOwnerActivationLinkCommand command, CancellationToken cancellationToken = default); - Task UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default); - Task UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default); - Task GetTenantBillingPolicyAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default); - Task UpsertTenantBillingPolicyAsync(PlatformAdminActor actor, UpsertTenantBillingPolicyCommand command, CancellationToken cancellationToken = default); - Task GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default); - Task GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task UpsertStaffAsync(PlatformAdminActor actor, UpsertPlatformStaffCommand command, CancellationToken cancellationToken = default); - Task UpdateStaffStatusAsync(PlatformAdminActor actor, UpdatePlatformStaffStatusCommand command, CancellationToken cancellationToken = default); - Task GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default); - Task GetBillingDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task UpsertBillingDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default); - Task DisableBillingDunningChannelAsync(PlatformAdminActor actor, DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default); - Task GetBillingDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); - Task GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default); - Task RetryBillingDunningEventAsync(PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); - Task AcknowledgeBillingDunningEventAsync(PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); - Task IgnoreBillingDunningEventAsync(PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); + + Task GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, + CancellationToken cancellationToken = default); + + Task GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, + CancellationToken cancellationToken = default); + + Task CreateTenantAsync(PlatformAdminActor actor, + CreatePlatformTenantCommand command, CancellationToken cancellationToken = default); + + Task ReplacePrimaryDomainAsync(PlatformAdminActor actor, + ReplacePlatformPrimaryDomainCommand command, CancellationToken cancellationToken = default); + + Task IssueOwnerActivationLinkAsync(PlatformAdminActor actor, + IssuePlatformOwnerActivationLinkCommand command, CancellationToken cancellationToken = default); + + Task UpdateTenantStatusAsync(PlatformAdminActor actor, + UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default); + + Task UpsertTenantBillingProfileAsync(PlatformAdminActor actor, + UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default); + + Task GetTenantBillingPolicyAsync(PlatformAdminActor actor, Guid tenantId, + CancellationToken cancellationToken = default); + + Task UpsertTenantBillingPolicyAsync(PlatformAdminActor actor, + UpsertTenantBillingPolicyCommand command, CancellationToken cancellationToken = default); + + Task GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, + CancellationToken cancellationToken = default); + + Task RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, + CancellationToken cancellationToken = default); + + Task GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, + CancellationToken cancellationToken = default); + + Task UpsertStaffAsync(PlatformAdminActor actor, UpsertPlatformStaffCommand command, + CancellationToken cancellationToken = default); + + Task UpdateStaffStatusAsync(PlatformAdminActor actor, UpdatePlatformStaffStatusCommand command, + CancellationToken cancellationToken = default); + + Task GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, + CancellationToken cancellationToken = default); + + Task GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, + CancellationToken cancellationToken = default); + + Task UpdateAuditAlertStatusAsync(PlatformAdminActor actor, + UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default); + + Task GetBillingDunningChannelsAsync(PlatformAdminActor actor, + PlatformAdminQuery query, CancellationToken cancellationToken = default); + + Task UpsertBillingDunningChannelAsync(PlatformAdminActor actor, + UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default); + + Task DisableBillingDunningChannelAsync(PlatformAdminActor actor, + DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default); + + Task GetBillingDunningEventsAsync(PlatformAdminActor actor, + PlatformAdminQuery query, CancellationToken cancellationToken = default); + + Task GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, + CancellationToken cancellationToken = default); + + Task RetryBillingDunningEventAsync(PlatformAdminActor actor, + RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); + + Task AcknowledgeBillingDunningEventAsync(PlatformAdminActor actor, + ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); + + Task IgnoreBillingDunningEventAsync(PlatformAdminActor actor, + ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default); } public sealed class PlatformAdminException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs b/Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs index e855a99..d2e2cf6 100644 --- a/Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformApprovalModels.cs @@ -63,19 +63,45 @@ public sealed record UpdatePlatformApprovalPolicyCommand( public interface IPlatformApprovalService { - Task> ListAsync(PlatformApprovalActor actor, PlatformApprovalRequestStatus? status, int limit, CancellationToken cancellationToken = default); - Task GetAsync(PlatformApprovalActor actor, Guid requestId, CancellationToken cancellationToken = default); - Task> ListPoliciesAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default); - Task UpdatePolicyAsync(PlatformApprovalActor actor, UpdatePlatformApprovalPolicyCommand command, CancellationToken cancellationToken = default); - Task ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default); - Task RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default); - Task CancelAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default); + Task> ListAsync(PlatformApprovalActor actor, + PlatformApprovalRequestStatus? status, int limit, CancellationToken cancellationToken = default); + + Task GetAsync(PlatformApprovalActor actor, Guid requestId, + CancellationToken cancellationToken = default); + + Task> ListPoliciesAsync(PlatformApprovalActor actor, + CancellationToken cancellationToken = default); + + Task UpdatePolicyAsync(PlatformApprovalActor actor, + UpdatePlatformApprovalPolicyCommand command, CancellationToken cancellationToken = default); + + Task ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, + CancellationToken cancellationToken = default); + + Task RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, + CancellationToken cancellationToken = default); + + Task CancelAsync(PlatformApprovalActor actor, Guid requestId, string reason, + CancellationToken cancellationToken = default); + Task ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default); - Task SubmitRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default); - Task ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, string idempotencyKey, CancellationToken cancellationToken = default); - Task UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, string idempotencyKey, CancellationToken cancellationToken = default); - Task UpsertPaymentChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, string idempotencyKey, CancellationToken cancellationToken = default); - Task ReplaceRoleBindingsAsync(BackofficeActor actor, ReplaceRoleBindingsCommand command, string idempotencyKey, CancellationToken cancellationToken = default); + + Task SubmitRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, + CancellationToken cancellationToken = default); + + Task ConfirmManualPaymentAsync(SaasCatalogActor actor, + ConfirmManualPaymentCommand command, string idempotencyKey, CancellationToken cancellationToken = default); + + Task UpdateTenantStatusAsync(PlatformAdminActor actor, + UpdatePlatformTenantStatusCommand command, string idempotencyKey, + CancellationToken cancellationToken = default); + + Task UpsertPaymentChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentChannelCommand command, string idempotencyKey, + CancellationToken cancellationToken = default); + + Task ReplaceRoleBindingsAsync(BackofficeActor actor, ReplaceRoleBindingsCommand command, + string idempotencyKey, CancellationToken cancellationToken = default); } public sealed class PlatformApprovalException(string message, string code) : Exception(message) @@ -85,8 +111,12 @@ public sealed class PlatformApprovalException(string message, string code) : Exc public static class PlatformApprovalRules { - public static bool RequiresApproval(bool enabled, bool alwaysRequireApproval, int? amountThresholdCents, int? amountCents) => - enabled && (alwaysRequireApproval || amountThresholdCents.HasValue && amountCents >= amountThresholdCents); + public static bool RequiresApproval(bool enabled, bool alwaysRequireApproval, int? amountThresholdCents, + int? amountCents) + { + return enabled && (alwaysRequireApproval || + (amountThresholdCents.HasValue && amountCents >= amountThresholdCents)); + } public static string? DecisionDenialCode( PlatformApprovalRequestStatus status, @@ -102,4 +132,4 @@ public static class PlatformApprovalRules if (requestedBy == decisionActor) return "approval_maker_checker_required"; return permissions.Contains(requiredPermission) ? null : "approval_business_permission_required"; } -} +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs b/Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs index 12fe90f..2d90221 100644 --- a/Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformGovernanceModels.cs @@ -3,40 +3,123 @@ using Tiku.Domain.Platform; namespace Tiku.Application.PlatformAdmin; -public sealed record PagedQuery(int Page = 1, int PageSize = 50, string? Search = null, string? SortBy = null, bool Descending = true) +public sealed record PagedQuery( + int Page = 1, + int PageSize = 50, + string? Search = null, + string? SortBy = null, + bool Descending = true) { public int SafePage => Math.Max(1, Page); public int SafePageSize => Math.Clamp(PageSize, 1, 200); } + public sealed record PagedResult(IReadOnlyCollection Items, int Total, int Page, int PageSize); -public sealed record PlatformConfigurationDefinitionItem(Guid Id, string Code, string Name, string Category, - PlatformConfigurationValueType ValueType, bool AllowRuntimeManagement, bool IsSensitive, string? Description, JsonElement ValidationSchema); -public sealed record PlatformConfigurationVersionItem(Guid Id, Guid DefinitionId, string Environment, int Version, - PlatformConfigurationVersionStatus Status, JsonElement? Value, string? SecretRef, Guid CreatedBy, Guid? PublishedBy, - Guid? RolledBackFromVersionId, string Reason, DateTimeOffset? PublishedAt, DateTimeOffset CreatedAt); -public sealed record SavePlatformConfigurationDraftCommand(string DefinitionCode, string Environment, JsonElement? Value, string? SecretRef, string Reason); +public sealed record PlatformConfigurationDefinitionItem( + Guid Id, + string Code, + string Name, + string Category, + PlatformConfigurationValueType ValueType, + bool AllowRuntimeManagement, + bool IsSensitive, + string? Description, + JsonElement ValidationSchema); -public sealed record PlatformNotificationTemplateItem(Guid Id, string Code, string Name, PlatformNotificationChannel Channel, - string SubjectTemplate, string BodyTemplate, bool Enabled, JsonElement Variables, DateTimeOffset UpdatedAt); -public sealed record UpsertPlatformNotificationTemplateCommand(Guid? Id, string Code, string Name, PlatformNotificationChannel Channel, - string SubjectTemplate, string BodyTemplate, bool Enabled, JsonElement Variables); -public sealed record SendPlatformNotificationCommand(Guid TemplateId, IReadOnlyCollection RoleCodes, - IReadOnlyDictionary Variables, string IdempotencyKey); -public sealed record PlatformNotificationDeliveryItem(Guid Id, Guid TemplateId, Guid RecipientUserId, string RecipientRoleCode, - PlatformNotificationChannel Channel, PlatformNotificationDeliveryStatus Status, string Subject, string Body, - int Attempts, string? LastError, DateTimeOffset? SentAt, DateTimeOffset CreatedAt); +public sealed record PlatformConfigurationVersionItem( + Guid Id, + Guid DefinitionId, + string Environment, + int Version, + PlatformConfigurationVersionStatus Status, + JsonElement? Value, + string? SecretRef, + Guid CreatedBy, + Guid? PublishedBy, + Guid? RolledBackFromVersionId, + string Reason, + DateTimeOffset? PublishedAt, + DateTimeOffset CreatedAt); + +public sealed record SavePlatformConfigurationDraftCommand( + string DefinitionCode, + string Environment, + JsonElement? Value, + string? SecretRef, + string Reason); + +public sealed record PlatformNotificationTemplateItem( + Guid Id, + string Code, + string Name, + PlatformNotificationChannel Channel, + string SubjectTemplate, + string BodyTemplate, + bool Enabled, + JsonElement Variables, + DateTimeOffset UpdatedAt); + +public sealed record UpsertPlatformNotificationTemplateCommand( + Guid? Id, + string Code, + string Name, + PlatformNotificationChannel Channel, + string SubjectTemplate, + string BodyTemplate, + bool Enabled, + JsonElement Variables); + +public sealed record SendPlatformNotificationCommand( + Guid TemplateId, + IReadOnlyCollection RoleCodes, + IReadOnlyDictionary Variables, + string IdempotencyKey); + +public sealed record PlatformNotificationDeliveryItem( + Guid Id, + Guid TemplateId, + Guid RecipientUserId, + string RecipientRoleCode, + PlatformNotificationChannel Channel, + PlatformNotificationDeliveryStatus Status, + string Subject, + string Body, + int Attempts, + string? LastError, + DateTimeOffset? SentAt, + DateTimeOffset CreatedAt); public interface IPlatformGovernanceService { - Task> GetConfigurationDefinitionsAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default); - Task> GetConfigurationVersionsAsync(PlatformApprovalActor actor, string definitionCode, string? environment, CancellationToken cancellationToken = default); - Task SaveConfigurationDraftAsync(PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default); - Task PublishConfigurationAsync(PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default); - Task RollbackConfigurationAsync(PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default); - Task> GetNotificationDeliveriesAsync(PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default); - Task> GetNotificationTemplatesAsync(PlatformApprovalActor actor, CancellationToken cancellationToken = default); - Task UpsertNotificationTemplateAsync(PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default); - Task> SendNotificationAsync(PlatformApprovalActor actor, SendPlatformNotificationCommand command, CancellationToken cancellationToken = default); - Task RetryNotificationAsync(PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default); -} + Task> GetConfigurationDefinitionsAsync( + PlatformApprovalActor actor, CancellationToken cancellationToken = default); + + Task> GetConfigurationVersionsAsync( + PlatformApprovalActor actor, string definitionCode, string? environment, + CancellationToken cancellationToken = default); + + Task SaveConfigurationDraftAsync(PlatformApprovalActor actor, + SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default); + + Task PublishConfigurationAsync(PlatformApprovalActor actor, Guid versionId, + CancellationToken cancellationToken = default); + + Task RollbackConfigurationAsync(PlatformApprovalActor actor, Guid versionId, + string reason, CancellationToken cancellationToken = default); + + Task> GetNotificationDeliveriesAsync(PlatformApprovalActor actor, + PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default); + + Task> GetNotificationTemplatesAsync( + PlatformApprovalActor actor, CancellationToken cancellationToken = default); + + Task UpsertNotificationTemplateAsync(PlatformApprovalActor actor, + UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default); + + Task> SendNotificationAsync(PlatformApprovalActor actor, + SendPlatformNotificationCommand command, CancellationToken cancellationToken = default); + + Task RetryNotificationAsync(PlatformApprovalActor actor, Guid deliveryId, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs index aeaa3dc..711c726 100644 --- a/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs @@ -137,19 +137,48 @@ public sealed record PlatformQuestionImportResult( public interface IPlatformQuestionBankService { - Task> GetBanksAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); - Task UpsertBankAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankCommand command, CancellationToken cancellationToken = default); - Task ArchiveBankAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); - Task> GetNodesAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); - Task UpsertNodeAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankNodeCommand command, CancellationToken cancellationToken = default); - Task> BatchCreateNodesAsync(PlatformAdminActor actor, BatchCreatePlatformQuestionBankNodesCommand command, CancellationToken cancellationToken = default); - Task ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId, CancellationToken cancellationToken = default); - Task GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); - Task UpsertQuestionAsync(PlatformAdminActor actor, UpsertPlatformQuestionCommand command, CancellationToken cancellationToken = default); - Task ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, CancellationToken cancellationToken = default); - Task PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); - Task ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); - Task GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default); - Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default); - Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); -} + Task> GetBanksAsync(PlatformAdminActor actor, + PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); + + Task UpsertBankAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankCommand command, + CancellationToken cancellationToken = default); + + Task ArchiveBankAsync(PlatformAdminActor actor, Guid bankId, + CancellationToken cancellationToken = default); + + Task> GetNodesAsync(PlatformAdminActor actor, Guid bankId, + CancellationToken cancellationToken = default); + + Task UpsertNodeAsync(PlatformAdminActor actor, + UpsertPlatformQuestionBankNodeCommand command, CancellationToken cancellationToken = default); + + Task> BatchCreateNodesAsync(PlatformAdminActor actor, + BatchCreatePlatformQuestionBankNodesCommand command, CancellationToken cancellationToken = default); + + Task ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId, + CancellationToken cancellationToken = default); + + Task GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default); + + Task UpsertQuestionAsync(PlatformAdminActor actor, UpsertPlatformQuestionCommand command, + CancellationToken cancellationToken = default); + + Task ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, + CancellationToken cancellationToken = default); + + Task PreviewImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); + + Task ExecuteImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); + + Task GetImportAsync(PlatformAdminActor actor, Guid jobId, + CancellationToken cancellationToken = default); + + Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, + CancellationToken cancellationToken = default); + + Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, + AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Tiku.Application/PlatformAdmin/PlatformTenantCapabilitiesModels.cs b/Tiku.Application/PlatformAdmin/PlatformTenantCapabilitiesModels.cs index 50ac60b..82c1c72 100644 --- a/Tiku.Application/PlatformAdmin/PlatformTenantCapabilitiesModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformTenantCapabilitiesModels.cs @@ -1,12 +1,13 @@ using System.Text.Json; +using Tiku.Application.Tenancy; using Tiku.Domain.Growth; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Application.PlatformAdmin; public sealed record PlatformCapabilityActor(Guid UserId); + public sealed record PlatformCapabilityQuery(Guid? TenantId = null, string? Status = null, int Limit = 100); public sealed record PlatformCrmConfigItem( @@ -40,15 +41,25 @@ public sealed record UpsertPlatformCrmConfigCommand( JsonElement? AssignmentConfig); public sealed record PlatformCrmLeadRetryCommand(Guid QueueId, string? Note); + public sealed record PlatformTenantCapabilityList(IReadOnlyCollection Items); public interface IPlatformCrmAdminService { - Task> GetConfigsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); - Task UpsertConfigAsync(PlatformCapabilityActor actor, UpsertPlatformCrmConfigCommand command, CancellationToken cancellationToken = default); - Task> GetLeadsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); - Task RetryLeadAsync(PlatformCapabilityActor actor, PlatformCrmLeadRetryCommand command, CancellationToken cancellationToken = default); - Task> GetLogsAsync(PlatformCapabilityActor actor, Guid? tenantId, Guid? queueId, int limit, CancellationToken cancellationToken = default); + Task> GetConfigsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + + Task UpsertConfigAsync(PlatformCapabilityActor actor, UpsertPlatformCrmConfigCommand command, + CancellationToken cancellationToken = default); + + Task> GetLeadsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + + Task RetryLeadAsync(PlatformCapabilityActor actor, PlatformCrmLeadRetryCommand command, + CancellationToken cancellationToken = default); + + Task> GetLogsAsync(PlatformCapabilityActor actor, Guid? tenantId, + Guid? queueId, int limit, CancellationToken cancellationToken = default); } public sealed record PlatformSmsChannelItem( @@ -116,14 +127,29 @@ public sealed record UpsertPlatformSmsTemplateCommand( public interface IPlatformSmsAdminService { - Task> GetChannelsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); - Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default); - Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default); - Task> GetTemplatesAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); - Task UpsertTemplateAsync(PlatformCapabilityActor actor, UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default); - Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default); - Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default); - Task> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + Task> GetChannelsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + + Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default); + + Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default); + + Task> GetTemplatesAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + + Task UpsertTemplateAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default); + + Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default); + + Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default); + + Task> GetLogsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); } public sealed record UpsertPlatformPaymentAppCommand( @@ -148,27 +174,50 @@ public sealed record UpsertPlatformPaymentChannelCommand( JsonElement ConfigPublic, JsonElement Metadata); -public sealed record PlatformRebateSummary(int GrossAmountCents, int CommissionAmountCents, int PendingSettlementCents, int PaidSettlementCents, int ExceptionCount); +public sealed record PlatformRebateSummary( + int GrossAmountCents, + int CommissionAmountCents, + int PendingSettlementCents, + int PaidSettlementCents, + int ExceptionCount); public interface IPlatformPaymentSettingsService { - Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default); - Task UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default); - Task> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, string? status, int limit, CancellationToken cancellationToken = default); - Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default); - Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default); - Task> GetEventsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default); - Task GetRebateSummaryAsync(PlatformCapabilityActor actor, CancellationToken cancellationToken = default); + Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, + CancellationToken cancellationToken = default); + + Task UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, + CancellationToken cancellationToken = default); + + Task> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, + string? status, int limit, CancellationToken cancellationToken = default); + + Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default); + + Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default); + + Task> GetEventsAsync(PlatformCapabilityActor actor, string? status, + int limit, CancellationToken cancellationToken = default); + + Task GetRebateSummaryAsync(PlatformCapabilityActor actor, + CancellationToken cancellationToken = default); } public interface IPlatformTenantPaymentAdminService { - Task> GetAppsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); - Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default); - Task> GetEventsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + Task> GetAppsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); + + Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, + UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default); + + Task> GetEventsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default); } public sealed class PlatformCapabilityException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs index 2f7f14b..643835c 100644 --- a/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs +++ b/Tiku.Application/PlatformBilling/PlatformBillingContracts.cs @@ -6,6 +6,7 @@ using Tiku.Domain.Platform; namespace Tiku.Application.PlatformBilling; public sealed record SaasCatalogActor(Guid UserId); + public sealed record TenantBillingActor(Guid UserId, Guid TenantId); public sealed record UpsertSaasFeatureCommand( @@ -76,13 +77,27 @@ public sealed record SaasCatalogSnapshot( public interface ISaasCatalogAdminService { Task GetCatalogAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default); - Task UpsertFeatureAsync(SaasCatalogActor actor, UpsertSaasFeatureCommand command, CancellationToken cancellationToken = default); - Task UpsertLimitDefinitionAsync(SaasCatalogActor actor, UpsertSaasFeatureLimitCommand command, CancellationToken cancellationToken = default); - Task UpsertOfferingAsync(SaasCatalogActor actor, UpsertSaasOfferingCommand command, CancellationToken cancellationToken = default); - Task UpsertDraftVersionAsync(SaasCatalogActor actor, UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default); - Task PublishVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default); - Task CloneVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default); - Task RetireVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default); + + Task UpsertFeatureAsync(SaasCatalogActor actor, UpsertSaasFeatureCommand command, + CancellationToken cancellationToken = default); + + Task UpsertLimitDefinitionAsync(SaasCatalogActor actor, + UpsertSaasFeatureLimitCommand command, CancellationToken cancellationToken = default); + + Task UpsertOfferingAsync(SaasCatalogActor actor, UpsertSaasOfferingCommand command, + CancellationToken cancellationToken = default); + + Task UpsertDraftVersionAsync(SaasCatalogActor actor, + UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default); + + Task PublishVersionAsync(SaasCatalogActor actor, Guid versionId, + CancellationToken cancellationToken = default); + + Task CloneVersionAsync(SaasCatalogActor actor, Guid versionId, + CancellationToken cancellationToken = default); + + Task RetireVersionAsync(SaasCatalogActor actor, Guid versionId, + CancellationToken cancellationToken = default); } public sealed record TenantBillingCatalog( @@ -119,6 +134,7 @@ public sealed record PlatformBillingQuoteView( IReadOnlyDictionary Limits); public sealed record CreatePlatformBillingOrderCommand(Guid QuoteId, string IdempotencyKey); + public sealed record CreatePlatformBillingPaymentCommand( string OrderNo, string Provider, @@ -159,39 +175,90 @@ public sealed record TenantSubscriptionView( Guid? ScheduledBaseOfferingVersionId, IReadOnlyCollection FeatureCodes); -public sealed record ChangeTenantSubscriptionCommand(Guid BaseOfferingVersionId, IReadOnlyCollection AddOnOfferingVersionIds); +public sealed record ChangeTenantSubscriptionCommand( + Guid BaseOfferingVersionId, + IReadOnlyCollection AddOnOfferingVersionIds); public interface ITenantBillingService { Task GetCatalogAsync(TenantBillingActor actor, CancellationToken cancellationToken = default); - Task CreateQuoteAsync(TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, CancellationToken cancellationToken = default); - Task CreateOrderAsync(TenantBillingActor actor, CreatePlatformBillingOrderCommand command, CancellationToken cancellationToken = default); - Task CreatePaymentAsync(TenantBillingActor actor, CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default); - Task> GetOrdersAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default); - Task GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default); - Task GetSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default); - Task ChangeSubscriptionAsync(TenantBillingActor actor, ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default); - Task RenewSubscriptionAsync(TenantBillingActor actor, string idempotencyKey, CancellationToken cancellationToken = default); - Task CancelSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default); - Task> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default); - Task> GetInvoicesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default); - Task> GetReceivablesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default); - Task CancelOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default); - Task> GetRefundsAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default); + + Task CreateQuoteAsync(TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, + CancellationToken cancellationToken = default); + + Task CreateOrderAsync(TenantBillingActor actor, CreatePlatformBillingOrderCommand command, + CancellationToken cancellationToken = default); + + Task CreatePaymentAsync(TenantBillingActor actor, + CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default); + + Task> GetOrdersAsync(TenantBillingActor actor, int limit, + CancellationToken cancellationToken = default); + + Task GetOrderAsync(TenantBillingActor actor, string orderNo, + CancellationToken cancellationToken = default); + + Task GetSubscriptionAsync(TenantBillingActor actor, + CancellationToken cancellationToken = default); + + Task ChangeSubscriptionAsync(TenantBillingActor actor, + ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default); + + Task RenewSubscriptionAsync(TenantBillingActor actor, string idempotencyKey, + CancellationToken cancellationToken = default); + + Task CancelSubscriptionAsync(TenantBillingActor actor, + CancellationToken cancellationToken = default); + + Task> GetUsageAsync(TenantBillingActor actor, + CancellationToken cancellationToken = default); + + Task> GetInvoicesAsync(TenantBillingActor actor, int limit, + CancellationToken cancellationToken = default); + + Task> GetReceivablesAsync(TenantBillingActor actor, int limit, + CancellationToken cancellationToken = default); + + Task CancelOrderAsync(TenantBillingActor actor, string orderNo, + CancellationToken cancellationToken = default); + + Task> GetRefundsAsync(TenantBillingActor actor, int limit, + CancellationToken cancellationToken = default); } public sealed record PlatformBillingAdminQuery(Guid? TenantId, string? Status, int Limit = 100); -public sealed record ConfirmManualPaymentCommand(Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, string Reason); -public sealed record UpsertTenantFeatureOverrideCommand(Guid TenantId, string FeatureCode, TenantFeatureOverrideMode Mode, DateTimeOffset? ExpiresAt, string Reason); -public sealed record GrantTenantTrialCommand(Guid TenantId, Guid BaseOfferingVersionId, int TrialDays, string IdempotencyKey, string Reason); + +public sealed record ConfirmManualPaymentCommand( + Guid PaymentId, + string? ProviderTradeNo, + DateTimeOffset? PaidAt, + string Reason); + +public sealed record UpsertTenantFeatureOverrideCommand( + Guid TenantId, + string FeatureCode, + TenantFeatureOverrideMode Mode, + DateTimeOffset? ExpiresAt, + string Reason); + +public sealed record GrantTenantTrialCommand( + Guid TenantId, + Guid BaseOfferingVersionId, + int TrialDays, + string IdempotencyKey, + string Reason); + public sealed record ChangePlatformSubscriptionCommand(Guid SubscriptionId, string Reason, int? ExtendDays = null); + public sealed record RequestPlatformRefundCommand( Guid PaymentId, int AmountCents, string Reason, string IdempotencyKey, PlatformBillingRefundSubscriptionEffect SubscriptionEffect); + public sealed record ReviewPlatformRefundCommand(Guid RefundId, string Reason); + public sealed record CommercialMetrics( int ActiveSubscriptions, int TrialSubscriptions, @@ -217,38 +284,81 @@ public sealed record TenantReceivableView( public interface IPlatformBillingAdminService { - Task> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); - Task ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default); - Task UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default); - Task GrantTrialAsync(SaasCatalogActor actor, GrantTenantTrialCommand command, CancellationToken cancellationToken = default); - Task SuspendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); - Task ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); - Task CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); - Task ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); - Task RequestRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default); - Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default); - Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default); - Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default); - Task GetCommercialMetricsAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default); + Task> GetOrdersAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task> GetPaymentsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task> GetRefundsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task> GetInvoicesAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, + CancellationToken cancellationToken = default); + + Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task> GetSubscriptionsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default); + + Task ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, + CancellationToken cancellationToken = default); + + Task UpsertFeatureOverrideAsync(SaasCatalogActor actor, + UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default); + + Task GrantTrialAsync(SaasCatalogActor actor, GrantTenantTrialCommand command, + CancellationToken cancellationToken = default); + + Task SuspendSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); + + Task ResumeSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); + + Task CancelSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); + + Task ExtendSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default); + + Task RequestRefundAsync(SaasCatalogActor actor, RequestPlatformRefundCommand command, + CancellationToken cancellationToken = default); + + Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default); + + Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default); + + Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default); + + Task GetCommercialMetricsAsync(SaasCatalogActor actor, + CancellationToken cancellationToken = default); } public interface IPlatformBillingPaymentGateway { - Task CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default); - Task ParseNotificationAsync(string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default); + Task CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, + CancellationToken cancellationToken = default); + + Task ParseNotificationAsync(string provider, PaymentNotificationRequest request, + CancellationToken cancellationToken = default); + Task CreateRefundAsync( string provider, CreateRefundProviderRequest request, - CancellationToken cancellationToken = default) => - Task.FromException(new PlatformBillingException( + CancellationToken cancellationToken = default) + { + return Task.FromException(new PlatformBillingException( "Platform refund gateway is not configured.", "platform_billing_refund_provider_not_supported")); + } } public interface ICommercialBillingProcessor @@ -304,4 +414,4 @@ public interface ISaasSubscriptionLifecycleService public sealed class PlatformBillingException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Points/PointModels.cs b/Tiku.Application/Points/PointModels.cs index 9b115d6..f1323c2 100644 --- a/Tiku.Application/Points/PointModels.cs +++ b/Tiku.Application/Points/PointModels.cs @@ -104,4 +104,4 @@ public interface IPointService public sealed class PointException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Profile/ProfileException.cs b/Tiku.Application/Profile/ProfileException.cs index 4791ada..61fae2e 100644 --- a/Tiku.Application/Profile/ProfileException.cs +++ b/Tiku.Application/Profile/ProfileException.cs @@ -3,4 +3,4 @@ namespace Tiku.Application.Profile; public sealed class ProfileException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Profile/ProfileModels.cs b/Tiku.Application/Profile/ProfileModels.cs index a4f7432..2598a46 100644 --- a/Tiku.Application/Profile/ProfileModels.cs +++ b/Tiku.Application/Profile/ProfileModels.cs @@ -172,14 +172,31 @@ public sealed record ProfileScoreEventList(IReadOnlyCollection GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default); - Task UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, CancellationToken cancellationToken = default); - Task GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default); - Task GetNotificationsAsync(ProfileActor actor, ProfileNotificationQuery query, CancellationToken cancellationToken = default); - Task UpdateNotificationStatusAsync(ProfileActor actor, UpdateNotificationStatusCommand command, CancellationToken cancellationToken = default); + Task GetMeAsync(ProfileActor actor, ProfileQuery query, + CancellationToken cancellationToken = default); + + Task UpdateMeAsync(ProfileActor actor, UpdateProfileCommand command, + CancellationToken cancellationToken = default); + + Task GetExamCountdownsAsync(ProfileActor actor, ProfileQuery query, + CancellationToken cancellationToken = default); + + Task GetNotificationsAsync(ProfileActor actor, ProfileNotificationQuery query, + CancellationToken cancellationToken = default); + + Task UpdateNotificationStatusAsync(ProfileActor actor, + UpdateNotificationStatusCommand command, CancellationToken cancellationToken = default); + Task GetBadgesAsync(ProfileActor actor, BadgeQuery query, CancellationToken cancellationToken = default); - Task> GetFeedbacksAsync(ProfileActor actor, FeedbackQuery query, CancellationToken cancellationToken = default); - Task SubmitFeedbackAsync(ProfileActor actor, SubmitFeedbackCommand command, CancellationToken cancellationToken = default); + + Task> GetFeedbacksAsync(ProfileActor actor, FeedbackQuery query, + CancellationToken cancellationToken = default); + + Task SubmitFeedbackAsync(ProfileActor actor, SubmitFeedbackCommand command, + CancellationToken cancellationToken = default); + Task CheckInAsync(ProfileActor actor, CancellationToken cancellationToken = default); - Task GetScoreEventsAsync(ProfileActor actor, ProfileScoreEventQuery query, CancellationToken cancellationToken = default); -} + + Task GetScoreEventsAsync(ProfileActor actor, ProfileScoreEventQuery query, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs b/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs index c744dc3..1805c0b 100644 --- a/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs +++ b/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs @@ -19,4 +19,4 @@ public interface IQuestionBankQueryService Task> GetQuestionVersionsAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs b/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs index 4557bbe..f1a28d8 100644 --- a/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs +++ b/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs @@ -24,4 +24,4 @@ public sealed class PublicQuestionAccessDeniedException(string code, string mess public sealed class QuestionLocatorException(string code, string message) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs b/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs index dc1314c..9f2bddd 100644 --- a/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs +++ b/Tiku.Application/QuestionBanks/QuestionBankExceptions.cs @@ -2,4 +2,4 @@ namespace Tiku.Application.QuestionBanks; public sealed class QuestionBankRequiredFieldException(string message) : Exception(message); -public sealed class QuestionBankNotFoundException(string message) : Exception(message); +public sealed class QuestionBankNotFoundException(string message) : Exception(message); \ No newline at end of file diff --git a/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs b/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs index 79d8529..827d46e 100644 --- a/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs +++ b/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; namespace Tiku.Application.QuestionBanks; @@ -78,4 +78,4 @@ public sealed record QuestionVersionCatalogItem( JsonElement SubQuestions, string? CodeLang, string? CodeTemplate, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt); \ No newline at end of file diff --git a/Tiku.Application/Scoreline/ScorelineModels.cs b/Tiku.Application/Scoreline/ScorelineModels.cs index 5e0dd94..8ea2cde 100644 --- a/Tiku.Application/Scoreline/ScorelineModels.cs +++ b/Tiku.Application/Scoreline/ScorelineModels.cs @@ -59,9 +59,16 @@ public sealed record ScorelineRecordCursorPage( public interface IScorelineQueryService { - Task> GetFieldsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default); + Task> GetFieldsAsync(ScorelineFilter filter, + CancellationToken cancellationToken = default); + Task GetRecordsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default); - Task GetRecordsCursorAsync(ScorelineFilter filter, string? cursor, CancellationToken cancellationToken = default); - Task> GetTrendAsync(ScorelineFilter filter, CancellationToken cancellationToken = default); + + Task GetRecordsCursorAsync(ScorelineFilter filter, string? cursor, + CancellationToken cancellationToken = default); + + Task> GetTrendAsync(ScorelineFilter filter, + CancellationToken cancellationToken = default); + Task> GetYearsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Scoreline/ScorelineQueryException.cs b/Tiku.Application/Scoreline/ScorelineQueryException.cs index 9a4396b..8837991 100644 --- a/Tiku.Application/Scoreline/ScorelineQueryException.cs +++ b/Tiku.Application/Scoreline/ScorelineQueryException.cs @@ -3,4 +3,4 @@ namespace Tiku.Application.Scoreline; public sealed class ScorelineQueryException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/AuthorizationCacheModels.cs b/Tiku.Application/Security/AuthorizationCacheModels.cs index 904d8ea..c19d17e 100644 --- a/Tiku.Application/Security/AuthorizationCacheModels.cs +++ b/Tiku.Application/Security/AuthorizationCacheModels.cs @@ -1,10 +1,15 @@ +using Tiku.Application.Auth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Application.Auth; namespace Tiku.Application.Security; -public enum AuthorizationCacheMode { Disabled, Shadow, Active } +public enum AuthorizationCacheMode +{ + Disabled, + Shadow, + Active +} public sealed class AuthorizationCacheOptions { @@ -17,15 +22,26 @@ public sealed class AuthorizationCacheOptions } public sealed record CachedSessionSecurityState( - Guid SessionId, Guid UserId, AuthRealm Realm, Guid? TenantId, - string SecurityStamp, DateTimeOffset ExpiresAt, bool Revoked); + Guid SessionId, + Guid UserId, + AuthRealm Realm, + Guid? TenantId, + string SecurityStamp, + DateTimeOffset ExpiresAt, + bool Revoked); + public sealed record CachedUserSecurityState(Guid UserId, UserStatus Status, string SecurityStamp); + public sealed record CachedTenantSecurityState(Guid TenantId, TenantStatus Status); + public sealed record CachedMembershipSecurityState(Guid TenantId, Guid UserId, MembershipStatus Status); + public sealed record CachedPlatformAccessState(Guid UserId, long AuthorizationVersion, bool Allowed); + public sealed record CachedAuthorizationVersion(AuthRealm Realm, Guid? TenantId, long Version); public sealed record AccessSecurityCacheLookup(Guid SessionId, Guid UserId, AuthRealm Realm, Guid? TenantId); + public sealed record AccessSecurityCacheState( CachedSessionSecurityState? Session, CachedUserSecurityState? User, @@ -35,8 +51,8 @@ public sealed record AccessSecurityCacheState( CachedAuthorizationVersion? AuthorizationVersion) { public bool Complete => Session is not null && User is not null && AuthorizationVersion is not null && - (Session.Realm == AuthRealm.Platform && PlatformAccess is not null || - Session.Realm == AuthRealm.Tenant && Tenant is not null && Membership is not null); + ((Session.Realm == AuthRealm.Platform && PlatformAccess is not null) || + (Session.Realm == AuthRealm.Tenant && Tenant is not null && Membership is not null)); } public sealed record CachedAuthorizationSnapshot(long Version, CurrentAccessSnapshot Snapshot); @@ -44,19 +60,27 @@ public sealed record CachedAuthorizationSnapshot(long Version, CurrentAccessSnap public interface IAccessSecurityCache { bool IsConfigured { get; } - Task GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default); + + Task GetAsync(AccessSecurityCacheLookup lookup, + CancellationToken cancellationToken = default); + Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default); Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default); Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default); Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default); - Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default); + + Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, + CancellationToken cancellationToken = default); } public interface IAuthorizationSnapshotCache { - Task GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default); - Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default); + Task GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, + CancellationToken cancellationToken = default); + + Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, + CancellationToken cancellationToken = default); } public interface IAuthorizationStateInvalidator @@ -84,4 +108,4 @@ public interface IRequestAccessValidator } public sealed class AuthorizationSecurityUnavailableException(Exception innerException) - : Exception("Authentication security dependencies are unavailable.", innerException); + : Exception("Authentication security dependencies are unavailable.", innerException); \ No newline at end of file diff --git a/Tiku.Application/Security/BackendPermissions.cs b/Tiku.Application/Security/BackendPermissions.cs index 4516b6d..3881de5 100644 --- a/Tiku.Application/Security/BackendPermissions.cs +++ b/Tiku.Application/Security/BackendPermissions.cs @@ -93,16 +93,13 @@ public static class BackendPermissions public static void EnsureTenant(string permissionCode) { if (!Tenant.Contains(permissionCode)) - { throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown tenant permission."); - } } public static void EnsurePlatform(string permissionCode) { if (!Platform.Contains(permissionCode)) - { - throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown platform permission."); - } + throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, + "Unknown platform permission."); } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/CapabilityOperation.cs b/Tiku.Application/Security/CapabilityOperation.cs index 5709b88..ec39fa8 100644 --- a/Tiku.Application/Security/CapabilityOperation.cs +++ b/Tiku.Application/Security/CapabilityOperation.cs @@ -4,4 +4,4 @@ public enum CapabilityOperation { Read, Write -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ClaimsPrincipalExtensions.cs b/Tiku.Application/Security/ClaimsPrincipalExtensions.cs index 0ca4ba9..97a8abb 100644 --- a/Tiku.Application/Security/ClaimsPrincipalExtensions.cs +++ b/Tiku.Application/Security/ClaimsPrincipalExtensions.cs @@ -14,4 +14,4 @@ internal static class ClaimsPrincipalExtensions var value = principal.FindValue(claimType); return Guid.TryParse(value, out var guid) ? guid : null; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/CurrentUser.cs b/Tiku.Application/Security/CurrentUser.cs index 30b9432..fb7b15e 100644 --- a/Tiku.Application/Security/CurrentUser.cs +++ b/Tiku.Application/Security/CurrentUser.cs @@ -18,4 +18,4 @@ public sealed class CurrentUser : ICurrentUser Phone = principal.FindValue(TikuClaimTypes.Phone); Email = principal.FindValue(TikuClaimTypes.Email); } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/DependencyReadiness.cs b/Tiku.Application/Security/DependencyReadiness.cs index 4b3e078..40bc32b 100644 --- a/Tiku.Application/Security/DependencyReadiness.cs +++ b/Tiku.Application/Security/DependencyReadiness.cs @@ -5,4 +5,4 @@ public sealed record DependencyReadiness(bool Ready, DateTimeOffset CheckedAt); public interface IDependencyReadinessProbe { Task CheckAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/FeatureQuotaConsumptionExtensions.cs b/Tiku.Application/Security/FeatureQuotaConsumptionExtensions.cs index f68a7e2..bb86f87 100644 --- a/Tiku.Application/Security/FeatureQuotaConsumptionExtensions.cs +++ b/Tiku.Application/Security/FeatureQuotaConsumptionExtensions.cs @@ -11,16 +11,11 @@ public static class FeatureQuotaConsumptionExtensions { var configured = (await featureAccessService.GetQuotaSummaryAsync(tenantId, cancellationToken)) .Any(item => string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal)); - if (!configured) - { - return false; - } + if (!configured) return false; if (!await featureAccessService.TryConsumeQuotaAsync(tenantId, metricCode, amount, cancellationToken)) - { throw new FeatureAccessException("The tenant feature quota has been exhausted.", "feature_quota_exhausted"); - } return true; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ICurrentAccessContext.cs b/Tiku.Application/Security/ICurrentAccessContext.cs index 5ad5489..1a72285 100644 --- a/Tiku.Application/Security/ICurrentAccessContext.cs +++ b/Tiku.Application/Security/ICurrentAccessContext.cs @@ -21,17 +21,12 @@ public sealed record CurrentDataScope( new HashSet(), true); - public bool AllowsResource(Guid currentUserId, Guid? ownerUserId = null, Guid? regionId = null, Guid? classId = null) + public bool AllowsResource(Guid currentUserId, Guid? ownerUserId = null, Guid? regionId = null, + Guid? classId = null) { - if (Mode == DataScopeMode.All) - { - return true; - } + if (Mode == DataScopeMode.All) return true; - if (IncludesSelf && ownerUserId == currentUserId) - { - return true; - } + if (IncludesSelf && ownerUserId == currentUserId) return true; return Mode == DataScopeMode.Restricted && ((regionId.HasValue && RegionIds.Contains(regionId.Value)) || @@ -49,9 +44,7 @@ public sealed record CurrentDataScope( { var parsed = Parse(roleScope); if (parsed.Mode == DataScopeMode.All) - { return new CurrentDataScope(DataScopeMode.All, new HashSet(), new HashSet(), true); - } includesSelf |= parsed.IncludesSelf; hasRestrictedScope |= parsed.Mode == DataScopeMode.Restricted; @@ -66,21 +59,13 @@ public sealed record CurrentDataScope( private static CurrentDataScope Parse(JsonElement value) { - if (value.ValueKind != JsonValueKind.Object) - { - return Self; - } + if (value.ValueKind != JsonValueKind.Object) return Self; var mode = ReadString(value, "mode") ?? ReadString(value, "type"); if (string.Equals(mode, nameof(DataScopeMode.All), StringComparison.OrdinalIgnoreCase)) - { return new CurrentDataScope(DataScopeMode.All, new HashSet(), new HashSet(), true); - } - if (string.Equals(mode, nameof(DataScopeMode.Self), StringComparison.OrdinalIgnoreCase)) - { - return Self; - } + if (string.Equals(mode, nameof(DataScopeMode.Self), StringComparison.OrdinalIgnoreCase)) return Self; var regions = ReadGuids(value, "regionIds"); var classes = ReadGuids(value, "classIds"); @@ -89,7 +74,8 @@ public sealed record CurrentDataScope( classes.Count > 0; return restricted - ? new CurrentDataScope(DataScopeMode.Restricted, regions, classes, ReadBoolean(value, "includesSelf") || ReadBoolean(value, "ownLeadsOnly")) + ? new CurrentDataScope(DataScopeMode.Restricted, regions, classes, + ReadBoolean(value, "includesSelf") || ReadBoolean(value, "ownLeadsOnly")) : Self; } @@ -111,17 +97,11 @@ public sealed record CurrentDataScope( { var result = new HashSet(); if (!value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) - { return result; - } foreach (var item in property.EnumerateArray()) - { if (item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)) - { result.Add(id); - } - } return result; } @@ -136,14 +116,18 @@ public sealed record CurrentAccessSnapshot( IReadOnlySet PlatformPermissions, CurrentDataScope DataScope) { - public bool HasTenantPermission(string permissionCode) => - IsCurrentTenantMember && TenantPermissions.Contains(permissionCode); + public bool HasTenantPermission(string permissionCode) + { + return IsCurrentTenantMember && TenantPermissions.Contains(permissionCode); + } - public bool HasPlatformPermission(string permissionCode) => - IsUserActive && PlatformPermissions.Contains(permissionCode); + public bool HasPlatformPermission(string permissionCode) + { + return IsUserActive && PlatformPermissions.Contains(permissionCode); + } } public interface ICurrentAccessContext { Task GetAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ICurrentUser.cs b/Tiku.Application/Security/ICurrentUser.cs index 3539862..da21fe1 100644 --- a/Tiku.Application/Security/ICurrentUser.cs +++ b/Tiku.Application/Security/ICurrentUser.cs @@ -10,4 +10,4 @@ public interface ICurrentUser string? Email { get; } bool IsAuthenticated { get; } void Load(ClaimsPrincipal principal); -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/IFeatureAccessService.cs b/Tiku.Application/Security/IFeatureAccessService.cs index 9629f84..751a8e4 100644 --- a/Tiku.Application/Security/IFeatureAccessService.cs +++ b/Tiku.Application/Security/IFeatureAccessService.cs @@ -61,4 +61,4 @@ public interface IFeatureAccessService public sealed class FeatureAccessException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/IFeatureUsageReconciliationService.cs b/Tiku.Application/Security/IFeatureUsageReconciliationService.cs index 81d8571..c4ade4d 100644 --- a/Tiku.Application/Security/IFeatureUsageReconciliationService.cs +++ b/Tiku.Application/Security/IFeatureUsageReconciliationService.cs @@ -28,4 +28,4 @@ public sealed class FeatureUsageReconciliationOptions public bool Enabled { get; set; } = true; public int BatchSize { get; set; } = 100; public int IntervalMinutes { get; set; } = 60; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/IJwtKeyRing.cs b/Tiku.Application/Security/IJwtKeyRing.cs index e6021b5..a44cd35 100644 --- a/Tiku.Application/Security/IJwtKeyRing.cs +++ b/Tiku.Application/Security/IJwtKeyRing.cs @@ -6,4 +6,4 @@ public interface IJwtKeyRing { SigningCredentials SigningCredentials { get; } IReadOnlyCollection ValidationKeys { get; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/IRedisSecurityStore.cs b/Tiku.Application/Security/IRedisSecurityStore.cs index 5e0701b..84b2c0a 100644 --- a/Tiku.Application/Security/IRedisSecurityStore.cs +++ b/Tiku.Application/Security/IRedisSecurityStore.cs @@ -13,4 +13,4 @@ public interface IRedisSecurityStore CancellationToken cancellationToken = default); Task PingAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ITenantContext.cs b/Tiku.Application/Security/ITenantContext.cs index 3eb082f..00e1d6d 100644 --- a/Tiku.Application/Security/ITenantContext.cs +++ b/Tiku.Application/Security/ITenantContext.cs @@ -28,50 +28,34 @@ public interface ITenantContextInitializer public sealed class TenantContext : ITenantContext, ITenantContextInitializer { + internal string? SystemReason { get; private set; } public Guid? TenantId { get; private set; } public string? TenantCode { get; private set; } public TenantResolutionSource ResolutionSource { get; private set; } public bool IsResolved => TenantId.HasValue; public bool IsSystem { get; private set; } - internal string? SystemReason { get; private set; } public void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source) { - if (tenantId == Guid.Empty) - { - throw new ArgumentException("Tenant ID cannot be empty.", nameof(tenantId)); - } + if (tenantId == Guid.Empty) throw new ArgumentException("Tenant ID cannot be empty.", nameof(tenantId)); - if (IsSystem) - { - throw new InvalidOperationException("A system tenant context cannot be replaced."); - } + if (IsSystem) throw new InvalidOperationException("A system tenant context cannot be replaced."); if (TenantId.HasValue && TenantId.Value != tenantId) - { throw new TenantContextConflictException(TenantId.Value, tenantId); - } var wasResolved = TenantId.HasValue; TenantId = tenantId; TenantCode = string.IsNullOrWhiteSpace(tenantCode) ? TenantCode : tenantCode.Trim(); - if (!wasResolved) - { - ResolutionSource = source; - } + if (!wasResolved) ResolutionSource = source; } public void InitializeSystem(Guid? targetTenantId, string reason) { - if (IsResolved || IsSystem) - { - throw new InvalidOperationException("Tenant context has already been initialized."); - } + if (IsResolved || IsSystem) throw new InvalidOperationException("Tenant context has already been initialized."); if (string.IsNullOrWhiteSpace(reason)) - { throw new ArgumentException("A system scope requires an audit reason.", nameof(reason)); - } TenantId = targetTenantId; ResolutionSource = TenantResolutionSource.System; @@ -85,4 +69,4 @@ public sealed class TenantContextConflictException(Guid expectedTenantId, Guid a { public Guid ExpectedTenantId { get; } = expectedTenantId; public Guid ActualTenantId { get; } = actualTenantId; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ITenantExecutionScope.cs b/Tiku.Application/Security/ITenantExecutionScope.cs index 26df60a..951ed7d 100644 --- a/Tiku.Application/Security/ITenantExecutionScope.cs +++ b/Tiku.Application/Security/ITenantExecutionScope.cs @@ -29,5 +29,4 @@ public interface ITenantExecutionScope SystemScopeRequest request, Func> operation, CancellationToken cancellationToken = default); - -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs b/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs index 9efc2fb..1e2bb5a 100644 --- a/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs +++ b/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs @@ -3,4 +3,4 @@ namespace Tiku.Application.Security; public interface ITenantFeatureCacheInvalidator { Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/JwtOptions.cs b/Tiku.Application/Security/JwtOptions.cs index a0234e8..c6bd4e6 100644 --- a/Tiku.Application/Security/JwtOptions.cs +++ b/Tiku.Application/Security/JwtOptions.cs @@ -1,25 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; + namespace Tiku.Application.Security; public sealed class JwtOptions { - [System.ComponentModel.DataAnnotations.Required] - public string Issuer { get; set; } = "tiku-backend"; + [Required] public string Issuer { get; set; } = "tiku-backend"; - [System.ComponentModel.DataAnnotations.Required] - public string Audience { get; set; } = "tiku-api"; + [Required] public string Audience { get; set; } = "tiku-api"; - [System.ComponentModel.DataAnnotations.Required] - public string KeyId { get; set; } = "development-ephemeral"; + [Required] public string KeyId { get; set; } = "development-ephemeral"; public string PrivateKeyPem { get; set; } = string.Empty; public Dictionary PublicKeys { get; set; } = new(StringComparer.Ordinal); - [System.ComponentModel.DataAnnotations.Range(1, 1440)] - public int AccessTokenMinutes { get; set; } = 15; + [Range(1, 1440)] public int AccessTokenMinutes { get; set; } = 15; - [System.ComponentModel.DataAnnotations.Range(1, 365)] - public int RefreshTokenDays { get; set; } = 30; + [Range(1, 365)] public int RefreshTokenDays { get; set; } = 30; public static bool BeValid(JwtOptions options, bool isProduction) { @@ -31,18 +29,13 @@ public sealed class JwtOptions options.KeyId, "development-ephemeral", StringComparison.Ordinal))) - { return false; - } if (string.IsNullOrWhiteSpace(options.PrivateKeyPem)) { - if (isProduction) - { - return false; - } + if (isProduction) return false; } - else if (!IsValidRsaPem(options.PrivateKeyPem, requirePrivateKey: true)) + else if (!IsValidRsaPem(options.PrivateKeyPem, true)) { return false; } @@ -50,32 +43,26 @@ public sealed class JwtOptions return options.PublicKeys.All(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.Equals(pair.Key, options.KeyId, StringComparison.Ordinal) && - IsValidRsaPem(pair.Value, requirePrivateKey: false)); + IsValidRsaPem(pair.Value, false)); } private static bool IsValidRsaPem(string pem, bool requirePrivateKey) { try { - using var rsa = System.Security.Cryptography.RSA.Create(); + using var rsa = RSA.Create(); rsa.ImportFromPem(pem); - if (rsa.KeySize < 2048) - { - return false; - } + if (rsa.KeySize < 2048) return false; - if (requirePrivateKey) - { - _ = rsa.ExportParameters(includePrivateParameters: true); - } + if (requirePrivateKey) _ = rsa.ExportParameters(true); return true; } catch (Exception exception) when ( exception is ArgumentException or - System.Security.Cryptography.CryptographicException) + CryptographicException) { return false; } } -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/SaasFeatureCatalog.cs b/Tiku.Application/Security/SaasFeatureCatalog.cs index f968b1c..2c846c8 100644 --- a/Tiku.Application/Security/SaasFeatureCatalog.cs +++ b/Tiku.Application/Security/SaasFeatureCatalog.cs @@ -43,7 +43,8 @@ public static class SaasFeatureCatalog return normalized switch { "question" or "questions" or "question_bank" or "question_banks" => PrivateQuestionBank, - "vocabulary" or "vocabulary_unit" or "vocabulary_units" or "vocabulary_word" or "vocabulary_words" => Vocabulary, + "vocabulary" or "vocabulary_unit" or "vocabulary_units" or "vocabulary_word" + or "vocabulary_words" => Vocabulary, "handbook" or "handbook_subject" or "handbook_subjects" or "handbook_chapter" or "handbook_chapters" or "handbook_entry" or "handbook_entries" => Handbook, "scoreline" or "scorelines" => Scoreline, @@ -88,40 +89,48 @@ public static class PermissionModuleCatalog ["commerce"] = SaasFeatureCatalog.StudentStore }; - public static string ResolvePermissionModuleCode(string permissionCode) => permissionCode switch + public static string ResolvePermissionModuleCode(string permissionCode) { - BackendPermissions.TenantDashboardView => "tenant_dashboard", - BackendPermissions.TenantStaffManage or BackendPermissions.TenantRoleManage => "tenant_staff", - BackendPermissions.TenantStudentManage => "tenant_student", - BackendPermissions.TenantContentManage => "tenant_question_bank", - BackendPermissions.TenantVocabularyManage => "tenant_vocabulary", - BackendPermissions.TenantHandbookManage => "tenant_handbook", - BackendPermissions.TenantVideoManage => "tenant_video", - BackendPermissions.TenantScorelineManage => "tenant_scoreline", - BackendPermissions.TenantSiteContentManage => "tenant_site_content", - BackendPermissions.TenantSettingsManage => "tenant_settings", - BackendPermissions.TenantProviderManage => "tenant_provider", - BackendPermissions.TenantCommerceOperate => "tenant_commerce", - BackendPermissions.TenantCrmManage => "tenant_crm", - BackendPermissions.TenantCommissionManage => "tenant_commission", - BackendPermissions.TenantJobManage => "tenant_job", - BackendPermissions.TenantBillingManage => "tenant_billing", - BackendPermissions.PlatformDashboardView => "platform_dashboard", - BackendPermissions.PlatformTenantManage => "platform_tenant", - BackendPermissions.PlatformStaffManage or BackendPermissions.PlatformRoleManage => "platform_staff", - BackendPermissions.PlatformQuestionBankManage => "platform_content", - BackendPermissions.PlatformAuditView => "platform_audit", - BackendPermissions.PlatformBillingNotification => "platform_billing", - BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => "platform_billing", - BackendPermissions.PlatformCrmRead or BackendPermissions.PlatformCrmWrite => "platform_crm", - BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms", - BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment", - BackendPermissions.PlatformOperationsView or BackendPermissions.PlatformOperationsManage => "platform_operations", - BackendPermissions.PlatformApprovalView or BackendPermissions.PlatformApprovalDecide or BackendPermissions.PlatformApprovalPolicyManage => "platform_governance", - BackendPermissions.PlatformConfigurationManage or BackendPermissions.PlatformNotificationManage => "platform_governance", - _ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce", - _ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.") - }; + return permissionCode switch + { + BackendPermissions.TenantDashboardView => "tenant_dashboard", + BackendPermissions.TenantStaffManage or BackendPermissions.TenantRoleManage => "tenant_staff", + BackendPermissions.TenantStudentManage => "tenant_student", + BackendPermissions.TenantContentManage => "tenant_question_bank", + BackendPermissions.TenantVocabularyManage => "tenant_vocabulary", + BackendPermissions.TenantHandbookManage => "tenant_handbook", + BackendPermissions.TenantVideoManage => "tenant_video", + BackendPermissions.TenantScorelineManage => "tenant_scoreline", + BackendPermissions.TenantSiteContentManage => "tenant_site_content", + BackendPermissions.TenantSettingsManage => "tenant_settings", + BackendPermissions.TenantProviderManage => "tenant_provider", + BackendPermissions.TenantCommerceOperate => "tenant_commerce", + BackendPermissions.TenantCrmManage => "tenant_crm", + BackendPermissions.TenantCommissionManage => "tenant_commission", + BackendPermissions.TenantJobManage => "tenant_job", + BackendPermissions.TenantBillingManage => "tenant_billing", + BackendPermissions.PlatformDashboardView => "platform_dashboard", + BackendPermissions.PlatformTenantManage => "platform_tenant", + BackendPermissions.PlatformStaffManage or BackendPermissions.PlatformRoleManage => "platform_staff", + BackendPermissions.PlatformQuestionBankManage => "platform_content", + BackendPermissions.PlatformAuditView => "platform_audit", + BackendPermissions.PlatformBillingNotification => "platform_billing", + BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => + "platform_billing", + BackendPermissions.PlatformCrmRead or BackendPermissions.PlatformCrmWrite => "platform_crm", + BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms", + BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment", + BackendPermissions.PlatformOperationsView or BackendPermissions.PlatformOperationsManage => + "platform_operations", + BackendPermissions.PlatformApprovalView or BackendPermissions.PlatformApprovalDecide + or BackendPermissions.PlatformApprovalPolicyManage => "platform_governance", + BackendPermissions.PlatformConfigurationManage or BackendPermissions.PlatformNotificationManage => + "platform_governance", + _ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce", + _ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, + "Permission module mapping is missing.") + }; + } } public static class SaasQuotaMetricCatalog @@ -146,4 +155,4 @@ public static class SaasQuotaMetricCatalog SmsCount, AiCallCount }; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/TikuClaimTypes.cs b/Tiku.Application/Security/TikuClaimTypes.cs index de8432e..6b1ddf2 100644 --- a/Tiku.Application/Security/TikuClaimTypes.cs +++ b/Tiku.Application/Security/TikuClaimTypes.cs @@ -10,4 +10,4 @@ public static class TikuClaimTypes public const string Realm = "scope"; public const string Phone = ClaimTypes.MobilePhone; public const string Email = ClaimTypes.Email; -} +} \ No newline at end of file diff --git a/Tiku.Application/Security/TikuPolicies.cs b/Tiku.Application/Security/TikuPolicies.cs index 1e51f8c..e5cee9a 100644 --- a/Tiku.Application/Security/TikuPolicies.cs +++ b/Tiku.Application/Security/TikuPolicies.cs @@ -23,4 +23,4 @@ public static class TikuPolicies BackendPermissions.EnsurePlatform(permissionCode); return permissionCode; } -} +} \ No newline at end of file diff --git a/Tiku.Application/Storage/IObjectStorageService.cs b/Tiku.Application/Storage/IObjectStorageService.cs index 59fbb71..47694da 100644 --- a/Tiku.Application/Storage/IObjectStorageService.cs +++ b/Tiku.Application/Storage/IObjectStorageService.cs @@ -29,6 +29,9 @@ public interface IObjectStorageService Task OpenReadAsync( ObjectStorageReadRequest request, - CancellationToken cancellationToken = default) => - Task.FromException(new NotSupportedException("Object storage read streaming is not configured.")); -} + CancellationToken cancellationToken = default) + { + return Task.FromException( + new NotSupportedException("Object storage read streaming is not configured.")); + } +} \ No newline at end of file diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs index 687b1e1..20d36bf 100644 --- a/Tiku.Application/Storage/ObjectStorageContracts.cs +++ b/Tiku.Application/Storage/ObjectStorageContracts.cs @@ -105,4 +105,4 @@ public class ObjectStorageException(string message, string code) : InvalidOperat } public sealed class ObjectStorageNotConfiguredException(string message) - : ObjectStorageException(message, "STORAGE_PROVIDER_NOT_CONFIGURED"); + : ObjectStorageException(message, "STORAGE_PROVIDER_NOT_CONFIGURED"); \ No newline at end of file diff --git a/Tiku.Application/StudyContent/IStudyContentQueryService.cs b/Tiku.Application/StudyContent/IStudyContentQueryService.cs index 04265a9..3283d29 100644 --- a/Tiku.Application/StudyContent/IStudyContentQueryService.cs +++ b/Tiku.Application/StudyContent/IStudyContentQueryService.cs @@ -23,4 +23,4 @@ public interface IStudyContentQueryService Task> GetHandbookEntriesAsync( StudyContentFilter filter, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/StudyContent/StudyContentQueryModels.cs b/Tiku.Application/StudyContent/StudyContentQueryModels.cs index 32e866c..4bfb043 100644 --- a/Tiku.Application/StudyContent/StudyContentQueryModels.cs +++ b/Tiku.Application/StudyContent/StudyContentQueryModels.cs @@ -81,4 +81,4 @@ public sealed record HandbookEntryCatalogItem( string? Content, JsonElement Tags, int Order, - JsonElement Metadata); + JsonElement Metadata); \ No newline at end of file diff --git a/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs b/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs index f992aaa..5601bec 100644 --- a/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs +++ b/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs @@ -3,4 +3,4 @@ namespace Tiku.Application.Tenancy; public interface ITenantPublicCacheInvalidator { Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/PublicTenantConfiguration.cs b/Tiku.Application/Tenancy/PublicTenantConfiguration.cs index 5d84bce..ea2618c 100644 --- a/Tiku.Application/Tenancy/PublicTenantConfiguration.cs +++ b/Tiku.Application/Tenancy/PublicTenantConfiguration.cs @@ -21,4 +21,4 @@ public interface IPublicTenantConfigurationQuery Task GetAsync( Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantDirectoryModels.cs b/Tiku.Application/Tenancy/TenantDirectoryModels.cs index 922d98d..9c91a8a 100644 --- a/Tiku.Application/Tenancy/TenantDirectoryModels.cs +++ b/Tiku.Application/Tenancy/TenantDirectoryModels.cs @@ -19,4 +19,4 @@ public interface ITenantDirectory Task FindByCodeAsync( string tenantCode, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs index c0f6fba..bd47700 100644 --- a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs +++ b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs @@ -14,6 +14,7 @@ public sealed class DomainLifecycleOptions } public sealed record DomainOwnershipResult(bool Verified, bool Configured, string? FailureReason); + public sealed record DomainGatewayResult(bool TlsReady, bool Configured, string? FailureReason); public interface IDomainOwnershipVerifier @@ -37,4 +38,4 @@ public interface ITenantDomainLifecycleService public interface ITenantRuntimeCacheInvalidator { Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantExternalProviderModels.cs b/Tiku.Application/Tenancy/TenantExternalProviderModels.cs index 076d396..d6d0d58 100644 --- a/Tiku.Application/Tenancy/TenantExternalProviderModels.cs +++ b/Tiku.Application/Tenancy/TenantExternalProviderModels.cs @@ -62,4 +62,4 @@ public interface ITenantExternalProviderConfigService public sealed class TenantExternalProviderException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantFrontendConfigModels.cs b/Tiku.Application/Tenancy/TenantFrontendConfigModels.cs index ebb5c9b..77b425b 100644 --- a/Tiku.Application/Tenancy/TenantFrontendConfigModels.cs +++ b/Tiku.Application/Tenancy/TenantFrontendConfigModels.cs @@ -33,18 +33,21 @@ public sealed record TenantRuntimeBootstrap( public interface ITenantFrontendConfigService { Task GetAsync(Guid tenantId, CancellationToken cancellationToken = default); + Task SaveDraftAsync( Guid tenantId, TenantFrontendConfigDraft draft, CancellationToken cancellationToken = default); + Task PublishAsync( Guid tenantId, int expectedVersion, CancellationToken cancellationToken = default); + Task GetRuntimeAsync(Guid tenantId, CancellationToken cancellationToken = default); } public sealed class TenantFrontendConfigException(string code, string message) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantLifecycleModels.cs b/Tiku.Application/Tenancy/TenantLifecycleModels.cs index bc0ac28..6d1cd93 100644 --- a/Tiku.Application/Tenancy/TenantLifecycleModels.cs +++ b/Tiku.Application/Tenancy/TenantLifecycleModels.cs @@ -25,15 +25,27 @@ public sealed record TenantLifecycleOperationItem( public interface ITenantLifecycleService { Task PreviewArchiveAsync(Guid tenantId, CancellationToken cancellationToken = default); - Task CreateExportAsync(Guid tenantId, Guid actorUserId, CancellationToken cancellationToken = default); - Task GetOperationAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default); - Task SignExportDownloadAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default); - Task ArchiveAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default); - Task RestoreAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default); - Task TransferOwnerAsync(Guid tenantId, Guid actorUserId, Guid targetUserId, string reason, CancellationToken cancellationToken = default); + + Task CreateExportAsync(Guid tenantId, Guid actorUserId, + CancellationToken cancellationToken = default); + + Task GetOperationAsync(Guid tenantId, Guid operationId, + CancellationToken cancellationToken = default); + + Task SignExportDownloadAsync(Guid tenantId, Guid operationId, + CancellationToken cancellationToken = default); + + Task ArchiveAsync(Guid tenantId, Guid actorUserId, string reason, + CancellationToken cancellationToken = default); + + Task RestoreAsync(Guid tenantId, Guid actorUserId, string reason, + CancellationToken cancellationToken = default); + + Task TransferOwnerAsync(Guid tenantId, Guid actorUserId, Guid targetUserId, + string reason, CancellationToken cancellationToken = default); } public sealed class TenantLifecycleException(string code, string message) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Tenancy/TenantOnboardingModels.cs b/Tiku.Application/Tenancy/TenantOnboardingModels.cs index a36f880..d8da42f 100644 --- a/Tiku.Application/Tenancy/TenantOnboardingModels.cs +++ b/Tiku.Application/Tenancy/TenantOnboardingModels.cs @@ -16,4 +16,4 @@ public sealed record TenantOnboardingStatus( public interface ITenantOnboardingService { Task GetStatusAsync(Guid tenantId, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index 52e2ef1..26a51a1 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -229,4 +229,4 @@ public interface ITenantAdminDirectService TenantAdminActor actor, UpdateTenantAdminFeedbackCommand command, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index 80d04b5..48826b5 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Tiku.Domain.Common; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; @@ -11,11 +10,9 @@ public sealed record TenantAdminActor(Guid TenantId, Guid UserId) public static TenantAdminActor FromResolvedIdentity(Guid? tenantId, Guid? userId) { if (tenantId is null || userId is null) - { throw new InvalidOperationException("Tenant admin actor was not resolved."); - } - return new(tenantId.Value, userId.Value); + return new TenantAdminActor(tenantId.Value, userId.Value); } } @@ -632,4 +629,4 @@ public sealed record TenantAdminFeedbackItem( public sealed class TenantAdminDirectException(string message, string code) : Exception(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Application/Tiku.Application.csproj b/Tiku.Application/Tiku.Application.csproj index c418dd3..411c9c8 100644 --- a/Tiku.Application/Tiku.Application.csproj +++ b/Tiku.Application/Tiku.Application.csproj @@ -1,20 +1,20 @@  - - - + + + - - - - + + + + - - net10.0 - enable - enable - true - $(NoWarn);1591 - + + net10.0 + enable + enable + true + $(NoWarn);1591 + diff --git a/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs b/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs index 9a8f2bf..d2b9842 100644 --- a/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs +++ b/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs @@ -22,4 +22,4 @@ public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory tenantContext.InitializeSystem(null, "EF Core design-time model generation"); return new TikuDbContext(options, tenantContext); } -} +} \ No newline at end of file diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs index ec96d07..64ecda3 100644 --- a/Tiku.DbMigrator/Program.cs +++ b/Tiku.DbMigrator/Program.cs @@ -1,14 +1,14 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Tiku.Infrastructure; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Bootstrap; using Tiku.Application; using Tiku.Application.Security; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Bootstrap; +using Tiku.Infrastructure.Persistence; var builder = Host.CreateApplicationBuilder(args); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); @@ -21,12 +21,10 @@ var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringC var skipDevelopmentSeed = args.Contains("--skip-development-seed", StringComparer.Ordinal); PlatformAdminBootstrapOptions? bootstrapOptions = null; if (bootstrapPlatformAdmin) -{ bootstrapOptions = new PlatformAdminBootstrapOptions( RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL"), RequiredBootstrapSetting(builder.Configuration, "TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD"), builder.Configuration["TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME"]); -} var connectionString = builder.Configuration.GetConnectionString("Database") ?? @@ -63,7 +61,8 @@ if (bootstrapOptions is not null) { var bootstrapper = ActivatorUtilities.CreateInstance(scope.ServiceProvider); var result = await bootstrapper.BootstrapAsync(bootstrapOptions); - Console.WriteLine($"Platform administrator '{result.Email}' was created and must change the temporary password at first sign-in."); + Console.WriteLine( + $"Platform administrator '{result.Email}' was created and must change the temporary password at first sign-in."); } static string RequiredBootstrapSetting(IConfiguration configuration, string key) @@ -71,4 +70,4 @@ static string RequiredBootstrapSetting(IConfiguration configuration, string key) return configuration[key] is { } value && !string.IsNullOrWhiteSpace(value) ? value : throw new InvalidOperationException($"{key} is required with --bootstrap-platform-admin."); -} +} \ No newline at end of file diff --git a/Tiku.DbMigrator/Tiku.DbMigrator.csproj b/Tiku.DbMigrator/Tiku.DbMigrator.csproj index 7463c78..efc386f 100644 --- a/Tiku.DbMigrator/Tiku.DbMigrator.csproj +++ b/Tiku.DbMigrator/Tiku.DbMigrator.csproj @@ -1,22 +1,22 @@  - - - + + + - - - all - all - - - + + + all + all + + + - - Exe - net10.0 - enable - enable - + + Exe + net10.0 + enable + enable + diff --git a/Tiku.Domain/Catalog/CatalogEntities.cs b/Tiku.Domain/Catalog/CatalogEntities.cs index 0c6375b..60124c3 100644 --- a/Tiku.Domain/Catalog/CatalogEntities.cs +++ b/Tiku.Domain/Catalog/CatalogEntities.cs @@ -123,4 +123,4 @@ public enum CategoryType { Chapter, Paper -} +} \ No newline at end of file diff --git a/Tiku.Domain/Catalog/ScorelineEntities.cs b/Tiku.Domain/Catalog/ScorelineEntities.cs index c9679d7..f1c9073 100644 --- a/Tiku.Domain/Catalog/ScorelineEntities.cs +++ b/Tiku.Domain/Catalog/ScorelineEntities.cs @@ -31,4 +31,4 @@ public sealed class ScorelineRecord : AuditableTenantEntity public string? SchoolName { get; set; } public string? MajorName { get; set; } public JsonElement FieldValues { get; set; } = JsonDefaults.Object(); -} +} \ No newline at end of file diff --git a/Tiku.Domain/Catalog/TaxonomyEntities.cs b/Tiku.Domain/Catalog/TaxonomyEntities.cs index 33ccc22..4464afb 100644 --- a/Tiku.Domain/Catalog/TaxonomyEntities.cs +++ b/Tiku.Domain/Catalog/TaxonomyEntities.cs @@ -19,12 +19,12 @@ public sealed class TaxonomyNode : AuditableTenantEntity public sealed class QuestionTaxonomyAssignment : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid QuestionId { get; set; } public Guid TaxonomyOwnerTenantId { get; set; } public Guid TaxonomyNodeId { get; set; } public bool IsPrimary { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public enum TaxonomyNodeType @@ -34,4 +34,4 @@ public enum TaxonomyNodeType KnowledgePoint, Paper, Custom -} +} \ No newline at end of file diff --git a/Tiku.Domain/Commerce/CommerceEntities.cs b/Tiku.Domain/Commerce/CommerceEntities.cs index 2476d40..afe3c56 100644 --- a/Tiku.Domain/Commerce/CommerceEntities.cs +++ b/Tiku.Domain/Commerce/CommerceEntities.cs @@ -91,7 +91,6 @@ public sealed class Payment : AuditableTenantEntity public sealed class PaymentEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid? PaymentId { get; set; } public string Provider { get; set; } = string.Empty; public string EventType { get; set; } = string.Empty; @@ -101,11 +100,11 @@ public sealed class PaymentEvent : Entity, ITenantOwned public DateTimeOffset? ProcessedAt { get; set; } public string? Error { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class Entitlement : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public string EntitlementType { get; set; } = "svip"; public EntitlementScopeType ScopeType { get; set; } = EntitlementScopeType.Tenant; @@ -121,6 +120,7 @@ public sealed class Entitlement : Entity, ITenantOwned public string? RevokedReason { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class CodeBatch : AuditableTenantEntity @@ -219,7 +219,6 @@ public sealed class CommerceRefundRequest : AuditableTenantEntity public sealed class CommerceRefundEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid RefundRequestId { get; set; } public CommerceRefundStatus? FromStatus { get; set; } public CommerceRefundStatus ToStatus { get; set; } = CommerceRefundStatus.Requested; @@ -227,6 +226,7 @@ public sealed class CommerceRefundEvent : Entity, ITenantOwned public Guid? ActorUserId { get; set; } public JsonElement Details { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class CommerceReconciliationBatch : AuditableTenantEntity @@ -256,7 +256,6 @@ public sealed class CommerceReconciliationBatch : AuditableTenantEntity public sealed class CommerceReconciliationItem : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid BatchId { get; set; } public Guid? OrderId { get; set; } public Guid? PaymentId { get; set; } @@ -280,6 +279,7 @@ public sealed class CommerceReconciliationItem : Entity, ITenantOwned public string? IssueCode { get; set; } public JsonElement Details { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class CommerceReconciliationIssue : AuditableTenantEntity @@ -315,7 +315,6 @@ public sealed class CommerceReconciliationIssue : AuditableTenantEntity public sealed class CommerceReconciliationIssueEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid IssueId { get; set; } public ReconciliationIssueStatus? FromStatus { get; set; } public ReconciliationIssueStatus? ToStatus { get; set; } @@ -324,6 +323,7 @@ public sealed class CommerceReconciliationIssueEvent : Entity, ITenantOwned public string? Note { get; set; } public JsonElement Details { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class CommerceAdjustmentVoucher : AuditableTenantEntity @@ -350,7 +350,6 @@ public sealed class CommerceAdjustmentVoucher : AuditableTenantEntity public sealed class CommerceAdjustmentVoucherEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid VoucherId { get; set; } public CommerceAdjustmentVoucherStatus? FromStatus { get; set; } public CommerceAdjustmentVoucherStatus ToStatus { get; set; } = CommerceAdjustmentVoucherStatus.Draft; @@ -358,25 +357,179 @@ public sealed class CommerceAdjustmentVoucherEvent : Entity, ITenantOwned public string? Note { get; set; } public JsonElement Details { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } -public enum ProductType { Material, Course, Service, Link, Other } -public enum OrderStatus { Pending, Paid, Failed, Closed, PartiallyRefunded, Refunded } -public enum PaymentStatus { Pending, Paid, Failed, Cancelled, PartiallyRefunded, Refunded } -public enum EntitlementScopeType { Tenant, Region, Module, Subject, QuestionBank } -public enum EntitlementStatus { Active, Revoked, Expired } -public enum DiscountType { Percent, Fixed } -public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled } -public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled } -public enum RefundEntitlementAction { None, RevokeOnSuccess } -public enum ReconciliationBillType { Payment, Refund, Combined } -public enum ReconciliationSource { ManualUpload, ProviderDownload, Api, Worker } -public enum ReconciliationBatchStatus { Preview, Pending, Processing, Completed, CompletedWithIssues, Failed } -public enum ReconciliationTransactionType { Payment, Refund } -public enum ReconciliationMatchStatus { Matched, AmountMismatch, StatusMismatch, MissingLocal, MissingProvider, Duplicate, Ignored } -public enum ReconciliationIssueMatchStatus { AmountMismatch, StatusMismatch, MissingLocal, MissingProvider, Duplicate } -public enum NotificationSeverity { Info, Warning, Error, Critical } -public enum ReconciliationIssueStatus { Open, Investigating, Resolved, Ignored, Escalated } -public enum ReconciliationResolutionType { None, ProviderConfirmed, LocalCorrected, ManualAdjustment, FalsePositive, Duplicate, WriteOff } -public enum CommerceAdjustmentVoucherStatus { Draft, PendingReview, Approved, Rejected, Closed, Void } -public enum CommerceAdjustmentDirection { IncreaseRevenue, DecreaseRevenue, IncreaseRefund, DecreaseRefund, WriteOff } +public enum ProductType +{ + Material, + Course, + Service, + Link, + Other +} + +public enum OrderStatus +{ + Pending, + Paid, + Failed, + Closed, + PartiallyRefunded, + Refunded +} + +public enum PaymentStatus +{ + Pending, + Paid, + Failed, + Cancelled, + PartiallyRefunded, + Refunded +} + +public enum EntitlementScopeType +{ + Tenant, + Region, + Module, + Subject, + QuestionBank +} + +public enum EntitlementStatus +{ + Active, + Revoked, + Expired +} + +public enum DiscountType +{ + Percent, + Fixed +} + +public enum CouponRedemptionStatus +{ + Claimed, + Used, + Expired, + Cancelled +} + +public enum CommerceRefundStatus +{ + Requested, + Approved, + Processing, + Succeeded, + Failed, + Rejected, + Cancelled +} + +public enum RefundEntitlementAction +{ + None, + RevokeOnSuccess +} + +public enum ReconciliationBillType +{ + Payment, + Refund, + Combined +} + +public enum ReconciliationSource +{ + ManualUpload, + ProviderDownload, + Api, + Worker +} + +public enum ReconciliationBatchStatus +{ + Preview, + Pending, + Processing, + Completed, + CompletedWithIssues, + Failed +} + +public enum ReconciliationTransactionType +{ + Payment, + Refund +} + +public enum ReconciliationMatchStatus +{ + Matched, + AmountMismatch, + StatusMismatch, + MissingLocal, + MissingProvider, + Duplicate, + Ignored +} + +public enum ReconciliationIssueMatchStatus +{ + AmountMismatch, + StatusMismatch, + MissingLocal, + MissingProvider, + Duplicate +} + +public enum NotificationSeverity +{ + Info, + Warning, + Error, + Critical +} + +public enum ReconciliationIssueStatus +{ + Open, + Investigating, + Resolved, + Ignored, + Escalated +} + +public enum ReconciliationResolutionType +{ + None, + ProviderConfirmed, + LocalCorrected, + ManualAdjustment, + FalsePositive, + Duplicate, + WriteOff +} + +public enum CommerceAdjustmentVoucherStatus +{ + Draft, + PendingReview, + Approved, + Rejected, + Closed, + Void +} + +public enum CommerceAdjustmentDirection +{ + IncreaseRevenue, + DecreaseRevenue, + IncreaseRefund, + DecreaseRefund, + WriteOff +} \ No newline at end of file diff --git a/Tiku.Domain/Commerce/PointEntities.cs b/Tiku.Domain/Commerce/PointEntities.cs index 6873131..efe2dcf 100644 --- a/Tiku.Domain/Commerce/PointEntities.cs +++ b/Tiku.Domain/Commerce/PointEntities.cs @@ -66,9 +66,47 @@ public sealed class PointExchangeOrder : AuditableTenantEntity public JsonElement Metadata { get; set; } = JsonDefaults.Object(); } -public enum PointActivityTaskType { Manual, DailyLogin, Practice, VocabularyReview, BadgeUnlock } -public enum PointActivityTaskStatus { Active, Disabled, Expired } -public enum PointActivityClaimStatus { Claimed, Cancelled } -public enum PointExchangeItemType { Entitlement, Coupon, Virtual, Other } -public enum PointExchangeItemStatus { Active, Disabled, SoldOut } -public enum PointExchangeOrderStatus { Pending, Completed, Cancelled, Failed } +public enum PointActivityTaskType +{ + Manual, + DailyLogin, + Practice, + VocabularyReview, + BadgeUnlock +} + +public enum PointActivityTaskStatus +{ + Active, + Disabled, + Expired +} + +public enum PointActivityClaimStatus +{ + Claimed, + Cancelled +} + +public enum PointExchangeItemType +{ + Entitlement, + Coupon, + Virtual, + Other +} + +public enum PointExchangeItemStatus +{ + Active, + Disabled, + SoldOut +} + +public enum PointExchangeOrderStatus +{ + Pending, + Completed, + Cancelled, + Failed +} \ No newline at end of file diff --git a/Tiku.Domain/Common/Entity.cs b/Tiku.Domain/Common/Entity.cs index a2b2f53..4a1d869 100644 --- a/Tiku.Domain/Common/Entity.cs +++ b/Tiku.Domain/Common/Entity.cs @@ -48,4 +48,4 @@ public static class JsonDefaults using var document = JsonDocument.Parse("[]"); return document.RootElement.Clone(); } -} +} \ No newline at end of file diff --git a/Tiku.Domain/Content/AssetEntities.cs b/Tiku.Domain/Content/AssetEntities.cs index 7b67330..b5e9620 100644 --- a/Tiku.Domain/Content/AssetEntities.cs +++ b/Tiku.Domain/Content/AssetEntities.cs @@ -268,4 +268,4 @@ public enum QuestionVideoType Specific, General, Related -} +} \ No newline at end of file diff --git a/Tiku.Domain/Content/ContentEntities.cs b/Tiku.Domain/Content/ContentEntities.cs index 18f6257..2feb026 100644 --- a/Tiku.Domain/Content/ContentEntities.cs +++ b/Tiku.Domain/Content/ContentEntities.cs @@ -190,4 +190,4 @@ public enum PracticeAssemblyType NodeDescendants, Manual, Filters -} +} \ No newline at end of file diff --git a/Tiku.Domain/Content/ContentPlatformEntities.cs b/Tiku.Domain/Content/ContentPlatformEntities.cs index 847b17d..b9ed73c 100644 --- a/Tiku.Domain/Content/ContentPlatformEntities.cs +++ b/Tiku.Domain/Content/ContentPlatformEntities.cs @@ -5,7 +5,6 @@ namespace Tiku.Domain.Content; public sealed class ContentAssetAccessEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid? AssetId { get; set; } public Guid? UserId { get; set; } public AssetAccessActorRole ActorRole { get; set; } = AssetAccessActorRole.Anonymous; @@ -22,11 +21,11 @@ public sealed class ContentAssetAccessEvent : Entity, ITenantOwned public string? UserAgent { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class ContentAssetSecurityScanEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid? AssetId { get; set; } public string Provider { get; set; } = string.Empty; public AssetSecurityScanStatus ScanStatus { get; set; } = AssetSecurityScanStatus.Pending; @@ -34,6 +33,7 @@ public sealed class ContentAssetSecurityScanEvent : Entity, ITenantOwned public string[] IssueCodes { get; set; } = []; public JsonElement Details { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantQuestionBankPreference : AuditableTenantEntity @@ -72,10 +72,55 @@ public sealed class AiRecommendationReport : AuditableTenantEntity public DateTimeOffset? GeneratedAt { get; set; } } -public enum AssetAccessActorRole { Anonymous, Student, TenantAdmin, TenantContentEditor, System } -public enum AssetAccessType { Download, Preview, AdminDownload, AdminPreview, UploadSign, UploadConfirm } -public enum AssetAccessDisposition { Attachment, Inline } -public enum AssetAccessResult { Granted, Denied } -public enum AssetSecurityRiskLevel { None, Low, Medium, High, Critical } -public enum QuestionSource { Platform, Tenant } -public enum AiRecommendationReportStatus { Draft, Generated, Failed } +public enum AssetAccessActorRole +{ + Anonymous, + Student, + TenantAdmin, + TenantContentEditor, + System +} + +public enum AssetAccessType +{ + Download, + Preview, + AdminDownload, + AdminPreview, + UploadSign, + UploadConfirm +} + +public enum AssetAccessDisposition +{ + Attachment, + Inline +} + +public enum AssetAccessResult +{ + Granted, + Denied +} + +public enum AssetSecurityRiskLevel +{ + None, + Low, + Medium, + High, + Critical +} + +public enum QuestionSource +{ + Platform, + Tenant +} + +public enum AiRecommendationReportStatus +{ + Draft, + Generated, + Failed +} \ No newline at end of file diff --git a/Tiku.Domain/Content/StudyContentEntities.cs b/Tiku.Domain/Content/StudyContentEntities.cs index ed08222..36cabd3 100644 --- a/Tiku.Domain/Content/StudyContentEntities.cs +++ b/Tiku.Domain/Content/StudyContentEntities.cs @@ -168,4 +168,4 @@ public enum HandbookSubjectType Cultural, Professional, Common -} +} \ No newline at end of file diff --git a/Tiku.Domain/Growth/GrowthEntities.cs b/Tiku.Domain/Growth/GrowthEntities.cs index ce9ec4f..f3dcd69 100644 --- a/Tiku.Domain/Growth/GrowthEntities.cs +++ b/Tiku.Domain/Growth/GrowthEntities.cs @@ -190,7 +190,6 @@ public sealed class CommissionSettlementProof : AuditableTenantEntity public sealed class CommissionSettlementExportEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid SettlementId { get; set; } public Guid? ExportedBy { get; set; } public string ExportFormat { get; set; } = "csv"; @@ -199,19 +198,111 @@ public sealed class CommissionSettlementExportEvent : Entity, ITenantOwned public string? ContentSha256 { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } -public enum ReferralCodeStatus { Active, Disabled } -public enum ReferralLeadBindType { FirstTouch, Manual, Imported } -public enum ReferralLeadStatus { Protected, Invalid, Released } -public enum ReferralAssignmentMode { None, Direct, RoundRobin, Referrer } -public enum ReferralTeamRelationType { SalesTeam, AgentNetwork, TeacherClass } -public enum ReferralTeamEdgeStatus { Active, Disabled } -public enum ReferralQrcodeStatus { Pending, Ready, Failed, Disabled } -public enum CrmWebhookQueueStatus { Pending, Processing, Retrying, Sent, Failed, Discarded } -public enum CommissionSettlementCycle { Manual, Weekly, Monthly } -public enum CommissionSettlementStatus { Draft, PendingReview, Approved, Paid, Rejected, Cancelled } -public enum CommissionSourceType { Order, ActivationCode } -public enum CommissionRateSource { Batch, Member, Default } -public enum CommissionProofType { Payment, Invoice, Receipt, Adjustment, Other } -public enum CommissionProofStatus { Submitted, Approved, Rejected, Voided } +public enum ReferralCodeStatus +{ + Active, + Disabled +} + +public enum ReferralLeadBindType +{ + FirstTouch, + Manual, + Imported +} + +public enum ReferralLeadStatus +{ + Protected, + Invalid, + Released +} + +public enum ReferralAssignmentMode +{ + None, + Direct, + RoundRobin, + Referrer +} + +public enum ReferralTeamRelationType +{ + SalesTeam, + AgentNetwork, + TeacherClass +} + +public enum ReferralTeamEdgeStatus +{ + Active, + Disabled +} + +public enum ReferralQrcodeStatus +{ + Pending, + Ready, + Failed, + Disabled +} + +public enum CrmWebhookQueueStatus +{ + Pending, + Processing, + Retrying, + Sent, + Failed, + Discarded +} + +public enum CommissionSettlementCycle +{ + Manual, + Weekly, + Monthly +} + +public enum CommissionSettlementStatus +{ + Draft, + PendingReview, + Approved, + Paid, + Rejected, + Cancelled +} + +public enum CommissionSourceType +{ + Order, + ActivationCode +} + +public enum CommissionRateSource +{ + Batch, + Member, + Default +} + +public enum CommissionProofType +{ + Payment, + Invoice, + Receipt, + Adjustment, + Other +} + +public enum CommissionProofStatus +{ + Submitted, + Approved, + Rejected, + Voided +} \ No newline at end of file diff --git a/Tiku.Domain/Identity/StudentProfile.cs b/Tiku.Domain/Identity/StudentProfile.cs index 668b252..f3c16d3 100644 --- a/Tiku.Domain/Identity/StudentProfile.cs +++ b/Tiku.Domain/Identity/StudentProfile.cs @@ -18,4 +18,4 @@ public sealed class StudentProfile : AuditableTenantEntity public JsonElement Progress { get; set; } = JsonDefaults.Object(); public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object(); public JsonElement RecentActivities { get; set; } = JsonDefaults.Array(); -} +} \ No newline at end of file diff --git a/Tiku.Domain/Identity/User.cs b/Tiku.Domain/Identity/User.cs index c1c1b9e..b0ade8d 100644 --- a/Tiku.Domain/Identity/User.cs +++ b/Tiku.Domain/Identity/User.cs @@ -32,4 +32,4 @@ public enum UserStatus Active, Disabled, Archived -} +} \ No newline at end of file diff --git a/Tiku.Domain/Identity/UserIdentity.cs b/Tiku.Domain/Identity/UserIdentity.cs index f587a25..cdcc489 100644 --- a/Tiku.Domain/Identity/UserIdentity.cs +++ b/Tiku.Domain/Identity/UserIdentity.cs @@ -11,4 +11,4 @@ public sealed class UserIdentity : AuditableEntity public string? OpenId { get; set; } public string? Phone { get; set; } public string? Email { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Domain/Import/PocketBaseImportEntities.cs b/Tiku.Domain/Import/PocketBaseImportEntities.cs index bca94cf..cf4329f 100644 --- a/Tiku.Domain/Import/PocketBaseImportEntities.cs +++ b/Tiku.Domain/Import/PocketBaseImportEntities.cs @@ -5,31 +5,30 @@ namespace Tiku.Domain.Import; public sealed class PocketBaseImportRun : Entity, ITenantOwned { - public Guid TenantId { get; set; } public string SourceName { get; set; } = string.Empty; public PocketBaseImportSourceKind SourceKind { get; set; } = PocketBaseImportSourceKind.Json; public PocketBaseImportRunStatus Status { get; set; } = PocketBaseImportRunStatus.Running; public JsonElement Stats { get; set; } = JsonDefaults.Object(); public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? FinishedAt { get; set; } + public Guid TenantId { get; set; } } public sealed class PocketBaseRawRecord : ITenantOwned { public Guid RunId { get; set; } - public Guid TenantId { get; set; } public string CollectionName { get; set; } = string.Empty; public string LegacyId { get; set; } = string.Empty; public JsonElement Record { get; set; } = JsonDefaults.Object(); public bool Normalized { get; set; } public JsonElement Errors { get; set; } = JsonDefaults.Array(); public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class PocketBaseImportIssue : Entity, ITenantOwned { public Guid? RunId { get; set; } - public Guid TenantId { get; set; } public string CollectionName { get; set; } = string.Empty; public string? LegacyId { get; set; } public PocketBaseImportIssueSeverity Severity { get; set; } = PocketBaseImportIssueSeverity.Warning; @@ -38,8 +37,28 @@ public sealed class PocketBaseImportIssue : Entity, ITenantOwned public string? FieldPath { get; set; } public string? RawValueSample { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } -public enum PocketBaseImportSourceKind { Json, Sqlite, Api, Archive } -public enum PocketBaseImportRunStatus { Running, Completed, Failed } -public enum PocketBaseImportIssueSeverity { Info, Warning, Error, Critical } +public enum PocketBaseImportSourceKind +{ + Json, + Sqlite, + Api, + Archive +} + +public enum PocketBaseImportRunStatus +{ + Running, + Completed, + Failed +} + +public enum PocketBaseImportIssueSeverity +{ + Info, + Warning, + Error, + Critical +} \ No newline at end of file diff --git a/Tiku.Domain/Learning/LearningEntities.cs b/Tiku.Domain/Learning/LearningEntities.cs index 5ada1dd..9e1bffb 100644 --- a/Tiku.Domain/Learning/LearningEntities.cs +++ b/Tiku.Domain/Learning/LearningEntities.cs @@ -101,7 +101,6 @@ public enum AnswerGradingStatus public sealed class LearningOperationIdempotency : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public Guid PracticeSessionId { get; set; } public string OperationType { get; set; } = string.Empty; @@ -110,22 +109,22 @@ public sealed class LearningOperationIdempotency : Entity, ITenantOwned public JsonElement ResponseSnapshot { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? CompletedAt { get; set; } + public Guid TenantId { get; set; } } public sealed class FavoriteQuestion : ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public Guid QuestionReferenceId { get; set; } public Guid QuestionOwnerTenantId { get; set; } public Guid QuestionId { get; set; } public string Source { get; set; } = "imported"; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class WrongQuestion : ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public Guid QuestionReferenceId { get; set; } public Guid QuestionOwnerTenantId { get; set; } @@ -133,6 +132,7 @@ public sealed class WrongQuestion : ITenantOwned public int WrongCount { get; set; } = 1; public DateTimeOffset LastWrongAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? ResolvedAt { get; set; } + public Guid TenantId { get; set; } } public sealed class RecentPractice : AuditableTenantEntity @@ -147,4 +147,4 @@ public sealed class RecentPractice : AuditableTenantEntity public DateTimeOffset? LastAccessAt { get; set; } public DateTimeOffset? LastPracticeAt { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); -} +} \ No newline at end of file diff --git a/Tiku.Domain/Learning/LearningReportEntities.cs b/Tiku.Domain/Learning/LearningReportEntities.cs index 224cc1b..3ff11d1 100644 --- a/Tiku.Domain/Learning/LearningReportEntities.cs +++ b/Tiku.Domain/Learning/LearningReportEntities.cs @@ -38,7 +38,6 @@ public sealed class Report : AuditableTenantEntity public sealed class ReportStatusEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid ReportId { get; set; } public ReportStatus? FromStatus { get; set; } public ReportStatus ToStatus { get; set; } = ReportStatus.Pending; @@ -46,11 +45,11 @@ public sealed class ReportStatusEvent : Entity, ITenantOwned public Guid? ActorUserId { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class UserScoreEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public UserScoreEventType EventType { get; set; } = UserScoreEventType.ManualAdjust; public int Points { get; set; } @@ -60,6 +59,7 @@ public sealed class UserScoreEvent : Entity, ITenantOwned public string? IdempotencyKey { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class PracticeDailyUsage : AuditableTenantEntity @@ -75,7 +75,6 @@ public sealed class PracticeDailyUsage : AuditableTenantEntity public sealed class PracticeAccessEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid? UserId { get; set; } public Guid? PracticeSessionId { get; set; } public PracticeAccessEventType EventType { get; set; } = PracticeAccessEventType.SessionCreated; @@ -89,6 +88,7 @@ public sealed class PracticeAccessEvent : Entity, ITenantOwned public Guid? EntitlementId { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class PracticeSessionReport : AuditableTenantEntity @@ -238,4 +238,4 @@ public enum PracticeAccessEventMode Svip, Staff, Denied -} +} \ No newline at end of file diff --git a/Tiku.Domain/Operations/OperationsEntities.cs b/Tiku.Domain/Operations/OperationsEntities.cs index 4f9d7e3..977978b 100644 --- a/Tiku.Domain/Operations/OperationsEntities.cs +++ b/Tiku.Domain/Operations/OperationsEntities.cs @@ -88,26 +88,26 @@ public sealed class TenantBackendRole : AuditableTenantEntity public sealed class TenantBackendRolePermission : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid RoleId { get; set; } public string PermissionCode { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantBackendRoleMenu : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid RoleId { get; set; } public string MenuCode { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantBackendUserRole : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid UserId { get; set; } public Guid RoleId { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class PlatformBackendRole : AuditableEntity @@ -259,7 +259,10 @@ public sealed class TenantContentNotification : AuditableTenantEntity public Guid? SourceQuestionBankId { get; set; } public Guid? CreatedBy { get; set; } public Guid? ReadBy { get; set; } - public TenantContentNotificationType NotificationType { get; set; } = TenantContentNotificationType.PublicQuestionBankSynced; + + public TenantContentNotificationType NotificationType { get; set; } = + TenantContentNotificationType.PublicQuestionBankSynced; + public TenantContentNotificationStatus Status { get; set; } = TenantContentNotificationStatus.Unread; public NotificationSeverity Severity { get; set; } = NotificationSeverity.Info; public string Title { get; set; } = string.Empty; @@ -298,16 +301,83 @@ public sealed class TenantThemeConfig : AuditableTenantEntity public Guid? DraftUpdatedBy { get; set; } } -public enum NotificationStatus { Unread, Read, Dismissed, Archived } -public enum NotificationSeverity { Info, Success, Warning, Error } -public enum TenantContentNotificationType { PublicQuestionBankSynced, PublicQuestionBankConflict } -public enum TenantContentNotificationStatus { Unread, Read, Dismissed, Resolved } -public enum TenantThemeTemplateStatus { Active, Disabled } -public enum TenantThemeConfigStatus { Draft, Published } -public enum BackendPermissionArea { Platform, Tenant, Both } -public enum BackendRoleStatus { Active, Disabled, Archived } -public enum BackgroundJobStatus { Pending, Processing, Succeeded, Failed, Cancelled } +public enum NotificationStatus +{ + Unread, + Read, + Dismissed, + Archived +} -public enum TenantLifecycleOperationType { Export, Archive, Restore, OwnerTransfer } +public enum NotificationSeverity +{ + Info, + Success, + Warning, + Error +} -public enum TenantLifecycleOperationStatus { Pending, Processing, Succeeded, Failed } +public enum TenantContentNotificationType +{ + PublicQuestionBankSynced, + PublicQuestionBankConflict +} + +public enum TenantContentNotificationStatus +{ + Unread, + Read, + Dismissed, + Resolved +} + +public enum TenantThemeTemplateStatus +{ + Active, + Disabled +} + +public enum TenantThemeConfigStatus +{ + Draft, + Published +} + +public enum BackendPermissionArea +{ + Platform, + Tenant, + Both +} + +public enum BackendRoleStatus +{ + Active, + Disabled, + Archived +} + +public enum BackgroundJobStatus +{ + Pending, + Processing, + Succeeded, + Failed, + Cancelled +} + +public enum TenantLifecycleOperationType +{ + Export, + Archive, + Restore, + OwnerTransfer +} + +public enum TenantLifecycleOperationStatus +{ + Pending, + Processing, + Succeeded, + Failed +} \ No newline at end of file diff --git a/Tiku.Domain/Platform/PlatformGovernanceEntities.cs b/Tiku.Domain/Platform/PlatformGovernanceEntities.cs index 2331e7d..756459d 100644 --- a/Tiku.Domain/Platform/PlatformGovernanceEntities.cs +++ b/Tiku.Domain/Platform/PlatformGovernanceEntities.cs @@ -54,8 +54,21 @@ public sealed class PlatformApprovalRequest : AuditableEntity public Guid ConcurrencyStamp { get; set; } = Guid.NewGuid(); } -public enum PlatformConfigurationValueType { String, Number, Boolean, Json, SecretReference } -public enum PlatformConfigurationVersionStatus { Draft, Published, Retired } +public enum PlatformConfigurationValueType +{ + String, + Number, + Boolean, + Json, + SecretReference +} + +public enum PlatformConfigurationVersionStatus +{ + Draft, + Published, + Retired +} public sealed class PlatformConfigurationDefinition : AuditableEntity { @@ -84,8 +97,21 @@ public sealed class PlatformConfigurationVersion : AuditableEntity public DateTimeOffset? PublishedAt { get; set; } } -public enum PlatformNotificationChannel { InApp, Sms, Email } -public enum PlatformNotificationDeliveryStatus { Pending, Processing, Sent, Failed, Cancelled } +public enum PlatformNotificationChannel +{ + InApp, + Sms, + Email +} + +public enum PlatformNotificationDeliveryStatus +{ + Pending, + Processing, + Sent, + Failed, + Cancelled +} public sealed class PlatformNotificationTemplate : AuditableEntity { @@ -113,4 +139,4 @@ public sealed class PlatformNotificationDelivery : AuditableEntity public DateTimeOffset? SentAt { get; set; } public Guid CreatedBy { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); -} +} \ No newline at end of file diff --git a/Tiku.Domain/Platform/PlatformOperationsEntities.cs b/Tiku.Domain/Platform/PlatformOperationsEntities.cs index b481267..f23c1b7 100644 --- a/Tiku.Domain/Platform/PlatformOperationsEntities.cs +++ b/Tiku.Domain/Platform/PlatformOperationsEntities.cs @@ -5,7 +5,6 @@ namespace Tiku.Domain.Platform; public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned { - public Guid TenantId { get; set; } public string? BillingName { get; set; } public string? TaxId { get; set; } public string? ContactName { get; set; } @@ -19,17 +18,18 @@ public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantBillingPolicy : IHasTimestamps, ITenantOwned { - public Guid TenantId { get; set; } public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online; public string DefaultPaymentProvider { get; set; } = "manual"; public bool AutoGenerateRenewal { get; set; } = true; public int RenewalLeadDays { get; set; } = 14; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantOwnerActivationGrant : AuditableTenantEntity @@ -130,7 +130,10 @@ public sealed class PlatformBillingDunningNotificationEvent : AuditableTenantEnt public Guid ReminderId { get; set; } public Guid InvoiceId { get; set; } public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic; - public PlatformBillingDunningNotificationStatus Status { get; set; } = PlatformBillingDunningNotificationStatus.Pending; + + public PlatformBillingDunningNotificationStatus Status { get; set; } = + PlatformBillingDunningNotificationStatus.Pending; + public int Attempts { get; set; } public DateTimeOffset ScheduledAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? NextAttemptAt { get; set; } @@ -167,15 +170,100 @@ public sealed class PlatformPaymentChannel : AuditableEntity public JsonElement Metadata { get; set; } = JsonDefaults.Object(); } -public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime } -public enum TenantBillingInvoiceTitleType { None, NormalVat, SpecialVat } -public enum PlatformBillingInvoiceReminderType { DueSoon, Overdue, FinalNotice, Manual } -public enum PlatformBillingInvoiceReminderChannel { Manual, Internal, Sms, Email, Wechat, Crm } -public enum PlatformBillingInvoiceReminderStatus { Pending, Sent, Acknowledged, Dismissed, Failed } -public enum PlatformAlertSeverity { Low, Medium, High, Critical } -public enum PlatformAuditAlertStatus { Open, Acknowledged, Resolved, Ignored } -public enum PlatformBillingDunningProvider { Generic, Dingtalk, Feishu, Wecom } -public enum PlatformBillingDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded, Acknowledged, Ignored } -public enum PlatformPaymentAppStatus { Active, Disabled, Testing } -public enum PlatformPaymentChannelStatus { Active, Disabled, Testing } -public enum TenantBillingCollectionMode { Online, Manual } +public enum PlatformBillingCycle +{ + Monthly, + Quarterly, + Yearly, + OneTime +} + +public enum TenantBillingInvoiceTitleType +{ + None, + NormalVat, + SpecialVat +} + +public enum PlatformBillingInvoiceReminderType +{ + DueSoon, + Overdue, + FinalNotice, + Manual +} + +public enum PlatformBillingInvoiceReminderChannel +{ + Manual, + Internal, + Sms, + Email, + Wechat, + Crm +} + +public enum PlatformBillingInvoiceReminderStatus +{ + Pending, + Sent, + Acknowledged, + Dismissed, + Failed +} + +public enum PlatformAlertSeverity +{ + Low, + Medium, + High, + Critical +} + +public enum PlatformAuditAlertStatus +{ + Open, + Acknowledged, + Resolved, + Ignored +} + +public enum PlatformBillingDunningProvider +{ + Generic, + Dingtalk, + Feishu, + Wecom +} + +public enum PlatformBillingDunningNotificationStatus +{ + Pending, + Processing, + Sent, + Retrying, + Failed, + Discarded, + Acknowledged, + Ignored +} + +public enum PlatformPaymentAppStatus +{ + Active, + Disabled, + Testing +} + +public enum PlatformPaymentChannelStatus +{ + Active, + Disabled, + Testing +} + +public enum TenantBillingCollectionMode +{ + Online, + Manual +} \ No newline at end of file diff --git a/Tiku.Domain/Platform/SaasBillingEntities.cs b/Tiku.Domain/Platform/SaasBillingEntities.cs index c3213af..a8ff8ad 100644 --- a/Tiku.Domain/Platform/SaasBillingEntities.cs +++ b/Tiku.Domain/Platform/SaasBillingEntities.cs @@ -137,7 +137,6 @@ public sealed class PlatformBillingQuote : AuditableTenantEntity public sealed class PlatformBillingQuoteItem : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid QuoteId { get; set; } public Guid OfferingVersionId { get; set; } public PlatformBillingItemType ItemType { get; set; } = PlatformBillingItemType.BasePlan; @@ -145,6 +144,7 @@ public sealed class PlatformBillingQuoteItem : Entity, ITenantOwned public int UnitAmountCents { get; set; } public int AmountCents { get; set; } public JsonElement Snapshot { get; set; } = JsonDefaults.Object(); + public Guid TenantId { get; set; } } public sealed class PlatformBillingOrder : AuditableTenantEntity @@ -166,7 +166,6 @@ public sealed class PlatformBillingOrder : AuditableTenantEntity public sealed class PlatformBillingOrderItem : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid OrderId { get; set; } public Guid OfferingVersionId { get; set; } public PlatformBillingItemType ItemType { get; set; } = PlatformBillingItemType.BasePlan; @@ -174,6 +173,7 @@ public sealed class PlatformBillingOrderItem : Entity, ITenantOwned public int UnitAmountCents { get; set; } public int AmountCents { get; set; } public JsonElement Snapshot { get; set; } = JsonDefaults.Object(); + public Guid TenantId { get; set; } } public sealed class PlatformBillingPayment : AuditableTenantEntity @@ -192,13 +192,13 @@ public sealed class PlatformBillingPayment : AuditableTenantEntity public sealed class PlatformBillingPaymentEvent : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid PaymentId { get; set; } public string Provider { get; set; } = string.Empty; public string ProviderEventId { get; set; } = string.Empty; public string EventType { get; set; } = string.Empty; public JsonElement Payload { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class PlatformBillingRefund : AuditableTenantEntity @@ -212,7 +212,10 @@ public sealed class PlatformBillingRefund : AuditableTenantEntity public string? ProviderRefundNo { get; set; } public DateTimeOffset? CompletedAt { get; set; } public string IdempotencyKey { get; set; } = string.Empty; - public PlatformBillingRefundSubscriptionEffect SubscriptionEffect { get; set; } = PlatformBillingRefundSubscriptionEffect.KeepService; + + public PlatformBillingRefundSubscriptionEffect SubscriptionEffect { get; set; } = + PlatformBillingRefundSubscriptionEffect.KeepService; + public Guid? RequestedBy { get; set; } public Guid? ReviewedBy { get; set; } public DateTimeOffset? ReviewedAt { get; set; } @@ -233,20 +236,131 @@ public sealed class PlatformBillingInvoice : AuditableTenantEntity public JsonElement BillingProfileSnapshot { get; set; } = JsonDefaults.Object(); } -public enum SaasFeatureStatus { Draft, Active, Archived } -public enum SaasOfferingType { BasePlan, AddOn } -public enum SaasOfferingStatus { Draft, Active, Archived } -public enum SaasOfferingVersionStatus { Draft, Published, Retired } -public enum SaasFeatureLimitKind { Current, Period } -public enum TenantSaasSubscriptionStatus { Trial, Active, PastDue, Cancelled, Expired, Suspended } -public enum TenantSaasSubscriptionItemType { BasePlan, AddOn } -public enum TenantSaasSubscriptionItemStatus { Pending, Active, Scheduled, Cancelled, Expired } -public enum TenantFeatureOverrideMode { Enabled, Disabled } -public enum PlatformBillingQuoteStatus { Active, Converted, Expired, Cancelled } -public enum PlatformBillingOrderPurpose { NewSubscription, Renewal, Upgrade, Downgrade, AddOn } -public enum PlatformBillingOrderStatus { PendingPayment, Paid, Cancelled, Expired, Refunded } -public enum PlatformBillingItemType { BasePlan, AddOn } -public enum PlatformBillingPaymentStatus { Pending, Succeeded, Failed, Refunded } -public enum PlatformBillingRefundStatus { Requested, Processing, Succeeded, Failed, Cancelled } -public enum PlatformBillingInvoiceStatus { Draft, Issued, Paid, Void, Overdue } -public enum PlatformBillingRefundSubscriptionEffect { KeepService, CancelAtPeriodEnd, TerminateImmediately } +public enum SaasFeatureStatus +{ + Draft, + Active, + Archived +} + +public enum SaasOfferingType +{ + BasePlan, + AddOn +} + +public enum SaasOfferingStatus +{ + Draft, + Active, + Archived +} + +public enum SaasOfferingVersionStatus +{ + Draft, + Published, + Retired +} + +public enum SaasFeatureLimitKind +{ + Current, + Period +} + +public enum TenantSaasSubscriptionStatus +{ + Trial, + Active, + PastDue, + Cancelled, + Expired, + Suspended +} + +public enum TenantSaasSubscriptionItemType +{ + BasePlan, + AddOn +} + +public enum TenantSaasSubscriptionItemStatus +{ + Pending, + Active, + Scheduled, + Cancelled, + Expired +} + +public enum TenantFeatureOverrideMode +{ + Enabled, + Disabled +} + +public enum PlatformBillingQuoteStatus +{ + Active, + Converted, + Expired, + Cancelled +} + +public enum PlatformBillingOrderPurpose +{ + NewSubscription, + Renewal, + Upgrade, + Downgrade, + AddOn +} + +public enum PlatformBillingOrderStatus +{ + PendingPayment, + Paid, + Cancelled, + Expired, + Refunded +} + +public enum PlatformBillingItemType +{ + BasePlan, + AddOn +} + +public enum PlatformBillingPaymentStatus +{ + Pending, + Succeeded, + Failed, + Refunded +} + +public enum PlatformBillingRefundStatus +{ + Requested, + Processing, + Succeeded, + Failed, + Cancelled +} + +public enum PlatformBillingInvoiceStatus +{ + Draft, + Issued, + Paid, + Void, + Overdue +} + +public enum PlatformBillingRefundSubscriptionEffect +{ + KeepService, + CancelAtPeriodEnd, + TerminateImmediately +} \ No newline at end of file diff --git a/Tiku.Domain/QuestionBanks/QuestionEntities.cs b/Tiku.Domain/QuestionBanks/QuestionEntities.cs index e0b3982..97179de 100644 --- a/Tiku.Domain/QuestionBanks/QuestionEntities.cs +++ b/Tiku.Domain/QuestionBanks/QuestionEntities.cs @@ -51,7 +51,6 @@ public enum QuestionStatus public sealed class QuestionVersion : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid QuestionId { get; set; } public int VersionNo { get; set; } = 1; public string? Content { get; set; } @@ -66,4 +65,5 @@ public sealed class QuestionVersion : Entity, ITenantOwned public string? SourceHash { get; set; } public Guid? CreatedBy { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; -} + public Guid TenantId { get; set; } +} \ No newline at end of file diff --git a/Tiku.Domain/Tenancy/Tenant.cs b/Tiku.Domain/Tenancy/Tenant.cs index 5cbec10..35194f0 100644 --- a/Tiku.Domain/Tenancy/Tenant.cs +++ b/Tiku.Domain/Tenancy/Tenant.cs @@ -38,4 +38,4 @@ public enum BillingStatus PastDue, Suspended, Cancelled -} +} \ No newline at end of file diff --git a/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs b/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs index 2870f19..9ebf9dc 100644 --- a/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs +++ b/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs @@ -34,7 +34,6 @@ public enum TenantDomainStatus public sealed class TenantBranding : IHasTimestamps, ITenantOwned { - public Guid TenantId { get; set; } public string BrandName { get; set; } = string.Empty; public string? ShortName { get; set; } public string? Slogan { get; set; } @@ -47,25 +46,26 @@ public sealed class TenantBranding : IHasTimestamps, ITenantOwned public JsonElement PublicAssets { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantSettings : IHasTimestamps, ITenantOwned { - public Guid TenantId { get; set; } public JsonElement FeatureFlags { get; set; } = JsonDefaults.Object(); public JsonElement AdminFeatureFlags { get; set; } = JsonDefaults.Object(); public JsonElement PublicConfig { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantAuthPolicy : IHasTimestamps, ITenantOwned { - public Guid TenantId { get; set; } public bool AllowExternalStudentSelfRegistration { get; set; } public string[] AllowedStudentLoginMethods { get; set; } = ["password"]; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantFrontendConfig : AuditableTenantEntity @@ -83,4 +83,4 @@ public sealed class TenantFrontendConfig : AuditableTenantEntity public JsonElement DraftNavigation { get; set; } = JsonDefaults.Array(); public JsonElement DraftHomeModules { get; set; } = JsonDefaults.Array(); public DateTimeOffset? PublishedAt { get; set; } -} +} \ No newline at end of file diff --git a/Tiku.Domain/Tenancy/TenantMembership.cs b/Tiku.Domain/Tenancy/TenantMembership.cs index 63e4904..8d91913 100644 --- a/Tiku.Domain/Tenancy/TenantMembership.cs +++ b/Tiku.Domain/Tenancy/TenantMembership.cs @@ -26,4 +26,4 @@ public enum MembershipStatus Active, Invited, Disabled -} +} \ No newline at end of file diff --git a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs index b6c1abc..aeff47f 100644 --- a/Tiku.Domain/Tenancy/TenantOperationsEntities.cs +++ b/Tiku.Domain/Tenancy/TenantOperationsEntities.cs @@ -32,7 +32,6 @@ public sealed class TenantSecret : AuditableTenantEntity public sealed class SmsVerificationCode : Entity, ITenantOwned { - public Guid TenantId { get; set; } public string Phone { get; set; } = string.Empty; public SmsPurpose Purpose { get; set; } = SmsPurpose.Login; public string CodeHash { get; set; } = string.Empty; @@ -45,6 +44,7 @@ public sealed class SmsVerificationCode : Entity, ITenantOwned public string? UserAgent { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class AuthLoginEvent : Entity @@ -80,7 +80,11 @@ public sealed class AuthSession : AuditableEntity public JsonElement Metadata { get; set; } = JsonDefaults.Object(); } -public enum AuthRealm { Tenant, Platform } +public enum AuthRealm +{ + Tenant, + Platform +} public sealed class AuthChallenge : Entity { @@ -98,16 +102,19 @@ public sealed class AuthChallenge : Entity public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } -public enum AuthChallengePurpose { PasswordChange } +public enum AuthChallengePurpose +{ + PasswordChange +} public sealed class SmsSendRateLimit : ITenantOwned { - public Guid TenantId { get; set; } public SmsRateLimitDimension Dimension { get; set; } = SmsRateLimitDimension.Phone; public string ScopeHash { get; set; } = string.Empty; public DateTimeOffset BucketStart { get; set; } public int RequestCount { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class SmsChannel : AuditableTenantEntity @@ -143,7 +150,6 @@ public sealed class SmsTemplate : AuditableTenantEntity public sealed class SmsSendLog : Entity, ITenantOwned { - public Guid TenantId { get; set; } public Guid? ChannelId { get; set; } public Guid? TemplateId { get; set; } public string Provider { get; set; } = "generic"; @@ -156,6 +162,7 @@ public sealed class SmsSendLog : Entity, ITenantOwned public DateTimeOffset? SentAt { get; set; } public JsonElement Metadata { get; set; } = JsonDefaults.Object(); public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid TenantId { get; set; } } public sealed class TenantClass : AuditableTenantEntity @@ -215,22 +222,152 @@ public sealed class TenantStudentFollowup : AuditableTenantEntity public Guid? UpdatedBy { get; set; } } -public enum TenantExternalProviderCapability { Identity, ObjectStorage, Sms, Payment, Notification, Ai } -public enum TenantExternalProviderStatus { Active, Disabled, Testing } -public enum TenantSecretStatus { Active, Disabled, Rotating } -public enum SmsPurpose { Login, BindPhone, ResetPassword } -public enum SmsVerificationStatus { Pending, Sent, Verified, Expired, Blocked, Failed } -public enum SmsTemplateType { VerificationCode, Marketing, Notification } -public enum SmsTemplateAuditStatus { Draft, PendingReview, Approved, Rejected } -public enum SmsTemplateStatus { Active, Disabled } -public enum SmsSendLogStatus { Pending, Sent, Failed } -public enum AuthLoginResult { Sent, Success, Failed, Blocked } -public enum SmsRateLimitDimension { Tenant, Phone, Ip, Device } -public enum TenantRecordStatus { Active, Disabled, Archived } -public enum TenantClassMemberType { Student, Teacher, Assistant, HeadTeacher } -public enum TenantClassMemberStatus { Active, Disabled, Removed } -public enum StudentNoteType { General, Learning, Service, Sales, Risk, FollowUp } -public enum StudentNoteVisibility { TenantStaff, ClassStaff, AuthorOnly } -public enum StudentFollowupType { Learning, Service, Sales, Renewal, Risk, Custom } -public enum StudentFollowupPriority { Low, Normal, High, Urgent } -public enum StudentFollowupStatus { Open, InProgress, Done, Cancelled } +public enum TenantExternalProviderCapability +{ + Identity, + ObjectStorage, + Sms, + Payment, + Notification, + Ai +} + +public enum TenantExternalProviderStatus +{ + Active, + Disabled, + Testing +} + +public enum TenantSecretStatus +{ + Active, + Disabled, + Rotating +} + +public enum SmsPurpose +{ + Login, + BindPhone, + ResetPassword +} + +public enum SmsVerificationStatus +{ + Pending, + Sent, + Verified, + Expired, + Blocked, + Failed +} + +public enum SmsTemplateType +{ + VerificationCode, + Marketing, + Notification +} + +public enum SmsTemplateAuditStatus +{ + Draft, + PendingReview, + Approved, + Rejected +} + +public enum SmsTemplateStatus +{ + Active, + Disabled +} + +public enum SmsSendLogStatus +{ + Pending, + Sent, + Failed +} + +public enum AuthLoginResult +{ + Sent, + Success, + Failed, + Blocked +} + +public enum SmsRateLimitDimension +{ + Tenant, + Phone, + Ip, + Device +} + +public enum TenantRecordStatus +{ + Active, + Disabled, + Archived +} + +public enum TenantClassMemberType +{ + Student, + Teacher, + Assistant, + HeadTeacher +} + +public enum TenantClassMemberStatus +{ + Active, + Disabled, + Removed +} + +public enum StudentNoteType +{ + General, + Learning, + Service, + Sales, + Risk, + FollowUp +} + +public enum StudentNoteVisibility +{ + TenantStaff, + ClassStaff, + AuthorOnly +} + +public enum StudentFollowupType +{ + Learning, + Service, + Sales, + Renewal, + Risk, + Custom +} + +public enum StudentFollowupPriority +{ + Low, + Normal, + High, + Urgent +} + +public enum StudentFollowupStatus +{ + Open, + InProgress, + Done, + Cancelled +} \ No newline at end of file diff --git a/Tiku.Domain/Tiku.Domain.csproj b/Tiku.Domain/Tiku.Domain.csproj index c4a0ded..743a0bf 100644 --- a/Tiku.Domain/Tiku.Domain.csproj +++ b/Tiku.Domain/Tiku.Domain.csproj @@ -1,13 +1,13 @@  - - net10.0 - enable - enable - + + net10.0 + enable + enable + - - - + + + diff --git a/Tiku.Infrastructure/Assets/AssetAccessService.cs b/Tiku.Infrastructure/Assets/AssetAccessService.cs index 07d5a42..8517a20 100644 --- a/Tiku.Infrastructure/Assets/AssetAccessService.cs +++ b/Tiku.Infrastructure/Assets/AssetAccessService.cs @@ -44,19 +44,13 @@ public sealed class AssetAccessService( item.Status == ContentStatus.Active, cancellationToken); - if (asset is null) - { - throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND"); - } + if (asset is null) throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND"); try { var access = await ResolveAccessAsync(request, asset, cancellationToken); AssertPublishedAsset(asset); - if (accessType == AssetAccessType.Preview) - { - AssertPreviewable(asset); - } + if (accessType == AssetAccessType.Preview) AssertPreviewable(asset); var ttl = ResolveTtl(request, accessType, asset.Visibility); var signedUrl = await objectStorageService.SignDownloadAsync( @@ -75,10 +69,7 @@ public sealed class AssetAccessService( disposition == AssetAccessDisposition.Inline ? "inline" : "attachment"), cancellationToken); - if (accessType == AssetAccessType.Download) - { - asset.DownloadCount++; - } + if (accessType == AssetAccessType.Download) asset.DownloadCount++; dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent( request, @@ -119,19 +110,12 @@ public sealed class AssetAccessService( CancellationToken cancellationToken) { if (asset.Visibility == ContentVisibility.Public || asset.IsPublic) - { - return new AssetAccessPrincipal(request.UserId, IsMember: false, HasSvip: false); - } + return new AssetAccessPrincipal(request.UserId, false, false); if (asset.Visibility == ContentVisibility.Hidden) - { throw new AssetAccessException("Asset is hidden.", "ASSET_HIDDEN"); - } - if (!request.UserId.HasValue) - { - throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED"); - } + if (!request.UserId.HasValue) throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED"); var isMember = await dbContext.TenantMemberships .AnyAsync( @@ -142,22 +126,16 @@ public sealed class AssetAccessService( cancellationToken); if (!isMember) - { - throw new AssetAccessException("Tenant membership is required for this asset.", "ASSET_MEMBERSHIP_REQUIRED"); - } + throw new AssetAccessException("Tenant membership is required for this asset.", + "ASSET_MEMBERSHIP_REQUIRED"); - if (asset.Visibility == ContentVisibility.Members) - { - return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: false); - } + if (asset.Visibility == ContentVisibility.Members) return new AssetAccessPrincipal(request.UserId, true, false); var hasSvip = await HasSvipAccessAsync(request, asset, cancellationToken); if (!hasSvip) - { throw new AssetAccessException("SVIP entitlement is required for this asset.", "ASSET_SVIP_REQUIRED"); - } - return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: true); + return new AssetAccessPrincipal(request.UserId, true, true); } private Task HasSvipAccessAsync( @@ -187,14 +165,10 @@ public sealed class AssetAccessService( private static void AssertPublishedAsset(ContentAsset asset) { if (!string.IsNullOrWhiteSpace(asset.ObjectKey) && asset.UploadStatus != AssetUploadStatus.Verified) - { throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED"); - } if (asset.SecurityScanStatus is not (AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired)) - { throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED"); - } } private static void AssertPreviewable(ContentAsset asset) @@ -204,9 +178,8 @@ public sealed class AssetAccessService( mimeType == "application/pdf" || mimeType.StartsWith("image/", StringComparison.Ordinal); if (!previewable) - { - throw new AssetAccessException("Asset type does not support inline preview.", "ASSET_PREVIEW_NOT_SUPPORTED"); - } + throw new AssetAccessException("Asset type does not support inline preview.", + "ASSET_PREVIEW_NOT_SUPPORTED"); } private static TimeSpan ResolveTtl( @@ -217,10 +190,7 @@ public sealed class AssetAccessService( var fallback = accessType == AssetAccessType.Preview ? DefaultPreviewTtl : DefaultDownloadTtl; var requested = request.RequestedExpiresIn ?? fallback; var max = visibility == ContentVisibility.Public ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(15); - if (requested <= TimeSpan.Zero) - { - return fallback; - } + if (requested <= TimeSpan.Zero) return fallback; return requested <= max ? requested : max; } @@ -325,4 +295,4 @@ public sealed class AssetAccessService( _ => ObjectStorageProviders.ExternalUrl }; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs index b2e5cbc..5b6c094 100644 --- a/Tiku.Infrastructure/Assets/AssetManagementService.cs +++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs @@ -1,15 +1,8 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; -using Tiku.Application.Catalog; -using Tiku.Application.Content; -using Tiku.Application.Security; using Tiku.Application.Jobs; +using Tiku.Application.Security; using Tiku.Application.Storage; using Tiku.Application.Tenancy; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -25,6 +18,4 @@ public sealed partial class AssetManagementService( private const int MaxLimit = 500; private static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15); private static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1); - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/AssetQueryService.cs b/Tiku.Infrastructure/Assets/AssetQueryService.cs index 4f35b43..e0863c2 100644 --- a/Tiku.Infrastructure/Assets/AssetQueryService.cs +++ b/Tiku.Infrastructure/Assets/AssetQueryService.cs @@ -19,46 +19,29 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ .AsNoTracking() .Where(asset => asset.TenantId == filter.TenantId); - if (!filter.IncludeInactive) - { - query = query.Where(asset => asset.Status == ContentStatus.Active); - } + if (!filter.IncludeInactive) query = query.Where(asset => asset.Status == ContentStatus.Active); if (!filter.IncludeLocked) - { query = query.Where(asset => asset.Visibility == ContentVisibility.Public || asset.IsPublic); - } if (filter.RegionId.HasValue) - { query = query.Where(asset => asset.RegionId == filter.RegionId.Value || asset.RegionId == null); - } if (filter.SubjectId.HasValue) - { query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value || asset.SubjectId == null); - } if (filter.CategoryId.HasValue) - { query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value || asset.CategoryId == null); - } if (filter.ContentNodeId.HasValue) - { - query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value || asset.ContentNodeId == null); - } + query = query.Where(asset => + asset.ContentNodeId == filter.ContentNodeId.Value || asset.ContentNodeId == null); - if (filter.AssetId.HasValue) - { - query = query.Where(asset => asset.Id == filter.AssetId.Value); - } + if (filter.AssetId.HasValue) query = query.Where(asset => asset.Id == filter.AssetId.Value); if (!string.IsNullOrWhiteSpace(filter.AssetType) && - Enum.TryParse(filter.AssetType, ignoreCase: true, out var assetType)) - { + Enum.TryParse(filter.AssetType, true, out var assetType)) query = query.Where(asset => asset.AssetType == assetType); - } if (!string.IsNullOrWhiteSpace(filter.Category)) { @@ -129,10 +112,7 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ .AsNoTracking() .Where(image => image.TenantId == filter.TenantId); - if (!filter.IncludeLocked) - { - query = query.Where(image => image.IsPublic); - } + if (!filter.IncludeLocked) query = query.Where(image => image.IsPublic); if (!string.IsNullOrWhiteSpace(filter.Category)) { @@ -212,15 +192,10 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ .AsNoTracking() .Where(video => video.TenantId == filter.TenantId); - if (!filter.IncludeInactive) - { - query = query.Where(video => video.IsActive); - } + if (!filter.IncludeInactive) query = query.Where(video => video.IsActive); if (filter.SubjectId.HasValue) - { query = query.Where(video => video.SubjectId == filter.SubjectId.Value || video.SubjectId == null); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -262,15 +237,9 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ .AsNoTracking() .Where(video => video.TenantId == filter.TenantId); - if (filter.QuestionId.HasValue) - { - query = query.Where(video => video.QuestionId == filter.QuestionId.Value); - } + if (filter.QuestionId.HasValue) query = query.Where(video => video.QuestionId == filter.QuestionId.Value); - if (filter.AssetId.HasValue) - { - query = query.Where(video => video.VideoId == filter.AssetId.Value); - } + if (filter.AssetId.HasValue) query = query.Where(video => video.VideoId == filter.AssetId.Value); var videoExplanations = dbContext.VideoExplanations.AsNoTracking(); var items = await query @@ -320,4 +289,4 @@ public sealed class AssetQueryService(TikuDbContext dbContext) : IAssetQueryServ { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs b/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs index 43891a2..db83e53 100644 --- a/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs +++ b/Tiku.Infrastructure/Assets/Audit/AssetManagementService.Audit.cs @@ -1,16 +1,6 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; -using Tiku.Application.Content; -using Tiku.Application.Security; -using Tiku.Application.Jobs; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -23,15 +13,9 @@ public sealed partial class AssetManagementService { var query = dbContext.ContentAssetAccessEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); - if (filter.AssetId.HasValue) - { - query = query.Where(item => item.AssetId == filter.AssetId.Value); - } + if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } + if (filter.UserId.HasValue) query = query.Where(item => item.UserId == filter.UserId.Value); var items = await query .OrderByDescending(item => item.CreatedAt) @@ -65,10 +49,7 @@ public sealed partial class AssetManagementService { var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); - if (filter.AssetId.HasValue) - { - query = query.Where(item => item.AssetId == filter.AssetId.Value); - } + if (filter.AssetId.HasValue) query = query.Where(item => item.AssetId == filter.AssetId.Value); var items = await query .OrderByDescending(item => item.CreatedAt) @@ -85,6 +66,4 @@ public sealed partial class AssetManagementService .ToArrayAsync(cancellationToken); return new CatalogList(items); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs b/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs index ebb4321..ee7f414 100644 --- a/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs +++ b/Tiku.Infrastructure/Assets/Catalog/AssetManagementService.Catalog.cs @@ -3,14 +3,7 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Security; -using Tiku.Application.Jobs; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -25,43 +18,26 @@ public sealed partial class AssetManagementService .AsNoTracking() .Where(asset => asset.TenantId == actor.TenantId); - if (filter.RegionId.HasValue) - { - query = query.Where(asset => asset.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(asset => asset.RegionId == filter.RegionId.Value); - if (filter.SubjectId.HasValue) - { - query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value); - if (filter.CategoryId.HasValue) - { - query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value); - } + if (filter.CategoryId.HasValue) query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.IsNullOrWhiteSpace(filter.AssetType) && - Enum.TryParse(filter.AssetType, ignoreCase: true, out var assetType)) - { + Enum.TryParse(filter.AssetType, true, out var assetType)) query = query.Where(asset => asset.AssetType == assetType); - } if (!string.IsNullOrWhiteSpace(filter.UploadStatus) && - Enum.TryParse(filter.UploadStatus, ignoreCase: true, out var uploadStatus)) - { + Enum.TryParse(filter.UploadStatus, true, out var uploadStatus)) query = query.Where(asset => asset.UploadStatus == uploadStatus); - } if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) && - Enum.TryParse(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus)) - { + Enum.TryParse(filter.SecurityScanStatus, true, out var securityScanStatus)) query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus); - } if (!string.IsNullOrWhiteSpace(filter.Category)) { @@ -111,18 +87,24 @@ public sealed partial class AssetManagementService asset.AssetType = ParseEnum(command.AssetType, asset.AssetType); asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic); asset.Status = ParseEnum(command.Status, asset.Status); - asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider))); + asset.StorageProvider = + ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, + ToObjectStorageProvider(asset.StorageProvider))); asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim(); asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey) ? asset.ObjectKey : objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim()); - asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim()); + asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) + ? asset.MimeType + : objectStorageService.ValidateMimeType(command.MimeType.Trim()); asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes); asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256; asset.PreviewUrl = NormalizeOptional(command.PreviewUrl); asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey; asset.SortOrder = command.Order ?? asset.SortOrder; - asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules; + asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined + ? asset.AccessRules + : command.AccessRules; asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata; asset.UpdatedBy = actor.UserId; @@ -133,6 +115,4 @@ public sealed partial class AssetManagementService cancellationToken); return new ContentManagementResult(ToItem(asset)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs b/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs index a23030b..c2aeee5 100644 --- a/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs +++ b/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs @@ -1,10 +1,10 @@ using System.Buffers.Binary; +using System.Diagnostics; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Options; using Tiku.Application.Assets; using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Assets; @@ -18,12 +18,14 @@ public sealed class ClamAvOptions public int ChunkBytes { get; set; } = 64 * 1024; public long StreamMaxLength { get; set; } = 500L * 1024 * 1024; - public static bool BeValid(ClamAvOptions options) => - !string.IsNullOrWhiteSpace(options.Host) && - options.Port is > 0 and <= 65535 && - options.TimeoutSeconds is >= 1 and <= 600 && - options.ChunkBytes is >= 1024 and <= 1024 * 1024 && - options.StreamMaxLength > 0; + public static bool BeValid(ClamAvOptions options) + { + return !string.IsNullOrWhiteSpace(options.Host) && + options.Port is > 0 and <= 65535 && + options.TimeoutSeconds is >= 1 and <= 600 && + options.ChunkBytes is >= 1024 and <= 1024 * 1024 && + options.StreamMaxLength > 0; + } } public sealed class ClamAvAssetSecurityScanner(IOptions options) : IAssetSecurityScanner @@ -37,11 +39,9 @@ public sealed class ClamAvAssetSecurityScanner(IOptions options) { var startedTimestamp = Stopwatch.GetTimestamp(); if (declaredLength > settings.StreamMaxLength) - { throw new AssetSecurityScannerException( "clamav_stream_too_large", "Asset exceeds the configured ClamAV StreamMaxLength."); - } using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds)); @@ -60,15 +60,14 @@ public sealed class ClamAvAssetSecurityScanner(IOptions options) if (read == 0) break; total += read; if (total > settings.StreamMaxLength) - { throw new AssetSecurityScannerException( "clamav_stream_too_large", "Asset exceeds the configured ClamAV StreamMaxLength."); - } BinaryPrimitives.WriteUInt32BigEndian(lengthBuffer, (uint)read); await network.WriteAsync(lengthBuffer, timeout.Token); await network.WriteAsync(buffer.AsMemory(0, read), timeout.Token); } + Array.Clear(lengthBuffer); await network.WriteAsync(lengthBuffer, timeout.Token); await network.FlushAsync(timeout.Token); @@ -78,6 +77,7 @@ public sealed class ClamAvAssetSecurityScanner(IOptions options) WorkerTelemetry.RecordScan("clean", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds); return new AssetSecurityScanResult(AssetSecurityScanVerdict.Clean, "clamav", null, total, response); } + if (response.EndsWith(" FOUND", StringComparison.Ordinal)) { var separator = response.IndexOf(": ", StringComparison.Ordinal); @@ -85,9 +85,12 @@ public sealed class ClamAvAssetSecurityScanner(IOptions options) ? response[(separator + 2)..^" FOUND".Length] : "unknown"; WorkerTelemetry.RecordScan("infected", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds); - return new AssetSecurityScanResult(AssetSecurityScanVerdict.Infected, "clamav", signature, total, response); + return new AssetSecurityScanResult(AssetSecurityScanVerdict.Infected, "clamav", signature, total, + response); } - throw new AssetSecurityScannerException("clamav_scan_error", $"ClamAV returned an error response: {response}"); + + throw new AssetSecurityScannerException("clamav_scan_error", + $"ClamAV returned an error response: {response}"); } catch (AssetSecurityScannerException) { @@ -133,10 +136,10 @@ public sealed class ClamAvAssetSecurityScanner(IOptions options) { buffer.WriteByte(single[0]); if (buffer.Length > 4096) - { - throw new AssetSecurityScannerException("clamav_response_too_large", "ClamAV response exceeded the safety limit."); - } + throw new AssetSecurityScannerException("clamav_response_too_large", + "ClamAV response exceeded the safety limit."); } + return Encoding.UTF8.GetString(buffer.ToArray()).Trim(); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs b/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs index c807e69..80fa908 100644 --- a/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs +++ b/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs @@ -1,16 +1,12 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; -using Tiku.Application.Catalog; -using Tiku.Application.Content; using Tiku.Application.Security; -using Tiku.Application.Jobs; using Tiku.Application.Storage; using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -32,10 +28,7 @@ public sealed partial class AssetManagementService item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } + if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); } if (asset is null) @@ -65,7 +58,9 @@ public sealed partial class AssetManagementService asset.StorageProvider = ToAssetStorageProvider(provider); asset.Bucket = bucket; asset.FileSizeBytes = fileSizeBytes; - asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata; + asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined + ? JsonDefaults.Object() + : command.Metadata; return asset; } @@ -80,10 +75,7 @@ public sealed partial class AssetManagementService asset = await dbContext.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value, cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } + if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); } else if (!string.IsNullOrWhiteSpace(command.LegacyId)) { @@ -93,10 +85,7 @@ public sealed partial class AssetManagementService cancellationToken); } - if (asset is not null) - { - return asset; - } + if (asset is not null) return asset; asset = new ContentAsset { @@ -119,12 +108,10 @@ public sealed partial class AssetManagementService CancellationToken cancellationToken) { var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active, + item => item.TenantId == actor.TenantId && item.Id == command.AssetId && + item.Status == ContentStatus.Active, cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } + if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); var provider = ToObjectStorageProvider(asset.StorageProvider); var objectKey = accessType == AssetAccessType.AdminPreview @@ -220,11 +207,9 @@ public sealed partial class AssetManagementService byteDelta, cancellationToken); if (!reserved) - { throw new FeatureAccessException( "Tenant storage quota is exhausted.", "feature_quota_exhausted"); - } try { @@ -239,18 +224,17 @@ public sealed partial class AssetManagementService CancellationToken.None); throw; } + return; } await dbContext.SaveChangesAsync(cancellationToken); if (byteDelta < 0) - { await featureAccessService.ReleaseQuotaAsync( tenantId, SaasQuotaMetricCatalog.StorageBytes, -byteDelta, CancellationToken.None); - } } private static long AccountedStorageBytes(ContentAsset asset) @@ -301,7 +285,8 @@ public sealed partial class AssetManagementService var trimmed = Path.GetFileName(fileName.Trim()); return string.Join( "-", - trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + trimmed.Split(Path.GetInvalidFileNameChars(), + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } private static string? NormalizeOptional(string? value) @@ -316,10 +301,7 @@ public sealed partial class AssetManagementService private static TimeSpan ResolveUploadTtl(int? expiresInSeconds) { - if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) - { - return DefaultUploadTtl; - } + if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) return DefaultUploadTtl; var requested = TimeSpan.FromSeconds(expiresInSeconds.Value); return requested <= MaxUploadTtl ? requested : MaxUploadTtl; @@ -327,10 +309,7 @@ public sealed partial class AssetManagementService private static int ResolveLimit(int? limit) { - if (!limit.HasValue || limit <= 0) - { - return DefaultLimit; - } + if (!limit.HasValue || limit <= 0) return DefaultLimit; return Math.Min(limit.Value, MaxLimit); } @@ -338,31 +317,17 @@ public sealed partial class AssetManagementService private static ContentAssetType ResolveAssetType(string? value, string mimeType) { if (!string.IsNullOrWhiteSpace(value) && - Enum.TryParse(value, ignoreCase: true, out var parsed)) - { + Enum.TryParse(value, true, out var parsed)) return parsed; - } var normalizedMimeType = mimeType.ToLowerInvariant(); - if (normalizedMimeType == "application/pdf") - { - return ContentAssetType.Pdf; - } + if (normalizedMimeType == "application/pdf") return ContentAssetType.Pdf; - if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) - { - return ContentAssetType.Image; - } + if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) return ContentAssetType.Image; - if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) - { - return ContentAssetType.Video; - } + if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) return ContentAssetType.Video; - if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) - { - return ContentAssetType.Audio; - } + if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) return ContentAssetType.Audio; return ContentAssetType.Document; } @@ -370,10 +335,8 @@ public sealed partial class AssetManagementService private static ContentVisibility ResolveVisibility(string? value, bool isPublic) { if (!string.IsNullOrWhiteSpace(value) && - Enum.TryParse(value, ignoreCase: true, out var parsed)) - { + Enum.TryParse(value, true, out var parsed)) return parsed; - } return isPublic ? ContentVisibility.Public : ContentVisibility.Members; } @@ -381,12 +344,9 @@ public sealed partial class AssetManagementService private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } + if (string.IsNullOrWhiteSpace(value)) return fallback; - return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) + return Enum.TryParse(value.Trim(), true, out var parsed) ? parsed : fallback; } @@ -409,7 +369,8 @@ public sealed partial class AssetManagementService ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos, ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo, ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev, - _ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported") + _ => throw new AssetManagementException("Storage provider is not supported.", + "storage_provider_not_supported") }; } @@ -438,11 +399,9 @@ public sealed partial class AssetManagementService cancellationToken: cancellationToken); var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket"); if (string.IsNullOrWhiteSpace(bucket)) - { throw new ObjectStorageException( "Object storage provider bucket is not configured.", "STORAGE_BUCKET_NOT_CONFIGURED"); - } return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim()); } @@ -456,19 +415,12 @@ public sealed partial class AssetManagementService private static string? GetJsonString(JsonElement element, params string[] keys) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) - { if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) - { return value.GetString(); - } - } return null; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs b/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs index 6e87bcc..889bf0f 100644 --- a/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs +++ b/Tiku.Infrastructure/Assets/Imports/AssetManagementService.Imports.cs @@ -1,16 +1,7 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; -using Tiku.Application.Content; -using Tiku.Application.Security; -using Tiku.Application.Jobs; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -26,22 +17,16 @@ public sealed partial class AssetManagementService .Where(job => job.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Status) && - Enum.TryParse(filter.Status, ignoreCase: true, out var status)) - { + Enum.TryParse(filter.Status, true, out var status)) query = query.Where(job => job.Status == status); - } if (!string.IsNullOrWhiteSpace(filter.ImportType) && - Enum.TryParse(filter.ImportType, ignoreCase: true, out var importType)) - { + Enum.TryParse(filter.ImportType, true, out var importType)) query = query.Where(job => job.ImportType == importType); - } if (!string.IsNullOrWhiteSpace(filter.SourceFormat) && - Enum.TryParse(filter.SourceFormat, ignoreCase: true, out var sourceFormat)) - { + Enum.TryParse(filter.SourceFormat, true, out var sourceFormat)) query = query.Where(job => job.SourceFormat == sourceFormat); - } var items = await query .OrderByDescending(job => job.CreatedAt) @@ -63,10 +48,7 @@ public sealed partial class AssetManagementService .Select(item => ToJobItem(item)) .SingleOrDefaultAsync(cancellationToken); - if (job is null) - { - throw new AssetManagementException("Import job was not found.", "import_job_not_found"); - } + if (job is null) throw new AssetManagementException("Import job was not found.", "import_job_not_found"); var items = await dbContext.ContentImportItems .AsNoTracking() @@ -107,6 +89,4 @@ public sealed partial class AssetManagementService return new ContentImportJobDetail(job, items, issues); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs b/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs index 52dda06..37bd5cd 100644 --- a/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs +++ b/Tiku.Infrastructure/Assets/Lifecycle/AssetManagementService.Lifecycle.cs @@ -1,16 +1,8 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; -using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Security; -using Tiku.Application.Jobs; -using Tiku.Application.Storage; -using Tiku.Application.Tenancy; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -24,28 +16,21 @@ public sealed partial class AssetManagementService var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == assetId, cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } + if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); if (asset.Status == ContentStatus.Archived) - { return new ContentManagementResult(ToItem(asset)); - } var accountedBytes = AccountedStorageBytes(asset); asset.Status = ContentStatus.Archived; asset.UpdatedBy = actor.UserId; await dbContext.SaveChangesAsync(cancellationToken); if (accountedBytes > 0) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, SaasQuotaMetricCatalog.StorageBytes, accountedBytes, CancellationToken.None); - } return new ContentManagementResult(ToItem(asset)); } @@ -65,6 +50,4 @@ public sealed partial class AssetManagementService { return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs b/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs index 8c0c148..052b2eb 100644 --- a/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs +++ b/Tiku.Infrastructure/Assets/Uploads/AssetManagementService.Uploads.cs @@ -1,16 +1,10 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; -using Tiku.Application.Catalog; -using Tiku.Application.Content; -using Tiku.Application.Security; using Tiku.Application.Jobs; using Tiku.Application.Storage; -using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Assets; @@ -29,7 +23,8 @@ public sealed partial class AssetManagementService var bucket = storageConfig.Bucket; var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim()); var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes); - var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken); + var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, + cancellationToken); var objectKey = objectStorageService.ValidateObjectKey( actor.TenantId, string.IsNullOrWhiteSpace(command.ObjectKey) @@ -65,7 +60,7 @@ public sealed partial class AssetManagementService mimeType, fileSizeBytes, expiresIn, - Upsert: true), + true), cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -85,15 +80,11 @@ public sealed partial class AssetManagementService item.Status == ContentStatus.Active, cancellationToken); - if (asset is null) - { - throw new AssetManagementException("Asset was not found.", "asset_not_found"); - } + if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found"); if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket)) - { - throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing"); - } + throw new AssetManagementException("Asset does not have a writable object location.", + "asset_location_missing"); var accountedBytesBefore = AccountedStorageBytes(asset); var provider = ToObjectStorageProvider(asset.StorageProvider); @@ -133,8 +124,10 @@ public sealed partial class AssetManagementService asset.UploadStatus = AssetUploadStatus.Failed; asset.UpdatedBy = actor.UserId; await dbContext.SaveChangesAsync(cancellationToken); - throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing"); + throw new AssetManagementException("Uploaded object was not found in object storage.", + "asset_upload_missing"); } + if (metadata.SizeBytes is not { } verifiedSizeBytes) { asset.UploadStatus = AssetUploadStatus.Failed; @@ -166,12 +159,11 @@ public sealed partial class AssetManagementService "asset_security_scan", JsonSerializer.SerializeToElement(new { assetId = asset.Id }), MaxRetries: 5, - IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}", + IdempotencyKey: + $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}", IsSystemJob: true), cancellationToken); return new AssetUploadConfirmResult(ToItem(asset), metadata); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs index 8721225..349574e 100644 --- a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs +++ b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs @@ -4,8 +4,6 @@ using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Identity; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -22,9 +20,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba var videos = dbContext.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); - } if (!string.IsNullOrWhiteSpace(query.Keyword)) { @@ -51,9 +47,8 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba await AssertActiveMemberAsync(actor, cancellationToken); var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken); if (command.QuestionId.HasValue) - { - await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken); - } + await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, + cancellationToken); var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken); progress.PlayCount++; @@ -97,23 +92,24 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba await AssertActiveMemberAsync(actor, cancellationToken); var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken); if (command.QuestionId.HasValue) - { - await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken); - } + await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, + cancellationToken); var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken); var positionSeconds = Math.Max(command.PositionSeconds, 0); var durationSeconds = command.DurationSeconds ?? video.DurationSeconds; var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds); var completed = command.IsCompleted == true || - durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3); + (durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3)); progress.PositionSeconds = positionSeconds; progress.DurationSeconds = durationSeconds; progress.WatchedSeconds = watchedSeconds; progress.IsCompleted = completed; progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt; progress.LastPlayedAt = DateTimeOffset.UtcNow; - progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object(); + progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object + ? command.Metadata.Clone() + : JsonDefaults.Object(); await dbContext.SaveChangesAsync(cancellationToken); return ToProgressItem(progress); } @@ -127,16 +123,14 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba var questionVideos = dbContext.QuestionVideos.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (query.QuestionId.HasValue) - { questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value); - } if (query.QuestionIds is { Count: > 0 }) - { - questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value)); - } + questionVideos = questionVideos.Where(item => + item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value)); - var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive); + var videos = dbContext.VideoExplanations.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.IsActive); var items = await questionVideos .OrderBy(item => item.SortOrder) .ThenBy(item => item.CreatedAt) @@ -176,10 +170,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba item.VideoId == videoId && item.QuestionId == questionId, cancellationToken); - if (progress is not null) - { - return progress; - } + if (progress is not null) return progress; progress = new VideoPlaybackProgress { @@ -200,9 +191,9 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba CancellationToken cancellationToken) { return await dbContext.VideoExplanations.SingleOrDefaultAsync( - video => video.TenantId == tenantId && video.Id == videoId && video.IsActive, - cancellationToken) - ?? throw new VideoPlaybackException("Video was not found.", "video_not_found"); + video => video.TenantId == tenantId && video.Id == videoId && video.IsActive, + cancellationToken) + ?? throw new VideoPlaybackException("Video was not found.", "video_not_found"); } private async Task AssertQuestionVideoAsync( @@ -217,10 +208,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba item.QuestionId == questionId && item.VideoId == videoId, cancellationToken); - if (!exists) - { - throw new VideoPlaybackException("Question video was not found.", "question_video_not_found"); - } + if (!exists) throw new VideoPlaybackException("Question video was not found.", "question_video_not_found"); } private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken) @@ -232,9 +220,7 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba membership.Status == MembershipStatus.Active, cancellationToken); if (!exists) - { throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied"); - } } private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video) @@ -271,4 +257,4 @@ public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlayba progress.PlayCount, progress.Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs index 121962c..dd9edfe 100644 --- a/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs +++ b/Tiku.Infrastructure/Auth/AliyunSmsProvider.cs @@ -34,16 +34,12 @@ internal sealed class AliyunSmsProvider( if (string.Equals(account.Provider, "noop", StringComparison.OrdinalIgnoreCase) || string.Equals(account.Provider, "local_dev", StringComparison.OrdinalIgnoreCase)) - { return new SmsProviderSendResult(account.Provider, "accepted"); - } if (!string.Equals(account.Provider, ProviderCode, StringComparison.OrdinalIgnoreCase)) - { throw new SmsProviderException( $"SMS provider '{account.Provider}' is not supported.", "sms_provider_unsupported"); - } var signName = Required(account.ConfigPublic, "signName", "smsSignName"); var templateCode = ResolveTemplateCode(account.ConfigPublic, request.Purpose); @@ -74,18 +70,14 @@ internal sealed class AliyunSmsProvider( var response = await client.SendSmsAsync(sendRequest).WaitAsync(cancellationToken); var body = response.Body; if (body is null) - { throw new SmsProviderException( "Aliyun SMS returned an empty response.", "aliyun_sms_empty_response"); - } if (!string.Equals(body.Code, "OK", StringComparison.OrdinalIgnoreCase)) - { throw new SmsProviderException( $"Aliyun SMS send failed: {body.Code}.", "aliyun_sms_send_rejected"); - } return new SmsProviderSendResult( ProviderCode, @@ -115,9 +107,7 @@ internal sealed class AliyunSmsProvider( if (templateCodes.TryGetProperty(purposeKey, out var purposeTemplate) && purposeTemplate.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(purposeTemplate.GetString())) - { return purposeTemplate.GetString()!; - } } return Required(config, "templateCode", "smsTemplateCode"); @@ -137,10 +127,7 @@ internal sealed class AliyunSmsProvider( private static string Required(JsonElement element, params string[] keys) { var value = Optional(element, keys); - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } + if (!string.IsNullOrWhiteSpace(value)) return value; throw new SmsProviderException( "Aliyun SMS provider configuration is incomplete.", @@ -149,21 +136,14 @@ internal sealed class AliyunSmsProvider( private static string? Optional(JsonElement element, params string[] keys) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString(); - } - } return null; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AuthAdministrationService.cs b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs index aa4c2c1..32b6443 100644 --- a/Tiku.Infrastructure/Auth/AuthAdministrationService.cs +++ b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; @@ -18,9 +19,7 @@ internal sealed class AuthAdministrationService( CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(request.Reason)) - { throw new InvalidCredentialsException("password_reset_reason_required"); - } var permitted = request.TenantId is { } tenantId ? await dbContext.TenantMemberships.AnyAsync( @@ -30,26 +29,18 @@ internal sealed class AuthAdministrationService( : await dbContext.PlatformBackendUserRoles.AnyAsync( item => item.UserId == request.TargetUserId, cancellationToken); - if (!permitted) - { - throw new AuthSessionNotFoundException(); - } + if (!permitted) throw new AuthSessionNotFoundException(); var user = await userManager.FindByIdAsync(request.TargetUserId.ToString()) - ?? throw new AuthSessionNotFoundException(); + ?? throw new AuthSessionNotFoundException(); var token = await userManager.GeneratePasswordResetTokenAsync(user); var reset = await userManager.ResetPasswordAsync(user, token, request.TemporaryPassword); - if (!reset.Succeeded) - { - throw new InvalidCredentialsException("invalid_new_password"); - } + if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password"); user.ForcePasswordChange = true; var updated = await userManager.UpdateAsync(user); if (!updated.Succeeded) - { throw new InvalidOperationException("Unable to require a password change after the administrative reset."); - } await sessionStore.RevokeAllAsync(user.Id, "administrative_password_reset", cancellationToken); dbContext.AuditLogs.Add(new AuditLog @@ -59,7 +50,7 @@ internal sealed class AuthAdministrationService( Action = "auth.password.reset_by_administrator", TargetType = "user", TargetId = request.TargetUserId.ToString(), - Details = System.Text.Json.JsonSerializer.SerializeToElement(new + Details = JsonSerializer.SerializeToElement(new { request.Reason, ForcePasswordChange = true @@ -67,4 +58,4 @@ internal sealed class AuthAdministrationService( }); await dbContext.SaveChangesAsync(cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index bd572a0..c9879a4 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -1,14 +1,8 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -28,8 +22,10 @@ public sealed partial class AuthService( 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"]; + private static readonly string[] WechatMiniAppProviderAliases = + ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; -} + private static readonly string[] WechatIdentityProviders = + ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/AuthSessionStore.cs b/Tiku.Infrastructure/Auth/AuthSessionStore.cs index 79b2abe..344c7b7 100644 --- a/Tiku.Infrastructure/Auth/AuthSessionStore.cs +++ b/Tiku.Infrastructure/Auth/AuthSessionStore.cs @@ -1,5 +1,8 @@ +using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; -using Microsoft.AspNetCore.Identity; +using System.Text; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -22,10 +25,16 @@ public sealed class AuthSessionStore( IOptions? configuredCacheOptions = null, IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore { + private readonly IAccessSecurityCache accessSecurityCache = + configuredAccessSecurityCache ?? new NullAuthorizationCache(); + + private readonly AuthorizationCacheOptions cacheOptions = + configuredCacheOptions?.Value ?? new AuthorizationCacheOptions(); + private readonly JwtOptions options = options.Value; - private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache(); - private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions(); - private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator(); + + private readonly IAuthorizationStateInvalidator stateInvalidator = + configuredStateInvalidator ?? new NullAuthorizationStateInvalidator(); public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId) { @@ -37,12 +46,10 @@ public sealed class AuthSessionStore( public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator) { locator = default; - var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? []; + var parts = refreshToken?.Split('.', 5) ?? []; if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 || !Guid.TryParseExact(parts[3], "N", out var sessionId)) - { return false; - } if (parts[1] == "p" && parts[2] == "-") { @@ -59,8 +66,11 @@ public sealed class AuthSessionStore( return false; } - public string HashRefreshToken(string refreshToken) => - Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant(); + public string HashRefreshToken(string refreshToken) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))) + .ToLowerInvariant(); + } public async Task IssueAsync( AuthSessionIssueRequest request, @@ -81,10 +91,7 @@ public sealed class AuthSessionStore( string? userAgent, CancellationToken cancellationToken = default) { - if (!TryParseRefreshToken(refreshToken, out var locator)) - { - throw new SessionRevokedException(); - } + if (!TryParseRefreshToken(refreshToken, out var locator)) throw new SessionRevokedException(); var tokenHash = HashRefreshToken(refreshToken); var now = DateTimeOffset.UtcNow; @@ -94,10 +101,7 @@ public sealed class AuthSessionStore( item => item.Id == locator.SessionId && item.Realm == locator.Realm && item.TenantId == locator.TenantId && item.TokenHash == tokenHash, cancellationToken); - if (current is null) - { - throw new SessionRevokedException(); - } + if (current is null) throw new SessionRevokedException(); if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now) { @@ -126,6 +130,7 @@ public sealed class AuthSessionStore( await transaction.CommitAsync(cancellationToken); throw new SessionRevokedException(); } + var nextId = Guid.NewGuid(); var updated = await dbContext.AuthSessions .Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null) @@ -164,27 +169,22 @@ public sealed class AuthSessionStore( var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId); AccessSecurityCacheState? shadowState = null; if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured) - { try { var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken); if (cached is not null) { var platformVersionStale = realm == AuthRealm.Platform && - cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version; - if (!platformVersionStale) - { - return ValidateCached(cached, lookup); - } + cached.PlatformAccess!.AuthorizationVersion != + cached.AuthorizationVersion!.Version; + if (!platformVersionStale) return ValidateCached(cached, lookup); } } catch (Exception exception) when (exception is not OperationCanceledException) { // Redis is an acceleration layer; PostgreSQL remains authoritative. } - } else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured) - { try { shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken); @@ -193,57 +193,61 @@ public sealed class AuthSessionStore( { // Shadow failures never affect the PostgreSQL-authoritative decision. } - } var now = DateTimeOffset.UtcNow; SessionValidationState? state; try { state = await ( - from session in dbContext.AuthSessions.AsNoTracking() - join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id - where session.Id == sessionId && - session.UserId == userId && - session.Realm == realm && - session.TenantId == tenantId - select new SessionValidationState( - user.Status, - user.SecurityStamp!, - session.SecurityStamp, - realm != AuthRealm.Tenant || + from session in dbContext.AuthSessions.AsNoTracking() + join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id + where session.Id == sessionId && + session.UserId == userId && + session.Realm == realm && + session.TenantId == tenantId + select new SessionValidationState( + user.Status, + user.SecurityStamp!, + session.SecurityStamp, + realm != AuthRealm.Tenant || (tenantId != null && dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) && dbContext.TenantMemberships.Any(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active)), - realm != AuthRealm.Platform || + 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(), - realm == AuthRealm.Tenant && tenantId != null - ? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault() - : null, - realm == AuthRealm.Tenant && tenantId != null - ? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId) - .Select(item => (MembershipStatus?)item.Status).FirstOrDefault() - : null, - dbContext.AuthorizationScopeVersions - .Where(item => item.Realm == realm && item.TenantId == tenantId) - .Select(item => (long?)item.Version).FirstOrDefault() ?? 1L, - session.ExpiresAt, - session.RevokedAt != null)) - .SingleOrDefaultAsync(cancellationToken); + 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(), + realm == AuthRealm.Tenant && tenantId != null + ? dbContext.Tenants.Where(item => item.Id == tenantId) + .Select(item => (TenantStatus?)item.Status).FirstOrDefault() + : null, + realm == AuthRealm.Tenant && tenantId != null + ? dbContext.TenantMemberships + .Where(item => item.TenantId == tenantId && item.UserId == userId) + .Select(item => (MembershipStatus?)item.Status).FirstOrDefault() + : null, + dbContext.AuthorizationScopeVersions + .Where(item => item.Realm == realm && item.TenantId == tenantId) + .Select(item => (long?)item.Version).FirstOrDefault() ?? 1L, + session.ExpiresAt, + session.RevokedAt != null)) + .SingleOrDefaultAsync(cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { throw new AuthorizationSecurityUnavailableException(exception); } + if (state is null || state.SessionRevoked || state.SessionExpiresAt <= now || @@ -255,7 +259,6 @@ public sealed class AuthSessionStore( if (state is not null && cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured) - { try { await accessSecurityCache.SetAsync(ToCacheState( @@ -265,21 +268,17 @@ public sealed class AuthSessionStore( { // A negative cache write failure does not change the denial decision. } - } + if (shadowState is not null) - { AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null); - } return null; } var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion); if (shadowState is not null) - { AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result); - } - if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured) - { + if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && + accessSecurityCache.IsConfigured) try { await accessSecurityCache.SetAsync( @@ -289,66 +288,10 @@ public sealed class AuthSessionStore( { // The database result is authoritative and remains usable. } - } + return result; } - private static AuthSessionValidationResult? ValidateCached( - AccessSecurityCacheState state, AccessSecurityCacheLookup lookup) - { - var session = state.Session!; - var user = state.User!; - var version = state.AuthorizationVersion!; - if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId || - session.Realm != lookup.Realm || session.TenantId != lookup.TenantId || - session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow || - user.UserId != lookup.UserId || user.Status != UserStatus.Active || - !string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) || - version.Realm != lookup.Realm || version.TenantId != lookup.TenantId) - { - return null; - } - if (lookup.Realm == AuthRealm.Tenant && - (state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active)) - { - return null; - } - if (lookup.Realm == AuthRealm.Platform && - (!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version)) - { - return null; - } - return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version); - } - - private static AccessSecurityCacheState ToCacheState( - SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new( - new CachedSessionSecurityState(sessionId, userId, realm, tenantId, - state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked), - new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp), - realm == AuthRealm.Tenant && state.TenantStatus.HasValue - ? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value) - : null, - realm == AuthRealm.Tenant && state.MembershipStatus.HasValue - ? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value) - : null, - realm == AuthRealm.Platform - ? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed) - : null, - new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion)); - - private sealed record SessionValidationState( - UserStatus UserStatus, - string UserSecurityStamp, - string SessionSecurityStamp, - bool TenantAllowed, - bool PlatformAllowed, - TenantStatus? TenantStatus, - MembershipStatus? MembershipStatus, - long AuthorizationVersion, - DateTimeOffset SessionExpiresAt, - bool SessionRevoked); - public async Task ResolveActiveSessionAsync( Guid sessionId, Guid userId, @@ -358,10 +301,7 @@ public sealed class AuthSessionStore( .Where(item => item.Id == sessionId && item.UserId == userId) .Select(item => new { item.Realm, item.TenantId }) .SingleOrDefaultAsync(cancellationToken); - if (session is null) - { - return null; - } + if (session is null) return null; return await ValidateAccessSessionAsync( sessionId, @@ -371,20 +311,16 @@ public sealed class AuthSessionStore( cancellationToken); } - public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default) + public async Task RevokeFamilyAsync(string refreshToken, string reason, + CancellationToken cancellationToken = default) { - if (!TryParseRefreshToken(refreshToken, out var locator)) - { - return; - } + if (!TryParseRefreshToken(refreshToken, out var locator)) return; var hash = HashRefreshToken(refreshToken); var session = await dbContext.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); - } } public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default) @@ -405,13 +341,11 @@ public sealed class AuthSessionStore( Action = "auth.sessions.revoked_all", TargetType = "user", TargetId = userId.ToString(), - Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) + Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) - { await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); - } await stateInvalidator.InvalidateUserAsync(userId, cancellationToken); } } @@ -426,10 +360,12 @@ public sealed class AuthSessionStore( ValidateRealm(realm, tenantId); var now = DateTimeOffset.UtcNow; var sessionIds = await dbContext.AuthSessions.AsNoTracking() - .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null) + .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 - .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null) + .Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && + item.RevokedAt == null) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.RevokedAt, now) .SetProperty(item => item.RevokedReason, reason), cancellationToken); @@ -442,13 +378,11 @@ public sealed class AuthSessionStore( Action = "auth.sessions.realm_revoked", TargetType = "user", TargetId = userId.ToString(), - Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now }) + Details = JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) - { await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); - } } } @@ -458,8 +392,9 @@ public sealed class AuthSessionStore( CancellationToken cancellationToken = default) { var current = await dbContext.AuthSessions.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) - ?? throw new SessionRevokedException(); + .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, + cancellationToken) + ?? throw new SessionRevokedException(); var now = DateTimeOffset.UtcNow; var sessions = await dbContext.AuthSessions.AsNoTracking() .Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId) @@ -469,7 +404,11 @@ public sealed class AuthSessionStore( return sessions .AsValueEnumerable() .GroupBy(item => item.TokenFamilyId) - .Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) }) + .Select(group => new + { + All = group.ToArray(), + Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) + }) .Where(value => value.Active is not null) .Select(value => new AuthSessionSummary( value.Active!.TokenFamilyId, @@ -494,39 +433,78 @@ public sealed class AuthSessionStore( CancellationToken cancellationToken = default) { var current = await dbContext.AuthSessions.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken) - ?? throw new SessionRevokedException(); - if (current.TokenFamilyId == sessionFamilyId) - { - throw new CurrentAuthSessionCannotBeRevokedException(); - } + .SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, + cancellationToken) + ?? throw new SessionRevokedException(); + if (current.TokenFamilyId == sessionFamilyId) throw new CurrentAuthSessionCannotBeRevokedException(); var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync( item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId && item.Realm == current.Realm && item.TenantId == current.TenantId, cancellationToken); - if (!owned) - { - throw new AuthSessionNotFoundException(); - } + if (!owned) throw new AuthSessionNotFoundException(); await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken); } - private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new() + private static AuthSessionValidationResult? ValidateCached( + AccessSecurityCacheState state, AccessSecurityCacheLookup lookup) { - Id = sessionId, - Realm = request.Realm, - TenantId = request.TenantId, - UserId = request.UserId, - TokenFamilyId = request.TokenFamilyId ?? sessionId, - ParentSessionId = request.ParentSessionId, - SecurityStamp = request.SecurityStamp, - Provider = request.Provider, - ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays), - IpAddress = request.IpAddress, - UserAgent = request.UserAgent - }; + var session = state.Session!; + var user = state.User!; + var version = state.AuthorizationVersion!; + if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId || + session.Realm != lookup.Realm || session.TenantId != lookup.TenantId || + session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow || + user.UserId != lookup.UserId || user.Status != UserStatus.Active || + !string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) || + version.Realm != lookup.Realm || version.TenantId != lookup.TenantId) + return null; + if (lookup.Realm == AuthRealm.Tenant && + (state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active)) + return null; + if (lookup.Realm == AuthRealm.Platform && + (!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version)) + return null; + return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version); + } + + private static AccessSecurityCacheState ToCacheState( + SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) + { + return new AccessSecurityCacheState( + new CachedSessionSecurityState(sessionId, userId, realm, tenantId, + state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked), + new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp), + realm == AuthRealm.Tenant && state.TenantStatus.HasValue + ? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value) + : null, + realm == AuthRealm.Tenant && state.MembershipStatus.HasValue + ? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value) + : null, + realm == AuthRealm.Platform + ? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed) + : null, + new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion)); + } + + private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) + { + return new AuthSession + { + Id = sessionId, + Realm = request.Realm, + TenantId = request.TenantId, + UserId = request.UserId, + TokenFamilyId = request.TokenFamilyId ?? sessionId, + ParentSessionId = request.ParentSessionId, + SecurityStamp = request.SecurityStamp, + Provider = request.Provider, + ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays), + IpAddress = request.IpAddress, + UserAgent = request.UserAgent + }; + } private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken) { @@ -544,12 +522,13 @@ public sealed class AuthSessionStore( { if (realm == AuthRealm.Tenant && tenantId.HasValue) { - var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) && - await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken); - if (active) - { - return; - } + var active = + await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, + cancellationToken) && + await dbContext.TenantMemberships.AnyAsync( + item => item.TenantId == tenantId && item.UserId == userId && + item.Status == MembershipStatus.Active, cancellationToken); + if (active) return; } else if (realm == AuthRealm.Platform) { @@ -559,7 +538,8 @@ public sealed class AuthSessionStore( 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) + (permission.Area == BackendPermissionArea.Platform || + permission.Area == BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); if (active) return; } @@ -567,7 +547,8 @@ public sealed class AuthSessionStore( throw new TenantAccessDeniedException(); } - private async Task RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken) + private async Task RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, + CancellationToken cancellationToken) { var sessionIds = await dbContext.AuthSessions.AsNoTracking() .Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null) @@ -589,13 +570,11 @@ public sealed class AuthSessionStore( Action = "auth.session_family.revoked", TargetType = "auth_session_family", TargetId = familyId.ToString(), - Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) + Details = JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now }) }); await dbContext.SaveChangesAsync(cancellationToken); foreach (var sessionId in sessionIds) - { await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken); - } } return count; @@ -603,27 +582,34 @@ public sealed class AuthSessionStore( private static void ValidateRealm(AuthRealm realm, Guid? tenantId) { - if ((realm == AuthRealm.Tenant) != tenantId.HasValue) - { + if (realm == AuthRealm.Tenant != tenantId.HasValue) throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one."); - } } private static string? MaskIpAddress(string? value) { - if (!System.Net.IPAddress.TryParse(value, out var address)) - { - return null; - } + if (!IPAddress.TryParse(value, out var address)) return null; var bytes = address.GetAddressBytes(); - if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + if (address.AddressFamily == AddressFamily.InterNetwork) { bytes[3] = 0; - return $"{new System.Net.IPAddress(bytes)}/24"; + return $"{new IPAddress(bytes)}/24"; } Array.Clear(bytes, 8, bytes.Length - 8); - return $"{new System.Net.IPAddress(bytes)}/64"; + return $"{new IPAddress(bytes)}/64"; } -} + + private sealed record SessionValidationState( + UserStatus UserStatus, + string UserSecurityStamp, + string SessionSecurityStamp, + bool TenantAllowed, + bool PlatformAllowed, + TenantStatus? TenantStatus, + MembershipStatus? MembershipStatus, + long AuthorizationVersion, + DateTimeOffset SessionExpiresAt, + bool SessionRevoked); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs index 6c4cfe0..ea45072 100644 --- a/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs +++ b/Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs @@ -15,10 +15,7 @@ internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICu .Where(item => item.Id == userId) .Select(item => new { item.Id, item.Phone, item.Email, item.Name }) .SingleOrDefaultAsync(cancellationToken); - if (user is null) - { - return null; - } + if (user is null) return null; var memberships = await dbContext.TenantMemberships.AsNoTracking() .Where(item => item.UserId == userId && item.Status == MembershipStatus.Active) @@ -59,4 +56,4 @@ internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICu membership.Role)) .SingleOrDefaultAsync(cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs b/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs index 86f27e9..433fbc4 100644 --- a/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs +++ b/Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs @@ -1,15 +1,14 @@ -using System.Text.Json; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -38,10 +37,7 @@ public sealed partial class AuthService var consumed = await dbContext.AuthChallenges .Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken); - if (consumed != 1) - { - throw new InvalidAuthChallengeException(); - } + if (consumed != 1) throw new InvalidAuthChallengeException(); } private async Task CompleteSuccessfulLoginAsync( @@ -92,11 +88,9 @@ public sealed partial class AuthService } if (user.ForcePasswordChange) - { return await CreateChallengeResultAsync( user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider, AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken); - } return await IssueAuthenticatedResultAsync( user, realm, tenant, membership, provider, @@ -154,9 +148,7 @@ public sealed partial class AuthService CancellationToken cancellationToken) { if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue) - { throw new InvalidCredentialsException("tenant_realm_required_for_wechat"); - } var config = await LoadWechatProviderOptionsAsync( request.TenantId.Value, @@ -202,10 +194,7 @@ public sealed partial class AuthService // tenant policy and existing membership state have accepted the login. // A denied first login must not leave a user or provider identity behind. await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return await CompleteSuccessfulLoginAsync( request.Realm, @@ -226,7 +215,6 @@ public sealed partial class AuthService { TenantExternalProviderAccount? account = null; foreach (var alias in aliases) - { try { account = await providerConfigService.GetActiveProviderAsync( @@ -239,19 +227,13 @@ public sealed partial class AuthService catch (TenantExternalProviderException) { } - } - if (account is null) - { - throw new AuthProviderNotConfiguredException(provider); - } + if (account is null) throw new AuthProviderNotConfiguredException(provider); var appId = GetJsonString(account.ConfigPublic, "appId", "clientId"); var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) - { throw new AuthProviderNotConfiguredException(provider); - } return new WechatProviderOptions(appId, appSecret); } @@ -312,10 +294,7 @@ public sealed partial class AuthService string? unionId, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(unionId)) - { - return null; - } + if (string.IsNullOrWhiteSpace(unionId)) return null; var identity = await dbContext.UserIdentities .Where(entity => @@ -341,10 +320,7 @@ public sealed partial class AuthService membership.Status == MembershipStatus.Active, cancellationToken); - if (activeMembershipExists) - { - return; - } + if (activeMembershipExists) return; var studentMembership = await dbContext.TenantMemberships .FirstOrDefaultAsync( @@ -354,17 +330,12 @@ public sealed partial class AuthService membership.Role == TenantRole.Student, cancellationToken); if (studentMembership is not null) - { // Invited and Disabled memberships require an explicit administrator action. throw new TenantAccessDeniedException(); - } var policy = await dbContext.TenantAuthPolicies.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); - if (policy is not null && !policy.AllowExternalStudentSelfRegistration) - { - throw new TenantAccessDeniedException(); - } + if (policy is not null && !policy.AllowExternalStudentSelfRegistration) throw new TenantAccessDeniedException(); await featureAccessService.ConsumeQuotaIfConfiguredAsync( tenantId, @@ -436,23 +407,18 @@ public sealed partial class AuthService CancellationToken cancellationToken) { if (realm == AuthRealm.Platform) - { return await ( from userRole in dbContext.PlatformBackendUserRoles join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.UserId == userId && - role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && - (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform || - permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Platform || + permission.Area == BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); - } - if (!tenantId.HasValue) - { - return false; - } + if (!tenantId.HasValue) return false; return await ( from userRole in dbContext.TenantBackendUserRoles @@ -461,14 +427,16 @@ public sealed partial class AuthService join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.TenantId == tenantId.Value && userRole.UserId == userId && binding.TenantId == tenantId.Value && - role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && - (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant || - permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) + role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Tenant || + permission.Area == BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); } - private static string HashChallengeToken(string token) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); + private static string HashChallengeToken(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); + } private async Task AddSecurityAuditAsync( Guid userId, @@ -479,7 +447,7 @@ public sealed partial class AuthService string? userAgent, CancellationToken cancellationToken) { - dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + dbContext.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = userId, @@ -521,20 +489,13 @@ public sealed partial class AuthService private static string? GetJsonString(JsonElement element, params string[] names) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var name in names) - { if (element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString()!.Trim(); - } - } return null; } @@ -549,5 +510,4 @@ public sealed partial class AuthService avatarUrl = identity.AvatarUrl }); } - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/JwtKeyRing.cs b/Tiku.Infrastructure/Auth/JwtKeyRing.cs index 6d7d293..ff59790 100644 --- a/Tiku.Infrastructure/Auth/JwtKeyRing.cs +++ b/Tiku.Infrastructure/Auth/JwtKeyRing.cs @@ -14,10 +14,7 @@ internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable var value = options.Value; var signingRsa = RSA.Create(3072); keys.Add(signingRsa); - if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem)) - { - signingRsa.ImportFromPem(value.PrivateKeyPem); - } + if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem)) signingRsa.ImportFromPem(value.PrivateKeyPem); var signingKey = CreateKey(signingRsa, value.KeyId); SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256); @@ -34,26 +31,26 @@ internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable ValidationKeys = validationKeys; } + public void Dispose() + { + foreach (var key in keys) key.Dispose(); + } + public SigningCredentials SigningCredentials { get; } public IReadOnlyCollection ValidationKeys { get; } - private static RsaSecurityKey CreateKey(RSA rsa, string keyId) => new(rsa) + private static RsaSecurityKey CreateKey(RSA rsa, string keyId) { - KeyId = keyId, - // IdentityModel caches signature providers globally by key identity. A key ring owns - // and disposes its RSA instances, so a provider retained by another in-process host - // could otherwise reference an RSA instance that has already been disposed. - CryptoProviderFactory = new CryptoProviderFactory + return new RsaSecurityKey(rsa) { - CacheSignatureProviders = false - } - }; - - public void Dispose() - { - foreach (var key in keys) - { - key.Dispose(); - } + KeyId = keyId, + // IdentityModel caches signature providers globally by key identity. A key ring owns + // and disposes its RSA instances, so a provider retained by another in-process host + // could otherwise reference an RSA instance that has already been disposed. + CryptoProviderFactory = new CryptoProviderFactory + { + CacheSignatureProviders = false + } + }; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs b/Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs index 143e356..236767c 100644 --- a/Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs +++ b/Tiku.Infrastructure/Auth/LetterAndDigitPasswordValidator.cs @@ -21,4 +21,4 @@ public sealed class LetterAndDigitPasswordValidator : IPasswordValidator< Description = "Password must be at least 8 characters and contain both letters and digits." })); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/NoopSmsProvider.cs b/Tiku.Infrastructure/Auth/NoopSmsProvider.cs index f56cf88..749bf15 100644 --- a/Tiku.Infrastructure/Auth/NoopSmsProvider.cs +++ b/Tiku.Infrastructure/Auth/NoopSmsProvider.cs @@ -11,4 +11,4 @@ internal sealed class NoopSmsProvider : ISmsProvider cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(new SmsProviderSendResult("noop", "accepted")); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/OwnerActivationService.cs b/Tiku.Infrastructure/Auth/OwnerActivationService.cs index 2365116..b4e67e8 100644 --- a/Tiku.Infrastructure/Auth/OwnerActivationService.cs +++ b/Tiku.Infrastructure/Auth/OwnerActivationService.cs @@ -33,8 +33,8 @@ internal sealed class OwnerActivationService( CancellationToken cancellationToken = default) { var result = await CompleteCoreAsync( - request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken) - ?? throw Error("Owner activation session could not be established.", "owner_activation_failed"); + request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken) + ?? throw Error("Owner activation session could not be established.", "owner_activation_failed"); await runtimeCacheInvalidator.InvalidateAsync(expectedTenantId, cancellationToken); return result; } @@ -46,8 +46,9 @@ internal sealed class OwnerActivationService( string? ipAddress, string? userAgent, bool authenticate, - CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync( + CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Anonymous, @@ -59,34 +60,32 @@ internal sealed class OwnerActivationService( { var dbContext = services.GetRequiredService(); var grant = await dbContext.TenantOwnerActivationGrants - .SingleOrDefaultAsync(value => value.Id == request.ActivationId, token) - ?? throw Error("Owner activation was not found.", "owner_activation_invalid"); + .SingleOrDefaultAsync(value => value.Id == request.ActivationId, token) + ?? throw Error("Owner activation was not found.", "owner_activation_invalid"); var now = DateTimeOffset.UtcNow; - var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token))).ToLowerInvariant(); + var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token))) + .ToLowerInvariant(); if (grant.ConsumedAt.HasValue) - { throw Error("Owner activation was already consumed.", "owner_activation_consumed"); - } + if (grant.RevokedAt.HasValue || grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals( Convert.FromHexString(grant.TokenHash), Convert.FromHexString(tokenHash))) - { throw Error("Owner activation is invalid or expired.", "owner_activation_invalid"); - } + if (expectedTenantId.HasValue) { if (grant.TenantId != expectedTenantId || grant.DomainId is not { } domainId) - { - throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch"); - } + throw Error("Owner activation does not belong to this tenant host.", + "owner_activation_host_mismatch"); + var normalizedHost = expectedHost!.Trim().TrimEnd('.').ToLowerInvariant(); var domainMatches = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => value.Id == domainId && value.TenantId == grant.TenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active && value.Host == normalizedHost, token); if (!domainMatches) - { - throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch"); - } + throw Error("Owner activation does not belong to this tenant host.", + "owner_activation_host_mismatch"); } var claimed = await dbContext.TenantOwnerActivationGrants @@ -96,31 +95,25 @@ internal sealed class OwnerActivationService( .SetProperty(value => value.ConsumedAt, now) .SetProperty(value => value.UpdatedAt, now), token); if (claimed != 1) - { throw Error("Owner activation is invalid or already consumed.", "owner_activation_consumed"); - } var userManager = services.GetRequiredService>(); var user = await userManager.FindByIdAsync(grant.UserId.ToString()) - ?? throw Error("Owner account was not found.", "owner_activation_invalid"); + ?? throw Error("Owner account was not found.", "owner_activation_invalid"); if (await userManager.HasPasswordAsync(user)) - { throw Error("Owner account was already activated.", "owner_activation_consumed"); - } var result = await userManager.AddPasswordAsync(user, request.NewPassword); if (!result.Succeeded) - { throw Error( string.Join("; ", result.Errors.Select(error => error.Description)), "owner_activation_password_invalid"); - } + user.ForcePasswordChange = false; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) - { throw Error("Owner account activation could not be completed.", "owner_activation_failed"); - } + await userManager.UpdateSecurityStampAsync(user); dbContext.AuditLogs.Add(new AuditLog { @@ -164,6 +157,10 @@ internal sealed class OwnerActivationService( return authentication; }, cancellationToken); + } - private static OwnerActivationException Error(string message, string code) => new(message, code); -} + private static OwnerActivationException Error(string message, string code) + { + return new OwnerActivationException(message, code); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs b/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs index 418750b..1a37fb9 100644 --- a/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs +++ b/Tiku.Infrastructure/Auth/PasswordLifecycle/AuthService.PasswordLifecycle.cs @@ -1,15 +1,7 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -22,20 +14,14 @@ public sealed partial class AuthService var challenge = await FindChallengeAsync( request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken); var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) - ?? throw new InvalidAuthChallengeException(); + ?? throw new InvalidAuthChallengeException(); var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword); - if (!reset.Succeeded) - { - throw new InvalidCredentialsException("invalid_new_password"); - } + if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password"); user.ForcePasswordChange = false; var updated = await userManager.UpdateAsync(user); - if (!updated.Succeeded) - { - throw new InvalidOperationException("Unable to clear the password-change requirement."); - } + if (!updated.Succeeded) throw new InvalidOperationException("Unable to clear the password-change requirement."); await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); await ConsumeChallengeAsync(challenge, cancellationToken); @@ -43,7 +29,8 @@ public sealed partial class AuthService user.Id, challenge.TenantId, "auth.password.changed", null, request.IpAddress, request.UserAgent, cancellationToken); return await CompleteSuccessfulLoginAsync( - challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(), + challenge.Realm, challenge.TenantId, user, challenge.Provider, + user.Email ?? user.Phone ?? user.Id.ToString(), request.IpAddress, request.UserAgent, cancellationToken); } @@ -60,10 +47,7 @@ public sealed partial class AuthService membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value && membership.Status == MembershipStatus.Active, cancellationToken); - if (!eligible) - { - return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5)); - } + if (!eligible) return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5)); return await smsVerificationService.CreateCodeAsync( new SendSmsCodeRequest( @@ -88,9 +72,7 @@ public sealed partial class AuthService membership => membership.TenantId == request.TenantId && membership.UserId == user.Id && membership.Status == MembershipStatus.Active, cancellationToken)) - { throw new InvalidCredentialsException(); - } await smsVerificationService.VerifyCodeAsync( request.TenantId, @@ -100,17 +82,11 @@ public sealed partial class AuthService cancellationToken); var token = await userManager.GeneratePasswordResetTokenAsync(user); var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword); - if (!reset.Succeeded) - { - throw new InvalidCredentialsException("invalid_new_password"); - } + if (!reset.Succeeded) throw new InvalidCredentialsException("invalid_new_password"); user.ForcePasswordChange = false; var updated = await userManager.UpdateAsync(user); - if (!updated.Succeeded) - { - throw new InvalidOperationException("Unable to finalize the password reset."); - } + if (!updated.Succeeded) throw new InvalidOperationException("Unable to finalize the password reset."); await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken); await AddSecurityAuditAsync( @@ -132,21 +108,20 @@ public sealed partial class AuthService request.UserId, cancellationToken) ?? throw new SessionRevokedException(); var user = await userManager.FindByIdAsync(request.UserId.ToString()) - ?? throw new InvalidCredentialsException(); + ?? throw new InvalidCredentialsException(); var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); if (!changed.Succeeded) { var currentPasswordInvalid = changed.Errors.Any(error => string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase)); - throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password"); + throw new InvalidCredentialsException(currentPasswordInvalid + ? "invalid_credentials" + : "invalid_new_password"); } user.ForcePasswordChange = false; var updated = await userManager.UpdateAsync(user); - if (!updated.Succeeded) - { - throw new InvalidOperationException("Unable to finalize the password change."); - } + if (!updated.Succeeded) throw new InvalidOperationException("Unable to finalize the password change."); await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); await AddSecurityAuditAsync( @@ -167,6 +142,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs b/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs index 8eae8fc..21eab1c 100644 --- a/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs +++ b/Tiku.Infrastructure/Auth/PasswordLogin/AuthService.PasswordLogin.cs @@ -1,15 +1,8 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -24,13 +17,13 @@ public sealed partial class AuthService var normalizedUserName = userManager.NormalizeName(identifier); var user = await dbContext.Users .SingleOrDefaultAsync(entity => - entity.Phone == identifier || - entity.NormalizedEmail == normalizedEmail || - entity.NormalizedUserName == normalizedUserName, + entity.Phone == identifier || + entity.NormalizedEmail == normalizedEmail || + entity.NormalizedUserName == normalizedUserName, cancellationToken); var passwordResult = user is null || user.Status != UserStatus.Active ? SignInResult.Failed - : await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); + : await signInManager.CheckPasswordSignInAsync(user, request.Password, true); if (!passwordResult.Succeeded) { @@ -63,6 +56,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs index 0c0b668..97edd7a 100644 --- a/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs +++ b/Tiku.Infrastructure/Auth/SelfHostedIdentityProvider.cs @@ -58,4 +58,4 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId user.Email, user.Name); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs b/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs index ee88a34..bf78b66 100644 --- a/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs +++ b/Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs @@ -1,15 +1,4 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -33,15 +22,10 @@ public sealed partial class AuthService public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default) { var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new InvalidCredentialsException(); + ?? throw new InvalidCredentialsException(); var stampResult = await userManager.UpdateSecurityStampAsync(user); - if (!stampResult.Succeeded) - { - throw new InvalidOperationException("Unable to update the user's security stamp."); - } + if (!stampResult.Succeeded) throw new InvalidOperationException("Unable to update the user's security stamp."); await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/SmsCodeHashing.cs b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs index f903d20..a19a253 100644 --- a/Tiku.Infrastructure/Auth/SmsCodeHashing.cs +++ b/Tiku.Infrastructure/Auth/SmsCodeHashing.cs @@ -36,4 +36,4 @@ public static class SmsCodeHashing { return phone.Trim(); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs b/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs index 68c8e54..767b17b 100644 --- a/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs +++ b/Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs @@ -1,15 +1,6 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -25,10 +16,7 @@ public sealed partial class AuthService try { - if (!request.TenantId.HasValue) - { - throw new InvalidCredentialsException("tenant_required_for_sms"); - } + if (!request.TenantId.HasValue) throw new InvalidCredentialsException("tenant_required_for_sms"); await smsVerificationService.VerifyCodeAsync( request.TenantId.Value, phone, @@ -76,6 +64,4 @@ public sealed partial class AuthService request.UserAgent, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 2affe7d..5951cbf 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -5,10 +5,10 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tiku.Application.Auth; +using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; -using Tiku.Application.Security; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Auth; @@ -20,6 +20,10 @@ public sealed class SmsVerificationService( IFeatureAccessService featureAccessService, IOptions securityOptions) : ISmsVerificationService { + private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); + private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1); + private readonly SmsSecurityOptions options = securityOptions.Value; + public SmsVerificationService( TikuDbContext dbContext, ISmsProvider smsProvider, @@ -29,10 +33,6 @@ public sealed class SmsVerificationService( { } - private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); - private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1); - private readonly SmsSecurityOptions options = securityOptions.Value; - public async Task CreateCodeAsync( SendSmsCodeRequest request, CancellationToken cancellationToken = default) @@ -48,11 +48,9 @@ public sealed class SmsVerificationService( 1, cancellationToken); if (!quotaReserved) - { throw new FeatureAccessException( "Tenant SMS quota is exhausted.", "feature_quota_exhausted"); - } var code = RandomNumberGenerator .GetInt32(100000, 1000000) @@ -116,13 +114,11 @@ public sealed class SmsVerificationService( finally { if (!providerAccepted) - { await featureAccessService.ReleaseQuotaAsync( request.TenantId, SaasQuotaMetricCatalog.SmsCount, 1, CancellationToken.None); - } } await ExpirePreviousCodesAsync( @@ -197,10 +193,7 @@ public sealed class SmsVerificationService( .OrderByDescending(entity => entity.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (verification is null) - { - throw new InvalidCredentialsException("invalid_sms_code"); - } + if (verification is null) throw new InvalidCredentialsException("invalid_sms_code"); if (verification.ExpiresAt <= now) { @@ -211,10 +204,7 @@ public sealed class SmsVerificationService( if (HashesMatch(verification.CodeHash, codeHash)) { var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken); - if (consumed) - { - return; - } + if (consumed) return; throw new InvalidCredentialsException("invalid_sms_code"); } @@ -229,10 +219,7 @@ public sealed class SmsVerificationService( SmsPurpose purpose, CancellationToken cancellationToken) { - if (!redisSecurityStore.IsConfigured) - { - return; - } + if (!redisSecurityStore.IsConfigured) return; var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))) .ToLowerInvariant(); @@ -245,10 +232,7 @@ public sealed class SmsVerificationService( options.MaxVerificationAttempts, CodeLifetime) ], cancellationToken); - if (!result.Allowed) - { - throw new SmsRateLimitedException(); - } + if (!result.Allowed) throw new SmsRateLimitedException(); } catch (RedisSecurityUnavailableException) { @@ -266,7 +250,6 @@ public sealed class SmsVerificationService( var bucketStart = TruncateToHour(now); if (redisSecurityStore.IsConfigured) - { try { var distributed = await redisSecurityStore.ConsumeAsync( @@ -275,16 +258,12 @@ public sealed class SmsVerificationService( limit.Maximum, TimeSpan.FromHours(1))).ToArray(), cancellationToken); - if (!distributed.Allowed) - { - throw new SmsRateLimitedException(); - } + if (!distributed.Allowed) throw new SmsRateLimitedException(); } catch (RedisSecurityUnavailableException) { throw new AuthSecurityUnavailableException(); } - } if (!dbContext.Database.IsRelational()) { @@ -297,16 +276,16 @@ public sealed class SmsVerificationService( { var dimension = ToSnakeCase(limit.Dimension); var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$""" - INSERT INTO sms_send_rate_limits - (tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at) - VALUES - ({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}}) - ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start) - DO UPDATE SET - request_count = sms_send_rate_limits.request_count + 1, - updated_at = EXCLUDED.updated_at - WHERE sms_send_rate_limits.request_count < {{limit.Maximum}} - """, cancellationToken); + INSERT INTO sms_send_rate_limits + (tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at) + VALUES + ({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}}) + ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start) + DO UPDATE SET + request_count = sms_send_rate_limits.request_count + 1, + updated_at = EXCLUDED.updated_at + WHERE sms_send_rate_limits.request_count < {{limit.Maximum}} + """, cancellationToken); if (affected == 0) { @@ -334,10 +313,7 @@ public sealed class SmsVerificationService( var counter = await dbContext.SmsSendRateLimits.FindAsync( [tenantId, limit.Dimension, limit.ScopeHash, bucketStart], cancellationToken); - if (counter?.RequestCount >= limit.Maximum) - { - throw new SmsRateLimitedException(); - } + if (counter?.RequestCount >= limit.Maximum) throw new SmsRateLimitedException(); counters.Add((limit, counter)); } @@ -374,27 +350,24 @@ public sealed class SmsVerificationService( var limits = new List { CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour), - CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour) + CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", + options.PhoneRequestsPerHour) }; if (!string.IsNullOrWhiteSpace(request.IpAddress)) - { limits.Add(CreateLimit( SmsRateLimitDimension.Ip, $"ip:{request.IpAddress.Trim()}", options.IpRequestsPerHour)); - } var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId) ? request.UserAgent : request.DeviceId; if (!string.IsNullOrWhiteSpace(deviceKey)) - { limits.Add(CreateLimit( SmsRateLimitDimension.Device, $"device:{deviceKey.Trim()}", options.DeviceRequestsPerHour)); - } return limits; } @@ -491,9 +464,7 @@ public sealed class SmsVerificationService( verification.ConsumedAt is not null || verification.ExpiresAt <= now || verification.Attempts >= options.MaxVerificationAttempts) - { return false; - } verification.Status = SmsVerificationStatus.Verified; verification.ConsumedAt = now; @@ -530,15 +501,11 @@ public sealed class SmsVerificationService( verification.ConsumedAt is not null || verification.ExpiresAt <= now || verification.Attempts >= options.MaxVerificationAttempts) - { return; - } verification.Attempts++; if (verification.Attempts >= options.MaxVerificationAttempts) - { verification.Status = SmsVerificationStatus.Blocked; - } await dbContext.SaveChangesAsync(cancellationToken); } @@ -546,11 +513,9 @@ public sealed class SmsVerificationService( private void EnsureValidOptions() { if (!SmsSecurityOptions.BeValid(options)) - { throw new InvalidOperationException( $"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " + "exactly five verification attempts, and positive rate limits."); - } } private static bool HashesMatch(string expected, string actual) @@ -588,4 +553,4 @@ public sealed class SmsVerificationService( SmsRateLimitDimension Dimension, string ScopeHash, int Maximum); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/TokenService.cs b/Tiku.Infrastructure/Auth/TokenService.cs index b6a8565..6d08991 100644 --- a/Tiku.Infrastructure/Auth/TokenService.cs +++ b/Tiku.Infrastructure/Auth/TokenService.cs @@ -25,24 +25,16 @@ public sealed class TokenService(IOptions options, IJwtKeyRing keyRi new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(TikuClaimTypes.SessionId, sessionId.ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), - new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), + new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64), new(TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant()) }; - if (tenantId.HasValue) - { - claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); - } + if (tenantId.HasValue) claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); - if (!string.IsNullOrWhiteSpace(phone)) - { - claims.Add(new Claim(TikuClaimTypes.Phone, phone)); - } + if (!string.IsNullOrWhiteSpace(phone)) claims.Add(new Claim(TikuClaimTypes.Phone, phone)); - if (!string.IsNullOrWhiteSpace(email)) - { - claims.Add(new Claim(TikuClaimTypes.Email, email)); - } + if (!string.IsNullOrWhiteSpace(email)) claims.Add(new Claim(TikuClaimTypes.Email, email)); var token = new JwtSecurityToken( options.Issuer, @@ -53,4 +45,4 @@ public sealed class TokenService(IOptions options, IJwtKeyRing keyRi return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs b/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs index 19a0f63..132a15e 100644 --- a/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs +++ b/Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs @@ -1,15 +1,4 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; @@ -38,6 +27,4 @@ public sealed partial class AuthService (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Auth/WechatOAuthClient.cs b/Tiku.Infrastructure/Auth/WechatOAuthClient.cs index 384c009..e9140d2 100644 --- a/Tiku.Infrastructure/Auth/WechatOAuthClient.cs +++ b/Tiku.Infrastructure/Auth/WechatOAuthClient.cs @@ -19,25 +19,18 @@ public sealed class WechatOAuthClient : IWechatOAuthClient var token = await OAuthApi.GetAccessTokenAsync( options.AppId, options.AppSecret, - code, - "authorization_code"); + code); cancellationToken.ThrowIfCancellationRequested(); EnsureSuccess(token.ErrorCodeValue, token.errmsg); if (string.IsNullOrWhiteSpace(token.access_token)) - { throw new InvalidCredentialsException("wechat_access_token_missing"); - } - if (string.IsNullOrWhiteSpace(token.openid)) - { - throw new InvalidCredentialsException("wechat_openid_missing"); - } + if (string.IsNullOrWhiteSpace(token.openid)) throw new InvalidCredentialsException("wechat_openid_missing"); var user = await OAuthApi.GetUserInfoAsync( token.access_token, - token.openid, - Senparc.Weixin.Language.zh_CN); + token.openid); cancellationToken.ThrowIfCancellationRequested(); return new WechatIdentity( @@ -74,20 +67,15 @@ public sealed class WechatOAuthClient : IWechatOAuthClient var result = await SnsApi.JsCode2JsonAsync( options.AppId, options.AppSecret, - code, - "authorization_code"); + code); cancellationToken.ThrowIfCancellationRequested(); EnsureSuccess(result.ErrorCodeValue, result.errmsg); if (string.IsNullOrWhiteSpace(result.openid)) - { throw new InvalidCredentialsException("wechat_openid_missing"); - } if (string.IsNullOrWhiteSpace(result.session_key)) - { throw new InvalidCredentialsException("wechat_session_key_missing"); - } return new WechatIdentity( result.openid.Trim(), @@ -109,10 +97,7 @@ public sealed class WechatOAuthClient : IWechatOAuthClient private static void EnsureSuccess(int errorCode, string? errorMessage) { - if (errorCode == 0) - { - return; - } + if (errorCode == 0) return; throw new InvalidCredentialsException( string.IsNullOrWhiteSpace(errorMessage) @@ -141,4 +126,4 @@ public sealed class WechatOAuthClient : IWechatOAuthClient ? "wechat_http_error" : "wechat_code_exchange_failed"; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index 43f315c..3e4d807 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -4,7 +4,6 @@ using Tiku.Application.Backoffice; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Operations; -using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -22,9 +21,7 @@ internal sealed class BackofficeService( { if (!access.IsUserActive || !access.IsCurrentTenantMember || access.UserId is null || access.TenantId is null) - { throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied"); - } var permissionCodes = await FilterTenantPermissionCodesAsync( access.TenantId.Value, @@ -38,7 +35,8 @@ internal sealed class BackofficeService( var features = await featureAccessService.GetEnabledFeaturesAsync( access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken); var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken); - return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), quotas); + return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), + quotas); } public async Task GetPlatformUiBootstrapAsync( @@ -46,9 +44,7 @@ internal sealed class BackofficeService( CancellationToken cancellationToken = default) { if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0) - { throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied"); - } var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray(); var menus = await LoadEffectiveMenusAsync( @@ -73,13 +69,15 @@ internal sealed class BackofficeService( permissions.Select(item => item.Code), CapabilityOperation.Read, cancellationToken); - permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray(); + permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)) + .ToArray(); var menus = await dbContext.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant) .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); menus = menus.Where(item => item.PermissionCode is null || - enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray(); + enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)) + .ToArray(); return new BackofficeBootstrap( permissions.Select(ToPermissionItem).ToArray(), menus.Select(ToMenuItem).ToArray(), @@ -135,7 +133,8 @@ internal sealed class BackofficeService( role.DataScope = command.DataScope ?? JsonDefaults.Object(); await dbContext.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); - await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken); + await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, + cancellationToken); return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken); } @@ -146,8 +145,10 @@ internal sealed class BackofficeService( { RequirePlatformAdmin(actor); var role = command.Id.HasValue - ? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken) - : await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken); + ? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, + cancellationToken) + : await dbContext.PlatformBackendRoles.SingleOrDefaultAsync( + item => item.Code == NormalizeCode(command.Code), cancellationToken); if (role is null) { role = new PlatformBackendRole { Code = NormalizeCode(command.Code) }; @@ -163,7 +164,8 @@ internal sealed class BackofficeService( role.Description = command.Description?.Trim(); await dbContext.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); - await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken); + await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, + cancellationToken); return await LoadPlatformRoleAsync(role.Id, cancellationToken); } @@ -177,13 +179,13 @@ internal sealed class BackofficeService( item => item.TenantId == tenantId && item.Id == command.RoleId, cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found"); if (role.IsSystem) - { throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked"); - } - await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken); + await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, + cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); - await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken); + await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, + cancellationToken); return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken); } @@ -197,13 +199,12 @@ internal sealed class BackofficeService( item => item.Id == command.RoleId, cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found"); if (role.IsSystem) - { throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked"); - } await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); - await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken); + await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, + cancellationToken); return await LoadPlatformRoleAsync(role.Id, cancellationToken); } @@ -225,17 +226,13 @@ internal sealed class BackofficeService( item.Status == MembershipStatus.Active, cancellationToken); 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( 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 .Where(item => item.TenantId == tenantId && item.UserId == command.UserId) @@ -248,7 +245,8 @@ internal sealed class BackofficeService( })); await dbContext.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); - await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); + await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, + cancellationToken); } public async Task ReplacePlatformUserRolesAsync( @@ -262,9 +260,7 @@ internal sealed class BackofficeService( 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 .Where(item => item.UserId == command.UserId) @@ -276,7 +272,8 @@ internal sealed class BackofficeService( })); await dbContext.SaveChangesAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken); - await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); + await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, + cancellationToken); } private async Task ReplaceTenantBindingsCoreAsync( @@ -292,16 +289,18 @@ internal sealed class BackofficeService( var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync( tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken); if (allowedPermissions.Count != normalizedPermissions.Length) - { throw new BackofficeException( "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).ExecuteDeleteAsync(cancellationToken); - await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); - dbContext.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.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId) + .ExecuteDeleteAsync(cancellationToken); + dbContext.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); } @@ -315,22 +314,25 @@ 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).ExecuteDeleteAsync(cancellationToken); - await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); - dbContext.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.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId) + .ExecuteDeleteAsync(cancellationToken); + dbContext.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); } - private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) + private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, + CancellationToken cancellationToken) { var count = await dbContext.BackendPermissions.CountAsync( item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both), cancellationToken); if (count != codes.Length) - { throw new BackofficeException("One or more permissions were not found.", "permission_not_found"); - } } private async Task LoadEffectiveMenusAsync( @@ -341,7 +343,7 @@ internal sealed class BackofficeService( var codes = permissionCodes.ToArray(); var menus = await dbContext.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == area && - (item.PermissionCode == null || codes.Contains(item.PermissionCode))) + (item.PermissionCode == null || codes.Contains(item.PermissionCode))) .OrderBy(item => item.SortOrder) .ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); @@ -362,15 +364,13 @@ internal sealed class BackofficeService( return enabled.Order(StringComparer.Ordinal).ToArray(); } - private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) + private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, + CancellationToken cancellationToken) { var count = await dbContext.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"); - } + if (count != codes.Length) throw new BackofficeException("One or more menus were not found.", "menu_not_found"); } private async Task LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken) @@ -404,7 +404,8 @@ internal sealed class BackofficeService( return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray(); } - private async Task LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken) + private async Task LoadTenantRoleAsync(Guid tenantId, Guid roleId, + CancellationToken cancellationToken) { return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId); } @@ -414,7 +415,8 @@ internal sealed class BackofficeService( return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId); } - private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) + private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, + object details, CancellationToken cancellationToken) { await auditService.WriteAsync(new BackofficeOperationAuditCommand( actor.TenantId, @@ -428,9 +430,7 @@ internal sealed class BackofficeService( private static Guid RequireTenantAdmin(BackofficeActor actor) { if (actor.TenantId is not { } tenantId) - { throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required"); - } return tenantId; } @@ -438,12 +438,13 @@ internal sealed class BackofficeService( private static void RequirePlatformAdmin(BackofficeActor actor) { if (!actor.IsPlatform) - { throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied"); - } } - private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant(); + private static string NormalizeCode(string code) + { + return code.Trim().ToLowerInvariant(); + } private static string[] NormalizeCodes(IEnumerable codes) { @@ -452,15 +453,18 @@ internal sealed class BackofficeService( private static BackofficePermissionItem ToPermissionItem(BackendPermission item) { - return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, item.Description, item.SortOrder); + return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, + item.Description, item.SortOrder); } private static BackofficeMenuItem ToMenuItem(BackendMenu item) { - return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive); + return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, + item.PermissionCode, item.SortOrder, item.IsActive); } - private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus) + private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, + TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus) { return new BackofficeRoleItem( role.Id, @@ -474,7 +478,8 @@ internal sealed class BackofficeService( role.DataScope); } - private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus) + private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, + PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus) { return new BackofficeRoleItem( role.Id, @@ -486,5 +491,4 @@ internal sealed class BackofficeService( permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(), menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray()); } - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs index 0346d7b..17cb6df 100644 --- a/Tiku.Infrastructure/Backoffice/OperationAuditService.cs +++ b/Tiku.Infrastructure/Backoffice/OperationAuditService.cs @@ -24,4 +24,4 @@ internal sealed class OperationAuditService(TikuDbContext dbContext) : IOperatio await dbContext.SaveChangesAsync(cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs index c2bb304..410d59c 100644 --- a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Tiku.Application.Security; using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Platform; @@ -73,7 +73,8 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"), new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"), new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"), - new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"), + new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, + "tenant_site_content"), new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"), new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), @@ -85,11 +86,15 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), - new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), + new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, + "platform_content"), new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), - new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"), - new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"), - new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"), + new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, + "platform_billing"), + new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, + "platform_billing"), + new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, + "platform_billing"), new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"), new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"), new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"), @@ -97,12 +102,16 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"), new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"), new(BackendPermissions.PlatformOperationsView, "平台运维查询", BackendPermissionArea.Platform, "platform_operations"), - new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform, "platform_operations"), + new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform, + "platform_operations"), new(BackendPermissions.PlatformApprovalView, "平台审批查询", BackendPermissionArea.Platform, "platform_governance"), new(BackendPermissions.PlatformApprovalDecide, "平台审批决策", BackendPermissionArea.Platform, "platform_governance"), - new(BackendPermissions.PlatformApprovalPolicyManage, "平台审批策略管理", BackendPermissionArea.Platform, "platform_governance"), - new(BackendPermissions.PlatformConfigurationManage, "平台配置中心管理", BackendPermissionArea.Platform, "platform_governance"), - new(BackendPermissions.PlatformNotificationManage, "平台通知中心管理", BackendPermissionArea.Platform, "platform_governance"), + new(BackendPermissions.PlatformApprovalPolicyManage, "平台审批策略管理", BackendPermissionArea.Platform, + "platform_governance"), + new(BackendPermissions.PlatformConfigurationManage, "平台配置中心管理", BackendPermissionArea.Platform, + "platform_governance"), + new(BackendPermissions.PlatformNotificationManage, "平台通知中心管理", BackendPermissionArea.Platform, + "platform_governance"), new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"), new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"), new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce") @@ -110,54 +119,108 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) private static readonly BuiltinMenu[] Menus = [ - new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10), + new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", + "tenant:dashboard:view", 10), new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20), - new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30), - new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40), - new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50), - new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60), - new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70), - new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80), - new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90), - new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100), - new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110), - new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120), - new("platform.dashboard", null, "经营概览", BackendPermissionArea.Platform, "/", BackendPermissions.PlatformDashboardView, 10), - new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/tenants", BackendPermissions.PlatformTenantManage, 20), - new("platform.subscriptions", null, "订阅与应收", BackendPermissionArea.Platform, "/subscriptions", BackendPermissions.PlatformSaasBillingManage, 30), - new("platform.usage", null, "用量计费", BackendPermissionArea.Platform, "/usage", BackendPermissions.PlatformSaasBillingManage, 40), - new("platform.dunning", null, "收款与催缴", BackendPermissionArea.Platform, "/dunning", BackendPermissions.PlatformBillingNotification, 50), - new("platform.refunds", null, "退款处理", BackendPermissionArea.Platform, "/refunds", BackendPermissions.PlatformSaasBillingManage, 60), - new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/question-banks", BackendPermissions.PlatformQuestionBankManage, 70), - new("platform.crm", null, "CRM 服务", BackendPermissionArea.Platform, "/crm", BackendPermissions.PlatformCrmRead, 80), - new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/sms", BackendPermissions.PlatformSmsRead, 90), - new("platform.payment", null, "支付服务", BackendPermissionArea.Platform, "/payments", BackendPermissions.PlatformPaymentRead, 100), - new("platform.staff", null, "员工与角色", BackendPermissionArea.Platform, "/staff", BackendPermissions.PlatformStaffManage, 110), - new("platform.audit", null, "审计日志", BackendPermissionArea.Platform, "/audit", BackendPermissions.PlatformAuditView, 120), - new("platform.alerts", null, "审计告警", BackendPermissionArea.Platform, "/alerts", BackendPermissions.PlatformAuditView, 130), - new("platform.operations", null, "运行中心", BackendPermissionArea.Platform, "/operations", BackendPermissions.PlatformOperationsView, 140), - new("platform.approvals", null, "审批中心", BackendPermissionArea.Platform, "/approvals", BackendPermissions.PlatformApprovalView, 150), - new("platform.configuration", null, "配置中心", BackendPermissionArea.Platform, "/configuration", BackendPermissions.PlatformConfigurationManage, 160), - new("platform.notifications", null, "通知中心", BackendPermissionArea.Platform, "/notifications", BackendPermissions.PlatformNotificationManage, 170) + new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", + 30), + new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", + BackendPermissions.TenantContentManage, 40), + new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", + BackendPermissions.TenantVocabularyManage, 50), + new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", + BackendPermissions.TenantHandbookManage, 60), + new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", + BackendPermissions.TenantVideoManage, 70), + new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", + BackendPermissions.TenantScorelineManage, 80), + new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", + BackendPermissions.TenantSiteContentManage, 90), + new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", + BackendPermissions.TenantProviderManage, 100), + new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", + BackendPermissions.TenantCommerceOperate, 110), + new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", + BackendPermissions.TenantBillingManage, 120), + new("platform.dashboard", null, "经营概览", BackendPermissionArea.Platform, "/", + BackendPermissions.PlatformDashboardView, 10), + new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/tenants", + BackendPermissions.PlatformTenantManage, 20), + new("platform.subscriptions", null, "订阅与应收", BackendPermissionArea.Platform, "/subscriptions", + BackendPermissions.PlatformSaasBillingManage, 30), + new("platform.usage", null, "用量计费", BackendPermissionArea.Platform, "/usage", + BackendPermissions.PlatformSaasBillingManage, 40), + new("platform.dunning", null, "收款与催缴", BackendPermissionArea.Platform, "/dunning", + BackendPermissions.PlatformBillingNotification, 50), + new("platform.refunds", null, "退款处理", BackendPermissionArea.Platform, "/refunds", + BackendPermissions.PlatformSaasBillingManage, 60), + new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/question-banks", + BackendPermissions.PlatformQuestionBankManage, 70), + new("platform.crm", null, "CRM 服务", BackendPermissionArea.Platform, "/crm", BackendPermissions.PlatformCrmRead, + 80), + new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/sms", BackendPermissions.PlatformSmsRead, + 90), + new("platform.payment", null, "支付服务", BackendPermissionArea.Platform, "/payments", + BackendPermissions.PlatformPaymentRead, 100), + new("platform.staff", null, "员工与角色", BackendPermissionArea.Platform, "/staff", + BackendPermissions.PlatformStaffManage, 110), + new("platform.audit", null, "审计日志", BackendPermissionArea.Platform, "/audit", + BackendPermissions.PlatformAuditView, 120), + new("platform.alerts", null, "审计告警", BackendPermissionArea.Platform, "/alerts", + BackendPermissions.PlatformAuditView, 130), + new("platform.operations", null, "运行中心", BackendPermissionArea.Platform, "/operations", + BackendPermissions.PlatformOperationsView, 140), + new("platform.approvals", null, "审批中心", BackendPermissionArea.Platform, "/approvals", + BackendPermissions.PlatformApprovalView, 150), + new("platform.configuration", null, "配置中心", BackendPermissionArea.Platform, "/configuration", + BackendPermissions.PlatformConfigurationManage, 160), + new("platform.notifications", null, "通知中心", BackendPermissionArea.Platform, "/notifications", + BackendPermissions.PlatformNotificationManage, 170) ]; private static readonly BuiltinPlatformRole[] PlatformRoles = [ new("platform_customer_service", "客服运营", "租户开通、客户服务与只读渠道排障", - [BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformTenantManage, BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformNotificationManage], - ["platform.dashboard", "platform.tenants", "platform.crm", "platform.sms", "platform.approvals", "platform.notifications"]), + [ + BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformTenantManage, + BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead, + BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformNotificationManage + ], + [ + "platform.dashboard", "platform.tenants", "platform.crm", "platform.sms", "platform.approvals", + "platform.notifications" + ]), new("platform_finance", "财务运营", "套餐、订阅、应收、催缴、退款与支付查询", - [BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformSaasCatalogManage, BackendPermissions.PlatformSaasBillingManage, BackendPermissions.PlatformBillingNotification, BackendPermissions.PlatformPaymentRead, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide], - ["platform.dashboard", "platform.subscriptions", "platform.usage", "platform.dunning", "platform.refunds", "platform.payment", "platform.approvals"]), + [ + BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformSaasCatalogManage, + BackendPermissions.PlatformSaasBillingManage, BackendPermissions.PlatformBillingNotification, + BackendPermissions.PlatformPaymentRead, BackendPermissions.PlatformApprovalView, + BackendPermissions.PlatformApprovalDecide + ], + [ + "platform.dashboard", "platform.subscriptions", "platform.usage", "platform.dunning", + "platform.refunds", "platform.payment", "platform.approvals" + ]), new("platform_content_operator", "内容运营", "平台公共题库运营", [BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformQuestionBankManage], ["platform.dashboard", "platform.content"]), new("platform_technical_operations", "技术运维", "依赖健康、Worker 与后台任务治理", - [BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformOperationsView, BackendPermissions.PlatformOperationsManage, BackendPermissions.PlatformConfigurationManage], + [ + BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformOperationsView, + BackendPermissions.PlatformOperationsManage, BackendPermissions.PlatformConfigurationManage + ], ["platform.dashboard", "platform.operations", "platform.configuration"]), new("platform_security_admin", "安全管理员", "平台员工、角色、审计与安全告警治理", - [BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformStaffManage, BackendPermissions.PlatformRoleManage, BackendPermissions.PlatformAuditView, BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide, BackendPermissions.PlatformApprovalPolicyManage, BackendPermissions.PlatformConfigurationManage], - ["platform.dashboard", "platform.staff", "platform.audit", "platform.alerts", "platform.approvals", "platform.configuration"]) + [ + BackendPermissions.PlatformDashboardView, BackendPermissions.PlatformStaffManage, + BackendPermissions.PlatformRoleManage, BackendPermissions.PlatformAuditView, + BackendPermissions.PlatformApprovalView, BackendPermissions.PlatformApprovalDecide, + BackendPermissions.PlatformApprovalPolicyManage, BackendPermissions.PlatformConfigurationManage + ], + [ + "platform.dashboard", "platform.staff", "platform.audit", "platform.alerts", "platform.approvals", + "platform.configuration" + ]) ]; public async Task SeedAsync(CancellationToken cancellationToken = default) @@ -262,6 +325,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) dbContext.PlatformBackendRoles.Add(role); existingRoles[builtin.Code] = role; } + role.Name = builtin.Name; role.Description = builtin.Description; role.Status = BackendRoleStatus.Active; @@ -270,29 +334,60 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) var approvalPolicies = new[] { - new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.TenantArchive, Name = "终止租户", RequiredPermission = BackendPermissions.PlatformTenantManage, AlwaysRequireApproval = true }, - new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.SuperAdminGrant, Name = "平台超级权限变更", RequiredPermission = BackendPermissions.PlatformRoleManage, AlwaysRequireApproval = true }, - new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.PaymentChannelChange, Name = "支付渠道或密钥引用变更", RequiredPermission = BackendPermissions.PlatformPaymentWrite, AlwaysRequireApproval = true }, - new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "大额退款及手工收款", RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 } + new PlatformApprovalPolicy + { + Code = PlatformApprovalPolicyCodes.TenantArchive, Name = "终止租户", + RequiredPermission = BackendPermissions.PlatformTenantManage, AlwaysRequireApproval = true + }, + new PlatformApprovalPolicy + { + Code = PlatformApprovalPolicyCodes.SuperAdminGrant, Name = "平台超级权限变更", + RequiredPermission = BackendPermissions.PlatformRoleManage, AlwaysRequireApproval = true + }, + new PlatformApprovalPolicy + { + Code = PlatformApprovalPolicyCodes.PaymentChannelChange, Name = "支付渠道或密钥引用变更", + RequiredPermission = BackendPermissions.PlatformPaymentWrite, AlwaysRequireApproval = true + }, + new PlatformApprovalPolicy + { + Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "大额退款及手工收款", + RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 + } }; var approvalPolicyCodes = approvalPolicies.Select(item => item.Code).ToArray(); var existingPolicyCodes = await dbContext.PlatformApprovalPolicies.AsNoTracking() .Where(policy => approvalPolicyCodes.Contains(policy.Code)) .Select(policy => policy.Code) .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); - dbContext.PlatformApprovalPolicies.AddRange(approvalPolicies.Where(policy => !existingPolicyCodes.Contains(policy.Code))); + dbContext.PlatformApprovalPolicies.AddRange(approvalPolicies.Where(policy => + !existingPolicyCodes.Contains(policy.Code))); var configurationDefinitions = new[] { - new PlatformConfigurationDefinition { Code = "platform.support-contact", Name = "平台支持联系方式", Category = "platform", ValueType = PlatformConfigurationValueType.String }, - new PlatformConfigurationDefinition { Code = "operations.notification-retention-days", Name = "通知投递保留天数", Category = "operations", ValueType = PlatformConfigurationValueType.Number }, - new PlatformConfigurationDefinition { Code = "security.jwt-key-ring", Name = "JWT 密钥环", Category = "security", ValueType = PlatformConfigurationValueType.SecretReference, IsSensitive = true, AllowRuntimeManagement = false, Description = "安全配置只能通过部署环境变更。" } + new PlatformConfigurationDefinition + { + Code = "platform.support-contact", Name = "平台支持联系方式", Category = "platform", + ValueType = PlatformConfigurationValueType.String + }, + new PlatformConfigurationDefinition + { + Code = "operations.notification-retention-days", Name = "通知投递保留天数", Category = "operations", + ValueType = PlatformConfigurationValueType.Number + }, + new PlatformConfigurationDefinition + { + Code = "security.jwt-key-ring", Name = "JWT 密钥环", Category = "security", + ValueType = PlatformConfigurationValueType.SecretReference, IsSensitive = true, + AllowRuntimeManagement = false, Description = "安全配置只能通过部署环境变更。" + } }; var configurationCodes = configurationDefinitions.Select(item => item.Code).ToArray(); var existingConfigurationCodes = await dbContext.PlatformConfigurationDefinitions.AsNoTracking() .Where(item => configurationCodes.Contains(item.Code)).Select(item => item.Code) .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); - dbContext.PlatformConfigurationDefinitions.AddRange(configurationDefinitions.Where(item => !existingConfigurationCodes.Contains(item.Code))); + dbContext.PlatformConfigurationDefinitions.AddRange( + configurationDefinitions.Where(item => !existingConfigurationCodes.Contains(item.Code))); await dbContext.SaveChangesAsync(cancellationToken); foreach (var builtin in PlatformRoles) @@ -303,7 +398,8 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) .ToArrayAsync(cancellationToken); dbContext.PlatformBackendRolePermissions.RemoveRange(boundPermissions .Where(binding => !builtin.PermissionCodes.Contains(binding.PermissionCode, StringComparer.Ordinal))); - var boundPermissionCodes = boundPermissions.Select(binding => binding.PermissionCode).ToHashSet(StringComparer.Ordinal); + var boundPermissionCodes = boundPermissions.Select(binding => binding.PermissionCode) + .ToHashSet(StringComparer.Ordinal); dbContext.PlatformBackendRolePermissions.AddRange(builtin.PermissionCodes .Where(code => !boundPermissionCodes.Contains(code)) .Select(code => new PlatformBackendRolePermission { RoleId = role.Id, PermissionCode = code })); @@ -342,8 +438,33 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) } private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder); - private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder); - private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode); - private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder); - private sealed record BuiltinPlatformRole(string Code, string Name, string Description, string[] PermissionCodes, string[] MenuCodes); -} + + private sealed record BuiltinPermissionModule( + string Code, + string Name, + BackendPermissionArea Area, + string? RequiredFeatureCode, + int SortOrder); + + private sealed record BuiltinPermission( + string Code, + string Name, + BackendPermissionArea Area, + string PermissionModuleCode); + + private sealed record BuiltinMenu( + string Code, + string? ParentCode, + string Title, + BackendPermissionArea Area, + string Path, + string PermissionCode, + int SortOrder); + + private sealed record BuiltinPlatformRole( + string Code, + string Name, + string Description, + string[] PermissionCodes, + string[] MenuCodes); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs index 3457185..f566925 100644 --- a/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs @@ -24,10 +24,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext) .Select(feature => feature.Code) .ToArrayAsync(cancellationToken); if (availableFeatureCodes.Length != FeatureCodes.Length) - { throw new InvalidOperationException( "The built-in feature catalog must be seeded before the starter offering."); - } var now = DateTimeOffset.UtcNow; var offering = await dbContext.SaasOfferings @@ -87,7 +85,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext) } else if (version.Status == SaasOfferingVersionStatus.Retired) { - throw new InvalidOperationException("The built-in starter offering version 1 is retired and cannot be repaired automatically."); + throw new InvalidOperationException( + "The built-in starter offering version 1 is retired and cannot be repaired automatically."); } var existingFeatureCodes = await dbContext.SaasOfferingVersionFeatures @@ -98,10 +97,8 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext) .Where(code => !existingFeatureCodes.Contains(code)) .ToArray(); if (version.Status == SaasOfferingVersionStatus.Published && missingFeatureCodes.Length > 0) - { throw new InvalidOperationException( "The published built-in starter offering is missing required features and cannot be repaired in place."); - } dbContext.SaasOfferingVersionFeatures.AddRange(missingFeatureCodes.Select(code => new SaasOfferingVersionFeature @@ -120,4 +117,4 @@ public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext) await dbContext.SaveChangesAsync(cancellationToken); } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs index 3961d0e..ef790ae 100644 --- a/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs @@ -5,10 +5,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; using Tiku.Application.Security; +using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.Growth; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -188,7 +188,6 @@ public static class DevelopmentPlatformAdminSeeder .Select(module => module.Code) .ToHashSet(StringComparer.Ordinal); foreach (var code in moduleCodes.Where(code => !existingModuleCodes.Contains(code))) - { dbContext.PermissionModules.Add(new PermissionModule { Code = code, @@ -196,14 +195,12 @@ public static class DevelopmentPlatformAdminSeeder Area = BackendPermissionArea.Platform, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] }); - } var existingPermissionCodes = dbContext.BackendPermissions .Where(permission => permissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToHashSet(StringComparer.Ordinal); foreach (var code in permissionCodes.Where(code => !existingPermissionCodes.Contains(code))) - { dbContext.BackendPermissions.Add(new BackendPermission { Code = code, @@ -213,16 +210,12 @@ public static class DevelopmentPlatformAdminSeeder Description = "Built-in platform permission.", IsSystem = true }); - } var superAdminRoleId = dbContext.PlatformBackendRoles .Where(role => role.Code == RoleCode) .Select(role => (Guid?)role.Id) .FirstOrDefault(); - if (superAdminRoleId is null) - { - return; - } + if (superAdminRoleId is null) return; var boundPermissionCodes = dbContext.PlatformBackendRolePermissions .Where(binding => binding.RoleId == superAdminRoleId.Value) @@ -251,10 +244,7 @@ public static class DevelopmentPlatformAdminSeeder private static Tenant EnsureTenant(TikuDbContext dbContext, string slug, string name) { var tenant = dbContext.Tenants.SingleOrDefault(value => value.Slug == slug); - if (tenant is not null) - { - return tenant; - } + if (tenant is not null) return tenant; tenant = new Tenant { @@ -277,7 +267,6 @@ public static class DevelopmentPlatformAdminSeeder private static void EnsureCrmDemo(TikuDbContext dbContext, Tenant tenant) { if (!dbContext.CrmConfigs.Any(value => value.TenantId == tenant.Id)) - { dbContext.CrmConfigs.Add(new CrmConfig { TenantId = tenant.Id, @@ -292,10 +281,9 @@ public static class DevelopmentPlatformAdminSeeder AssignmentPool = JsonSerializer.SerializeToElement(new[] { "顾问一", "顾问二" }), AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3, source = "development_seed" }) }); - } - if (!dbContext.CrmWebhookQueue.Any(value => value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead")) - { + if (!dbContext.CrmWebhookQueue.Any(value => + value.TenantId == tenant.Id && value.IdempotencyKey == "demo-crm-failed-lead")) dbContext.CrmWebhookQueue.Add(new CrmWebhookQueueItem { TenantId = tenant.Id, @@ -311,13 +299,13 @@ public static class DevelopmentPlatformAdminSeeder IdempotencyKey = "demo-crm-failed-lead", Payload = JsonSerializer.SerializeToElement(new { student = "王同学", phoneMasked = "138****0001" }) }); - } } private static void EnsureSmsDemo(TikuDbContext dbContext, Tenant tenant, string provider, string templateName) { var scene = templateName.Contains("登录", StringComparison.Ordinal) ? "login" : "marketing"; - var channel = dbContext.SmsChannels.SingleOrDefault(value => value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene); + var channel = dbContext.SmsChannels.SingleOrDefault(value => + value.TenantId == tenant.Id && value.Provider == provider && value.Scene == scene); if (channel is null) { channel = new SmsChannel @@ -337,7 +325,8 @@ public static class DevelopmentPlatformAdminSeeder } var templateCode = $"demo_{provider}_{scene}"; - var template = dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode); + var template = + dbContext.SmsTemplates.SingleOrDefault(value => value.TenantId == tenant.Id && value.Code == templateCode); if (template is null) { template = new SmsTemplate @@ -357,8 +346,8 @@ public static class DevelopmentPlatformAdminSeeder dbContext.SmsTemplates.Add(template); } - if (!dbContext.SmsSendLogs.Any(value => value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001")) - { + if (!dbContext.SmsSendLogs.Any(value => + value.TenantId == tenant.Id && value.ProviderMessageId == $"demo-{provider}-{scene}-001")) dbContext.SmsSendLogs.Add(new SmsSendLog { TenantId = tenant.Id, @@ -372,7 +361,6 @@ public static class DevelopmentPlatformAdminSeeder SentAt = DateTimeOffset.UtcNow, Metadata = JsonSerializer.SerializeToElement(new { demo = true }) }); - } } private static void EnsurePlatformPaymentDemo(TikuDbContext dbContext, Tenant tenant) @@ -393,7 +381,6 @@ public static class DevelopmentPlatformAdminSeeder } if (!dbContext.PlatformPaymentChannels.Any(value => value.AppId == app.Id && value.Provider == "manual")) - { dbContext.PlatformPaymentChannels.Add(new PlatformPaymentChannel { AppId = app.Id, @@ -405,7 +392,6 @@ public static class DevelopmentPlatformAdminSeeder CallbackPath = "/api/platform-billing/payments/notify/manual", ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true }) }); - } if (!dbContext.PlatformBillingPayments.Any(value => value.PaymentNo == "PB-DEMO-MANUAL-001")) { @@ -460,8 +446,9 @@ public static class DevelopmentPlatformAdminSeeder private static void EnsureTenantPaymentDemo(TikuDbContext dbContext, Tenant tenant) { - if (!dbContext.TenantExternalProviders.Any(value => value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && value.Provider == "manual")) - { + if (!dbContext.TenantExternalProviders.Any(value => + value.TenantId == tenant.Id && value.Capability == TenantExternalProviderCapability.Payment && + value.Provider == "manual")) dbContext.TenantExternalProviders.Add(new TenantExternalProvider { TenantId = tenant.Id, @@ -473,18 +460,16 @@ public static class DevelopmentPlatformAdminSeeder ConfigPublic = JsonSerializer.SerializeToElement(new { manual = true }), Metadata = JsonSerializer.SerializeToElement(new { demo = true }) }); - } } - private static string GenerateTemporaryPassword() => - $"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a"; + private static string GenerateTemporaryPassword() + { + return $"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a"; + } private static void ReloadPostgresTypes(TikuDbContext dbContext) { - if (dbContext.Database.IsNpgsql()) - { - ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes(); - } + if (dbContext.Database.IsNpgsql()) ((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes(); } private static async Task ReloadPostgresTypesAsync( @@ -492,19 +477,15 @@ public static class DevelopmentPlatformAdminSeeder CancellationToken cancellationToken) { if (dbContext.Database.IsNpgsql()) - { await ((NpgsqlConnection)dbContext.Database.GetDbConnection()) .ReloadTypesAsync(cancellationToken); - } } private static void EnsureEmailIsAvailable(bool isAssigned) { if (isAssigned) - { throw new InvalidOperationException( $"Cannot create the Development platform administrator because '{Email}' is already assigned."); - } } private static void WriteFirstLoginInstructions(string temporaryPassword) @@ -514,4 +495,4 @@ public static class DevelopmentPlatformAdminSeeder Console.WriteLine($" Temporary password: {temporaryPassword}"); Console.WriteLine(" Change the temporary password at first sign-in. This password is shown only once."); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs index 965cef2..da1121a 100644 --- a/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs +++ b/Tiku.Infrastructure/Bootstrap/PlatformAdminBootstrapper.cs @@ -31,52 +31,46 @@ public sealed class PlatformAdminBootstrapper( ArgumentNullException.ThrowIfNull(options); var email = options.Email.Trim(); if (email.Length == 0) - { throw new ArgumentException("Platform administrator email is required.", nameof(options)); - } if (string.IsNullOrWhiteSpace(options.TemporaryPassword)) - { throw new ArgumentException("Platform administrator temporary password is required.", nameof(options)); - } IDbContextTransaction? transaction = null; if (dbContext.Database.IsRelational()) - { - transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); - } + transaction = + await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); await using (transaction) { var existingAdministrator = await ( - from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() - join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole.Id - join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id - where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active - select boundUser.Id) + from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() + join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole + .Id + join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id + where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active + select boundUser.Id) .AnyAsync(cancellationToken); if (existingAdministrator) - { throw new PlatformAdminBootstrapException( "A platform administrator already exists. Bootstrap is a one-time operation.", "platform_admin_already_exists"); - } var normalizedEmail = userManager.NormalizeEmail(email); if (await dbContext.Users.AsNoTracking().AnyAsync( user => user.NormalizedEmail == normalizedEmail || user.NormalizedUserName == normalizedEmail, cancellationToken)) - { throw new PlatformAdminBootstrapException( "The bootstrap email is already assigned to a user.", "bootstrap_user_already_exists"); - } var user = new User { Email = email, UserName = email, - Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(), + Name = string.IsNullOrWhiteSpace(options.DisplayName) + ? "Platform Administrator" + : options.DisplayName.Trim(), EmailConfirmed = true, Status = UserStatus.Active, ForcePasswordChange = true @@ -84,7 +78,8 @@ public sealed class PlatformAdminBootstrapper( var createResult = await userManager.CreateAsync(user, options.TemporaryPassword); if (!createResult.Succeeded) { - var errors = string.Join(", ", createResult.Errors.Select(error => $"{error.Code}: {error.Description}")); + var errors = string.Join(", ", + createResult.Errors.Select(error => $"{error.Code}: {error.Description}")); throw new PlatformAdminBootstrapException( $"Platform administrator could not be created: {errors}", "bootstrap_user_invalid"); @@ -121,8 +116,8 @@ public sealed class PlatformAdminBootstrapper( .Where(permission => platformPermissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); - foreach (var permissionCode in platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code))) - { + foreach (var permissionCode in + platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code))) dbContext.BackendPermissions.Add(new BackendPermission { Code = permissionCode, @@ -132,7 +127,6 @@ public sealed class PlatformAdminBootstrapper( Description = "Built-in platform permission.", IsSystem = true }); - } dbContext.PlatformBackendRolePermissions.AddRange( platformPermissionCodes.Select(permissionCode => new PlatformBackendRolePermission @@ -161,10 +155,7 @@ public sealed class PlatformAdminBootstrapper( }); await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new PlatformAdminBootstrapResult(user.Id, role.Id, email); } @@ -174,4 +165,4 @@ public sealed class PlatformAdminBootstrapper( public sealed class PlatformAdminBootstrapException(string message, string code) : InvalidOperationException(message) { public string Code { get; } = code; -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Catalog/CatalogQueryService.cs b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs index 4b42a7e..a9406d1 100644 --- a/Tiku.Infrastructure/Catalog/CatalogQueryService.cs +++ b/Tiku.Infrastructure/Catalog/CatalogQueryService.cs @@ -54,10 +54,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery module.TenantId == filter.TenantId && module.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(module => module.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(module => module.RegionId == filter.RegionId.Value); query = ApplyKeyword(query, filter.Keyword); @@ -94,29 +91,15 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery node.TenantId == filter.TenantId && node.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(node => node.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value); - if (filter.ModuleId.HasValue) - { - query = query.Where(node => node.ModuleId == filter.ModuleId.Value); - } + if (filter.ModuleId.HasValue) query = query.Where(node => node.ModuleId == filter.ModuleId.Value); if (filter.ParentIsRoot) - { query = query.Where(node => node.ParentId == null); - } - else if (filter.ParentId.HasValue) - { - query = query.Where(node => node.ParentId == filter.ParentId.Value); - } + else if (filter.ParentId.HasValue) query = query.Where(node => node.ParentId == filter.ParentId.Value); - if (TryParseModuleNodeType(filter.Type, out var nodeType)) - { - query = query.Where(node => node.Type == nodeType); - } + if (TryParseModuleNodeType(filter.Type, out var nodeType)) query = query.Where(node => node.Type == nodeType); query = ApplyKeyword(query, filter.Keyword); @@ -150,15 +133,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery .AsNoTracking() .Where(school => school.TenantId == filter.TenantId); - if (filter.RegionId.HasValue) - { - query = query.Where(school => school.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(school => school.RegionId == filter.RegionId.Value); - if (filter.ModuleId.HasValue) - { - query = query.Where(school => school.ModuleId == filter.ModuleId.Value); - } + if (filter.ModuleId.HasValue) query = query.Where(school => school.ModuleId == filter.ModuleId.Value); query = ApplyKeyword(query, filter.Keyword); @@ -189,15 +166,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery major.TenantId == filter.TenantId && major.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(major => major.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(major => major.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(major => major.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(major => major.SchoolId == filter.SchoolId.Value); query = ApplyKeyword(query, filter.Keyword); @@ -230,30 +201,16 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery subject.TenantId == filter.TenantId && subject.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(subject => subject.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(subject => subject.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value); - if (filter.MajorId.HasValue) - { - query = query.Where(subject => subject.MajorId == filter.MajorId.Value); - } + if (filter.MajorId.HasValue) query = query.Where(subject => subject.MajorId == filter.MajorId.Value); - if (filter.ModuleId.HasValue) - { - query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value); - } + if (filter.ModuleId.HasValue) query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value); if (TryParseSubjectType(filter.Type, out var subjectType)) - { query = query.Where(subject => subject.Type == subjectType); - } query = ApplyKeyword(query, filter.Keyword); @@ -291,20 +248,12 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery category.TenantId == filter.TenantId && category.IsActive); - if (filter.SubjectId.HasValue) - { - query = query.Where(category => category.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(category => category.SubjectId == filter.SubjectId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(category => category.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(category => category.NodeId == filter.NodeId.Value); if (TryParseCategoryType(filter.Type, out var categoryType)) - { query = query.Where(category => category.CategoryType == categoryType); - } query = ApplyKeyword(query, filter.Keyword); @@ -337,10 +286,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery banner.TenantId == filter.TenantId && banner.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(banner => banner.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(banner => banner.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -383,10 +329,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery faq.TenantId == filter.TenantId && faq.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(faq => faq.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(faq => faq.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -458,23 +401,17 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery examDate.IsActive); if (filter.RegionId.HasValue) - { query = query.Where(examDate => examDate.RegionId == filter.RegionId.Value || examDate.RegionId == null); - } if (filter.SchoolId.HasValue) - { query = query.Where(examDate => examDate.SchoolId == filter.SchoolId.Value || examDate.SchoolId == null); - } if (!string.IsNullOrWhiteSpace(filter.Type)) - { query = query.Where(examDate => examDate.ExamType == filter.Type.Trim()); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -520,7 +457,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery examDate.SortOrder, examDate.IsActive, examDate.ExamAt.HasValue - ? (int?)(examDate.ExamAt.Value.Date - today).Days + ? (examDate.ExamAt.Value.Date - today).Days : null)) .ToArray(); @@ -537,15 +474,10 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery product.TenantId == filter.TenantId && product.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(product => product.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(product => product.RegionId == filter.RegionId.Value); if (TryParseProductType(filter.Type, out var productType)) - { query = query.Where(product => product.Type == productType); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -588,11 +520,9 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery plan.IsActive); if (filter.RegionId.HasValue) - { query = query.Where(plan => plan.RegionId == filter.RegionId.Value || plan.RegionId == null); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -633,10 +563,7 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery private static IQueryable ApplyKeyword(IQueryable query, string? keyword) where T : class { - if (string.IsNullOrWhiteSpace(keyword)) - { - return query; - } + if (string.IsNullOrWhiteSpace(keyword)) return query; var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, nameof(Region.Name)).Contains(trimmed)); @@ -649,22 +576,22 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery private static bool TryParseSubjectType(string? value, out SubjectType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseModuleNodeType(string? value, out ModuleNodeType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseCategoryType(string? value, out CategoryType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseProductType(string? value, out ProductType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static string? NormalizeEnumValue(string? value) @@ -674,4 +601,4 @@ public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQuery : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Catalog/TaxonomyService.cs b/Tiku.Infrastructure/Catalog/TaxonomyService.cs index ffe275d..48d452b 100644 --- a/Tiku.Infrastructure/Catalog/TaxonomyService.cs +++ b/Tiku.Infrastructure/Catalog/TaxonomyService.cs @@ -86,22 +86,22 @@ public sealed class TaxonomyService( _ => throw new InvalidOperationException("A parent source is required when parentId is provided.") }; parent = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), - "Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")), - async (provider, token) => - { - var systemDbContext = provider.GetRequiredService(); - return await systemDbContext.TaxonomyNodes.AsNoTracking() - .Where(node => - node.TenantId == parentOwnerTenantId && - node.Id == command.ParentId.Value && - node.IsActive) - .Select(node => new TaxonomyParent(node.Path, node.Depth)) - .SingleOrDefaultAsync(token); - }, - cancellationToken) - ?? throw new InvalidOperationException("Taxonomy parent was not found."); + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), + "Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")), + async (provider, token) => + { + var systemDbContext = provider.GetRequiredService(); + return await systemDbContext.TaxonomyNodes.AsNoTracking() + .Where(node => + node.TenantId == parentOwnerTenantId && + node.Id == command.ParentId.Value && + node.IsActive) + .Select(node => new TaxonomyParent(node.Path, node.Depth)) + .SingleOrDefaultAsync(token); + }, + cancellationToken) + ?? throw new InvalidOperationException("Taxonomy parent was not found."); } var node = new TaxonomyNode @@ -151,4 +151,4 @@ public sealed class TaxonomyService( } private sealed record TaxonomyParent(string? Path, int Depth); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs b/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs index a042fed..2ec27fa 100644 --- a/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs +++ b/Tiku.Infrastructure/Commerce/ActivationCodes/CommerceAdminService.ActivationCodes.cs @@ -1,16 +1,8 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -23,23 +15,18 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); if (command.TotalCount is < 1 or > 1000) - { - throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count"); - } + throw new CommerceException("Code batch total count must be between 1 and 1000.", + "invalid_code_batch_count"); if (command.Days <= 0) - { throw new CommerceException("Activation code days must be positive.", "invalid_activation_days"); - } if (command.RegionId.HasValue) { var regionExists = await dbContext.Regions - .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); - if (!regionExists) - { - throw new CommerceException("Region was not found.", "region_not_found"); - } + .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, + cancellationToken); + if (!regionExists) throw new CommerceException("Region was not found.", "region_not_found"); } var batch = new CodeBatch @@ -59,7 +46,6 @@ internal sealed partial class CommerceAdminService }; dbContext.CodeBatches.Add(batch); for (var index = 0; index < command.TotalCount; index++) - { dbContext.ActivationCodes.Add(new ActivationCode { TenantId = actor.TenantId, @@ -70,7 +56,6 @@ internal sealed partial class CommerceAdminService UnitPriceCents = batch.DefaultUnitPriceCents, Remark = batch.Remark }); - } await dbContext.SaveChangesAsync(cancellationToken); return ToCodeBatchItem(batch); @@ -104,25 +89,20 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); var code = await dbContext.ActivationCodes - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Code == command.Code.Trim(), - cancellationToken) - ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); - if (code.IsUsed) - { - throw new CommerceException("Activation code has already been used.", "activation_code_used"); - } + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Code == command.Code.Trim(), + cancellationToken) + ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); + if (code.IsUsed) throw new CommerceException("Activation code has already been used.", "activation_code_used"); var userIsMember = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == command.UserId && - item.Status == MembershipStatus.Active, + item.TenantId == actor.TenantId && + item.UserId == command.UserId && + item.Status == MembershipStatus.Active, cancellationToken); if (!userIsMember) - { throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found"); - } code.IsUsed = true; code.UsedBy = command.UserId; @@ -144,6 +124,4 @@ internal sealed partial class CommerceAdminService await dbContext.SaveChangesAsync(cancellationToken); return ToActivationCodeItem(code); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs index 1fc76c0..eb488a4 100644 --- a/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs +++ b/Tiku.Infrastructure/Commerce/Adjustments/CommerceAdminService.Adjustments.cs @@ -1,16 +1,8 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -25,9 +17,7 @@ internal sealed partial class CommerceAdminService var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) - { vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); - } var items = await vouchers .OrderByDescending(item => item.CreatedAt) @@ -43,8 +33,9 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); return await dbContext.CommerceAdjustmentVouchers.AsNoTracking() - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) - ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, + cancellationToken) + ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); } public async Task CreateAdjustmentVoucherAsync( @@ -54,12 +45,18 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); - await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, + "reconciliation_issue_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, + "reconciliation_batch_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, + "reconciliation_item_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", + cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", + cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, + "refund_not_found", cancellationToken); var voucher = new CommerceAdjustmentVoucher { TenantId = actor.TenantId, @@ -89,7 +86,8 @@ internal sealed partial class CommerceAdminService Note = voucher.Reason, Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents }) }); - await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); + await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, + new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return voucher; } @@ -101,13 +99,13 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, - cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, + cancellationToken) ?? + throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); var fromStatus = voucher.Status; if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status)) - { - throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition"); - } + throw new CommerceException("Adjustment voucher status transition is invalid.", + "invalid_adjustment_status_transition"); voucher.Status = command.Status; if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected) @@ -130,7 +128,8 @@ internal sealed partial class CommerceAdminService Note = command.Note, Details = JsonSerializer.SerializeToElement(new { }) }); - await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); + await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", + voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return voucher; } @@ -144,10 +143,7 @@ internal sealed partial class CommerceAdminService var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken); - if (!exists) - { - throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); - } + if (!exists) throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) @@ -162,15 +158,27 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); return new TenantAdjustmentReport( - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), - await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, + cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, + cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, + cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, + cancellationToken), await dbContext.CommerceAdjustmentVouchers - .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) + .Where(item => + item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && + item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) .SumAsync(item => item.AmountCents, cancellationToken), await dbContext.CommerceAdjustmentVouchers - .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) + .Where(item => + item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && + item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) .SumAsync(item => item.AmountCents, cancellationToken)); } @@ -184,9 +192,7 @@ internal sealed partial class CommerceAdminService item => item.TenantId == actor.TenantId && item.Id == batchId, cancellationToken); if (!batchExists) - { throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found"); - } var items = await dbContext.CommerceReconciliationItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) @@ -206,9 +212,7 @@ internal sealed partial class CommerceAdminService item => item.TenantId == actor.TenantId && item.Id == issueId, cancellationToken); if (!issueExists) - { throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); - } var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) @@ -229,7 +233,8 @@ internal sealed partial class CommerceAdminService item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, cancellationToken); var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync( - item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, + item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && + item.Status != ReconciliationIssueStatus.Ignored, cancellationToken); var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync( item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, @@ -299,10 +304,10 @@ internal sealed partial class CommerceAdminService foreach (var row in EnumerateImportRows(command.Rows)) { rowNo++; - var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row); + var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), + row); dbContext.CommerceReconciliationItems.Add(item); if (item.MatchStatus != ReconciliationMatchStatus.Matched) - { dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue { TenantId = actor.TenantId, @@ -330,10 +335,10 @@ internal sealed partial class CommerceAdminService CreatedBy = actor.UserId, Metadata = item.Details }); - } } - await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, + new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return batch; } @@ -357,7 +362,8 @@ internal sealed partial class CommerceAdminService command.RunAfter, 5), cancellationToken); - await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, + new { command.Provider, command.BillDate, command.BillType }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return job; } @@ -368,7 +374,8 @@ internal sealed partial class CommerceAdminService CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); + return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation", + Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); } public async Task ProcessRefundNotificationAsync( @@ -389,10 +396,7 @@ internal sealed partial class CommerceAdminService item.EventType == "refund" && item.EventId == eventId, cancellationToken); - if (duplicate) - { - return refund; - } + if (duplicate) return refund; dbContext.PaymentEvents.Add(new PaymentEvent { @@ -435,6 +439,4 @@ internal sealed partial class CommerceAdminService await dbContext.SaveChangesAsync(cancellationToken); return refund; } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/AlipayProvider.cs b/Tiku.Infrastructure/Commerce/AlipayProvider.cs index ac08a4f..da3d0d0 100644 --- a/Tiku.Infrastructure/Commerce/AlipayProvider.cs +++ b/Tiku.Infrastructure/Commerce/AlipayProvider.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using Aop.Api; using Aop.Api.Domain; @@ -20,10 +21,7 @@ internal sealed class AlipayProvider : IPaymentProvider var client = BuildClient(account); var alipayRequest = new AlipayTradeWapPayRequest(); alipayRequest.SetNotifyUrl(request.NotifyUrl); - if (!string.IsNullOrWhiteSpace(request.ReturnUrl)) - { - alipayRequest.SetReturnUrl(request.ReturnUrl); - } + if (!string.IsNullOrWhiteSpace(request.ReturnUrl)) alipayRequest.SetReturnUrl(request.ReturnUrl); alipayRequest.SetBizModel(new AlipayTradeWapPayModel { @@ -35,9 +33,7 @@ internal sealed class AlipayProvider : IPaymentProvider }); var response = client.pageExecute(alipayRequest); if (string.IsNullOrWhiteSpace(response.Body)) - { throw new PaymentProviderException("Alipay create payment returned an empty body.", "alipay_create_failed"); - } var clientPayload = JsonSerializer.SerializeToElement(new { @@ -74,7 +70,8 @@ internal sealed class AlipayProvider : IPaymentProvider false); var eventId = GetValue(values, "notify_id") ?? GetValue(values, "trade_no") ?? Guid.NewGuid().ToString("N"); var orderNo = GetValue(values, "out_trade_no") - ?? throw new PaymentProviderException("Alipay notification order number is missing.", "alipay_notify_order_missing"); + ?? throw new PaymentProviderException("Alipay notification order number is missing.", + "alipay_notify_order_missing"); var tradeStatus = GetValue(values, "trade_status"); var amountCents = YuanToCents(GetValue(values, "total_amount") ?? GetValue(values, "receipt_amount")); DateTimeOffset? paidAt = DateTimeOffset.TryParse(GetValue(values, "gmt_payment"), out var parsedPaidAt) @@ -112,17 +109,11 @@ internal sealed class AlipayProvider : IPaymentProvider private static string Required(JsonElement element, params string[] keys) { if (element.ValueKind == JsonValueKind.Object) - { foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString()!; - } - } - } throw new PaymentProviderException( "Alipay provider configuration is incomplete.", @@ -132,44 +123,34 @@ internal sealed class AlipayProvider : IPaymentProvider private static Dictionary ToDictionary(JsonElement element) { var values = new Dictionary(StringComparer.Ordinal); - if (element.ValueKind != JsonValueKind.Object) - { - return values; - } + if (element.ValueKind != JsonValueKind.Object) return values; foreach (var property in element.EnumerateObject()) - { values[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.GetRawText(); - } return values; } private static string? GetString(JsonElement element, params string[] keys) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String) - { return property.GetString(); - } - } return null; } - private static string? GetValue(IReadOnlyDictionary values, string key) => - values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + private static string? GetValue(IReadOnlyDictionary values, string key) + { + return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null; + } private static int YuanToCents(string? value) { @@ -178,6 +159,8 @@ internal sealed class AlipayProvider : IPaymentProvider : 0; } - private static string FormatYuan(int cents) => - (cents / 100m).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); -} + private static string FormatYuan(int cents) + { + return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index 0f201fb..38642dc 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -1,16 +1,8 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -22,5 +14,4 @@ internal sealed partial class CommerceAdminService( IBackgroundJobQueue backgroundJobQueue, IBackgroundJobOperations backgroundJobOperations) : ICommerceAdminService { - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs index 5cd9008..dbcefe0 100644 --- a/Tiku.Infrastructure/Commerce/CommerceService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceService.cs @@ -1,11 +1,5 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -15,6 +9,4 @@ public sealed partial class CommerceService( IPaymentProviderGateway paymentGateway) : ICommerceService { private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents); - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs b/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs index 3b6c292..56cdb2f 100644 --- a/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs +++ b/Tiku.Infrastructure/Commerce/Coupons/CommerceAdminService.Coupons.cs @@ -1,16 +1,6 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -73,9 +63,7 @@ internal sealed partial class CommerceAdminService var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) - { redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); - } var items = await redemptions .OrderByDescending(item => item.CreatedAt) @@ -90,16 +78,16 @@ internal sealed partial class CommerceAdminService CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); + var couponCount = + await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var claimedCount = await redemptions.CountAsync(cancellationToken); - var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); + var usedCount = + await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); var discountApplied = await redemptions .Where(item => item.Status == CouponRedemptionStatus.Used) .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0; return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs b/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs index a4e7f9c..a2fc2c3 100644 --- a/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs +++ b/Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs @@ -1,12 +1,6 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -29,15 +23,10 @@ public sealed partial class CommerceService item.CouponId == coupon.Id) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (existing is not null) - { - return ToCouponItem(coupon, existing, null); - } + if (existing is not null) return ToCouponItem(coupon, existing, null); if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } var redemption = new CouponRedemption { @@ -66,9 +55,7 @@ public sealed partial class CommerceService .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) - { redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); - } var items = await redemptions .OrderByDescending(item => item.CreatedAt) @@ -99,18 +86,16 @@ public sealed partial class CommerceService { await AssertActiveMemberAsync(actor, cancellationToken); if (command.Quantity is < 1 or > 99) - { throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); - } var plan = await dbContext.SvipPlans - .AsNoTracking() - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Id == command.PlanId && - item.IsActive, - cancellationToken) - ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); + .AsNoTracking() + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Id == command.PlanId && + item.IsActive, + cancellationToken) + ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); var originalAmountCents = checked(plan.PriceCents * command.Quantity); try { @@ -140,6 +125,4 @@ public sealed partial class CommerceService FormatCny(originalAmountCents)); } } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs b/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs index 25512e8..b1cdfe0 100644 --- a/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs +++ b/Tiku.Infrastructure/Commerce/Entitlements/CommerceService.Entitlements.cs @@ -1,12 +1,6 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -29,10 +23,7 @@ public sealed partial class CommerceService .OrderByDescending(item => item.ExpiresAt) .FirstOrDefaultAsync(cancellationToken); - if (entitlement is null) - { - return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null); - } + if (entitlement is null) return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null); return new CurrentEntitlementItem( true, @@ -44,6 +35,4 @@ public sealed partial class CommerceService ? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays)) : null); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs index f3b6754..c5d0c84 100644 --- a/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceAdminService.Foundation.cs @@ -1,16 +1,15 @@ using System.Globalization; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; +using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity; namespace Tiku.Infrastructure.Commerce; @@ -23,9 +22,7 @@ internal sealed partial class CommerceAdminService access.UserId != actor.UserId || access.TenantId != actor.TenantId || !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate)) - { throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); - } } private async Task RequireDataScopeAsync( @@ -36,8 +33,9 @@ internal sealed partial class CommerceAdminService return (await currentAccessContext.GetAsync(cancellationToken)).DataScope; } - private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => - new( + private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) + { + return new TenantPaymentProviderItem( item.Id, item.Provider, GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect", @@ -48,71 +46,118 @@ internal sealed partial class CommerceAdminService item.ConfigPublic, item.CreatedAt, item.UpdatedAt); + } - private static TenantSecretItem ToSecretItem(TenantSecret item) => - new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt); + private static TenantSecretItem ToSecretItem(TenantSecret item) + { + return new TenantSecretItem(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, + item.Status.ToString(), + item.RotatedAt, item.ExpiresAt, item.UpdatedAt); + } - private static CodeBatchItem ToCodeBatchItem(CodeBatch item) => - new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt); + private static CodeBatchItem ToCodeBatchItem(CodeBatch item) + { + return new CodeBatchItem(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, + item.Channel, + item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt); + } - private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) => - new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt); + private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) + { + return new ActivationCodeItem(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, + item.UsedAt, item.SaleType, + item.SoldTo, item.Remark, item.CreatedAt); + } - private static CommerceOrderItem ToOrderItem(Order order) => - new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); + private static CommerceOrderItem ToOrderItem(Order order) + { + return new CommerceOrderItem(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, + order.ProductType, + order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, + order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); + } - private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) => - new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload); + private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) + { + return new CommercePaymentItem(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, + payment.Status.ToString(), + payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, + JsonSerializer.SerializeToElement(new { }), payment.RawPayload); + } - private static OrderStatus ParseOrderStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static OrderStatus ParseOrderStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Order status is invalid.", "invalid_order_status"); + } - private static PaymentStatus ParsePaymentStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static PaymentStatus ParsePaymentStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Payment status is invalid.", "invalid_payment_status"); + } - private static PointActivityTaskStatus ParsePointTaskStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static PointActivityTaskStatus ParsePointTaskStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point task status is invalid.", "invalid_point_task_status"); + } - private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed - : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status"); + : throw new CommerceException("Point exchange item status is invalid.", + "invalid_point_exchange_item_status"); + } - private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed - : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); + : throw new CommerceException("Point exchange order status is invalid.", + "invalid_point_exchange_order_status"); + } - private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status"); + } - private static CommerceRefundStatus ParseRefundStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static CommerceRefundStatus ParseRefundStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Refund status is invalid.", "invalid_refund_status"); + } - private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed - : throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status"); + : throw new CommerceException("Reconciliation batch status is invalid.", + "invalid_reconciliation_batch_status"); + } - private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed - : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status"); + : throw new CommerceException("Reconciliation issue status is invalid.", + "invalid_reconciliation_issue_status"); + } - private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) => - Enum.TryParse(NormalizeEnum(status), true, out var parsed) + private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) + { + return Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status"); + } private async Task AssertOptionalReferenceAsync( DbSet set, @@ -122,18 +167,12 @@ internal sealed partial class CommerceAdminService CancellationToken cancellationToken) where TEntity : class { - if (!id.HasValue) - { - return; - } + if (!id.HasValue) return; var exists = await set.AnyAsync( item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); - if (!exists) - { - throw new CommerceException("Referenced commerce entity was not found.", code); - } + if (!exists) throw new CommerceException("Referenced commerce entity was not found.", code); } private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) @@ -156,7 +195,8 @@ internal sealed partial class CommerceAdminService cancellationToken); if (payment is not null) { - payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents); + payment.RefundedAmountCents = + Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents); payment.Status = payment.RefundedAmountCents >= payment.AmountCents ? PaymentStatus.Refunded : PaymentStatus.PartiallyRefunded; @@ -168,7 +208,8 @@ internal sealed partial class CommerceAdminService { return from switch { - CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled, + CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected + or CommerceRefundStatus.Cancelled, CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed, CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled, @@ -176,12 +217,15 @@ internal sealed partial class CommerceAdminService }; } - private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to) + private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, + CommerceAdjustmentVoucherStatus to) { return from switch { - CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void, - CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview + or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved + or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void, CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed, _ => false }; @@ -216,7 +260,7 @@ internal sealed partial class CommerceAdminService CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, @@ -228,12 +272,15 @@ internal sealed partial class CommerceAdminService return Task.CompletedTask; } - private static string NormalizeEnum(string? value) => - string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); + private static string NormalizeEnum(string? value) + { + return string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); + } private static string NormalizeProvider(string? provider) { - var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant() + .Replace("-", "_", StringComparison.Ordinal); return normalized switch { "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, @@ -247,12 +294,8 @@ internal sealed partial class CommerceAdminService { var values = new Dictionary(StringComparer.Ordinal); if (element.ValueKind == JsonValueKind.Object) - { foreach (var property in element.EnumerateObject()) - { values[property.Name] = property.Value.Clone(); - } - } values["mode"] = JsonSerializer.SerializeToElement( string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim()); @@ -261,48 +304,35 @@ internal sealed partial class CommerceAdminService private static string? GetJsonString(JsonElement element, params string[] keys) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) - { if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) - { return value.GetString(); - } - } return null; } - private static JsonElement JsonObjectOrDefault(JsonElement element) => - element.ValueKind == JsonValueKind.Object + private static JsonElement JsonObjectOrDefault(JsonElement element) + { + return element.ValueKind == JsonValueKind.Object ? element.Clone() : JsonSerializer.SerializeToElement(new { }); + } private static void AssertNoSecrets(JsonElement element, string path) { - if (element.ValueKind != JsonValueKind.Object) - { - return; - } + if (element.ValueKind != JsonValueKind.Object) return; foreach (var property in element.EnumerateObject()) { var key = property.Name.ToLowerInvariant(); - if (key is "secretref" or "secret_ref") - { - continue; - } + if (key is "secretref" or "secret_ref") continue; if (key.Contains("secret", StringComparison.Ordinal) || key.Contains("privatekey", StringComparison.Ordinal) || key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret") - { throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret"); - } AssertNoSecrets(property.Value, $"{path}.{property.Name}"); } @@ -317,7 +347,9 @@ internal sealed partial class CommerceAdminService var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched); var amountCents = parsedRows.Sum(row => row.AmountCents); var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents); - var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant(); + var sourceHash = Convert + .ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))) + .ToLowerInvariant(); return new ReconciliationImportPreview( parsedRows.Length, paymentCount, @@ -328,26 +360,10 @@ internal sealed partial class CommerceAdminService sourceHash); } - private sealed record ReconciliationImportRow( - ReconciliationTransactionType TransactionType, - string? ProviderTradeNo, - string? ProviderRefundNo, - string? OrderNo, - string? RefundNo, - int AmountCents, - int RefundAmountCents, - string? ProviderStatus, - string? LocalStatus, - ReconciliationMatchStatus MatchStatus, - string? IssueCode, - JsonElement Details); - private static IEnumerable EnumerateImportRows(JsonElement rows) { if (rows.ValueKind != JsonValueKind.Array) - { throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows"); - } foreach (var row in rows.EnumerateArray()) { @@ -401,8 +417,9 @@ internal sealed partial class CommerceAdminService } } - private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) => - new( + private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) + { + return new ReconciliationImportRow( ReconciliationTransactionType.Payment, null, null, @@ -415,14 +432,16 @@ internal sealed partial class CommerceAdminService ReconciliationMatchStatus.AmountMismatch, issueCode, row.Clone()); + } private static CommerceReconciliationItem CreateReconciliationItem( Guid tenantId, Guid batchId, int rowNo, string provider, - ReconciliationImportRow row) => - new() + ReconciliationImportRow row) + { + return new CommerceReconciliationItem { TenantId = tenantId, BatchId = batchId, @@ -438,29 +457,24 @@ internal sealed partial class CommerceAdminService ProviderStatus = row.ProviderStatus, LocalStatus = row.LocalStatus, MatchStatus = row.MatchStatus, - Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning, + Severity = row.MatchStatus == ReconciliationMatchStatus.Matched + ? NotificationSeverity.Info + : NotificationSeverity.Warning, IssueCode = row.IssueCode, Details = row.Details }; + } private static int GetJsonInt(JsonElement element, params string[] keys) { foreach (var key in keys) { - if (!element.TryGetProperty(key, out var value)) - { - continue; - } + if (!element.TryGetProperty(key, out var value)) continue; - if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) - { - return number; - } + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number; - if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) - { - return parsed; - } + if (value.ValueKind == JsonValueKind.String && + int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) return parsed; } return 0; @@ -473,6 +487,22 @@ internal sealed partial class CommerceAdminService return $"TKU{Convert.ToHexString(bytes)}"; } - private static string FormatCny(int cents) => - (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); -} + private static string FormatCny(int cents) + { + return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); + } + + private sealed record ReconciliationImportRow( + ReconciliationTransactionType TransactionType, + string? ProviderTradeNo, + string? ProviderRefundNo, + string? OrderNo, + string? RefundNo, + int AmountCents, + int RefundAmountCents, + string? ProviderStatus, + string? LocalStatus, + ReconciliationMatchStatus MatchStatus, + string? IssueCode, + JsonElement Details); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs b/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs index 59e53ff..815cc68 100644 --- a/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs +++ b/Tiku.Infrastructure/Commerce/Foundation/CommerceService.Foundation.cs @@ -4,9 +4,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -86,10 +84,7 @@ public sealed partial class CommerceService int originalAmountCents, CancellationToken cancellationToken) { - if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) - { - return null; - } + if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) return null; var coupon = command.CouponRedemptionId.HasValue ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) @@ -106,9 +101,7 @@ public sealed partial class CommerceService CancellationToken cancellationToken) { if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode)) - { throw new CommerceException("Coupon code or redemption id is required.", "coupon_required"); - } var coupon = command.CouponRedemptionId.HasValue ? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken) @@ -131,15 +124,10 @@ public sealed partial class CommerceService item.CouponId == coupon.Id) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (existing is not null) - { - return new CouponApplication(coupon, existing, 0); - } + if (existing is not null) return new CouponApplication(coupon, existing, 0); if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } var redemption = new CouponRedemption { @@ -171,10 +159,7 @@ public sealed partial class CommerceService item.CouponId == coupon.Id) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (redemption is not null) - { - return new CouponApplication(coupon, redemption, 0); - } + if (redemption is not null) return new CouponApplication(coupon, redemption, 0); ValidateCouponClaimable(coupon, null); return new CouponApplication( @@ -198,20 +183,21 @@ public sealed partial class CommerceService CancellationToken cancellationToken) { var redemption = await dbContext.CouponRedemptions - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Id == couponRedemptionId, - cancellationToken) - ?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found"); + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Id == couponRedemptionId, + cancellationToken) + ?? throw new CommerceException("Coupon redemption was not found.", + "coupon_redemption_not_found"); if (redemption.CouponId is null) - { throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid"); - } var coupon = await dbContext.Coupons - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken) - ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); + .SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, + cancellationToken) + ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); return new CouponApplication(coupon, redemption, 0); } @@ -222,23 +208,19 @@ public sealed partial class CommerceService { var code = NormalizeRequired(couponCode, "coupon_code_required"); return await dbContext.Coupons - .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken) - ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); + .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken) + ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); } private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption) { var now = DateTimeOffset.UtcNow; - if (coupon.ValidFrom is not null && coupon.ValidFrom > now || - coupon.ValidTo is not null && coupon.ValidTo <= now) - { + if ((coupon.ValidFrom is not null && coupon.ValidFrom > now) || + (coupon.ValidTo is not null && coupon.ValidTo <= now)) throw new CommerceException("Coupon is expired or not started.", "coupon_inactive"); - } if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value) - { throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached"); - } } private static void ValidateCouponUsable( @@ -249,22 +231,16 @@ public sealed partial class CommerceService { ValidateCouponClaimable(coupon, redemption); if (redemption.Status != CouponRedemptionStatus.Claimed) - { throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid"); - } - if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id || - redemption.PlanId.HasValue && redemption.PlanId != plan.Id) - { + if ((coupon.PlanId.HasValue && coupon.PlanId != plan.Id) || + (redemption.PlanId.HasValue && redemption.PlanId != plan.Id)) throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable"); - } if (redemption.RegionId.HasValue && regionId.HasValue && redemption.RegionId != regionId) - { throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable"); - } } private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents) @@ -282,10 +258,7 @@ public sealed partial class CommerceService private static decimal PercentFactor(decimal value) { - if (value <= 0) - { - return 0; - } + if (value <= 0) return 0; return value <= 1 ? value : value / 100; } @@ -293,14 +266,11 @@ public sealed partial class CommerceService private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active, + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active, cancellationToken); - if (!exists) - { - throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); - } + if (!exists) throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); } private async Task FindActorOrderAsync( @@ -310,12 +280,12 @@ public sealed partial class CommerceService { var trimmed = orderNo.Trim(); return await dbContext.Orders - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.OrderNo == trimmed, - cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.OrderNo == trimmed, + cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); } private static CommerceOrderItem ToOrderItem(Order order) @@ -392,10 +362,12 @@ public sealed partial class CommerceService : throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status"); } - private static string NormalizeEnum(string? value) => - string.Concat((value ?? string.Empty).Split( + private static string NormalizeEnum(string? value) + { + return string.Concat((value ?? string.Empty).Split( ['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); + } private static string NormalizeRequired(string? value, string code) { @@ -428,10 +400,12 @@ public sealed partial class CommerceService return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; } - private static bool IsPaid(string status) => - string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); + private static 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() { @@ -440,6 +414,8 @@ public sealed partial class CommerceService return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"; } - private static string FormatCny(int cents) => - (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); -} + private static string FormatCny(int cents) + { + return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs index 563f990..087462e 100644 --- a/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs +++ b/Tiku.Infrastructure/Commerce/ManualPaymentProvider.cs @@ -58,4 +58,4 @@ internal sealed class ManualPaymentProvider : IPaymentProvider $"manual-{request.RefundNo}", payload)); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs index 31969ba..35b975a 100644 --- a/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceAdminService.Orders.cs @@ -1,15 +1,5 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -31,9 +21,7 @@ internal sealed partial class CommerceAdminService item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (!string.IsNullOrWhiteSpace(query.Status)) - { orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); - } var items = await orders .OrderByDescending(item => item.CreatedAt) @@ -57,10 +45,10 @@ internal sealed partial class CommerceAdminService order => order.UserId == actor.UserId, order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value)); var payments = from payment in dbContext.Payments.AsNoTracking() - join order in scopedOrders - on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } - where payment.TenantId == actor.TenantId - select new { payment, order.OrderNo }; + join order in scopedOrders + on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } + where payment.TenantId == actor.TenantId + select new { payment, order.OrderNo }; if (!string.IsNullOrWhiteSpace(query.Provider)) { var provider = NormalizeProvider(query.Provider); @@ -68,9 +56,7 @@ internal sealed partial class CommerceAdminService } if (!string.IsNullOrWhiteSpace(query.Status)) - { payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status)); - } var rows = await payments .OrderByDescending(item => item.payment.CreatedAt) @@ -78,6 +64,4 @@ internal sealed partial class CommerceAdminService .ToArrayAsync(cancellationToken); return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray()); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs b/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs index 0784b3d..4ff1bc4 100644 --- a/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs +++ b/Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs @@ -1,12 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -18,35 +13,29 @@ public sealed partial class CommerceService CancellationToken cancellationToken = default) { if (command.Quantity is < 1 or > 99) - { throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); - } await AssertActiveMemberAsync(actor, cancellationToken); var plan = await dbContext.SvipPlans - .AsNoTracking() - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.Id == command.PlanId && - item.IsActive, - cancellationToken) - ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); + .AsNoTracking() + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.Id == command.PlanId && + item.IsActive, + cancellationToken) + ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); if (plan.CouponOnly && string.IsNullOrWhiteSpace(command.CouponCode) && !command.CouponRedemptionId.HasValue) - { throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); - } if (command.RegionId.HasValue) { var regionExists = await dbContext.Regions - .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); - if (!regionExists) - { - throw new CommerceException("Region was not found.", "region_not_found"); - } + .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() @@ -60,9 +49,7 @@ public sealed partial class CommerceService originalAmountCents, cancellationToken); if (plan.CouponOnly && coupon is null) - { throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); - } var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0)); var order = new Order @@ -147,10 +134,7 @@ public sealed partial class CommerceService } await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return ToOrderItem(order); } @@ -164,9 +148,7 @@ public sealed partial class CommerceService var orders = dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) - { orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); - } var items = await orders .OrderByDescending(item => item.CreatedAt) @@ -185,6 +167,4 @@ public sealed partial class CommerceService var order = await FindActorOrderAsync(actor, orderNo, cancellationToken); return ToOrderItem(order); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs index 5d8ee0f..95e1da7 100644 --- a/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs +++ b/Tiku.Infrastructure/Commerce/PaymentCallbacks/CommerceService.PaymentCallbacks.cs @@ -1,12 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -32,9 +27,7 @@ public sealed partial class CommerceService cancellationToken); if (!notification.SignatureValid) - { throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid"); - } var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync( item => @@ -43,26 +36,21 @@ public sealed partial class CommerceService item.ProcessedAt != null, cancellationToken); if (alreadyProcessed) - { return new PaymentNotificationProcessResult( normalizedProvider, notification.EventId, notification.OrderNo, "processed", true); - } var order = await dbContext.Orders - .SingleOrDefaultAsync(item => - item.TenantId == tenantId && - item.OrderNo == notification.OrderNo, - cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); + .SingleOrDefaultAsync(item => + item.TenantId == tenantId && + item.OrderNo == notification.OrderNo, + cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); - if (order.UserId is null) - { - throw new CommerceException("Order does not belong to a user.", "order_user_missing"); - } + if (order.UserId is null) throw new CommerceException("Order does not belong to a user.", "order_user_missing"); if (order.AmountCents != notification.AmountCents) { @@ -102,7 +90,6 @@ public sealed partial class CommerceService } if (notification.Paid && order.Status == OrderStatus.Pending) - { await MarkPaidAsync( new CommerceActor(tenantId, order.UserId.Value), order, @@ -114,9 +101,7 @@ public sealed partial class CommerceService notification.SignatureValid, notification.PaidAt, cancellationToken); - } else - { dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, @@ -128,7 +113,6 @@ public sealed partial class CommerceService Payload = notification.RawPayload, ProcessedAt = DateTimeOffset.UtcNow }); - } await dbContext.SaveChangesAsync(cancellationToken); return new PaymentNotificationProcessResult( @@ -138,6 +122,4 @@ public sealed partial class CommerceService "processed", false); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs b/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs index 8648eb6..46cc697 100644 --- a/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs +++ b/Tiku.Infrastructure/Commerce/PaymentConfiguration/CommerceAdminService.PaymentConfiguration.cs @@ -1,16 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -65,7 +56,8 @@ internal sealed partial class CommerceAdminService : command.SecretRef.Trim(); var provider = NormalizeProvider(command.Provider); var secret = await dbContext.TenantSecrets - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, + cancellationToken); if (secret is null) { secret = new TenantSecret @@ -98,6 +90,4 @@ internal sealed partial class CommerceAdminService await dbContext.SaveChangesAsync(cancellationToken); return ToSecretItem(secret); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs index db40b4d..67e9ae6 100644 --- a/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs +++ b/Tiku.Infrastructure/Commerce/PaymentProviderConfigService.cs @@ -1,10 +1,7 @@ using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -53,20 +50,13 @@ internal sealed class PaymentProviderConfigService( private static string? GetString(JsonElement element, params string[] keys) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String) - { return property.GetString(); - } - } return null; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs b/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs index 9e5bd8f..72a0750 100644 --- a/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs +++ b/Tiku.Infrastructure/Commerce/PaymentProviderGateway.cs @@ -42,4 +42,4 @@ internal sealed class PaymentProviderGateway( "Payment provider is not supported.", "payment_provider_not_supported"); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs b/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs index 21ad4bd..7ed985f 100644 --- a/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs +++ b/Tiku.Infrastructure/Commerce/Payments/CommerceService.Payments.cs @@ -1,12 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; @@ -20,9 +15,7 @@ public sealed partial class CommerceService await AssertActiveMemberAsync(actor, cancellationToken); var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken); if (order.Status != OrderStatus.Pending) - { throw new CommerceException("Only pending orders can create payments.", "order_status_invalid"); - } var provider = NormalizeProvider(command.Provider); var method = NormalizeMethod(command.Method); @@ -66,13 +59,9 @@ public sealed partial class CommerceService payment.Method = result.Method; payment.RawPayload = result.RawPayload; - if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) - { - payment.ProviderTradeNo = result.ProviderTradeNo; - } + if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) payment.ProviderTradeNo = result.ProviderTradeNo; if (IsPaid(result.Status)) - { await MarkPaidAsync( actor, order, @@ -84,9 +73,7 @@ public sealed partial class CommerceService true, null, cancellationToken); - } else - { dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, @@ -95,11 +82,8 @@ public sealed partial class CommerceService EventType = "payment_created", Payload = result.RawPayload }); - } await dbContext.SaveChangesAsync(cancellationToken); return ToPaymentItem(payment, order.OrderNo, result.ClientPayload); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs b/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs index 76ebfd5..cbbd6ed 100644 --- a/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs +++ b/Tiku.Infrastructure/Commerce/Points/CommerceAdminService.Points.cs @@ -1,16 +1,6 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -25,9 +15,7 @@ internal sealed partial class CommerceAdminService var tasks = dbContext.PointActivityTasks.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) - { tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status)); - } var items = await tasks .OrderBy(item => item.SortOrder) @@ -44,9 +32,7 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); if (command.Points <= 0 || command.MaxClaimsPerUser <= 0) - { throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task"); - } var task = command.Id.HasValue ? await dbContext.PointActivityTasks.SingleOrDefaultAsync( @@ -86,10 +72,7 @@ internal sealed partial class CommerceAdminService await AssertAdminAsync(actor, cancellationToken); var claims = dbContext.PointActivityClaims.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); - if (query.UserId.HasValue) - { - claims = claims.Where(item => item.UserId == query.UserId.Value); - } + if (query.UserId.HasValue) claims = claims.Where(item => item.UserId == query.UserId.Value); var items = await claims .OrderByDescending(item => item.CreatedAt) @@ -107,14 +90,10 @@ internal sealed partial class CommerceAdminService var items = dbContext.PointExchangeItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) - { items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status)); - } if (query.RegionId.HasValue) - { items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); - } var result = await items .OrderBy(item => item.SortOrder) @@ -131,9 +110,7 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); if (command.PointsCost <= 0) - { throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item"); - } var item = command.Id.HasValue ? await dbContext.PointExchangeItems.SingleOrDefaultAsync( @@ -175,15 +152,10 @@ internal sealed partial class CommerceAdminService await AssertAdminAsync(actor, cancellationToken); var orders = dbContext.PointExchangeOrders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); - if (query.UserId.HasValue) - { - orders = orders.Where(item => item.UserId == query.UserId.Value); - } + if (query.UserId.HasValue) orders = orders.Where(item => item.UserId == query.UserId.Value); if (!string.IsNullOrWhiteSpace(query.Status)) - { orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status)); - } var result = await orders .OrderByDescending(item => item.CreatedAt) @@ -199,8 +171,10 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); var order = await dbContext.PointExchangeOrders - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken) - ?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found"); + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, + cancellationToken) + ?? throw new CommerceException("Point exchange order was not found.", + "point_exchange_order_not_found"); order.Status = command.Status; if (command.Status == PointExchangeOrderStatus.Completed) { @@ -215,6 +189,4 @@ internal sealed partial class CommerceAdminService await dbContext.SaveChangesAsync(cancellationToken); return order; } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs index 1c867e8..5ada10c 100644 --- a/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs +++ b/Tiku.Infrastructure/Commerce/Reconciliation/CommerceAdminService.Reconciliation.cs @@ -1,16 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -31,9 +22,7 @@ internal sealed partial class CommerceAdminService } if (!string.IsNullOrWhiteSpace(query.Status)) - { batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status)); - } var items = await batches.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) @@ -61,7 +50,8 @@ internal sealed partial class CommerceAdminService Metadata = JsonObjectOrDefault(command.Metadata) }; dbContext.CommerceReconciliationBatches.Add(batch); - await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, + new { batch.Provider, batch.BillDate }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return batch; } @@ -81,9 +71,7 @@ internal sealed partial class CommerceAdminService } if (!string.IsNullOrWhiteSpace(query.Status)) - { issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status)); - } var items = await issues.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) @@ -98,8 +86,10 @@ internal sealed partial class CommerceAdminService { await AssertAdminAsync(actor, cancellationToken); var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync( - item => item.TenantId == actor.TenantId && item.Id == command.IssueId, - cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); + item => item.TenantId == actor.TenantId && item.Id == command.IssueId, + cancellationToken) ?? + throw new CommerceException("Reconciliation issue was not found.", + "reconciliation_issue_not_found"); var fromStatus = issue.Status; issue.Status = command.Status; issue.ResolutionType = command.ResolutionType; @@ -122,10 +112,9 @@ internal sealed partial class CommerceAdminService Note = command.Note, Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo }) }); - await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", + issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return issue; } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs b/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs index 27c921b..af01fa0 100644 --- a/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs +++ b/Tiku.Infrastructure/Commerce/Refunds/CommerceAdminService.Refunds.cs @@ -1,15 +1,7 @@ -using System.Globalization; using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; @@ -36,9 +28,7 @@ internal sealed partial class CommerceAdminService order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))); if (!string.IsNullOrWhiteSpace(query.Status)) - { refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); - } var items = await refunds .OrderByDescending(item => item.CreatedAt) @@ -56,32 +46,26 @@ internal sealed partial class CommerceAdminService var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var order = await dbContext.Orders - .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) - .ApplyDataScope( - scope, - item => item.UserId == actor.UserId, - item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) - .SingleOrDefaultAsync(cancellationToken) - ?? throw new CommerceException("Order was not found.", "order_not_found"); + .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Order was not found.", "order_not_found"); if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) - { throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); - } if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents) - { throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount"); - } if (command.PaymentId.HasValue) { var paymentExists = await dbContext.Payments.AnyAsync( - item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, + item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && + item.OrderId == order.Id, cancellationToken); - if (!paymentExists) - { - throw new CommerceException("Payment was not found.", "payment_not_found"); - } + if (!paymentExists) throw new CommerceException("Payment was not found.", "payment_not_found"); } var refund = new CommerceRefundRequest @@ -99,8 +83,10 @@ internal sealed partial class CommerceAdminService Metadata = JsonObjectOrDefault(command.Metadata) }; dbContext.CommerceRefundRequests.Add(refund); - AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason }); - await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken); + AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, + new { refund.AmountCents, refund.Reason }); + await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, + new { refund.RefundNo, refund.AmountCents }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return refund; } @@ -114,23 +100,22 @@ internal sealed partial class CommerceAdminService var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refund = await dbContext.CommerceRefundRequests - .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) - .ApplyDataScope( - scope, - item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), - item => dbContext.Orders.Any(order => - order.TenantId == actor.TenantId && - order.Id == item.OrderId && - order.RegionId.HasValue && - regionIds.Contains(order.RegionId.Value))) - .SingleOrDefaultAsync(cancellationToken) - ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) + .ApplyDataScope( + scope, + item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && order.Id == item.OrderId && + order.UserId == actor.UserId), + item => dbContext.Orders.Any(order => + order.TenantId == actor.TenantId && + order.Id == item.OrderId && + order.RegionId.HasValue && + regionIds.Contains(order.RegionId.Value))) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); var fromStatus = refund.Status; if (!IsAllowedRefundTransition(fromStatus, command.Status)) - { throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition"); - } refund.Status = command.Status; refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) @@ -159,8 +144,10 @@ internal sealed partial class CommerceAdminService break; } - AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo }); - await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); + AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, + new { command.Reason, command.ProviderRefundNo }); + await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, + new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return refund; } @@ -185,10 +172,7 @@ internal sealed partial class CommerceAdminService order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))) .AnyAsync(cancellationToken); - if (!refundExists) - { - throw new CommerceException("Refund request was not found.", "refund_not_found"); - } + if (!refundExists) throw new CommerceException("Refund request was not found.", "refund_not_found"); var items = await dbContext.CommerceRefundEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) @@ -196,6 +180,4 @@ internal sealed partial class CommerceAdminService .ToArrayAsync(cancellationToken); return new TenantRefundEventList(items); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs b/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs index 82b626e..88ac237 100644 --- a/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs +++ b/Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Options; namespace Tiku.Infrastructure.Commerce; @@ -15,10 +16,7 @@ public sealed class TenantSecretEncryptionOptions public static bool BeValid(TenantSecretEncryptionOptions options) { - if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey)) - { - return false; - } + if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey)) return false; try { @@ -30,8 +28,10 @@ public sealed class TenantSecretEncryptionOptions } } - public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) => - string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal); + public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) + { + return string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal); + } } public interface ITenantSecretProtector @@ -54,7 +54,7 @@ public sealed record ProtectedTenantSecret( byte[] Tag); public sealed class TenantSecretProtector( - Microsoft.Extensions.Options.IOptions options) : ITenantSecretProtector + IOptions options) : ITenantSecretProtector { private readonly TenantSecretEncryptionOptions options = options.Value; @@ -89,10 +89,8 @@ public sealed class TenantSecretProtector( byte[] tag) { if (!string.Equals(keyId, options.KeyId, StringComparison.Ordinal)) - { throw new InvalidOperationException( $"Tenant secret uses unknown encryption key '{keyId}'."); - } var key = Convert.FromBase64String(options.MasterKey); var plaintext = new byte[ciphertext.Length]; @@ -111,6 +109,8 @@ public sealed class TenantSecretProtector( } } - private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) => - Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}"); -} + private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) + { + return Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}"); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/TenantSecretService.cs b/Tiku.Infrastructure/Commerce/TenantSecretService.cs index 55dc962..8a9daef 100644 --- a/Tiku.Infrastructure/Commerce/TenantSecretService.cs +++ b/Tiku.Infrastructure/Commerce/TenantSecretService.cs @@ -16,11 +16,9 @@ internal sealed class TenantSecretService( CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(secretRef)) - { throw new PaymentProviderException( "Payment provider secret is not configured.", "payment_secret_not_configured"); - } var now = DateTimeOffset.UtcNow; var secret = await dbContext.TenantSecrets @@ -33,14 +31,11 @@ internal sealed class TenantSecretService( .SingleOrDefaultAsync(cancellationToken); if (secret is null) - { throw new PaymentProviderException( "Payment provider secret is not configured.", "payment_secret_not_configured"); - } if (secret.EncryptedPayload.Length > 0) - { return tenantSecretProtector.Unprotect( tenantId, secretRef, @@ -48,10 +43,9 @@ internal sealed class TenantSecretService( secret.EncryptedPayload, secret.EncryptionNonce, secret.EncryptionTag); - } throw new PaymentProviderException( "Payment provider secret is not configured.", "payment_secret_not_configured"); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs index ae9789b..c7a16d8 100644 --- a/Tiku.Infrastructure/Commerce/WechatPayProvider.cs +++ b/Tiku.Infrastructure/Commerce/WechatPayProvider.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Senparc.Weixin; using Senparc.Weixin.Entities; -using Senparc.Weixin.TenPayV3; using Senparc.Weixin.TenPayV3.Apis; using Senparc.Weixin.TenPayV3.Apis.BasePay; using Senparc.Weixin.TenPayV3.Helpers; @@ -30,9 +29,11 @@ internal sealed class WechatPayProvider : IPaymentProvider _ = BuildTenPaySettings(account); var signatureValid = HasWechatPaySignatureHeaders(request.Headers); var payload = request.Body; - var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N"); + var eventId = GetString(request.Body, "id") ?? + GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N"); var orderNo = GetString(payload, "out_trade_no", "outTradeNo") - ?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing"); + ?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", + "wechat_pay_notify_order_missing"); var tradeNo = GetString(payload, "transaction_id", "transactionId"); var tradeState = GetString(payload, "trade_state", "tradeState") ?? GetString(request.Body, "event_type"); var amount = GetInt(payload, "amount", "total") ?? GetInt(payload, "amountCents") ?? 0; @@ -80,9 +81,7 @@ internal sealed class WechatPayProvider : IPaymentProvider CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.OpenId)) - { throw new PaymentProviderException("WeChat Pay JSAPI requires openId.", "wechat_pay_openid_required"); - } var tenPaySettings = BuildTenPaySettings(account); var appId = Required(account.ConfigPublic, "appId"); @@ -98,11 +97,7 @@ internal sealed class WechatPayProvider : IPaymentProvider request.NotifyUrl, null, new TransactionsRequestData.Amount(request.AmountCents, "CNY"), - new TransactionsRequestData.Payer(request.OpenId), - null, - null, - null, - false); + new TransactionsRequestData.Payer(request.OpenId)); object response = string.Equals(request.Method, "h5", StringComparison.OrdinalIgnoreCase) ? await payApis.H5Async(wxRequest).WaitAsync(cancellationToken) : await payApis.JsApiAsync(wxRequest).WaitAsync(cancellationToken); @@ -110,11 +105,9 @@ internal sealed class WechatPayProvider : IPaymentProvider var prepayId = GetPropertyValue(response, "prepay_id", "PrepayId"); var h5Url = GetPropertyValue(response, "h5_url", "H5Url"); if (string.IsNullOrWhiteSpace(prepayId) && string.IsNullOrWhiteSpace(h5Url)) - { throw new PaymentProviderException( "WeChat Pay create transaction failed.", "wechat_pay_create_failed"); - } var clientPayload = string.IsNullOrWhiteSpace(prepayId) ? JsonSerializer.SerializeToElement(new { h5Url }) @@ -139,17 +132,11 @@ internal sealed class WechatPayProvider : IPaymentProvider private static string Required(JsonElement element, params string[] keys) { if (element.ValueKind == JsonValueKind.Object) - { foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString()!; - } - } - } throw new PaymentProviderException( "WeChat Pay provider configuration is incomplete.", @@ -159,17 +146,11 @@ internal sealed class WechatPayProvider : IPaymentProvider private static string? Optional(JsonElement element, params string[] keys) { if (element.ValueKind == JsonValueKind.Object) - { foreach (var key in keys) - { if (element.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString(); - } - } - } return null; } @@ -193,10 +174,7 @@ internal sealed class WechatPayProvider : IPaymentProvider foreach (var name in names) { var property = type.GetProperty(name); - if (property?.GetValue(value) is T typed) - { - return typed; - } + if (property?.GetValue(value) is T typed) return typed; } return default; @@ -211,6 +189,45 @@ internal sealed class WechatPayProvider : IPaymentProvider }); } + private static string? GetString(JsonElement element, params string[] keys) + { + if (element.ValueKind != JsonValueKind.Object) return null; + + foreach (var key in keys) + if (element.TryGetProperty(key, out var property) && + property.ValueKind == JsonValueKind.String) + return property.GetString(); + + return null; + } + + private static int? GetInt(JsonElement element, string parentKey, string childKey) + { + if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(parentKey, out var parent) && + parent.ValueKind == JsonValueKind.Object && + parent.TryGetProperty(childKey, out var child) && + child.TryGetInt32(out var value)) + return value; + + return null; + } + + private static int? GetInt(JsonElement element, string key) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(key, out var property) && + property.TryGetInt32(out var value) + ? value + : null; + } + + private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys) + { + var value = GetString(element, keys); + return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; + } + private sealed class WechatPaySettings : ISenparcWeixinSettingForTenpayV3 { public string ItemKey { get; set; } = string.Empty; @@ -233,52 +250,4 @@ internal sealed class WechatPayProvider : IPaymentProvider public string TenPayV3_WxOpenTenpayNotify { get; set; } = string.Empty; public CertType? EncryptionType { get; set; } } - - private static string? GetString(JsonElement element, params string[] keys) - { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } - - foreach (var key in keys) - { - if (element.TryGetProperty(key, out var property) && - property.ValueKind == JsonValueKind.String) - { - return property.GetString(); - } - } - - return null; - } - - private static int? GetInt(JsonElement element, string parentKey, string childKey) - { - if (element.ValueKind == JsonValueKind.Object && - element.TryGetProperty(parentKey, out var parent) && - parent.ValueKind == JsonValueKind.Object && - parent.TryGetProperty(childKey, out var child) && - child.TryGetInt32(out var value)) - { - return value; - } - - return null; - } - - private static int? GetInt(JsonElement element, string key) - { - return element.ValueKind == JsonValueKind.Object && - element.TryGetProperty(key, out var property) && - property.TryGetInt32(out var value) - ? value - : null; - } - - private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys) - { - var value = GetString(element, keys); - return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; - } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs b/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs index 83fcad6..161a888 100644 --- a/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs +++ b/Tiku.Infrastructure/Content/Collections/ContentManagementService.Collections.cs @@ -1,15 +1,9 @@ -using System.Text; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -31,30 +25,16 @@ public sealed partial class ContentManagementService collection => collection.CreatedBy == actor.UserId, collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value)); - if (!filter.IncludeInactive) - { - query = query.Where(collection => collection.Status == ContentStatus.Active); - } + if (!filter.IncludeInactive) query = query.Where(collection => collection.Status == ContentStatus.Active); - if (filter.RegionId.HasValue) - { - query = query.Where(collection => collection.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(collection => collection.RegionId == filter.RegionId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(collection => collection.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(collection => collection.EntryId == filter.EntryId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(collection => collection.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(collection => collection.NodeId == filter.NodeId.Value); if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType)) - { query = query.Where(collection => collection.CollectionType == collectionType); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -83,8 +63,10 @@ public sealed partial class ContentManagementService await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", + cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", + cancellationToken); var collection = await ResolveEntityByIdOrLegacyAsync( dbContext.QuestionCollections, @@ -95,19 +77,13 @@ public sealed partial class ContentManagementService var isNew = collection is null; if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value)) - { throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId)) - { throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } collection ??= new QuestionCollection { @@ -124,8 +100,10 @@ public sealed partial class ContentManagementService collection.QuestionBankId = command.QuestionBankId; collection.LegacyId = Normalize(command.LegacyId); collection.Name = command.Name.Trim(); - collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid"); - collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid"); + collection.CollectionType = + Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid"); + collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, + "collection_source_type_invalid"); collection.Filters = JsonObjectOrDefault(command.Filters); collection.TotalScore = command.TotalScore; collection.DurationMinutes = command.DurationMinutes; @@ -135,10 +113,7 @@ public sealed partial class ContentManagementService collection.Metadata = JsonObjectOrDefault(command.Metadata); collection.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.QuestionCollections.Add(collection); - } + if (isNew) dbContext.QuestionCollections.Add(collection); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToCollectionItem(collection)); @@ -160,9 +135,7 @@ public sealed partial class ContentManagementService .SingleOrDefaultAsync(cancellationToken); if (collection is null) - { throw new ContentManagementException("Collection was not found.", "collection_not_found"); - } var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>(); foreach (var question in command.Questions) @@ -206,6 +179,4 @@ public sealed partial class ContentManagementService collection.QuestionCount, items.Select(ToCollectionItemItem).ToArray()); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/ContentManagementService.cs b/Tiku.Infrastructure/Content/ContentManagementService.cs index 755078f..b28c430 100644 --- a/Tiku.Infrastructure/Content/ContentManagementService.cs +++ b/Tiku.Infrastructure/Content/ContentManagementService.cs @@ -1,16 +1,7 @@ -using System.Text; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -21,6 +12,4 @@ public sealed partial class ContentManagementService( { private const int DefaultLimit = 100; private const int MaxLimit = 1000; - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs index bed3b6e..c077674 100644 --- a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs +++ b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs @@ -23,20 +23,13 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo entry.TenantId == filter.TenantId && entry.IsActive); - if (!filter.IncludeHidden) - { - query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden); - } + if (!filter.IncludeHidden) query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden); if (filter.RegionId.HasValue) - { query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null); - } if (TryParseEntryType(filter.EntryType, out var entryType)) - { query = query.Where(entry => entry.EntryType == entryType); - } query = ApplyKeyword(query, filter.Keyword); @@ -67,10 +60,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo ContentNavigationFilter filter, CancellationToken cancellationToken = default) { - if (!filter.EntryId.HasValue) - { - throw new RequiredFieldException("entryId is required."); - } + if (!filter.EntryId.HasValue) throw new RequiredFieldException("entryId is required."); var query = dbContext.ContentNodes .AsNoTracking() @@ -78,27 +68,18 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo node.TenantId == filter.TenantId && node.EntryId == filter.EntryId.Value); - if (!filter.IncludeInactive) - { - query = query.Where(node => node.IsActive); - } + if (!filter.IncludeInactive) query = query.Where(node => node.IsActive); if (filter.RegionId.HasValue) - { query = query.Where(node => node.RegionId == filter.RegionId.Value || node.RegionId == null); - } if (filter.ParentWasSpecified) - { query = filter.ParentIsRoot ? query.Where(node => node.ParentId == null) : query.Where(node => node.ParentId == filter.ParentId); - } if (TryParseMarkerType(filter.MarkerType, out var markerType)) - { query = query.Where(node => node.MarkerType == markerType); - } query = ApplyKeyword(query, filter.Keyword); @@ -142,24 +123,15 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo collection.Status == ContentStatus.Active); if (filter.RegionId.HasValue) - { - query = query.Where(collection => collection.RegionId == filter.RegionId.Value || collection.RegionId == null); - } + query = query.Where(collection => + collection.RegionId == filter.RegionId.Value || collection.RegionId == null); - if (filter.EntryId.HasValue) - { - query = query.Where(collection => collection.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(collection => collection.EntryId == filter.EntryId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(collection => collection.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(collection => collection.NodeId == filter.NodeId.Value); if (TryParseCollectionType(filter.CollectionType, out var collectionType)) - { query = query.Where(collection => collection.CollectionType == collectionType); - } query = ApplyKeyword(query, filter.Keyword); @@ -203,29 +175,16 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo blueprint.Status == ContentStatus.Active); if (filter.RegionId.HasValue) - { query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null); - } - if (filter.EntryId.HasValue) - { - query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); if (filter.CollectionId.HasValue) - { query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); - } - if (TryParsePracticeMode(filter.Mode, out var mode)) - { - query = query.Where(blueprint => blueprint.Mode == mode); - } + if (TryParsePracticeMode(filter.Mode, out var mode)) query = query.Where(blueprint => blueprint.Mode == mode); query = ApplyKeyword(query, filter.Keyword); @@ -261,10 +220,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo ContentNavigationFilter filter, CancellationToken cancellationToken = default) { - if (!filter.CollectionId.HasValue) - { - throw new RequiredFieldException("collectionId is required."); - } + if (!filter.CollectionId.HasValue) throw new RequiredFieldException("collectionId is required."); var collectionExists = await dbContext.QuestionCollections .AsNoTracking() @@ -275,10 +231,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo collection.Status == ContentStatus.Active, cancellationToken); - if (!collectionExists) - { - throw new ContentNavigationNotFoundException("Question collection was not found."); - } + if (!collectionExists) throw new ContentNavigationNotFoundException("Question collection was not found."); var emptyOptions = JsonDefaults.Array(); var emptyCorrectOptionIndices = JsonDefaults.Array(); @@ -286,7 +239,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo var query = from item in dbContext.QuestionCollectionItems.AsNoTracking() join question in dbContext.Questions.AsNoTracking() - on new { item.TenantId, QuestionId = item.QuestionId } equals new { question.TenantId, QuestionId = question.Id } + on new { item.TenantId, item.QuestionId } equals new { question.TenantId, QuestionId = question.Id } join version in dbContext.QuestionVersions.AsNoTracking() on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId } equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id } @@ -336,10 +289,7 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo private static IQueryable ApplyKeyword(IQueryable query, string? keyword) where T : class { - if (string.IsNullOrWhiteSpace(keyword)) - { - return query; - } + if (string.IsNullOrWhiteSpace(keyword)) return query; var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, nameof(ContentEntry.Name)).Contains(trimmed)); @@ -352,22 +302,22 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo private static bool TryParseEntryType(string? value, out ContentEntryType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseCollectionType(string? value, out QuestionCollectionType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParsePracticeMode(string? value, out PracticeMode mode) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode); + return Enum.TryParse(NormalizeEnumValue(value), true, out mode); } private static bool TryParseMarkerType(string? value, out ContentMarkerType type) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); + return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static string? NormalizeEnumValue(string? value) @@ -377,4 +327,4 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs index 9d0dcc4..bb860c6 100644 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -1,20 +1,8 @@ -using System.Text.Json; using System.Text.RegularExpressions; -using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; -using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -24,6 +12,9 @@ public sealed partial class DirectContentService( ICurrentAccessContext currentAccessContext, IFeatureAccessService featureAccessService) : IDirectContentService { + private const int DefaultLimit = 100; + private const int MaxLimit = 1000; + private static readonly string[] ContentPermissions = [ BackendPermissions.TenantContentManage, @@ -34,9 +25,9 @@ public sealed partial class DirectContentService( BackendPermissions.TenantSiteContentManage, BackendPermissions.TenantJobManage ]; - private const int DefaultLimit = 100; - private const int MaxLimit = 1000; + private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled); + private static readonly HashSet SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase) { "questions", @@ -45,6 +36,4 @@ public sealed partial class DirectContentService( "scoreline", "videos" }; - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs b/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs index ff88428..997dafc 100644 --- a/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs +++ b/Tiku.Infrastructure/Content/EducationCatalog/DirectContentService.EducationCatalog.cs @@ -1,19 +1,7 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -30,10 +18,7 @@ public sealed partial class DirectContentService var query = dbContext.Schools.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -55,7 +40,8 @@ public sealed partial class DirectContentService var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found"); item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -64,10 +50,7 @@ public sealed partial class DirectContentService item.Name = command.Name.Trim(); item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); item.Metadata = JsonObjectOrDefault(command.Metadata); - if (isNew) - { - dbContext.Schools.Add(item); - } + if (isNew) dbContext.Schools.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -83,25 +66,18 @@ public sealed partial class DirectContentService var query = dbContext.Majors.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + query = query.Where(item => + item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query @@ -120,7 +96,8 @@ public sealed partial class DirectContentService ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found"); item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -132,14 +109,9 @@ public sealed partial class DirectContentService 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) dbContext.Majors.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs b/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs index d5ba571..35dc00e 100644 --- a/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs +++ b/Tiku.Infrastructure/Content/Entries/ContentManagementService.Entries.cs @@ -1,15 +1,7 @@ -using System.Text; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -31,20 +23,12 @@ public sealed partial class ContentManagementService entry => entry.CreatedBy == actor.UserId, entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)); - if (!filter.IncludeInactive) - { - query = query.Where(entry => entry.IsActive); - } + if (!filter.IncludeInactive) query = query.Where(entry => entry.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(entry => entry.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(entry => entry.RegionId == filter.RegionId.Value); if (TryParse(filter.EntryType, out ContentEntryType entryType)) - { query = query.Where(entry => entry.EntryType == entryType); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -86,19 +70,13 @@ public sealed partial class ContentManagementService var isNew = entry is null; if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value)) - { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId)) - { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } entry ??= new ContentEntry { @@ -122,14 +100,9 @@ public sealed partial class ContentManagementService entry.IsActive = command.IsActive ?? true; entry.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.ContentEntries.Add(entry); - } + if (isNew) dbContext.ContentEntries.Add(entry); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToEntryItem(entry)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs b/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs index 75e0564..76eb67a 100644 --- a/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs +++ b/Tiku.Infrastructure/Content/Foundation/ContentManagementService.Foundation.cs @@ -1,21 +1,148 @@ -using System.Text; +using System.Linq.Expressions; using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; public sealed partial class ContentManagementService { + private static readonly IReadOnlyDictionary Specs = + new Dictionary(StringComparer.Ordinal) + { + ["questions"] = new( + "questions", + "题目导入模板", + "用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。", + [ + Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", + "tj-english-2026-001"), + Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", + "choice"), + Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", + "多租户 SaaS 最重要的安全边界是什么?"), + Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }), + Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", + new[] { 1 }), + Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"), + Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"), + Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2), + Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" }) + ], + [ + ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"], + [ + "tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", + "2", "安全|多租户" + ] + ], + new + { + items = new[] + { + new + { + legacyId = "tj-english-2026-001", + type = "choice", + content = "多租户 SaaS 最重要的安全边界是什么?", + options = new[] { "前端隐藏", "后端权限" }, + correctOptionIndices = new[] { 1 }, + explanation = "最终权限以后端强制为准。", + difficulty = 2, + tags = new[] { "安全", "多租户" } + } + } + }), + ["vocabulary"] = new( + "vocabulary", + "单词导入模板", + "用于导入词汇单元和单词,后端会按单元归组并幂等写入。", + [ + Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"), + Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"), + Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"), + Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"), + Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.") + ], + [ + ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"], + ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"] + ], + new + { + units = new[] + { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } + }), + ["handbook"] = new( + "handbook", + "知识手册导入模板", + "用于导入手册科目、章节、小节和知识点。", + [ + Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"), + Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"), + Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"), + Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。") + ], + [ + ["subjectName", "chapterName", "title", "content", "tags"], + ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"] + ], + new + { + subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } + }), + ["scoreline"] = new( + "scoreline", + "分数线导入模板", + "用于导入动态字段、院校、专业和年份分数线记录。", + [ + Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"), + Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"), + Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"), + Field("year", "年份", false, ["年份"], "record 常用。", 2026), + Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 }) + ], + [ + ["kind", "schoolName", "majorName", "year", "minScore"], + ["record", "天津职业技术师范大学", "软件工程", "2026", "188"] + ], + new + { + records = new[] + { + new + { + schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, + fieldValues = new { minScore = 188 } + } + } + }), + ["videos"] = new( + "videos", + "视频解析导入模板", + "用于导入视频解析元数据并绑定到题目。", + [ + Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"), + Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", + "https://cdn.example.test/video.mp4"), + Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", + "00000000-0000-0000-0000-000000000000"), + Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", + "tj-english-2026-001") + ], + [ + ["title", "videoUrl", "legacyQuestionId", "videoType"], + ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"] + ], + new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) + }; + private async Task<(string Path, int Depth)> BuildNodePathAsync( Guid tenantId, Guid entryId, @@ -24,10 +151,7 @@ public sealed partial class ContentManagementService CancellationToken cancellationToken) { var label = $"n_{nodeId:N}"; - if (!parentId.HasValue) - { - return (label, 0); - } + if (!parentId.HasValue) return (label, 0); var parent = await dbContext.ContentNodes .AsNoTracking() @@ -36,9 +160,7 @@ public sealed partial class ContentManagementService .SingleOrDefaultAsync(cancellationToken); if (parent is null) - { throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found"); - } return ($"{parent.Path}.{label}", parent.Depth + 1); } @@ -59,10 +181,7 @@ public sealed partial class ContentManagementService Guid? entryId, CancellationToken cancellationToken) { - if (!entryId.HasValue) - { - return; - } + if (!entryId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); var exists = await dbContext.ContentEntries @@ -72,10 +191,7 @@ public sealed partial class ContentManagementService entry => entry.CreatedBy == actor.UserId, entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)) .AnyAsync(cancellationToken); - if (!exists) - { - throw new ContentManagementException("Content entry was not found.", "entry_not_found"); - } + if (!exists) throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) @@ -89,10 +205,7 @@ public sealed partial class ContentManagementService Guid? nodeId, CancellationToken cancellationToken) { - if (!nodeId.HasValue) - { - return; - } + if (!nodeId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); var exists = await dbContext.ContentNodes @@ -102,10 +215,7 @@ public sealed partial class ContentManagementService node => node.CreatedBy == actor.UserId, node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)) .AnyAsync(cancellationToken); - if (!exists) - { - throw new ContentManagementException("Content node was not found.", "node_not_found"); - } + if (!exists) throw new ContentManagementException("Content node was not found.", "node_not_found"); } private async Task RequireDataScopeAsync( @@ -114,9 +224,7 @@ public sealed partial class ContentManagementService { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) - { throw new ContentManagementException("Content resource was not found.", "content_not_found"); - } return access.DataScope; } @@ -128,41 +236,32 @@ public sealed partial class ContentManagementService CancellationToken cancellationToken) where TEntity : class { - if (!id.HasValue) - { - return; - } + if (!id.HasValue) return; var exists = await dbContext.Set() .AnyAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, cancellationToken); - if (!exists) - { - throw new ContentManagementException("Referenced entity was not found in this tenant.", code); - } + if (!exists) throw new ContentManagementException("Referenced entity was not found in this tenant.", code); } private static async Task ResolveEntityAsync( DbSet set, Guid tenantId, Guid? id, - System.Linq.Expressions.Expression> alternatePredicate, + Expression> alternatePredicate, CancellationToken cancellationToken) where TEntity : class { if (id.HasValue) { var byId = await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, cancellationToken); - if (byId is not null) - { - return byId; - } + if (byId is not null) return byId; } return await set @@ -181,24 +280,18 @@ public sealed partial class ContentManagementService if (id.HasValue) { var byId = await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, cancellationToken); - if (byId is not null) - { - return byId; - } + if (byId is not null) return byId; } var normalizedLegacyId = Normalize(legacyId); - if (normalizedLegacyId is null) - { - return null; - } + if (normalizedLegacyId is null) return null; return await set.SingleOrDefaultAsync(entity => - EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && - EF.Property(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId, + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId, cancellationToken); } @@ -342,15 +435,9 @@ public sealed partial class ContentManagementService private static TEnum Parse(string? value, TEnum fallback, string code) where TEnum : struct { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } + if (string.IsNullOrWhiteSpace(value)) return fallback; - if (Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } + if (Enum.TryParse(value, true, out var parsed)) return parsed; throw new ContentManagementException("Invalid enum value.", code); } @@ -358,15 +445,9 @@ public sealed partial class ContentManagementService private static TEnum? ParseNullable(string? value, string code) where TEnum : struct { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } + if (string.IsNullOrWhiteSpace(value)) return null; - if (Enum.TryParse(value, ignoreCase: true, out var parsed)) - { - return parsed; - } + if (Enum.TryParse(value, true, out var parsed)) return parsed; throw new ContentManagementException("Invalid enum value.", code); } @@ -374,7 +455,7 @@ public sealed partial class ContentManagementService private static bool TryParse(string? value, out TEnum parsed) where TEnum : struct { - return Enum.TryParse(value, ignoreCase: true, out parsed); + return Enum.TryParse(value, true, out parsed); } private static string EscapeCsv(string value) @@ -392,14 +473,6 @@ public sealed partial class ContentManagementService : throw new ContentManagementException("Import type is not supported.", "import_type_invalid"); } - private sealed record ImportSpec( - string ImportType, - string Title, - string Description, - IReadOnlyCollection Fields, - string[][] CsvRows, - object JsonExample); - private static ImportFieldSpec Field( string field, string label, @@ -417,106 +490,11 @@ public sealed partial class ContentManagementService JsonSerializer.SerializeToElement(example)); } - private static readonly IReadOnlyDictionary Specs = - new Dictionary(StringComparer.Ordinal) - { - ["questions"] = new( - "questions", - "题目导入模板", - "用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。", - [ - Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"), - Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"), - Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"), - Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }), - Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }), - Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"), - Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"), - Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2), - Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" }) - ], - [ - ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"], - ["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"] - ], - new - { - items = new[] - { - new - { - legacyId = "tj-english-2026-001", - type = "choice", - content = "多租户 SaaS 最重要的安全边界是什么?", - options = new[] { "前端隐藏", "后端权限" }, - correctOptionIndices = new[] { 1 }, - explanation = "最终权限以后端强制为准。", - difficulty = 2, - tags = new[] { "安全", "多租户" } - } - } - }), - ["vocabulary"] = new( - "vocabulary", - "单词导入模板", - "用于导入词汇单元和单词,后端会按单元归组并幂等写入。", - [ - Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"), - Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"), - Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"), - Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"), - Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.") - ], - [ - ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"], - ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"] - ], - new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }), - ["handbook"] = new( - "handbook", - "知识手册导入模板", - "用于导入手册科目、章节、小节和知识点。", - [ - Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"), - Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"), - Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"), - Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。") - ], - [ - ["subjectName", "chapterName", "title", "content", "tags"], - ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"] - ], - new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }), - ["scoreline"] = new( - "scoreline", - "分数线导入模板", - "用于导入动态字段、院校、专业和年份分数线记录。", - [ - Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"), - Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"), - Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"), - Field("year", "年份", false, ["年份"], "record 常用。", 2026), - Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 }) - ], - [ - ["kind", "schoolName", "majorName", "year", "minScore"], - ["record", "天津职业技术师范大学", "软件工程", "2026", "188"] - ], - new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }), - ["videos"] = new( - "videos", - "视频解析导入模板", - "用于导入视频解析元数据并绑定到题目。", - [ - Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"), - Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"), - Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"), - Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001") - ], - [ - ["title", "videoUrl", "legacyQuestionId", "videoType"], - ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"] - ], - new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) - }; -} + private sealed record ImportSpec( + string ImportType, + string Title, + string Description, + IReadOnlyCollection Fields, + string[][] CsvRows, + object JsonExample); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs index 8117374..19889ad 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentService.MappingAndValidation.cs @@ -1,20 +1,13 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; -using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -224,9 +217,7 @@ public sealed partial class DirectContentService access.UserId != actor.UserId || access.TenantId != actor.TenantId || !ContentPermissions.Any(access.HasTenantPermission)) - { throw new ContentManagementException("Tenant content access was denied.", "content_access_denied"); - } return access.DataScope; } @@ -242,9 +233,7 @@ public sealed partial class DirectContentService var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId); var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId); if (!canAccessCurrent || !canAccessTarget) - { throw new ContentManagementException("Content resource was not found.", notFoundCode); - } } private async Task ResolveByIdOrLegacyAsync( @@ -256,9 +245,8 @@ public sealed partial class DirectContentService where TEntity : AuditableTenantEntity { if (id.HasValue) - { - return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken); - } + return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, + cancellationToken); var normalizedLegacyId = Normalize(legacyId); return normalizedLegacyId is null @@ -275,17 +263,13 @@ public sealed partial class DirectContentService CancellationToken cancellationToken) where TEntity : class { - if (!id.HasValue) - { - return; - } + if (!id.HasValue) return; var exists = await dbContext.Set() - .AnyAsync(item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); - if (!exists) - { - throw new ContentManagementException("Referenced entity was not found.", code); - } + .AnyAsync( + item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, + cancellationToken); + if (!exists) throw new ContentManagementException("Referenced entity was not found.", code); } private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) @@ -293,10 +277,7 @@ public sealed partial class DirectContentService var exists = await dbContext.ContentImportJobs.AnyAsync( item => item.TenantId == tenantId && item.Id == jobId, cancellationToken); - if (!exists) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } + if (!exists) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); } private static string NormalizeOperationKind(string kind) @@ -328,15 +309,9 @@ public sealed partial class DirectContentService private static TEnum Parse(string? value, TEnum fallback, string code) where TEnum : struct { - if (string.IsNullOrWhiteSpace(value)) - { - return fallback; - } + if (string.IsNullOrWhiteSpace(value)) return fallback; - if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) - { - return parsed; - } + if (Enum.TryParse(value.Trim(), true, out var parsed)) return parsed; throw new ContentManagementException("Enum value is invalid.", code); } @@ -344,15 +319,9 @@ public sealed partial class DirectContentService private static TEnum? ParseNullable(string? value, string code) where TEnum : struct { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } + if (string.IsNullOrWhiteSpace(value)) return null; - if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) - { - return parsed; - } + if (Enum.TryParse(value.Trim(), true, out var parsed)) return parsed; throw new ContentManagementException("Enum value is invalid.", code); } @@ -386,20 +355,14 @@ public sealed partial class DirectContentService private static string? GetString(JsonElement payload, string name) { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); } private static int? GetInt(JsonElement payload, string name) { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) ? number @@ -410,10 +373,7 @@ public sealed partial class DirectContentService private static Guid? GetGuid(JsonElement payload, string name) { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var guid) @@ -423,10 +383,7 @@ public sealed partial class DirectContentService private static bool? GetBool(JsonElement payload, string name) { - if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) - { - return null; - } + if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) return null; return value.ValueKind switch { @@ -436,4 +393,4 @@ public sealed partial class DirectContentService _ => null }; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs index f1da10d..fd6dac7 100644 --- a/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs +++ b/Tiku.Infrastructure/Content/Foundation/DirectContentService.Writes.cs @@ -1,20 +1,14 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; -using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -27,13 +21,12 @@ public sealed partial class DirectContentService CancellationToken cancellationToken) { if (!SupportedImportTypes.Contains(command.ImportType)) - { throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); - } var importType = ParseImportType(command.ImportType); var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); - var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); + var items = command.Items + .Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); var job = new ContentImportJob { TenantId = actor.TenantId, @@ -203,10 +196,12 @@ public sealed partial class DirectContentService } } - private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, + CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; @@ -220,19 +215,18 @@ public sealed partial class DirectContentService 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) dbContext.Banners.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return item; } - private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, + CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; @@ -241,18 +235,17 @@ public sealed partial class DirectContentService 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) dbContext.Faqs.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return item; } - private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, + CancellationToken cancellationToken) { - var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.LegacyId = Normalize(command.LegacyId); @@ -261,21 +254,20 @@ public sealed partial class DirectContentService 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) dbContext.Announcements.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return item; } - private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) + private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, + CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; @@ -288,10 +280,7 @@ public sealed partial class DirectContentService 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) dbContext.ExamDates.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return item; @@ -319,10 +308,7 @@ public sealed partial class DirectContentService private static void ValidateQuestionForPublication(QuestionWriteCommand command) { var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); - if (status != QuestionStatus.Published) - { - return; - } + if (status != QuestionStatus.Published) return; var type = Normalize(command.Type) ?? "choice"; if (!QuestionGrader.HasValidAuthoritativeAnswer( @@ -330,11 +316,9 @@ public sealed partial class DirectContentService command.CorrectOptionIndex, command.CorrectOptionIndices, command.AnswerText)) - { throw new ContentManagementException( "Published questions require a valid authoritative answer.", "question_grading_rule_invalid"); - } } private static QuestionVersion BuildQuestionVersion( @@ -368,15 +352,18 @@ public sealed partial class DirectContentService version.SourceHash = Normalize(command.SourceHash); } - private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) + private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, + CancellationToken cancellationToken) { - await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", + cancellationToken); await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken); - await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken); + await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", + cancellationToken); } private async Task SyncPrimaryCollectionItemAsync( @@ -384,10 +371,7 @@ public sealed partial class DirectContentService Question question, CancellationToken cancellationToken) { - if (!question.PrimaryCollectionId.HasValue) - { - return; - } + if (!question.PrimaryCollectionId.HasValue) return; var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( item => @@ -403,7 +387,8 @@ public sealed partial class DirectContentService new QuestionLocator(QuestionSource.Tenant, question.Id), cancellationToken); var nextOrder = await dbContext.QuestionCollectionItems - .Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) + .Where(item => + item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) .Select(item => (int?)item.SortOrder) .MaxAsync(cancellationToken) ?? -1; dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem @@ -433,18 +418,13 @@ public sealed partial class DirectContentService Guid? contentNodeId, CancellationToken cancellationToken) { - if (!unitId.HasValue) - { - return (entryId, contentNodeId); - } + if (!unitId.HasValue) return (entryId, contentNodeId); var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == unitId.Value, cancellationToken); if (unit is null) - { throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found"); - } return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); } @@ -456,18 +436,13 @@ public sealed partial class DirectContentService Guid? contentNodeId, CancellationToken cancellationToken) { - if (!subjectId.HasValue) - { - return (entryId, contentNodeId); - } + if (!subjectId.HasValue) return (entryId, contentNodeId); var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == subjectId.Value, cancellationToken); if (subject is null) - { throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found"); - } return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); } @@ -479,21 +454,14 @@ public sealed partial class DirectContentService Guid? contentNodeId, CancellationToken cancellationToken) { - if (!chapterId.HasValue) - { - return (entryId, contentNodeId); - } + if (!chapterId.HasValue) return (entryId, contentNodeId); var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == chapterId.Value, cancellationToken); if (chapter is null) - { throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found"); - } return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs b/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs index e3af135..73d9e15 100644 --- a/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs +++ b/Tiku.Infrastructure/Content/Handbook/DirectContentService.Handbook.cs @@ -1,19 +1,8 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -30,40 +19,25 @@ public sealed partial class DirectContentService var query = dbContext.HandbookSubjects.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - if (filter.MajorId.HasValue) - { - query = query.Where(item => item.MajorId == filter.MajorId.Value); - } + if (filter.MajorId.HasValue) query = query.Where(item => item.MajorId == filter.MajorId.Value); if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + query = query.Where(item => + item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query @@ -84,9 +58,11 @@ public sealed partial class DirectContentService await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", + cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found"); item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -104,10 +80,7 @@ public sealed partial class DirectContentService 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) dbContext.HandbookSubjects.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -119,30 +92,21 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.SubjectId.HasValue) - { - query = query.Where(item => item.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(item => item.SubjectId == filter.SubjectId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + query = query.Where(item => + item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query @@ -158,11 +122,14 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", + cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", + cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var chapterNavigation = await ResolveHandbookSubjectNavigationAsync( @@ -180,10 +147,7 @@ public sealed partial class DirectContentService 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) dbContext.HandbookChapters.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -195,30 +159,21 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.ChapterId.HasValue) - { - query = query.Where(item => item.ChapterId == filter.ChapterId.Value); - } + if (filter.ChapterId.HasValue) query = query.Where(item => item.ChapterId == filter.ChapterId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); + query = query.Where(item => + item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); } return new CatalogList(await query @@ -234,11 +189,14 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", + cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", + cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var entryNavigation = await ResolveHandbookChapterNavigationAsync( @@ -258,14 +216,9 @@ public sealed partial class DirectContentService 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) dbContext.HandbookEntries.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs b/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs index 377734a..88bf7ac 100644 --- a/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs +++ b/Tiku.Infrastructure/Content/Imports/DirectContentService.Imports.cs @@ -1,20 +1,8 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -25,7 +13,7 @@ public sealed partial class DirectContentService SimpleImportCommand command, CancellationToken cancellationToken = default) { - return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken); + return CreateImportJobAsync(actor, command with { DryRun = true }, false, cancellationToken); } public Task ExecuteImportAsync( @@ -33,7 +21,7 @@ public sealed partial class DirectContentService SimpleImportCommand command, CancellationToken cancellationToken = default) { - return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken); + return CreateImportJobAsync(actor, command with { DryRun = false }, true, cancellationToken); } public async Task GetImportJobAsync( @@ -45,10 +33,7 @@ public sealed partial class DirectContentService .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"); - } + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var items = await dbContext.ContentImportItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) @@ -109,10 +94,7 @@ public sealed partial class DirectContentService var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); - if (job is null) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var counts = JsonSerializer.SerializeToElement(new { @@ -145,10 +127,7 @@ public sealed partial class DirectContentService var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); - if (job is null) - { - throw new ContentManagementException("Import job was not found.", "import_job_not_found"); - } + if (job is null) throw new ContentManagementException("Import job was not found.", "import_job_not_found"); var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); var counts = JsonSerializer.SerializeToElement(new @@ -163,6 +142,4 @@ public sealed partial class DirectContentService }); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs b/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs index b111960..4a3a2f0 100644 --- a/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs +++ b/Tiku.Infrastructure/Content/Nodes/ContentManagementService.Nodes.cs @@ -1,15 +1,7 @@ -using System.Text; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -22,10 +14,7 @@ public sealed partial class ContentManagementService CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); - if (!filter.EntryId.HasValue) - { - throw new ContentManagementException("entryId is required.", "entry_id_required"); - } + if (!filter.EntryId.HasValue) throw new ContentManagementException("entryId is required.", "entry_id_required"); await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); var regionIds = scope.RegionIds.ToArray(); @@ -37,32 +26,20 @@ public sealed partial class ContentManagementService node => node.CreatedBy == actor.UserId, node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)); - if (!filter.IncludeInactive) - { - query = query.Where(node => node.IsActive); - } + if (!filter.IncludeInactive) query = query.Where(node => node.IsActive); - if (filter.RegionId.HasValue) - { - query = query.Where(node => node.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value); if (filter.ParentId is not null) { if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(node => node.ParentId == null); - } else if (Guid.TryParse(filter.ParentId, out var parentId)) - { query = query.Where(node => node.ParentId == parentId); - } } if (TryParse(filter.MarkerType, out ContentMarkerType markerType)) - { query = query.Where(node => node.MarkerType == markerType); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -107,19 +84,13 @@ public sealed partial class ContentManagementService var isNew = node is null; if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) - { throw new ContentManagementException("Content node was not found.", "node_not_found"); - } if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) - { throw new ContentManagementException("Content node was not found.", "node_not_found"); - } if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { throw new ContentManagementException("Content node was not found.", "node_not_found"); - } node ??= new ContentNode { @@ -130,7 +101,8 @@ public sealed partial class ContentManagementService CreatedBy = actor.UserId }; - var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken); + var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, + cancellationToken); node.EntryId = command.EntryId; node.RegionId = command.RegionId; node.ParentId = command.ParentId; @@ -149,25 +121,17 @@ public sealed partial class ContentManagementService node.Metadata = JsonObjectOrDefault(command.Metadata); node.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.ContentNodes.Add(node); - } + if (isNew) dbContext.ContentNodes.Add(node); if (command.ParentId.HasValue) { var parent = await dbContext.ContentNodes.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value, cancellationToken); - if (parent is not null) - { - parent.IsLeaf = false; - } + if (parent is not null) parent.IsLeaf = false; } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNodeItem(node)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs b/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs index 0172276..9e1913f 100644 --- a/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs +++ b/Tiku.Infrastructure/Content/OperationContent/DirectContentService.OperationContent.cs @@ -1,20 +1,6 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -61,7 +47,8 @@ public sealed partial class DirectContentService .ThenBy(item => item.SortOrder) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), - _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + _ => throw new ContentManagementException("Operation content kind is invalid.", + "operation_content_kind_invalid") }; return new CatalogList(items); @@ -73,17 +60,16 @@ public sealed partial class DirectContentService OperationContentCommand command, CancellationToken cancellationToken = default) { - OperationContentItem item = NormalizeOperationKind(kind) switch + var item = NormalizeOperationKind(kind) switch { "banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)), "faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)), "announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)), "exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)), - _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") + _ => throw new ContentManagementException("Operation content kind is invalid.", + "operation_content_kind_invalid") }; return new ContentManagementResult(item); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs b/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs index 73df18e..2c8cb32 100644 --- a/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs +++ b/Tiku.Infrastructure/Content/PracticeBlueprints/ContentManagementService.PracticeBlueprints.cs @@ -3,13 +3,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -31,35 +25,18 @@ public sealed partial class ContentManagementService blueprint => blueprint.CreatedBy == actor.UserId, blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); - if (!filter.IncludeInactive) - { - query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); - } + if (!filter.IncludeInactive) query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); - if (filter.RegionId.HasValue) - { - query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); if (filter.CollectionId.HasValue) - { query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); - } - if (TryParse(filter.Mode, out PracticeMode mode)) - { - query = query.Where(blueprint => blueprint.Mode == mode); - } + if (TryParse(filter.Mode, out PracticeMode mode)) query = query.Where(blueprint => blueprint.Mode == mode); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -87,7 +64,8 @@ public sealed partial class ContentManagementService await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", + cancellationToken); var blueprint = await ResolveEntityByIdOrLegacyAsync( dbContext.PracticeBlueprints, @@ -98,19 +76,13 @@ public sealed partial class ContentManagementService var isNew = blueprint is null; if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) - { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) - { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) - { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); - } blueprint ??= new PracticeBlueprint { @@ -126,7 +98,8 @@ public sealed partial class ContentManagementService blueprint.LegacyId = Normalize(command.LegacyId); blueprint.Name = command.Name.Trim(); blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid"); - blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid"); + blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, + "practice_assembly_type_invalid"); blueprint.QuestionLimit = command.QuestionLimit; blueprint.DurationMinutes = command.DurationMinutes; blueprint.TotalScore = command.TotalScore; @@ -138,10 +111,7 @@ public sealed partial class ContentManagementService blueprint.SortOrder = command.Order ?? 0; blueprint.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.PracticeBlueprints.Add(blueprint); - } + if (isNew) dbContext.PracticeBlueprints.Add(blueprint); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBlueprintItem(blueprint)); @@ -168,7 +138,8 @@ public sealed partial class ContentManagementService "csv" => string.Join( "\n", spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))), - _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid") + _ => throw new ContentManagementException("Import template format is not supported.", + "import_template_format_invalid") }; return new ImportTemplateItem( @@ -180,6 +151,4 @@ public sealed partial class ContentManagementService content, spec.Fields); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs b/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs index 9af2f44..d68287d 100644 --- a/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs +++ b/Tiku.Infrastructure/Content/Questions/DirectContentService.Questions.cs @@ -1,20 +1,7 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; -using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -38,12 +25,10 @@ public sealed partial class DirectContentService }; ApplyQuestion(question, command); if (question.Status != QuestionStatus.Archived) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( actor.TenantId, SaasQuotaMetricCatalog.PrivateQuestionCount, cancellationToken: cancellationToken); - } dbContext.Questions.Add(question); await dbContext.SaveChangesAsync(cancellationToken); @@ -53,10 +38,7 @@ public sealed partial class DirectContentService await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } @@ -66,19 +48,14 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { if (!command.QuestionId.HasValue) - { throw new ContentManagementException("questionId is required.", "question_id_required"); - } ValidateQuestionForPublication(command); var question = await dbContext.Questions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, cancellationToken); - if (question is null) - { - throw new ContentManagementException("Question was not found.", "question_not_found"); - } + 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 @@ -88,12 +65,10 @@ public sealed partial class DirectContentService ApplyQuestion(question, command); var isCounted = question.Status != QuestionStatus.Archived; if (!wasCounted && isCounted) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( actor.TenantId, SaasQuotaMetricCatalog.PrivateQuestionCount, cancellationToken: cancellationToken); - } QuestionVersion? version; if (command.CreateVersion || !question.CurrentVersionId.HasValue) { @@ -129,19 +104,12 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); if (wasCounted && !isCounted) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, SaasQuotaMetricCatalog.PrivateQuestionCount, 1, cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs b/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs index a5342d7..9bcc9dd 100644 --- a/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs +++ b/Tiku.Infrastructure/Content/Scorelines/DirectContentService.Scorelines.cs @@ -1,19 +1,7 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -31,9 +19,7 @@ public sealed partial class DirectContentService .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) - { query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -57,12 +43,11 @@ public sealed partial class DirectContentService ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) - { throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid"); - } await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found"); item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -80,10 +65,7 @@ public sealed partial class DirectContentService item.Placeholder = Normalize(command.Placeholder); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; - if (isNew) - { - dbContext.ScorelineFields.Add(item); - } + if (isNew) dbContext.ScorelineFields.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -99,25 +81,13 @@ public sealed partial class DirectContentService var query = dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - if (filter.MajorId.HasValue) - { - query = query.Where(item => item.MajorId == filter.MajorId.Value); - } + if (filter.MajorId.HasValue) query = query.Where(item => item.MajorId == filter.MajorId.Value); - if (filter.Year.HasValue) - { - query = query.Where(item => item.Year == filter.Year.Value); - } + if (filter.Year.HasValue) query = query.Where(item => item.Year == filter.Year.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -142,14 +112,13 @@ public sealed partial class DirectContentService { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (command.Year is < 1900 or > 3000) - { throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); - } await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found"); item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -161,10 +130,7 @@ public sealed partial class DirectContentService item.SchoolName = Normalize(command.SchoolName); item.MajorName = Normalize(command.MajorName); item.FieldValues = JsonObjectOrDefault(command.FieldValues); - if (isNew) - { - dbContext.ScorelineRecords.Add(item); - } + if (isNew) dbContext.ScorelineRecords.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -180,15 +146,9 @@ public sealed partial class DirectContentService var query = dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(item => item.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); var years = await query .Select(item => item.Year) @@ -225,6 +185,4 @@ public sealed partial class DirectContentService return new CatalogList(items); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs b/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs index 152929e..dcc70bd 100644 --- a/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs +++ b/Tiku.Infrastructure/Content/Videos/DirectContentService.Videos.cs @@ -1,20 +1,9 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -26,20 +15,16 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.SubjectId.HasValue) - { - query = query.Where(item => item.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(item => item.SubjectId == filter.SubjectId.Value); if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + query = query.Where(item => + item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } var items = await query @@ -58,7 +43,8 @@ public sealed partial class DirectContentService { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.SubjectId = command.SubjectId; @@ -74,10 +60,7 @@ public sealed partial class DirectContentService 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) dbContext.VideoExplanations.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToVideoItem(item)); @@ -88,10 +71,13 @@ public sealed partial class DirectContentService QuestionVideoCommand command, CancellationToken cancellationToken = default) { - await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", + cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", + cancellationToken); var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( - link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, + link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && + link.VideoId == command.VideoId, cancellationToken); var isNew = item is null; item ??= new QuestionVideo { TenantId = actor.TenantId }; @@ -101,10 +87,7 @@ public sealed partial class DirectContentService 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) dbContext.QuestionVideos.Add(item); var question = await dbContext.Questions.SingleAsync( question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, @@ -113,6 +96,4 @@ public sealed partial class DirectContentService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionVideoItem(item)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs b/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs index 1992656..fc0c529 100644 --- a/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs +++ b/Tiku.Infrastructure/Content/Vocabulary/DirectContentService.Vocabulary.cs @@ -1,19 +1,8 @@ -using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; @@ -30,30 +19,21 @@ public sealed partial class DirectContentService var query = dbContext.VocabularyUnits.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); + query = query.Where(item => + item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query @@ -72,9 +52,11 @@ public sealed partial class DirectContentService ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", + cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found"); item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; @@ -88,10 +70,7 @@ public sealed partial class DirectContentService 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) dbContext.VocabularyUnits.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); @@ -103,30 +82,21 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (filter.UnitId.HasValue) - { - query = query.Where(item => item.UnitId == filter.UnitId.Value); - } + if (filter.UnitId.HasValue) query = query.Where(item => item.UnitId == filter.UnitId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(item => item.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(item => item.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.IsActive); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); + query = query.Where(item => + item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); } return new CatalogList(await query @@ -142,11 +112,14 @@ public sealed partial class DirectContentService CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Word); - await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", + cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", + cancellationToken); - var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, + command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var vocabularyNavigation = await ResolveVocabularyNavigationAsync( @@ -169,14 +142,9 @@ public sealed partial class DirectContentService 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) dbContext.VocabularyWords.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 4b80f38..b19c70c 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -1,14 +1,16 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Npgsql; +using StackExchange.Redis; using Tiku.Application.Security; +using Tiku.Domain.Identity; using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Observability; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; -using Tiku.Domain.Identity; -using StackExchange.Redis; -using Tiku.Infrastructure.Observability; namespace Tiku.Infrastructure; @@ -75,16 +77,16 @@ public static class DependencyInjection services.AddSingleton(provider => new RedisSecurityStore( provider.GetRequiredService(), environmentName, - provider.GetRequiredService>())); + provider.GetRequiredService>())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => new RedisAuthorizationCache( provider.GetRequiredService(), - provider.GetRequiredService>(), + provider.GetRequiredService>(), environmentName)); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options); return services; } - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs b/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs index a3af549..0cba3cf 100644 --- a/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs +++ b/Tiku.Infrastructure/Growth/Analytics/ReferralService.Analytics.cs @@ -1,15 +1,6 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; -using Tiku.Application.Security; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -37,9 +28,7 @@ public sealed partial class ReferralService .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); if (query.ReferrerUserId.HasValue) - { referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); - } var referrerIds = await referrerIdsQuery .Select(item => item.ReferrerUserId!.Value) @@ -48,9 +37,7 @@ public sealed partial class ReferralService .ToArrayAsync(cancellationToken); var stats = new List(referrerIds.Length); foreach (var referrerId in referrerIds) - { stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); - } return new ReferralList( stats @@ -69,10 +56,7 @@ public sealed partial class ReferralService var today = DateOnly.FromDateTime(DateTime.UtcNow); var endDate = query.EndDate ?? today; var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); - if (endDate < startDate) - { - throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); - } + if (endDate < startDate) throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); @@ -83,9 +67,7 @@ public sealed partial class ReferralService item.BoundAt >= start && item.BoundAt < endExclusive); if (query.ReferrerUserId.HasValue) - { leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); - } var leads = await leadsQuery .OrderByDescending(item => item.BoundAt) @@ -145,6 +127,4 @@ public sealed partial class ReferralService .ToArrayAsync(cancellationToken); return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/CommissionService.cs b/Tiku.Infrastructure/Growth/CommissionService.cs index bb63145..34fac73 100644 --- a/Tiku.Infrastructure/Growth/CommissionService.cs +++ b/Tiku.Infrastructure/Growth/CommissionService.cs @@ -9,7 +9,6 @@ using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Operations; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -18,36 +17,43 @@ public sealed class CommissionService( TikuDbContext dbContext, ICurrentAccessContext currentAccessContext) : ICommissionService { - public async Task GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default) + public async Task GetSettingsAsync(CommissionAdminActor actor, + CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); return ToSettingsItem(await GetSettingsCoreAsync(actor.TenantId, cancellationToken)); } - public async Task UpdateSettingsAsync(CommissionAdminActor actor, UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default) + public async Task UpdateSettingsAsync(CommissionAdminActor actor, + UpdateCommissionSettingsCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken); settings.DefaultRate = command.DefaultRate ?? settings.DefaultRate; settings.MinSettlementCents = command.MinSettlementCents ?? settings.MinSettlementCents; - settings.SettlementCycle = ParseEnum(command.SettlementCycle, settings.SettlementCycle, "invalid_commission_cycle"); + settings.SettlementCycle = + ParseEnum(command.SettlementCycle, settings.SettlementCycle, "invalid_commission_cycle"); settings.Config = command.Config ?? settings.Config; settings.UpdatedBy = actor.UserId; await dbContext.SaveChangesAsync(cancellationToken); return ToSettingsItem(settings); } - public async Task UpdateMemberRateAsync(CommissionAdminActor actor, UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default) + public async Task UpdateMemberRateAsync(CommissionAdminActor actor, + UpdateMemberCommissionRateCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var member = await dbContext.TenantMemberships - .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken) - ?? throw new CommissionException("Commission member was not found.", "commission_member_not_found"); + .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, + cancellationToken) + ?? throw new CommissionException("Commission member was not found.", + "commission_member_not_found"); var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken); var config = settings.Config.ValueKind == JsonValueKind.Object ? JsonSerializer.Deserialize>(settings.Config.GetRawText()) ?? [] : []; - var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object + var memberRates = config.TryGetValue("memberRates", out var existingRates) && + existingRates.ValueKind == JsonValueKind.Object ? JsonSerializer.Deserialize>(existingRates.GetRawText()) ?? [] : []; memberRates[member.UserId.ToString("N")] = JsonSerializer.SerializeToElement(new @@ -58,10 +64,14 @@ public sealed class CommissionService( config["memberRates"] = JsonSerializer.SerializeToElement(memberRates); settings.Config = JsonSerializer.SerializeToElement(config); await dbContext.SaveChangesAsync(cancellationToken); - return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig }; + return new + { + member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig + }; } - public async Task GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default) + public async Task GetSummaryAsync(CommissionAdminActor actor, CommissionPeriodQuery query, + CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var rows = await BuildSourcesAsync(actor.TenantId, query, cancellationToken); @@ -72,24 +82,32 @@ public sealed class CommissionService( rows.Sum(item => item.CommissionAmountCents)); } - public async Task> GetOrdersAsync(CommissionAdminActor actor, CommissionPeriodQuery query, CancellationToken cancellationToken = default) + public async Task> GetOrdersAsync(CommissionAdminActor actor, + CommissionPeriodQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var rows = await BuildSourcesAsync(actor.TenantId, query, cancellationToken); - return new CommissionList(rows.Take(Math.Clamp(query.Limit ?? 100, 1, 500)).Select(ToSourceItem).ToArray()); + return new CommissionList(rows.Take(Math.Clamp(query.Limit ?? 100, 1, 500)) + .Select(ToSourceItem).ToArray()); } - public async Task> GetSettlementsAsync(CommissionAdminActor actor, CommissionSettlementQuery query, CancellationToken cancellationToken = default) + public async Task> GetSettlementsAsync(CommissionAdminActor actor, + CommissionSettlementQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var items = dbContext.CommissionSettlements.AsNoTracking().Where(item => item.TenantId == actor.TenantId); - if (query.ReferrerUserId.HasValue) items = items.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); - if (!string.IsNullOrWhiteSpace(query.Status)) items = items.Where(item => item.Status == ParseEnum(query.Status, CommissionSettlementStatus.Draft, "invalid_commission_status")); - var result = await items.OrderByDescending(item => item.CreatedAt).Take(Math.Clamp(query.Limit ?? 100, 1, 500)).ToArrayAsync(cancellationToken); + if (query.ReferrerUserId.HasValue) + items = items.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); + if (!string.IsNullOrWhiteSpace(query.Status)) + items = items.Where(item => + item.Status == ParseEnum(query.Status, CommissionSettlementStatus.Draft, "invalid_commission_status")); + var result = await items.OrderByDescending(item => item.CreatedAt).Take(Math.Clamp(query.Limit ?? 100, 1, 500)) + .ToArrayAsync(cancellationToken); return new CommissionList(result.Select(ToSettlementItem).ToArray()); } - public async Task ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId, string? format, CancellationToken cancellationToken = default) + public async Task ExportSettlementAsync(CommissionAdminActor actor, Guid settlementId, + string? format, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var settlement = await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken); @@ -115,21 +133,29 @@ public sealed class CommissionService( ContentSha256 = sha, Metadata = JsonSerializer.SerializeToElement(new { sizeBytes = bytes.Length }) }); - await AddAuditAsync(actor, "commission.settlement.exported", "commission_settlements", settlementId, new { format = resolvedFormat, filename, rowCount = items.Length, sha }, cancellationToken); + await AddAuditAsync(actor, "commission.settlement.exported", "commission_settlements", settlementId, + new { format = resolvedFormat, filename, rowCount = items.Length, sha }, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return new CommissionExportItem(settlementId, filename, resolvedFormat, resolvedFormat == "json" ? "application/json" : "text/csv", items.Length, Convert.ToBase64String(bytes), sha, bytes.Length); + return new CommissionExportItem(settlementId, filename, resolvedFormat, + resolvedFormat == "json" ? "application/json" : "text/csv", items.Length, Convert.ToBase64String(bytes), + sha, bytes.Length); } - public async Task GenerateSettlementAsync(CommissionAdminActor actor, GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default) + public async Task GenerateSettlementAsync(CommissionAdminActor actor, + GenerateCommissionSettlementCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - if (command.EndDate < command.StartDate) throw new CommissionException("Commission period was invalid.", "invalid_commission_period"); + if (command.EndDate < command.StartDate) + throw new CommissionException("Commission period was invalid.", "invalid_commission_period"); var query = new CommissionPeriodQuery(command.StartDate, command.EndDate, command.ReferrerUserId, 5000); - var candidates = (await BuildSourcesAsync(actor.TenantId, query, cancellationToken)).Where(item => item.SettlementId is null).ToArray(); - if (candidates.Length == 0) throw new CommissionException("No unsettled commission sources found.", "commission_no_unsettled_sources"); + var candidates = (await BuildSourcesAsync(actor.TenantId, query, cancellationToken)) + .Where(item => item.SettlementId is null).ToArray(); + if (candidates.Length == 0) + throw new CommissionException("No unsettled commission sources found.", "commission_no_unsettled_sources"); var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken); var amount = candidates.Sum(item => item.CommissionAmountCents); - if (amount < settings.MinSettlementCents) throw new CommissionException("Commission amount is below settlement minimum.", "commission_below_minimum"); + if (amount < settings.MinSettlementCents) + throw new CommissionException("Commission amount is below settlement minimum.", "commission_below_minimum"); var settlement = new CommissionSettlement { TenantId = actor.TenantId, @@ -149,7 +175,6 @@ public sealed class CommissionService( }; dbContext.CommissionSettlements.Add(settlement); foreach (var source in candidates) - { dbContext.CommissionSettlementItems.Add(new CommissionSettlementItem { TenantId = actor.TenantId, @@ -167,12 +192,12 @@ public sealed class CommissionService( AttributionType = source.AttributionType, Metadata = JsonSerializer.SerializeToElement(new { source = "commission_generate" }) }); - } await dbContext.SaveChangesAsync(cancellationToken); return ToSettlementItem(settlement); } - public async Task UpdateSettlementStatusAsync(CommissionAdminActor actor, UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default) + public async Task UpdateSettlementStatusAsync(CommissionAdminActor actor, + UpdateCommissionSettlementStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var item = await GetSettlementAsync(actor.TenantId, command.SettlementId, cancellationToken); @@ -182,11 +207,13 @@ public sealed class CommissionService( item.Status = status; item.Remark = command.ReviewNote ?? item.Remark; item.Metadata = command.Metadata ?? item.Metadata; - if (status is CommissionSettlementStatus.Approved or CommissionSettlementStatus.Rejected or CommissionSettlementStatus.Cancelled) + if (status is CommissionSettlementStatus.Approved or CommissionSettlementStatus.Rejected + or CommissionSettlementStatus.Cancelled) { item.ReviewedBy = actor.UserId; item.ReviewedAt = DateTimeOffset.UtcNow; } + if (status == CommissionSettlementStatus.Paid) { item.PaidBy = actor.UserId; @@ -194,11 +221,13 @@ public sealed class CommissionService( item.PaymentMethod = command.PaymentMethod ?? item.PaymentMethod; item.PaymentAccount = command.PaymentAccount ?? item.PaymentAccount; } + await dbContext.SaveChangesAsync(cancellationToken); return ToSettlementItem(item); } - public async Task> GetProofsAsync(CommissionAdminActor actor, Guid settlementId, CancellationToken cancellationToken = default) + public async Task> GetProofsAsync(CommissionAdminActor actor, Guid settlementId, + CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); await GetSettlementAsync(actor.TenantId, settlementId, cancellationToken); @@ -209,7 +238,8 @@ public sealed class CommissionService( return new CommissionList(items.Select(ToProofItem).ToArray()); } - public async Task CreateProofAsync(CommissionAdminActor actor, CreateCommissionProofCommand command, CancellationToken cancellationToken = default) + public async Task CreateProofAsync(CommissionAdminActor actor, + CreateCommissionProofCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var settlement = await GetSettlementAsync(actor.TenantId, command.SettlementId, cancellationToken); @@ -235,14 +265,18 @@ public sealed class CommissionService( return ToProofItem(proof); } - public async Task UpdateProofStatusAsync(CommissionAdminActor actor, UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default) + public async Task UpdateProofStatusAsync(CommissionAdminActor actor, + UpdateCommissionProofStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); - var proof = await dbContext.CommissionSettlementProofs.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.ProofId, cancellationToken) - ?? throw new CommissionException("Commission proof was not found.", "commission_proof_not_found"); + var proof = await dbContext.CommissionSettlementProofs.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.ProofId, cancellationToken) + ?? throw new CommissionException("Commission proof was not found.", "commission_proof_not_found"); var status = ParseEnum(command.Status, CommissionProofStatus.Approved, "invalid_commission_proof_status"); - if (status == CommissionProofStatus.Submitted) throw new CommissionException("Cannot move proof back to submitted.", "invalid_commission_proof_status"); - if (proof.Status is CommissionProofStatus.Approved or CommissionProofStatus.Rejected or CommissionProofStatus.Voided && proof.Status != status) + if (status == CommissionProofStatus.Submitted) + throw new CommissionException("Cannot move proof back to submitted.", "invalid_commission_proof_status"); + if (proof.Status is CommissionProofStatus.Approved or CommissionProofStatus.Rejected + or CommissionProofStatus.Voided && proof.Status != status) throw new CommissionException("Closed proof cannot change status.", "commission_proof_closed"); proof.Status = status; proof.ReviewedBy = actor.UserId; @@ -253,7 +287,8 @@ public sealed class CommissionService( return ToProofItem(proof); } - private async Task> BuildSourcesAsync(Guid tenantId, CommissionPeriodQuery query, CancellationToken cancellationToken) + private async Task> BuildSourcesAsync(Guid tenantId, + CommissionPeriodQuery query, CancellationToken cancellationToken) { var today = DateOnly.FromDateTime(DateTime.UtcNow); var startDate = query.StartDate ?? today.AddDays(-29); @@ -267,27 +302,36 @@ public sealed class CommissionService( .ToArrayAsync(cancellationToken); var result = new List(); var orders = await ( - from order in dbContext.Orders.AsNoTracking() - join lead in dbContext.ReferralLeads.AsNoTracking() - on new { order.TenantId, StudentUserId = order.UserId!.Value } equals new { lead.TenantId, lead.StudentUserId } - where order.TenantId == tenantId && order.UserId != null && lead.ReferrerUserId != null && - order.Status == OrderStatus.Paid && order.PaidAt >= start && order.PaidAt < end - select new { order, lead }) + from order in dbContext.Orders.AsNoTracking() + join lead in dbContext.ReferralLeads.AsNoTracking() + on new { order.TenantId, StudentUserId = order.UserId!.Value } equals new + { lead.TenantId, lead.StudentUserId } + where order.TenantId == tenantId && order.UserId != null && lead.ReferrerUserId != null && + order.Status == OrderStatus.Paid && order.PaidAt >= start && order.PaidAt < end + select new { order, lead }) .ToArrayAsync(cancellationToken); foreach (var row in orders) { if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue; var rate = GetMemberRate(settings.Config, row.lead.ReferrerUserId!.Value) ?? settings.DefaultRate; - var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)?.SettlementId; - result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, "protected_lead")); + var settled = existing + .FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id) + ?.SettlementId; + result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, + row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, + (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, + "protected_lead")); } + var codes = await ( - from code in dbContext.ActivationCodes.AsNoTracking() - join batch in dbContext.CodeBatches.AsNoTracking() - on new { code.TenantId, BatchId = code.BatchId } equals new { batch.TenantId, BatchId = (Guid?)batch.Id } into batches - from batch in batches.DefaultIfEmpty() - where code.TenantId == tenantId && code.IsUsed && code.AgentUserId != null && code.UsedAt >= start && code.UsedAt < end - select new { code, batch }) + from code in dbContext.ActivationCodes.AsNoTracking() + join batch in dbContext.CodeBatches.AsNoTracking() + on new { code.TenantId, code.BatchId } equals new { batch.TenantId, BatchId = (Guid?)batch.Id } into + batches + from batch in batches.DefaultIfEmpty() + where code.TenantId == tenantId && code.IsUsed && code.AgentUserId != null && code.UsedAt >= start && + code.UsedAt < end + select new { code, batch }) .ToArrayAsync(cancellationToken); foreach (var row in codes) { @@ -296,9 +340,14 @@ public sealed class CommissionService( if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue; var rate = row.batch?.CommissionRate ?? GetMemberRate(settings.Config, agentUserId) ?? settings.DefaultRate; var sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0; - var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId; - result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, row.code.UsedAt, "activation_code_agent")); + var settled = existing.FirstOrDefault(item => + item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId; + result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, + row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), + row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, + row.code.UsedAt, "activation_code_agent")); } + return result.OrderBy(item => item.SourcePaidAt).ToArray(); } @@ -312,25 +361,30 @@ public sealed class CommissionService( !memberRate.TryGetProperty("commissionRate", out var value) || value.ValueKind != JsonValueKind.Number || !value.TryGetDecimal(out var rate)) - { return null; - } return rate; } private async Task GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken) { - var settings = await dbContext.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var settings = + await dbContext.TenantCommissionSettings.FirstOrDefaultAsync(item => item.TenantId == tenantId, + cancellationToken); if (settings is not null) return settings; settings = new TenantCommissionSetting { TenantId = tenantId }; dbContext.TenantCommissionSettings.Add(settings); return settings; } - private async Task GetSettlementAsync(Guid tenantId, Guid settlementId, CancellationToken cancellationToken) => - await dbContext.CommissionSettlements.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Id == settlementId, cancellationToken) - ?? throw new CommissionException("Commission settlement was not found.", "commission_settlement_not_found"); + private async Task GetSettlementAsync(Guid tenantId, Guid settlementId, + CancellationToken cancellationToken) + { + return await dbContext.CommissionSettlements.FirstOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == settlementId, cancellationToken) + ?? throw new CommissionException("Commission settlement was not found.", + "commission_settlement_not_found"); + } private async Task AssertAdminAsync(CommissionAdminActor actor, CancellationToken cancellationToken) { @@ -339,39 +393,91 @@ public sealed class CommissionService( access.TenantId != actor.TenantId || access.UserId != actor.UserId || !access.HasTenantPermission(BackendPermissions.TenantCommissionManage)) - { throw new CommissionException("Commission admin access was denied.", "commission_access_denied"); - } } - private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) + private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, + object details, CancellationToken cancellationToken) { - dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, + TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) + }); await Task.CompletedTask.WaitAsync(cancellationToken); } private static string BuildCsv(IEnumerable items) { - var builder = new StringBuilder("sourceType,sourceNo,referrerUserId,studentUserId,grossAmountCents,commissionRate,commissionAmountCents\n"); + var builder = + new StringBuilder( + "sourceType,sourceNo,referrerUserId,studentUserId,grossAmountCents,commissionRate,commissionAmountCents\n"); foreach (var item in items) - builder.Append(CultureInfo.InvariantCulture, $"{item.SourceType},{item.SourceNo},{item.ReferrerUserId},{item.StudentUserId},{item.GrossAmountCents},{item.CommissionRate},{item.CommissionAmountCents}\n"); + builder.Append(CultureInfo.InvariantCulture, + $"{item.SourceType},{item.SourceNo},{item.ReferrerUserId},{item.StudentUserId},{item.GrossAmountCents},{item.CommissionRate},{item.CommissionAmountCents}\n"); return builder.ToString(); } - private static CommissionSettingsItem ToSettingsItem(TenantCommissionSetting item) => new(item.DefaultRate, item.MinSettlementCents, item.SettlementCycle.ToString(), item.Config); - private static CommissionSourceItem ToSourceItem(SourceCandidate item) => new(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, item.StudentUserId, item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), item.SettlementId); - private static CommissionSourceItem ToSourceItem(CommissionSettlementItem item) => new(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, item.StudentUserId, item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), item.SettlementId); - private static CommissionSettlementItemDto ToSettlementItem(CommissionSettlement item) => new(item.Id, item.SettlementNo, item.ReferrerUserId, item.Status.ToString(), item.PeriodStart, item.PeriodEnd, item.SourceCount, item.PaidUserCount, item.GrossAmountCents, item.CommissionAmountCents, item.ReviewedAt, item.PaidAt); - private static CommissionProofItem ToProofItem(CommissionSettlementProof item) => new(item.Id, item.SettlementId, item.ProofType.ToString(), item.Status.ToString(), item.Title, item.AssetId, item.ExternalUrl, item.AmountCents, item.PaymentMethod, item.PaymentAccount, item.PaidAt, item.ReviewedAt); + private static CommissionSettingsItem ToSettingsItem(TenantCommissionSetting item) + { + return new CommissionSettingsItem(item.DefaultRate, item.MinSettlementCents, item.SettlementCycle.ToString(), + item.Config); + } - private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum + private static CommissionSourceItem ToSourceItem(SourceCandidate item) + { + return new CommissionSourceItem(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, + item.StudentUserId, + item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), + item.SettlementId); + } + + private static CommissionSourceItem ToSourceItem(CommissionSettlementItem item) + { + return new CommissionSourceItem(item.SourceType.ToString(), item.SourceId, item.SourceNo, item.ReferrerUserId, + item.StudentUserId, + item.GrossAmountCents, item.CommissionRate, item.CommissionAmountCents, item.RateSource.ToString(), + item.SettlementId); + } + + private static CommissionSettlementItemDto ToSettlementItem(CommissionSettlement item) + { + return new CommissionSettlementItemDto(item.Id, item.SettlementNo, item.ReferrerUserId, item.Status.ToString(), + item.PeriodStart, + item.PeriodEnd, item.SourceCount, item.PaidUserCount, item.GrossAmountCents, item.CommissionAmountCents, + item.ReviewedAt, item.PaidAt); + } + + private static CommissionProofItem ToProofItem(CommissionSettlementProof item) + { + return new CommissionProofItem(item.Id, item.SettlementId, item.ProofType.ToString(), item.Status.ToString(), + item.Title, + item.AssetId, item.ExternalUrl, item.AmountCents, item.PaymentMethod, item.PaymentAccount, item.PaidAt, + item.ReviewedAt); + } + + private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) + where TEnum : struct, Enum { if (string.IsNullOrWhiteSpace(value)) return defaultValue; var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); foreach (var enumValue in Enum.GetValues()) - if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) return enumValue; + if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) + return enumValue; throw new CommissionException("Commission enum value was invalid.", errorCode); } - private sealed record SourceCandidate(CommissionSourceType SourceType, Guid SourceId, string? SourceNo, Guid ReferrerUserId, Guid? StudentUserId, int GrossAmountCents, decimal CommissionRate, int CommissionAmountCents, CommissionRateSource RateSource, Guid? SettlementId, DateTimeOffset? SourcePaidAt, string AttributionType); -} + private sealed record SourceCandidate( + CommissionSourceType SourceType, + Guid SourceId, + string? SourceNo, + Guid ReferrerUserId, + Guid? StudentUserId, + int GrossAmountCents, + decimal CommissionRate, + int CommissionAmountCents, + CommissionRateSource RateSource, + Guid? SettlementId, + DateTimeOffset? SourcePaidAt, + string AttributionType); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/CrmService.cs b/Tiku.Infrastructure/Growth/CrmService.cs index 1b43517..a806390 100644 --- a/Tiku.Infrastructure/Growth/CrmService.cs +++ b/Tiku.Infrastructure/Growth/CrmService.cs @@ -66,7 +66,8 @@ internal sealed class CrmService( config.ExamType = NormalizeOptional(command.ExamType); config.TimeoutSeconds = command.TimeoutSeconds; config.DelaySeconds = command.DelaySeconds; - config.AssignmentMode = ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None, "invalid_assignment_mode"); + config.AssignmentMode = + ParseEnum(command.AssignmentMode, ReferralAssignmentMode.None, "invalid_assignment_mode"); config.AssignmentPool = EnsureArray(command.AssignmentPool); config.AssignmentConfig = EnsureObject(command.AssignmentConfig); @@ -107,9 +108,8 @@ internal sealed class CrmService( } if (!string.IsNullOrWhiteSpace(query.Status)) - { - deadLetters = deadLetters.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Failed, "invalid_crm_queue_status")); - } + deadLetters = deadLetters.Where(item => + item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Failed, "invalid_crm_queue_status")); var items = await deadLetters .OrderBy(item => item.CreatedAt) @@ -144,11 +144,11 @@ internal sealed class CrmService( if (query.QueueId.HasValue) { var queue = await dbContext.CrmWebhookQueue - .AsNoTracking() - .Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value) - .Select(item => new { item.Id, item.RecordId }) - .FirstOrDefaultAsync(cancellationToken) - ?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found"); + .AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.Id == query.QueueId.Value) + .Select(item => new { item.Id, item.RecordId }) + .FirstOrDefaultAsync(cancellationToken) + ?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found"); logs = logs.Where(item => item.RecordId == queue.RecordId || item.RecordId == queue.Id.ToString()); } @@ -166,15 +166,15 @@ internal sealed class CrmService( { await AssertAdminAsync(actor, cancellationToken); var item = await dbContext.CrmWebhookQueue - .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId, cancellationToken) - ?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found"); + .FirstOrDefaultAsync(entry => entry.TenantId == actor.TenantId && entry.Id == command.QueueId, + cancellationToken) + ?? throw new CrmException("CRM queue item was not found.", "crm_queue_not_found"); var action = NormalizeOptional(command.Action) ?? "retry"; if (action.Equals("retry", StringComparison.OrdinalIgnoreCase)) { - if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying)) - { + if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded + or CrmWebhookQueueStatus.Retrying)) throw new CrmException("CRM queue item cannot be retried.", "crm_queue_status_invalid"); - } item.Status = CrmWebhookQueueStatus.Pending; item.NextAttemptAt = DateTimeOffset.UtcNow; @@ -183,9 +183,7 @@ internal sealed class CrmService( else if (action.Equals("ignore", StringComparison.OrdinalIgnoreCase)) { if (item.Status is CrmWebhookQueueStatus.Sent) - { throw new CrmException("CRM queue item cannot be ignored.", "crm_queue_status_invalid"); - } item.Status = CrmWebhookQueueStatus.Discarded; item.LastError = NormalizeOptional(command.Note) ?? item.LastError; @@ -215,15 +213,11 @@ internal sealed class CrmService( var items = dbContext.CrmWebhookQueue .AsNoTracking() .Where(item => item.TenantId == tenantId); - if (query.QueueId.HasValue) - { - items = items.Where(item => item.Id == query.QueueId.Value); - } + if (query.QueueId.HasValue) items = items.Where(item => item.Id == query.QueueId.Value); if (!string.IsNullOrWhiteSpace(query.Status)) - { - items = items.Where(item => item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending, "invalid_crm_queue_status")); - } + items = items.Where(item => + item.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending, "invalid_crm_queue_status")); if (!string.IsNullOrWhiteSpace(query.Source)) { @@ -241,7 +235,8 @@ internal sealed class CrmService( CancellationToken cancellationToken) { var item = await dbContext.TenantSecrets - .FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef, cancellationToken); + .FirstOrDefaultAsync(secretItem => secretItem.TenantId == tenantId && secretItem.SecretRef == secretRef, + cancellationToken); if (item is null) { item = new TenantSecret @@ -278,9 +273,7 @@ internal sealed class CrmService( access.TenantId != actor.TenantId || !access.HasTenantPermission(BackendPermissions.TenantCrmManage) || access.DataScope.Mode != DataScopeMode.All) - { throw new CrmException("CRM admin access was denied.", "crm_access_denied"); - } } private static CrmConfigItem ToConfigItem(CrmConfig item) @@ -334,14 +327,10 @@ internal sealed class CrmService( private static JsonElement EnsureObject(JsonElement? value) { if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { return JsonDefaults.Object(); - } if (value.Value.ValueKind != JsonValueKind.Object) - { throw new CrmException("CRM JSON value must be an object.", "invalid_json_payload"); - } return value.Value.Clone(); } @@ -349,21 +338,17 @@ internal sealed class CrmService( private static JsonElement EnsureArray(JsonElement? value) { if (value is null || value.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { return JsonDefaults.Array(); - } if (value.Value.ValueKind != JsonValueKind.Array) - { throw new CrmException("CRM JSON value must be an array.", "invalid_json_payload"); - } return value.Value.Clone(); } private static JsonElement Redact(JsonElement value) { - object? converted = RedactValue(value); + var converted = RedactValue(value); return JsonSerializer.SerializeToElement(converted); } @@ -387,16 +372,10 @@ internal sealed class CrmService( private static string? RedactText(string? value) { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } + if (string.IsNullOrWhiteSpace(value)) return value; var result = value; - foreach (var key in SensitiveKeys) - { - result = result.Replace(key, "***", StringComparison.OrdinalIgnoreCase); - } + foreach (var key in SensitiveKeys) result = result.Replace(key, "***", StringComparison.OrdinalIgnoreCase); return result; } @@ -404,19 +383,12 @@ internal sealed class CrmService( private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum { - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } + if (string.IsNullOrWhiteSpace(value)) return defaultValue; var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); foreach (var enumValue in Enum.GetValues()) - { if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) - { return enumValue; - } - } throw new CrmException("CRM enum value was invalid.", errorCode); } @@ -425,4 +397,4 @@ internal sealed class CrmService( { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs b/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs index 3c23d87..68692fc 100644 --- a/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs +++ b/Tiku.Infrastructure/Growth/Foundation/ReferralService.Foundation.cs @@ -7,9 +7,7 @@ using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; -using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -31,20 +29,17 @@ public sealed partial class ReferralService var now = DateTimeOffset.UtcNow; var lead = await dbContext.ReferralLeads .FirstOrDefaultAsync(item => - item.TenantId == tenantId && - item.StudentUserId == studentUserId, + item.TenantId == tenantId && + item.StudentUserId == studentUserId, cancellationToken); if (lead is not null) { - if (lead.ReferrerUserId == referrerUserId) - { - return lead; - } + if (lead.ReferrerUserId == referrerUserId) return lead; - if (!force && lead.Status == ReferralLeadStatus.Protected && (lead.ProtectedUntil is null || lead.ProtectedUntil > now)) - { - throw new ReferralException("Referral lead is protected and cannot be rebound.", "referral_lead_protected"); - } + if (!force && lead.Status == ReferralLeadStatus.Protected && + (lead.ProtectedUntil is null || lead.ProtectedUntil > now)) + throw new ReferralException("Referral lead is protected and cannot be rebound.", + "referral_lead_protected"); } else { @@ -76,19 +71,14 @@ public sealed partial class ReferralService var config = await dbContext.CrmConfigs .AsNoTracking() .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken); - if (config is null || string.IsNullOrWhiteSpace(config.Url)) - { - return null; - } + if (config is null || string.IsNullOrWhiteSpace(config.Url)) return null; var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture); var idempotencyKey = $"{source}:{recordId}"; var existing = await dbContext.CrmWebhookQueue - .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) - { - return existing; - } + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey, + cancellationToken); + if (existing is not null) return existing; var queue = new CrmWebhookQueueItem { @@ -118,14 +108,15 @@ public sealed partial class ReferralService return queue; } - private async Task ResolveCodeCoreAsync(Guid tenantId, string code, CancellationToken cancellationToken) + private async Task ResolveCodeCoreAsync(Guid tenantId, string code, + CancellationToken cancellationToken) { return await dbContext.ReferralCodes .AsNoTracking() .FirstOrDefaultAsync(item => - item.TenantId == tenantId && - item.Code == code && - item.Status == ReferralCodeStatus.Active, + item.TenantId == tenantId && + item.Code == code && + item.Status == ReferralCodeStatus.Active, cancellationToken); } @@ -137,10 +128,7 @@ public sealed partial class ReferralService item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken); - if (!exists) - { - throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); - } + if (!exists) throw new ReferralException("Tenant member was not found.", "tenant_access_denied"); } private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken) @@ -150,9 +138,7 @@ public sealed partial class ReferralService access.TenantId != actor.TenantId || access.UserId != actor.UserId || !access.HasTenantPermission(BackendPermissions.TenantCrmManage)) - { throw new ReferralException("Referral admin access was denied.", "referral_access_denied"); - } } private async Task BuildStatsAsync( @@ -217,10 +203,7 @@ public sealed partial class ReferralService var exists = await dbContext.ReferralCodes.AnyAsync( item => item.TenantId == tenantId && item.Code == code, cancellationToken); - if (!exists) - { - return code; - } + if (!exists) return code; } throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed"); @@ -232,17 +215,15 @@ public sealed partial class ReferralService Span bytes = stackalloc byte[8]; RandomNumberGenerator.Fill(bytes); Span chars = stackalloc char[8]; - for (var index = 0; index < chars.Length; index++) - { - chars[index] = alphabet[bytes[index] % alphabet.Length]; - } + for (var index = 0; index < chars.Length; index++) chars[index] = alphabet[bytes[index] % alphabet.Length]; return new string(chars); } private static Guid RequireUser(ReferralActor actor) { - return actor.UserId ?? throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); + return actor.UserId ?? + throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied"); } private static string? NormalizeCode(string? value) @@ -258,10 +239,7 @@ public sealed partial class ReferralService string defaultValue, string errorCode) { - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } + if (string.IsNullOrWhiteSpace(value)) return defaultValue; var normalized = value.Trim().ToLowerInvariant(); return allowed.Contains(normalized) @@ -272,19 +250,12 @@ public sealed partial class ReferralService private static TEnum ParseEnum(string? value, TEnum defaultValue, string errorCode) where TEnum : struct, Enum { - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } + if (string.IsNullOrWhiteSpace(value)) return defaultValue; var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal); foreach (var enumValue in Enum.GetValues()) - { if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) - { return enumValue; - } - } throw new ReferralException("Referral enum value was invalid.", errorCode); } @@ -356,4 +327,4 @@ public sealed partial class ReferralService item.Status.ToString(), item.Metadata); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs index 65b2f20..1548baa 100644 --- a/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs +++ b/Tiku.Infrastructure/Growth/ReferralQrcodeGenerator.cs @@ -28,7 +28,8 @@ public sealed class ReferralQrcodeGenerator( var provider = NormalizeProvider(request.Provider); return provider == "wechat-miniapp" ? await GenerateWechatMiniappAsync(request with { Provider = provider }, cancellationToken) - : throw new ReferralException("Referral qrcode provider is not supported.", "referral_qrcode_provider_not_supported"); + : throw new ReferralException("Referral qrcode provider is not supported.", + "referral_qrcode_provider_not_supported"); } private async Task GenerateWechatMiniappAsync( @@ -39,7 +40,7 @@ public sealed class ReferralQrcodeGenerator( var imageBytes = await GenerateWechatMiniappImageAsync(options, request, cancellationToken); var checksum = Convert.ToHexString(SHA256.HashData(imageBytes)).ToLowerInvariant(); var objectKey = BuildObjectKey(request.TenantId, request.UserId, request.RefCode, request.Page, request.Scene); - await using var content = new MemoryStream(imageBytes, writable: false); + await using var content = new MemoryStream(imageBytes, false); var storage = await objectStorageService.WriteObjectAsync( new ObjectStorageWriteRequest( request.TenantId, @@ -59,11 +60,9 @@ public sealed class ReferralQrcodeGenerator( cancellationToken); if (storage.PublicUrl is null) - { throw new ReferralException( "Referral qrcode storage public base URL is not configured.", "referral_qrcode_public_url_not_configured"); - } return new ReferralQrcodeGenerateResult( storage.PublicUrl.ToString(), @@ -85,7 +84,6 @@ public sealed class ReferralQrcodeGenerator( { TenantExternalProviderAccount? provider = null; foreach (var alias in WechatMiniappProviderAliases) - { try { provider = await providerConfigService.GetActiveProviderAsync( @@ -98,23 +96,18 @@ public sealed class ReferralQrcodeGenerator( catch (TenantExternalProviderException) { } - } if (provider is null) - { throw new ReferralException( "Wechat miniapp auth provider is not configured.", "referral_qrcode_provider_not_configured"); - } var appId = GetJsonString(provider.ConfigPublic, "appId", "clientId"); var appSecret = GetJsonString(provider.SecretPayload, "appSecret", "clientSecret", "secret"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) - { throw new ReferralException( "Wechat miniapp auth provider configuration is incomplete.", "referral_qrcode_provider_not_configured"); - } return new WechatMiniappOptions(appId, appSecret); } @@ -129,8 +122,7 @@ public sealed class ReferralQrcodeGenerator( { var accessToken = await AccessTokenContainer.TryGetAccessTokenAsync( options.AppId, - options.AppSecret, - false); + options.AppSecret); await using var imageStream = new MemoryStream(); var result = await WxAppApi.GetWxaCodeUnlimitAsync( accessToken, @@ -147,19 +139,15 @@ public sealed class ReferralQrcodeGenerator( cancellationToken.ThrowIfCancellationRequested(); if (result.errcode != 0) - { throw new ReferralException( $"Wechat miniapp qrcode generation failed: {result.errcode}.", "referral_qrcode_wechat_failed"); - } var imageBytes = imageStream.ToArray(); if (imageBytes.Length == 0) - { throw new ReferralException( "Wechat miniapp qrcode response was empty.", "referral_qrcode_wechat_empty"); - } return imageBytes; } @@ -212,23 +200,16 @@ public sealed class ReferralQrcodeGenerator( private static string? GetJsonString(JsonElement element, params string[] names) { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } + if (element.ValueKind != JsonValueKind.Object) return null; foreach (var name in names) - { if (element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString()!.Trim(); - } - } return null; } private sealed record WechatMiniappOptions(string AppId, string AppSecret); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/ReferralService.cs b/Tiku.Infrastructure/Growth/ReferralService.cs index c2518a2..4224edf 100644 --- a/Tiku.Infrastructure/Growth/ReferralService.cs +++ b/Tiku.Infrastructure/Growth/ReferralService.cs @@ -1,14 +1,5 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -38,6 +29,4 @@ public sealed partial class ReferralService( "manual", "unknown" }; - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs b/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs index 39f09a4..e99bd82 100644 --- a/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs +++ b/Tiku.Infrastructure/Growth/Student/ReferralService.Student.cs @@ -1,15 +1,9 @@ -using System.Globalization; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; -using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; -using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -25,20 +19,14 @@ public sealed partial class ReferralService var existing = await dbContext.ReferralCodes .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == userId, + item.TenantId == actor.TenantId && + item.UserId == userId, cancellationToken); if (existing is not null) { - if (!string.IsNullOrWhiteSpace(command.Channel)) - { - existing.Channel = command.Channel.Trim(); - } + if (!string.IsNullOrWhiteSpace(command.Channel)) existing.Channel = command.Channel.Trim(); - if (!string.IsNullOrWhiteSpace(command.LandingPath)) - { - existing.LandingPath = command.LandingPath.Trim(); - } + if (!string.IsNullOrWhiteSpace(command.LandingPath)) existing.LandingPath = command.LandingPath.Trim(); existing.Status = ReferralCodeStatus.Active; await dbContext.SaveChangesAsync(cancellationToken); @@ -66,30 +54,28 @@ public sealed partial class ReferralService CancellationToken cancellationToken = default) { var code = NormalizeCode(command.Code); - if (code is null) - { - return new ReferralResolutionItem(false, null, null, null, null); - } + if (code is null) return new ReferralResolutionItem(false, null, null, null, null); var row = await ( - from referralCode in dbContext.ReferralCodes.AsNoTracking() - join membership in dbContext.TenantMemberships.AsNoTracking() - on new { referralCode.TenantId, referralCode.UserId } equals new { membership.TenantId, membership.UserId } - join user in dbContext.Users.AsNoTracking() - on referralCode.UserId equals user.Id - where referralCode.TenantId == actor.TenantId && - referralCode.Code == code && - referralCode.Status == ReferralCodeStatus.Active && - membership.Status == MembershipStatus.Active - select new - { - referralCode.Code, - referralCode.UserId, - membership.Role, - user.Name, - user.UserName, - user.Phone - }) + from referralCode in dbContext.ReferralCodes.AsNoTracking() + join membership in dbContext.TenantMemberships.AsNoTracking() + on new { referralCode.TenantId, referralCode.UserId } equals new + { membership.TenantId, membership.UserId } + join user in dbContext.Users.AsNoTracking() + on referralCode.UserId equals user.Id + where referralCode.TenantId == actor.TenantId && + referralCode.Code == code && + referralCode.Status == ReferralCodeStatus.Active && + membership.Status == MembershipStatus.Active + select new + { + referralCode.Code, + referralCode.UserId, + membership.Role, + user.Name, + user.UserName, + user.Phone + }) .FirstOrDefaultAsync(cancellationToken); return row is null @@ -143,18 +129,12 @@ public sealed partial class ReferralService command.Metadata, cancellationToken); var setFirstTrack = lead.FirstTrackId is null; - if (setFirstTrack) - { - await dbContext.SaveChangesAsync(cancellationToken); - } + if (setFirstTrack) await dbContext.SaveChangesAsync(cancellationToken); dbContext.ReferralTracks.Add(track); track.LeadId = lead.Id; await dbContext.SaveChangesAsync(cancellationToken); - if (setFirstTrack) - { - lead.FirstTrackId = track.Id; - } + if (setFirstTrack) lead.FirstTrackId = track.Id; crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken); } @@ -165,7 +145,8 @@ public sealed partial class ReferralService await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue)); + return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), + ToQueuePreview(crmQueue)); } public async Task BindAsync( @@ -181,14 +162,12 @@ public sealed partial class ReferralService var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken) ?? throw new ReferralException("Referral code was not found.", "referral_code_not_found"); if (resolution.UserId == userId) - { throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - } var existing = await dbContext.ReferralLeads .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == userId, + item.TenantId == actor.TenantId && + item.StudentUserId == userId, cancellationToken); var beforeReferrerId = existing?.ReferrerUserId; var lead = await BindLeadCoreAsync( @@ -206,7 +185,8 @@ public sealed partial class ReferralService : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.bind", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); + return new ReferralBindResult(ToLeadItem(lead, beforeReferrerId != lead.ReferrerUserId), + ToQueuePreview(crmQueue)); } public async Task GetOrCreateQrcodeAsync( @@ -216,7 +196,8 @@ public sealed partial class ReferralService { var userId = RequireUser(actor); await AssertActiveMemberAsync(actor.TenantId, userId, cancellationToken); - var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)).InviteCode; + var refCode = (await GetOrCreateInviteCodeAsync(actor, new ReferralInviteCommand("qrcode"), cancellationToken)) + .InviteCode; var page = NormalizeOptional(command.Page) ?? "pages/index/index"; var provider = NormalizeOptional(command.Provider) ?? "wechat-miniapp"; var scene = NormalizeOptional(command.Scene) ?? $"ref={refCode}"; @@ -233,10 +214,10 @@ public sealed partial class ReferralService var item = await dbContext.ReferralQrcodes .FirstOrDefaultAsync(entry => - entry.TenantId == actor.TenantId && - entry.Provider == generated.Provider && - entry.Scene == scene && - entry.Page == page, + entry.TenantId == actor.TenantId && + entry.Provider == generated.Provider && + entry.Scene == scene && + entry.Page == page, cancellationToken); if (item is null) { @@ -261,6 +242,4 @@ public sealed partial class ReferralService await dbContext.SaveChangesAsync(cancellationToken); return ToQrcodeItem(item); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs index 04f4e68..73a2ebe 100644 --- a/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs +++ b/Tiku.Infrastructure/Growth/TenantAdmin/ReferralService.TenantAdmin.cs @@ -1,15 +1,7 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; -using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Growth; @@ -22,9 +14,7 @@ public sealed partial class ReferralService { await AssertAdminAsync(actor, cancellationToken); if (command.StudentUserId == command.ReferrerUserId) - { throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed"); - } var refCode = await dbContext.ReferralCodes .Where(item => @@ -34,18 +24,16 @@ public sealed partial class ReferralService .Select(item => item.Code) .FirstOrDefaultAsync(cancellationToken); if (refCode is null) - { refCode = (await GetOrCreateInviteCodeAsync( new ReferralActor(actor.TenantId, command.ReferrerUserId), new ReferralInviteCommand("manual"), cancellationToken)).InviteCode; - } var before = await dbContext.ReferralLeads .AsNoTracking() .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == command.StudentUserId, + item.TenantId == actor.TenantId && + item.StudentUserId == command.StudentUserId, cancellationToken); var lead = await BindLeadCoreAsync( actor.TenantId, @@ -63,7 +51,8 @@ public sealed partial class ReferralService ? null : await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue)); + return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), + ToQueuePreview(crmQueue)); } public async Task> GetTeamAsync( @@ -75,10 +64,7 @@ public sealed partial class ReferralService var edges = dbContext.ReferralTeamEdges .AsNoTracking() .Where(item => item.TenantId == actor.TenantId); - if (query.LeaderUserId.HasValue) - { - edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); - } + if (query.LeaderUserId.HasValue) edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value); var items = await edges .OrderBy(item => item.RelationType) @@ -95,17 +81,16 @@ public sealed partial class ReferralService await AssertAdminAsync(actor, cancellationToken); await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken); if (command.LeaderUserId.HasValue) - { await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken); - } - var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation"); + var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, + "invalid_referral_team_relation"); var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status"); var edge = await dbContext.ReferralTeamEdges .FirstOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.MemberUserId == command.MemberUserId && - item.RelationType == relationType, + item.TenantId == actor.TenantId && + item.MemberUserId == command.MemberUserId && + item.RelationType == relationType, cancellationToken); if (edge is null) { @@ -124,6 +109,4 @@ public sealed partial class ReferralService await dbContext.SaveChangesAsync(cancellationToken); return ToTeamItem(edge); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs index 24abd89..0d88168 100644 --- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -1,20 +1,6 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; -using System.Formats.Tar; -using System.IO.Compression; -using Tiku.Application.Assets; -using Tiku.Application.Content; using Tiku.Application.Jobs; using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Operations; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; @@ -24,6 +10,4 @@ internal sealed partial class BackgroundJobService( IFeatureAccessService featureAccessService) : IBackgroundJobService { private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs b/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs index be53cf0..e1c2fe3 100644 --- a/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs +++ b/Tiku.Infrastructure/Jobs/Foundation/BackgroundJobService.Foundation.cs @@ -1,20 +1,7 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using System.Text.Json; -using System.Formats.Tar; -using System.IO.Compression; -using Tiku.Application.Assets; -using Tiku.Application.Content; using Tiku.Application.Jobs; using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Operations; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; @@ -30,21 +17,24 @@ internal sealed partial class BackgroundJobService var normalized = value?.Trim(); if (string.IsNullOrEmpty(normalized)) return null; if (normalized.Length > 200) - { - throw new BackgroundJobException("background_job_idempotency_key_too_long", "Idempotency key cannot exceed 200 characters."); - } + throw new BackgroundJobException("background_job_idempotency_key_too_long", + "Idempotency key cannot exceed 200 characters."); return normalized; } - private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch + private static string ResolveRequiredFeature(string jobType, JsonElement payload) { - "content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType")) - ?? throw new InvalidOperationException("Background content import type is not supported."), - "content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank, - "commerce_reconciliation" => SaasFeatureCatalog.StudentStore, - "statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice, - _ => SaasFeatureCatalog.CoreBackoffice - }; + return jobType switch + { + "content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType")) + ?? throw new InvalidOperationException( + "Background content import type is not supported."), + "content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank, + "commerce_reconciliation" => SaasFeatureCatalog.StudentStore, + "statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice, + _ => SaasFeatureCatalog.CoreBackoffice + }; + } private static string NormalizeProvider(string? provider) { @@ -67,9 +57,7 @@ internal sealed partial class BackgroundJobService { if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property)) - { return null; - } return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value) ? value @@ -81,9 +69,7 @@ internal sealed partial class BackgroundJobService if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) - { return []; - } return property.EnumerateArray().Select(item => item.Clone()).ToArray(); } @@ -121,4 +107,4 @@ internal sealed partial class BackgroundJobService job.OutputAssetId, job.Result); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs b/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs index 0365077..cb567a5 100644 --- a/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs +++ b/Tiku.Infrastructure/Jobs/Handlers/BackgroundJobService.Handlers.cs @@ -1,20 +1,19 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; using System.Formats.Tar; using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Assets; using Tiku.Application.Content; -using Tiku.Application.Jobs; using Tiku.Application.Security; +using Tiku.Application.Storage; using Tiku.Application.Tenancy; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Operations; +using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; @@ -31,10 +30,13 @@ internal sealed partial class BackgroundJobService { ["content_export"] = () => ProcessContentExportAsync(scopedDbContext, job, cancellationToken), ["content_import"] = () => ProcessContentImportAsync(scopedProvider, job, cancellationToken), - ["asset_security_scan"] = () => ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken), + ["asset_security_scan"] = () => + ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken), ["tenant_export"] = () => ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken), - ["statistics_aggregation"] = () => ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken), - ["commerce_reconciliation"] = () => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken), + ["statistics_aggregation"] = () => + ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken), + ["commerce_reconciliation"] = + () => ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken), ["tenant_domain_recheck"] = () => ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken) }; return handlers.TryGetValue(job.JobType, out var handler) @@ -49,13 +51,11 @@ internal sealed partial class BackgroundJobService { var directContentService = scopedProvider.GetRequiredService(); var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty; - if (createdBy == Guid.Empty) - { - throw new InvalidOperationException("content_import job requires createdBy."); - } + if (createdBy == Guid.Empty) throw new InvalidOperationException("content_import job requires createdBy."); var command = new SimpleImportCommand( - GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."), + GetJsonString(job.Payload, "importType") ?? + throw new InvalidOperationException("content_import job requires importType."), GetJsonString(job.Payload, "sourceFormat"), GetJsonString(job.Payload, "sourceName"), GetJsonGuid(job.Payload, "regionId"), @@ -97,27 +97,27 @@ internal sealed partial class BackgroundJobService if (asset.UploadStatus != AssetUploadStatus.Verified || string.IsNullOrWhiteSpace(asset.Bucket) || string.IsNullOrWhiteSpace(asset.ObjectKey)) - { throw new InvalidOperationException("Asset must have a verified object location before security scanning."); - } asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning; await scopedDbContext.SaveChangesAsync(cancellationToken); var scanner = scopedProvider.GetRequiredService(); - var storage = scopedProvider.GetRequiredService(); + var storage = scopedProvider.GetRequiredService(); await using var content = await storage.OpenReadAsync( - new Tiku.Application.Storage.ObjectStorageReadRequest( + new ObjectStorageReadRequest( job.TenantId, asset.StorageProvider switch { - AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss, - AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev, - _ => throw new InvalidOperationException("Asset storage provider does not support security scanning.") + AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, + AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, + _ => throw new InvalidOperationException( + "Asset storage provider does not support security scanning.") }, asset.Bucket, asset.ObjectKey), cancellationToken); - var result = await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken); + var result = + await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken); var infected = result.Verdict == AssetSecurityScanVerdict.Infected; asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed; asset.SecurityScannedAt = DateTimeOffset.UtcNow; @@ -154,18 +154,12 @@ internal sealed partial class BackgroundJobService CancellationToken cancellationToken) { var assetId = GetJsonGuid(job.Payload, "assetId"); - if (assetId is null) - { - return; - } + if (assetId is null) return; var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( item => item.TenantId == job.TenantId && item.Id == assetId, cancellationToken); - if (asset is null) - { - return; - } + if (asset is null) return; asset.SecurityScanStatus = AssetSecurityScanStatus.Pending; asset.SecurityScanProvider = "clamav"; @@ -192,8 +186,8 @@ internal sealed partial class BackgroundJobService var operationId = GetJsonGuid(job.Payload, "operationId") ?? throw new InvalidOperationException("tenant_export job requires operationId."); var operation = await scopedDbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item => - item.TenantId == job.TenantId && item.Id == operationId && - item.OperationType == TenantLifecycleOperationType.Export, + item.TenantId == job.TenantId && item.Id == operationId && + item.OperationType == TenantLifecycleOperationType.Export, cancellationToken) ?? throw new InvalidOperationException("Tenant export operation was not found."); operation.Status = TenantLifecycleOperationStatus.Processing; operation.StartedAt ??= DateTimeOffset.UtcNow; @@ -203,23 +197,28 @@ internal sealed partial class BackgroundJobService var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz"); try { - var tenant = await scopedDbContext.Tenants.AsNoTracking().SingleAsync(item => item.Id == job.TenantId, cancellationToken); + var tenant = await scopedDbContext.Tenants.AsNoTracking() + .SingleAsync(item => item.Id == job.TenantId, cancellationToken); var memberships = await scopedDbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == job.TenantId) .Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt }) .ToArrayAsync(cancellationToken); var domains = await scopedDbContext.TenantDomains.AsNoTracking() .Where(item => item.TenantId == job.TenantId) - .Select(item => new { item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt }) + .Select(item => new + { + item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt + }) .ToArrayAsync(cancellationToken); var assets = await scopedDbContext.ContentAssets.AsNoTracking() .Where(item => item.TenantId == job.TenantId && item.Status == ContentStatus.Active) .ToArrayAsync(cancellationToken); - var storage = scopedProvider.GetRequiredService(); + var storage = scopedProvider.GetRequiredService(); - await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, FileOptions.Asynchronous)) - await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, leaveOpen: false)) - await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: false)) + await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 128 * 1024, FileOptions.Asynchronous)) + await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, false)) + await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, false)) { await WriteJsonEntryAsync(archive, "manifest.json", new { @@ -228,14 +227,23 @@ internal sealed partial class BackgroundJobService tenantId = job.TenantId, operationId, generatedAt = DateTimeOffset.UtcNow, - exclusions = new[] { "password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys", "global_platform_data" }, + exclusions = new[] + { + "password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys", + "global_platform_data" + }, tables = new[] { "tenant", "tenant_memberships", "tenant_domains", "content_assets" } }, cancellationToken); await WriteJsonLinesEntryAsync(archive, "data/tenant.jsonl", new[] { - new { tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt } + new + { + tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, + tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt + } }, cancellationToken); - await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships, cancellationToken); + await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships, + cancellationToken); await WriteJsonLinesEntryAsync(archive, "data/tenant_domains.jsonl", domains, cancellationToken); await WriteJsonLinesEntryAsync(archive, "data/content_assets.jsonl", assets.Select(item => new { @@ -257,19 +265,20 @@ internal sealed partial class BackgroundJobService foreach (var asset in assets.Where(item => item.UploadStatus == AssetUploadStatus.Verified && - item.SecurityScanStatus is AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired && + item.SecurityScanStatus is AssetSecurityScanStatus.Passed + or AssetSecurityScanStatus.NotRequired && !string.IsNullOrWhiteSpace(item.Bucket) && !string.IsNullOrWhiteSpace(item.ObjectKey))) { var provider = asset.StorageProvider switch { - AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss, - AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev, + AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, + AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, _ => null }; if (provider is null) continue; await using var content = await storage.OpenReadAsync( - new Tiku.Application.Storage.ObjectStorageReadRequest( + new ObjectStorageReadRequest( job.TenantId, provider, asset.Bucket!, asset.ObjectKey!), cancellationToken); var name = SanitizeTarPath(asset.FileName ?? asset.Id.ToString("N")); @@ -280,12 +289,14 @@ internal sealed partial class BackgroundJobService } } - await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous); + await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, + 128 * 1024, FileOptions.Asynchronous); var providerName = storage.ConfiguredDefaultProvider(); var bucket = storage.ConfiguredDefaultBucket(); - var objectKey = storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz"); + var objectKey = + storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz"); var written = await storage.WriteObjectAsync( - new Tiku.Application.Storage.ObjectStorageWriteRequest( + new ObjectStorageWriteRequest( job.TenantId, providerName, bucket, @@ -303,8 +314,8 @@ internal sealed partial class BackgroundJobService AssetType = ContentAssetType.Document, StorageProvider = providerName switch { - Tiku.Application.Storage.ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, - Tiku.Application.Storage.ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev, + ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss, + ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev, _ => AssetStorageProvider.ExternalUrl }, Bucket = written.Bucket, @@ -366,7 +377,7 @@ internal sealed partial class BackgroundJobService CancellationToken cancellationToken) { var stream = new MemoryStream(); - await using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false), leaveOpen: true)) + await using (var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true)) { foreach (var value in values) { @@ -374,6 +385,7 @@ internal sealed partial class BackgroundJobService await writer.WriteLineAsync(JsonSerializer.Serialize(value)); } } + stream.Position = 0; archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream }); await stream.DisposeAsync(); @@ -410,9 +422,12 @@ internal sealed partial class BackgroundJobService scopedDbContext.ContentAssets.Add(asset); } - var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); - var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); - var studentCount = await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var questionBankCount = + await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var questionCount = + await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var studentCount = + await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; asset.Title = "Content export manifest"; asset.Description = $"Generated content export manifest for {exportType}."; @@ -448,14 +463,13 @@ internal sealed partial class BackgroundJobService var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync( item => item.TenantId == job.TenantId && - item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment && + item.Capability == TenantExternalProviderCapability.Payment && item.Provider == provider && - item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active, + item.Status == TenantExternalProviderStatus.Active, cancellationToken); if (!hasProviderConfig) - { - throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job."); - } + throw new InvalidOperationException( + $"Active payment provider '{provider}' is required for commerce reconciliation job."); var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date); var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined); @@ -482,7 +496,8 @@ internal sealed partial class BackgroundJobService Metadata = JsonSerializer.SerializeToElement(new { jobId = job.Id, - note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." + note = + "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." }) }; scopedDbContext.CommerceReconciliationBatches.Add(batch); @@ -549,6 +564,4 @@ internal sealed partial class BackgroundJobService quotaUsage }); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs index f7c43d7..1cbeacd 100644 --- a/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs +++ b/Tiku.Infrastructure/Jobs/Operations/BackgroundJobService.Operations.cs @@ -1,20 +1,7 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using System.Text.Json; -using System.Formats.Tar; -using System.IO.Compression; -using Tiku.Application.Assets; -using Tiku.Application.Content; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Operations; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; @@ -47,10 +34,7 @@ internal sealed partial class BackgroundJobService CancellationToken cancellationToken = default) { var query = dbContext.BackgroundJobs.AsNoTracking().Where(item => item.Id == jobId); - if (tenantId.HasValue) - { - query = query.Where(item => item.TenantId == tenantId.Value); - } + if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value); var job = await query.SingleOrDefaultAsync(cancellationToken); return job is null ? null : ToItem(job); } @@ -69,6 +53,7 @@ internal sealed partial class BackgroundJobService var normalized = NormalizeJobType(jobType); query = query.Where(item => item.JobType == normalized); } + if (status.HasValue) query = query.Where(item => item.Status == status.Value); return (await query.OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(limit, 1, 500)) @@ -86,14 +71,12 @@ internal sealed partial class BackgroundJobService { dbContext.ChangeTracker.Clear(); if (string.IsNullOrWhiteSpace(reason)) - { - throw new BackgroundJobException("background_job_cancel_reason_required", "Cancellation reason is required."); - } + throw new BackgroundJobException("background_job_cancel_reason_required", + "Cancellation reason is required."); var job = await FindMutableAsync(jobId, tenantId, cancellationToken); if (job.Status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled) - { - throw new BackgroundJobException("background_job_not_cancellable", "Only pending or processing jobs can be cancelled."); - } + throw new BackgroundJobException("background_job_not_cancellable", + "Only pending or processing jobs can be cancelled."); var now = DateTimeOffset.UtcNow; job.CancellationRequestedAt = now; @@ -104,6 +87,7 @@ internal sealed partial class BackgroundJobService job.Status = BackgroundJobStatus.Cancelled; job.CompletedAt = now; } + AddMutationAudit(job, actorUserId, "background_job.cancel_requested"); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(job); @@ -118,9 +102,8 @@ internal sealed partial class BackgroundJobService dbContext.ChangeTracker.Clear(); var job = await FindMutableAsync(jobId, tenantId, cancellationToken); if (job.Status is not (BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled)) - { - throw new BackgroundJobException("background_job_not_retryable", "Only failed or cancelled jobs can be retried."); - } + throw new BackgroundJobException("background_job_not_retryable", + "Only failed or cancelled jobs can be retried."); job.Status = BackgroundJobStatus.Pending; job.RunAfter = DateTimeOffset.UtcNow; @@ -160,6 +143,4 @@ internal sealed partial class BackgroundJobService Details = JsonSerializer.SerializeToElement(new { job.JobType, job.Status }) }); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs index 9fc50dc..e59dd02 100644 --- a/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs +++ b/Tiku.Infrastructure/Jobs/Processor/BackgroundJobService.Processor.cs @@ -1,20 +1,11 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; -using System.Formats.Tar; -using System.IO.Compression; -using Tiku.Application.Assets; -using Tiku.Application.Content; -using Tiku.Application.Jobs; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Operations; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; using System.Diagnostics; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Observability; namespace Tiku.Infrastructure.Jobs; @@ -29,26 +20,26 @@ internal sealed partial class BackgroundJobService var now = DateTimeOffset.UtcNow; var leaseExpiresAt = now.Add(LeaseDuration); var claimedIds = await dbContext.Database.SqlQuery($""" - UPDATE background_jobs AS job - SET status = 'processing', - locked_by = {workerId}, - lock_expires_at = {leaseExpiresAt}, - started_at = COALESCE(started_at, {now}), - updated_at = {now} - WHERE job.id IN ( - SELECT candidate.id - FROM background_jobs AS candidate - WHERE ( - (candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND - (candidate.run_after IS NULL OR candidate.run_after <= {now})) OR - (candidate.status = 'processing' AND candidate.lock_expires_at <= {now}) - ) - ORDER BY candidate.created_at, candidate.id - FOR UPDATE SKIP LOCKED - LIMIT {Math.Clamp(batchSize, 1, 100)} - ) - RETURNING job.id AS "Value" - """) + UPDATE background_jobs AS job + SET status = 'processing', + locked_by = {workerId}, + lock_expires_at = {leaseExpiresAt}, + started_at = COALESCE(started_at, {now}), + updated_at = {now} + WHERE job.id IN ( + SELECT candidate.id + FROM background_jobs AS candidate + WHERE ( + (candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND + (candidate.run_after IS NULL OR candidate.run_after <= {now})) OR + (candidate.status = 'processing' AND candidate.lock_expires_at <= {now}) + ) + ORDER BY candidate.created_at, candidate.id + FOR UPDATE SKIP LOCKED + LIMIT {Math.Clamp(batchSize, 1, 100)} + ) + RETURNING job.id AS "Value" + """) .ToArrayAsync(cancellationToken); var processed = 0; @@ -57,7 +48,7 @@ internal sealed partial class BackgroundJobService { cancellationToken.ThrowIfCancellationRequested(); var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken); - if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++; + if (await ProcessJobAsync(job, workerId, true, cancellationToken)) processed++; dbContext.ChangeTracker.Clear(); } @@ -81,24 +72,17 @@ internal sealed partial class BackgroundJobService .SetProperty(item => item.LockedBy, workerId) .SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration)) .SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken); - if (claimed == 0) - { - return false; - } + if (claimed == 0) return false; dbContext.ChangeTracker.Clear(); var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync( item => item.Id == jobId && item.TenantId == tenantId, cancellationToken); if (job is null) - { throw new InvalidOperationException("The requested background job does not exist in the target tenant."); - } if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal)) - { throw new InvalidOperationException("The requested background job type does not match the persisted job."); - } - return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken); + return await ProcessJobAsync(job, workerId, true, cancellationToken); } private async Task ProcessJobAsync( @@ -110,9 +94,7 @@ internal sealed partial class BackgroundJobService var startedTimestamp = Stopwatch.GetTimestamp(); if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) || (alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId))) - { return false; - } await dbContext.Entry(job).ReloadAsync(cancellationToken); if (job.CancellationRequestedAt.HasValue) { @@ -126,6 +108,7 @@ internal sealed partial class BackgroundJobService cancellationToken); return true; } + if (job.JobType is not ("asset_security_scan" or "tenant_export") && !(await featureAccessService.EvaluateAsync( job.TenantId, ResolveRequiredFeature(job.JobType, job.Payload), @@ -176,9 +159,7 @@ internal sealed partial class BackgroundJobService ? $"{scannerException.Code}: {scannerException.Message}" : exception.Message; if (exception is AssetSecurityScannerException assetScanException) - { await RecordAssetScanRetryAsync(job, assetScanException, cancellationToken); - } job.Status = job.RetryCount > job.MaxRetries ? BackgroundJobStatus.Failed : BackgroundJobStatus.Pending; @@ -189,7 +170,8 @@ internal sealed partial class BackgroundJobService finally { await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken); - WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(), Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds); + WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(), + Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds); } return true; @@ -206,17 +188,15 @@ internal sealed partial class BackgroundJobService await dbContext.BackgroundJobs .Where(value => value.Id == job.Id && value.LockedBy == workerId) .ExecuteUpdateAsync(setters => setters - .SetProperty(value => value.Status, status) - .SetProperty(value => value.RetryCount, job.RetryCount) - .SetProperty(value => value.RunAfter, job.RunAfter) - .SetProperty(value => value.CompletedAt, job.CompletedAt) - .SetProperty(value => value.LastError, lastError) - .SetProperty(value => value.OutputAssetId, job.OutputAssetId) - .SetProperty(value => value.Result, result) - .SetProperty(value => value.LockedBy, (string?)null) - .SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null), + .SetProperty(value => value.Status, status) + .SetProperty(value => value.RetryCount, job.RetryCount) + .SetProperty(value => value.RunAfter, job.RunAfter) + .SetProperty(value => value.CompletedAt, job.CompletedAt) + .SetProperty(value => value.LastError, lastError) + .SetProperty(value => value.OutputAssetId, job.OutputAssetId) + .SetProperty(value => value.Result, result) + .SetProperty(value => value.LockedBy, (string?)null) + .SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null), cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs index f19c419..ec262a1 100644 --- a/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs +++ b/Tiku.Infrastructure/Jobs/Queue/BackgroundJobService.Queue.cs @@ -1,20 +1,7 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; -using System.Formats.Tar; -using System.IO.Compression; -using Tiku.Application.Assets; -using Tiku.Application.Content; using Tiku.Application.Jobs; using Tiku.Application.Security; -using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Operations; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Infrastructure.Jobs; @@ -32,10 +19,7 @@ internal sealed partial class BackgroundJobService item => item.TenantId == command.TenantId && item.JobType == normalizedJobType && item.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) - { - return ToItem(existing); - } + if (existing is not null) return ToItem(existing); } if (!command.IsSystemJob && !(await featureAccessService.EvaluateAsync( @@ -43,9 +27,7 @@ internal sealed partial class BackgroundJobService ResolveRequiredFeature(normalizedJobType, command.Payload), FeatureAccessOperation.Write, cancellationToken)).Allowed) - { throw new InvalidOperationException("Tenant feature entitlement does not allow this background job."); - } var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType); var quotaConsumed = false; if (quotaMetric is not null) @@ -57,11 +39,11 @@ internal sealed partial class BackgroundJobService quotaConsumed = await featureAccessService.TryConsumeQuotaAsync( command.TenantId, quotaMetric, 1, cancellationToken); if (!quotaConsumed) - { - throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted"); - } + throw new FeatureAccessException("The background job quota has been exhausted.", + "feature_quota_exhausted"); } } + var job = new BackgroundJob { TenantId = command.TenantId, @@ -86,30 +68,30 @@ internal sealed partial class BackgroundJobService if (existing is not null) { if (quotaConsumed && quotaMetric is not null) - { - await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None); - } + await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, + CancellationToken.None); return ToItem(existing); } + throw; } catch { if (quotaConsumed && quotaMetric is not null) - { await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None); - } throw; } + return ToItem(job); } - private static string? ResolveQuotaMetric(string jobType) => jobType switch + private static string? ResolveQuotaMetric(string jobType) { - "content_import" => SaasQuotaMetricCatalog.ImportCount, - "content_export" => SaasQuotaMetricCatalog.ExportCount, - _ => null - }; - - -} + return jobType switch + { + "content_import" => SaasQuotaMetricCatalog.ImportCount, + "content_export" => SaasQuotaMetricCatalog.ExportCount, + _ => null + }; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs b/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs index 3c31b1f..47c147a 100644 --- a/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs +++ b/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs @@ -1,18 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -125,6 +113,4 @@ public sealed partial class LearningActivityService items.FirstOrDefault(item => item.UserId == actor.UserId), DateTimeOffset.UtcNow); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs b/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs index d71d1c7..f54b080 100644 --- a/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs +++ b/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs @@ -1,19 +1,9 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -25,47 +15,43 @@ public sealed partial class LearningActivityService CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) - { throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); - } if (command.SelectedOptionIndices?.Any(index => index < 0) == true) - { - throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values."); - } + throw new LearningValidationException("selected_option_index_invalid", + "Selected option indices must be zero-based non-negative values."); var now = DateTimeOffset.UtcNow; var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken); if (sessionQuestion is null) - { throw new LearningResourceNotFoundException( "session_question_not_found", "An active practice session question was not found."); - } var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Id == sessionQuestion.PracticeSessionId, cancellationToken); if (session is null) - { - throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); - } + throw new LearningResourceNotFoundException("practice_session_not_found", + "Practice session was not found."); var requestHash = HashAnswer(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == session.Id && - item.OperationType == "answer" && - item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == session.Id && + item.OperationType == "answer" && + item.IdempotencyKey == command.IdempotencyKey, cancellationToken); if (existingOperation is not null) { if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) { AnswerConflicts.Add(1); - throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); + throw new LearningValidationException("idempotency_conflict", + "The idempotency key was used with a different request."); } IdempotencyReplays.Add(1); @@ -80,6 +66,7 @@ public sealed partial class LearningActivityService await dbContext.SaveChangesAsync(cancellationToken); throw new LearningValidationException("practice_session_expired", "The practice session has expired."); } + EnsureAnswerSessionState(session, command); var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer => answer.TenantId == actor.TenantId && @@ -87,10 +74,7 @@ public sealed partial class LearningActivityService answer.PracticeSessionId == session.Id && answer.SessionQuestionId == sessionQuestion.Id && answer.IsCurrent, cancellationToken); - if (current is not null) - { - current.IsCurrent = false; - } + if (current is not null) current.IsCurrent = false; var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? []; var score = sessionQuestion.Score ?? 1; @@ -157,11 +141,12 @@ public sealed partial class LearningActivityService } catch (DbUpdateConcurrencyException) { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); + throw new LearningValidationException("practice_session_version_conflict", + "The practice session changed. Reload it before answering."); } catch (DbUpdateException exception) when ( - exception.InnerException is Npgsql.PostgresException postgresException && - postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation) + exception.InnerException is PostgresException postgresException && + postgresException.SqlState == PostgresErrorCodes.UniqueViolation) { dbContext.ChangeTracker.Clear(); var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => @@ -176,12 +161,12 @@ public sealed partial class LearningActivityService return replay.ResponseSnapshot.Deserialize() ?? throw new InvalidOperationException("The stored answer response is invalid."); } + AnswerConflicts.Add(1); - throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation."); + throw new LearningValidationException("practice_answer_conflict", + "The answer conflicted with another client operation."); } return response; } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs b/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs index 6411e3e..b10e56f 100644 --- a/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs +++ b/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs @@ -1,10 +1,8 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; @@ -13,7 +11,6 @@ using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -37,10 +34,7 @@ public sealed partial class LearningActivityService command.DurationMinutes, command.TotalScore); - if (!command.BlueprintId.HasValue) - { - return assembly; - } + if (!command.BlueprintId.HasValue) return assembly; var blueprint = await dbContext.PracticeBlueprints .AsNoTracking() @@ -52,9 +46,8 @@ public sealed partial class LearningActivityService cancellationToken); if (blueprint is null) - { - throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found."); - } + throw new LearningResourceNotFoundException("practice_blueprint_not_found", + "Practice blueprint was not found."); return assembly with { @@ -76,7 +69,6 @@ public sealed partial class LearningActivityService CancellationToken cancellationToken) { if (assembly.Mode == "wrong_review") - { return await dbContext.WrongQuestions .AsNoTracking() .Where(item => @@ -88,10 +80,8 @@ public sealed partial class LearningActivityService .Take(assembly.QuestionLimit) .Select(item => item.QuestionReferenceId) .ToListAsync(cancellationToken); - } if (assembly.Mode == "favorite_review") - { return await dbContext.FavoriteQuestions .AsNoTracking() .Where(item => @@ -101,10 +91,8 @@ public sealed partial class LearningActivityService .Take(assembly.QuestionLimit) .Select(item => item.QuestionReferenceId) .ToListAsync(cancellationToken); - } if (assembly.CollectionId.HasValue) - { return await dbContext.QuestionCollectionItems .AsNoTracking() .Where(item => @@ -114,7 +102,6 @@ public sealed partial class LearningActivityService .Take(assembly.QuestionLimit) .Select(item => item.QuestionReferenceId) .ToListAsync(cancellationToken); - } var query = dbContext.Questions .AsNoTracking() @@ -123,21 +110,13 @@ public sealed partial class LearningActivityService question.Status == QuestionStatus.Published); if (assembly.ContentNodeId.HasValue) - { query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value); - } else if (assembly.EntryId.HasValue) - { query = query.Where(question => question.EntryId == assembly.EntryId.Value); - } else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType)) - { query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value); - } else - { throw new LearningValidationException("practice_target_required", "Practice target is required."); - } var questionIds = await query .OrderBy(question => question.CreatedAt) @@ -188,52 +167,50 @@ public sealed partial class LearningActivityService { var systemDbContext = provider.GetRequiredService(); return await ( - from reference in systemDbContext.TenantQuestionReferences.AsNoTracking() - join question in systemDbContext.Questions.AsNoTracking() - on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } - equals new { question.TenantId, question.Id } - join version in systemDbContext.QuestionVersions.AsNoTracking() - on new - { - TenantId = reference.QuestionOwnerTenantId, + from reference in systemDbContext.TenantQuestionReferences.AsNoTracking() + join question in systemDbContext.Questions.AsNoTracking() + on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } + equals new { question.TenantId, question.Id } + join version in systemDbContext.QuestionVersions.AsNoTracking() + on new + { + TenantId = reference.QuestionOwnerTenantId, + reference.QuestionId, + Id = question.CurrentVersionId + } + equals new + { + version.TenantId, + version.QuestionId, + Id = (Guid?)version.Id + } + where reference.TenantId == tenantId && + questionReferenceIds.Contains(reference.Id) && + question.Status == QuestionStatus.Published + select new QuestionSelection( + reference.Id, + reference.QuestionOwnerTenantId, reference.QuestionId, - Id = question.CurrentVersionId - } - equals new - { - version.TenantId, - version.QuestionId, - Id = (Guid?)version.Id - } - where reference.TenantId == tenantId && - questionReferenceIds.Contains(reference.Id) && - question.Status == QuestionStatus.Published - select new QuestionSelection( - reference.Id, - reference.QuestionOwnerTenantId, - reference.QuestionId, - version.Id, - question.Type, - question.TypeLabel, - question.Difficulty, - question.Tags, - version.Content, - version.Options, - version.CorrectOptionIndex, - version.CorrectOptionIndices, - version.AnswerText, - version.Explanation)) + version.Id, + question.Type, + question.TypeLabel, + question.Difficulty, + question.Tags, + version.Content, + version.Options, + version.CorrectOptionIndex, + version.CorrectOptionIndices, + version.AnswerText, + version.Explanation)) .ToArrayAsync(token); }, cancellationToken); var byReference = rows.ToDictionary(row => row.QuestionReferenceId); if (byReference.Count != questionReferenceIds.Distinct().Count()) - { throw new LearningValidationException( "practice_question_unavailable", "One or more practice questions have no published version."); - } return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray(); } @@ -251,26 +228,26 @@ public sealed partial class LearningActivityService { var systemDbContext = provider.GetRequiredService(); return await ( - from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking() - where sessionQuestion.TenantId == tenantId && - sessionQuestion.PracticeSessionId == practiceSessionId - orderby sessionQuestion.Position - select new PracticeSessionQuestionItem( - sessionQuestion.Id, - sessionQuestion.QuestionReferenceId, - new QuestionLocator( - sessionQuestion.QuestionOwnerTenantId == tenantId - ? QuestionSource.Tenant - : QuestionSource.Platform, - sessionQuestion.QuestionId), - sessionQuestion.QuestionId, - sessionQuestion.QuestionType, - sessionQuestion.TypeLabelSnapshot, - sessionQuestion.DifficultySnapshot, - sessionQuestion.TagsSnapshot, - sessionQuestion.QuestionVersionId, - sessionQuestion.ContentSnapshot, - sessionQuestion.OptionsSnapshot)) + from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking() + where sessionQuestion.TenantId == tenantId && + sessionQuestion.PracticeSessionId == practiceSessionId + orderby sessionQuestion.Position + select new PracticeSessionQuestionItem( + sessionQuestion.Id, + sessionQuestion.QuestionReferenceId, + new QuestionLocator( + sessionQuestion.QuestionOwnerTenantId == tenantId + ? QuestionSource.Tenant + : QuestionSource.Platform, + sessionQuestion.QuestionId), + sessionQuestion.QuestionId, + sessionQuestion.QuestionType, + sessionQuestion.TypeLabelSnapshot, + sessionQuestion.DifficultySnapshot, + sessionQuestion.TagsSnapshot, + sessionQuestion.QuestionVersionId, + sessionQuestion.ContentSnapshot, + sessionQuestion.OptionsSnapshot)) .ToArrayAsync(token); }, cancellationToken); @@ -282,9 +259,7 @@ public sealed partial class LearningActivityService CancellationToken cancellationToken) { if (!practiceSessionId.HasValue) - { throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - } var session = await dbContext.PracticeSessions .SingleOrDefaultAsync( @@ -295,9 +270,8 @@ public sealed partial class LearningActivityService cancellationToken); if (session is null) - { - throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); - } + throw new LearningResourceNotFoundException("practice_session_not_found", + "Practice session was not found."); return session; } @@ -314,9 +288,8 @@ public sealed partial class LearningActivityService .OrderBy(item => item.Position) .ToArrayAsync(cancellationToken); if (sessionQuestions.Length == 0) - { - throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot."); - } + throw new LearningValidationException("practice_session_empty", + "Practice session has no question snapshot."); var answers = await dbContext.AnswerRecords .AsNoTracking() @@ -489,10 +462,7 @@ public sealed partial class LearningActivityService question.Status == QuestionStatus.Published, cancellationToken); - if (!exists) - { - throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); - } + if (!exists) throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); } private async Task EnsureWordExistsAsync( @@ -507,10 +477,7 @@ public sealed partial class LearningActivityService word.IsActive, cancellationToken); - if (!exists) - { - throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); - } + if (!exists) throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); } private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion) @@ -603,10 +570,7 @@ public sealed partial class LearningActivityService private static List ReadGuidArray(JsonElement value) { - if (value.ValueKind is not JsonValueKind.Array) - { - return []; - } + if (value.ValueKind is not JsonValueKind.Array) return []; return value.EnumerateArray() .Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id) @@ -622,58 +586,60 @@ public sealed partial class LearningActivityService SubmitAnswerCommand command) { if (session.Status != PracticeSessionStatus.Active) - { - throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers."); - } + throw new LearningValidationException("practice_session_not_active", + "Only an active practice session accepts answers."); if (session.Version != command.ExpectedSessionVersion) - { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering."); - } + throw new LearningValidationException("practice_session_version_conflict", + "The practice session changed. Reload it before answering."); if (command.ClientSequence <= session.LastClientSequence) - { - throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session."); - } + throw new LearningValidationException("practice_client_sequence_conflict", + "Client sequence must increase within a practice session."); } - private static JsonElement BuildGradingRules(QuestionSelection selection) => - JsonSerializer.SerializeToElement(new + private static JsonElement BuildGradingRules(QuestionSelection selection) + { + return JsonSerializer.SerializeToElement(new { version = 1, normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase) ? "nfkc_trim_casefold_whitespace" : "exact" }); + } - private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new + private static string HashAnswer(SubmitAnswerCommand command) { - command.SessionQuestionId, - command.ExpectedSessionVersion, - command.ClientSequence, - selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [], - answerText = command.AnswerText?.Trim() - })); + return Hash(JsonSerializer.Serialize(new + { + command.SessionQuestionId, + command.ExpectedSessionVersion, + command.ClientSequence, + selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [], + answerText = command.AnswerText?.Trim() + })); + } - private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new + private static string HashSubmission(SubmitPracticeSessionCommand command) { - command.PracticeSessionId, - command.ExpectedSessionVersion - })); + return Hash(JsonSerializer.Serialize(new + { + command.PracticeSessionId, + command.ExpectedSessionVersion + })); + } - private static string Hash(string value) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + private static string Hash(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now) { - if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) - { - return "finished"; - } + if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) return "finished"; if (session.Status == PracticeSessionStatus.Expired || - session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now) - { + (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)) return "expired"; - } return "active"; } @@ -694,7 +660,7 @@ public sealed partial class LearningActivityService private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) { - return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status); + return Enum.TryParse(NormalizeEnumValue(value), true, out status); } private static int ResolveLimit(int? limit) @@ -737,4 +703,4 @@ public sealed partial class LearningActivityService JsonElement CorrectOptionIndices, string? AnswerText, string? Explanation); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs index 9daa6a6..36c3d4d 100644 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -1,19 +1,9 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; +using Microsoft.Extensions.Logging; using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -27,10 +17,16 @@ public sealed partial class LearningActivityService( private const int DefaultLimit = 100; private const int MaxLimit = 500; private static readonly Meter LearningMeter = new("Tiku.Learning"); - private static readonly Counter IdempotencyReplays = LearningMeter.CreateCounter("tiku.learning.idempotency.replays"); - private static readonly Counter AnswerConflicts = LearningMeter.CreateCounter("tiku.learning.answer.conflicts"); - private static readonly Counter SubmissionConflicts = LearningMeter.CreateCounter("tiku.learning.submission.conflicts"); - private static readonly Counter ScoringFailures = LearningMeter.CreateCounter("tiku.learning.scoring.failures"); + private static readonly Counter IdempotencyReplays = + LearningMeter.CreateCounter("tiku.learning.idempotency.replays"); -} + private static readonly Counter AnswerConflicts = + LearningMeter.CreateCounter("tiku.learning.answer.conflicts"); + + private static readonly Counter SubmissionConflicts = + LearningMeter.CreateCounter("tiku.learning.submission.conflicts"); + + private static readonly Counter ScoringFailures = + LearningMeter.CreateCounter("tiku.learning.scoring.failures"); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs b/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs index 3e27fbe..7644fbc 100644 --- a/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs +++ b/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs @@ -1,19 +1,10 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Npgsql; using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -27,9 +18,8 @@ public sealed partial class LearningActivityService var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken); var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken); if (questionReferenceIds.Count == 0) - { - throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target."); - } + throw new LearningValidationException("no_practice_questions", + "No published questions are available for this practice target."); var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync( @@ -39,9 +29,7 @@ public sealed partial class LearningActivityService reference.Source == QuestionSource.Platform, cancellationToken); if (containsPlatformQuestion) - { await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken); - } var now = DateTimeOffset.UtcNow; var session = new PracticeSession @@ -80,18 +68,15 @@ public sealed partial class LearningActivityService questionReferenceIds, cancellationToken); foreach (var selection in selections) - { if (!QuestionGrader.HasValidAuthoritativeAnswer( selection.QuestionType, selection.CorrectOptionIndex, selection.CorrectOptionIndices, selection.AnswerText)) - { throw new LearningValidationException( "practice_question_grading_rule_invalid", $"Question '{selection.QuestionId}' has no valid authoritative grading rule."); - } - } + var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0 ? session.TotalScore.Value / selections.Count : (decimal?)null; @@ -172,25 +157,25 @@ public sealed partial class LearningActivityService CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) - { throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); - } await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken); var requestHash = HashSubmission(command); - var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == session.Id && - item.OperationType == "submit" && - item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == session.Id && + item.OperationType == "submit" && + item.IdempotencyKey == command.IdempotencyKey, cancellationToken); if (existingOperation is not null) { if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) { SubmissionConflicts.Add(1); - throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); + throw new LearningValidationException("idempotency_conflict", + "The idempotency key was used with a different request."); } IdempotencyReplays.Add(1); @@ -199,9 +184,8 @@ public sealed partial class LearningActivityService } if (session.Status != PracticeSessionStatus.Active) - { - throw new LearningValidationException("practice_session_not_active", "Only an active practice session can be submitted."); - } + throw new LearningValidationException("practice_session_not_active", + "Only an active practice session can be submitted."); if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow) { session.Status = PracticeSessionStatus.Expired; @@ -210,10 +194,10 @@ public sealed partial class LearningActivityService await transaction.CommitAsync(cancellationToken); throw new LearningValidationException("practice_session_expired", "The practice session has expired."); } + if (session.Version != command.ExpectedSessionVersion) - { - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before submitting."); - } + throw new LearningValidationException("practice_session_version_conflict", + "The practice session changed. Reload it before submitting."); session.Status = PracticeSessionStatus.Scoring; session.Version++; @@ -241,12 +225,14 @@ public sealed partial class LearningActivityService catch (DbUpdateConcurrencyException) { SubmissionConflicts.Add(1); - throw new LearningValidationException("practice_session_version_conflict", "The practice session changed during submission."); + throw new LearningValidationException("practice_session_version_conflict", + "The practice session changed during submission."); } - catch (DbUpdateException exception) when (exception.InnerException is Npgsql.NpgsqlException) + catch (DbUpdateException exception) when (exception.InnerException is NpgsqlException) { SubmissionConflicts.Add(1); - throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request."); + throw new LearningValidationException("practice_submission_conflict", + "The practice session was already submitted by another request."); } return response; @@ -258,9 +244,7 @@ public sealed partial class LearningActivityService CancellationToken cancellationToken = default) { if (!filter.PracticeSessionId.HasValue) - { throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - } var report = await dbContext.PracticeSessionReports .AsNoTracking() @@ -272,9 +256,8 @@ public sealed partial class LearningActivityService cancellationToken); if (report is null) - { - throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found."); - } + throw new LearningResourceNotFoundException("practice_report_not_found", + "Practice session report was not found."); return ToItem(report); } @@ -290,15 +273,9 @@ public sealed partial class LearningActivityService report.TenantId == actor.TenantId && report.UserId == actor.UserId); - if (filter.BlueprintId.HasValue) - { - query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); - } + if (filter.BlueprintId.HasValue) query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); - if (!string.IsNullOrWhiteSpace(filter.Mode)) - { - query = query.Where(report => report.Mode == filter.Mode.Trim()); - } + if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(report => report.Mode == filter.Mode.Trim()); var items = await query .OrderByDescending(report => report.SubmittedAt) @@ -319,10 +296,7 @@ public sealed partial class LearningActivityService session.TenantId == actor.TenantId && session.UserId == actor.UserId); - if (!string.IsNullOrWhiteSpace(filter.Mode)) - { - query = query.Where(session => session.Mode == filter.Mode.Trim()); - } + if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(session => session.Mode == filter.Mode.Trim()); var rows = await query .GroupJoin( @@ -361,6 +335,4 @@ public sealed partial class LearningActivityService return new LearningList(items); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs b/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs index c048583..22aef04 100644 --- a/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs +++ b/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs @@ -1,19 +1,9 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -61,7 +51,6 @@ public sealed partial class LearningActivityService if (favorite) { if (item is null) - { dbContext.FavoriteQuestions.Add(new FavoriteQuestion { TenantId = actor.TenantId, @@ -72,7 +61,6 @@ public sealed partial class LearningActivityService Source = reference.Source.ToString().ToLowerInvariant(), CreatedAt = DateTimeOffset.UtcNow }); - } } else if (item is not null) { @@ -95,9 +83,7 @@ public sealed partial class LearningActivityService item.UserId == actor.UserId); if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) - { query = query.Where(item => item.ResolvedAt == null); - } var items = await query .OrderByDescending(item => item.LastWrongAt) @@ -130,9 +116,7 @@ public sealed partial class LearningActivityService cancellationToken); if (item is null) - { throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found."); - } item.ResolvedAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); @@ -169,6 +153,4 @@ public sealed partial class LearningActivityService recommendedEndpoint = "/api/student/learning/practice-sessions" })); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs b/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs index ab27995..489d849 100644 --- a/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs +++ b/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs @@ -1,19 +1,7 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Learning; -using Tiku.Domain.QuestionBanks; -using Tiku.Infrastructure.Persistence; -using ZLinq; namespace Tiku.Infrastructure.Learning; @@ -31,17 +19,13 @@ public sealed partial class LearningActivityService item.UserId == actor.UserId); if (filter.UnitId.HasValue) - { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); - } if (TryParseWordProgressStatus(filter.Status, out var status)) - { query = query.Where(item => item.Status == status); - } var items = await query .OrderBy(item => item.NextReviewAt == null) @@ -93,17 +77,11 @@ public sealed partial class LearningActivityService } if (TryParseWordProgressStatus(command.Status, out var status)) - { item.Status = status; - } else if (string.IsNullOrWhiteSpace(command.Status)) - { item.Status = WordProgressStatus.Learning; - } else - { throw new LearningValidationException("invalid_word_status", "Word progress status is invalid."); - } var correctDelta = command.CorrectDelta ?? 0; var wrongDelta = command.WrongDelta ?? 0; @@ -139,12 +117,10 @@ public sealed partial class LearningActivityService var query = dbContext.UserWordProgress.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); - } var items = await query .Where(item => item.NextReviewAt == null || item.NextReviewAt <= now) @@ -196,12 +172,10 @@ public sealed partial class LearningActivityService var query = dbContext.UserWordProgress.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) - { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); - } return new WordStatsItem( await query.CountAsync(cancellationToken), @@ -227,12 +201,10 @@ public sealed partial class LearningActivityService item.UserId == actor.UserId); if (filter.UnitId.HasValue) - { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); - } var items = await query .OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt) @@ -286,6 +258,4 @@ public sealed partial class LearningActivityService await dbContext.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/AuthModule.cs b/Tiku.Infrastructure/Modules/AuthModule.cs index e7c3f4f..1969ebb 100644 --- a/Tiku.Infrastructure/Modules/AuthModule.cs +++ b/Tiku.Infrastructure/Modules/AuthModule.cs @@ -25,4 +25,4 @@ internal static class AuthModule services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/CommerceModule.cs b/Tiku.Infrastructure/Modules/CommerceModule.cs index 3beb1ed..680b30e 100644 --- a/Tiku.Infrastructure/Modules/CommerceModule.cs +++ b/Tiku.Infrastructure/Modules/CommerceModule.cs @@ -31,4 +31,4 @@ internal static class CommerceModule services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/ContentModule.cs b/Tiku.Infrastructure/Modules/ContentModule.cs index 98840b8..046ff97 100644 --- a/Tiku.Infrastructure/Modules/ContentModule.cs +++ b/Tiku.Infrastructure/Modules/ContentModule.cs @@ -48,4 +48,4 @@ internal static class ContentModule services.AddSingleton(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/JobsModule.cs b/Tiku.Infrastructure/Modules/JobsModule.cs index 4fb04b1..7987740 100644 --- a/Tiku.Infrastructure/Modules/JobsModule.cs +++ b/Tiku.Infrastructure/Modules/JobsModule.cs @@ -15,4 +15,4 @@ internal static class JobsModule services.AddScoped(provider => provider.GetRequiredService()); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/LearningModule.cs b/Tiku.Infrastructure/Modules/LearningModule.cs index 6ac9528..a224a01 100644 --- a/Tiku.Infrastructure/Modules/LearningModule.cs +++ b/Tiku.Infrastructure/Modules/LearningModule.cs @@ -11,4 +11,4 @@ internal static class LearningModule services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/PlatformCoreModule.cs b/Tiku.Infrastructure/Modules/PlatformCoreModule.cs index 37cfea8..fcd7024 100644 --- a/Tiku.Infrastructure/Modules/PlatformCoreModule.cs +++ b/Tiku.Infrastructure/Modules/PlatformCoreModule.cs @@ -18,7 +18,8 @@ internal static class PlatformCoreModule services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); services.AddMemoryCache(); services.AddScoped(); services.AddScoped(); @@ -34,7 +35,8 @@ internal static class PlatformCoreModule services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddOptions(); @@ -43,4 +45,4 @@ internal static class PlatformCoreModule services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/PlatformModule.cs b/Tiku.Infrastructure/Modules/PlatformModule.cs index 26b8a48..b5e57b7 100644 --- a/Tiku.Infrastructure/Modules/PlatformModule.cs +++ b/Tiku.Infrastructure/Modules/PlatformModule.cs @@ -33,4 +33,4 @@ internal static class PlatformModule services.AddOptions(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Modules/TenantAdminModule.cs b/Tiku.Infrastructure/Modules/TenantAdminModule.cs index dfe94c3..afcdd05 100644 --- a/Tiku.Infrastructure/Modules/TenantAdminModule.cs +++ b/Tiku.Infrastructure/Modules/TenantAdminModule.cs @@ -15,4 +15,4 @@ internal static class TenantAdminModule services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs index c04af4d..4b4c3fa 100644 --- a/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs +++ b/Tiku.Infrastructure/Notifications/InAppNotificationProvider.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Notifications; using Tiku.Domain.Common; @@ -14,10 +15,10 @@ internal sealed class InAppNotificationProvider(TikuDbContext dbContext) : INoti { var item = !string.IsNullOrWhiteSpace(request.DedupeKey) ? await dbContext.UserNotifications.FirstOrDefaultAsync(notification => - notification.TenantId == request.TenantId && - notification.UserId == request.UserId && - notification.NotificationType == request.NotificationType && - notification.DedupeKey == request.DedupeKey, + notification.TenantId == request.TenantId && + notification.UserId == request.UserId && + notification.NotificationType == request.NotificationType && + notification.DedupeKey == request.DedupeKey, cancellationToken) : null; @@ -38,18 +39,17 @@ internal sealed class InAppNotificationProvider(TikuDbContext dbContext) : INoti item.SourceType = Normalize(request.SourceType); item.SourceId = request.SourceId; item.DedupeKey = Normalize(request.DedupeKey); - item.Metadata = request.Metadata.ValueKind == System.Text.Json.JsonValueKind.Object + item.Metadata = request.Metadata.ValueKind == JsonValueKind.Object ? request.Metadata.Clone() : JsonDefaults.Object(); - if (isNew) - { - dbContext.UserNotifications.Add(item); - } + if (isNew) dbContext.UserNotifications.Add(item); return item; } - private static string? Normalize(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); -} + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs b/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs index 0ae5a68..e77b22e 100644 --- a/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs +++ b/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs @@ -72,12 +72,10 @@ public sealed class DatabasePerformanceInterceptor( DatabaseRequestMetrics.Record(duration, fingerprint); if (duration.TotalMilliseconds >= SlowCommandMilliseconds) - { logger.LogWarning( "Slow database command {CommandFingerprint} ({Operation}) completed in {ElapsedMilliseconds:F1} ms.", fingerprint, operation, duration.TotalMilliseconds); - } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs b/Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs index 8cc3647..7651ffc 100644 --- a/Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs +++ b/Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs @@ -16,20 +16,24 @@ public static class DatabasePerformanceTelemetry public const string MeterName = "Tiku.Database"; internal static readonly Meter Meter = new(MeterName, "1.0.0"); + internal static readonly Counter CommandCounter = Meter.CreateCounter( "tiku.database.commands", description: "Number of database commands executed."); + internal static readonly Histogram CommandDuration = Meter.CreateHistogram( "tiku.database.command.duration", - unit: "ms", - description: "Database command execution duration."); + "ms", + "Database command execution duration."); + internal static readonly Histogram RequestCommandCount = Meter.CreateHistogram( "tiku.database.request.commands", description: "Database commands executed during one HTTP request."); + internal static readonly Histogram RequestCommandDuration = Meter.CreateHistogram( "tiku.database.request.duration", - unit: "ms", - description: "Aggregate database command duration during one HTTP request."); + "ms", + "Aggregate database command duration during one HTTP request."); public static string Fingerprint(string commandText) { @@ -59,9 +63,9 @@ public static class DatabaseRequestMetrics { private readonly Stopwatch stopwatch = Stopwatch.StartNew(); private int commandCount; - private long totalTicks; - private long slowestTicks; private string? slowestFingerprint; + private long slowestTicks; + private long totalTicks; public void Record(TimeSpan duration, string fingerprint) { @@ -91,10 +95,7 @@ public static class DatabaseRequestMetrics public void Dispose() { - if (disposed) - { - return; - } + if (disposed) return; disposed = true; var snapshot = current.Complete(); @@ -103,4 +104,4 @@ public static class DatabaseRequestMetrics CurrentState.Value = previous; } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs index 58ccfd3..0f4dd7f 100644 --- a/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs +++ b/Tiku.Infrastructure/Observability/DependencyReadinessProbe.cs @@ -1,7 +1,5 @@ -using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Observability; @@ -15,4 +13,4 @@ internal sealed class DependencyReadinessProbe( var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken); return new DependencyReadiness(database && redis, DateTimeOffset.UtcNow); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Observability/WorkerTelemetry.cs b/Tiku.Infrastructure/Observability/WorkerTelemetry.cs index afd908f..21ae48b 100644 --- a/Tiku.Infrastructure/Observability/WorkerTelemetry.cs +++ b/Tiku.Infrastructure/Observability/WorkerTelemetry.cs @@ -1,5 +1,5 @@ -using System.Diagnostics.Metrics; using System.Diagnostics; +using System.Diagnostics.Metrics; namespace Tiku.Infrastructure.Observability; @@ -8,11 +8,19 @@ public static class WorkerTelemetry public const string MeterName = "Tiku.Worker"; private static readonly Meter Meter = new(MeterName, "1.0.0"); private static readonly Counter JobCounter = Meter.CreateCounter("tiku.worker.jobs"); - private static readonly Histogram JobDuration = Meter.CreateHistogram("tiku.worker.job.duration", "ms"); + + private static readonly Histogram JobDuration = + Meter.CreateHistogram("tiku.worker.job.duration", "ms"); + private static readonly Counter ScanCounter = Meter.CreateCounter("tiku.asset.security_scans"); - private static readonly Histogram ScanDuration = Meter.CreateHistogram("tiku.asset.security_scan.duration", "ms"); + + private static readonly Histogram ScanDuration = + Meter.CreateHistogram("tiku.asset.security_scan.duration", "ms"); + private static readonly Counter IterationCounter = Meter.CreateCounter("tiku.worker.iterations"); - private static readonly Histogram IterationDuration = Meter.CreateHistogram("tiku.worker.iteration.duration", "ms"); + + private static readonly Histogram IterationDuration = + Meter.CreateHistogram("tiku.worker.iteration.duration", "ms"); public static void RecordJob(string jobType, string status, double elapsedMilliseconds) { @@ -34,4 +42,4 @@ public static class WorkerTelemetry IterationCounter.Add(1, tags); IterationDuration.Record(elapsedMilliseconds, tags); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs index 43341ad..d9e3a03 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs @@ -84,7 +84,8 @@ internal sealed class ContentAssetConfiguration : IEntityTypeConfiguration= 0"); table.HasCheckConstraint("ck_content_assets_download_count", "download_count >= 0"); - table.HasCheckConstraint("ck_content_assets_verified_size", "verified_size_bytes is null or verified_size_bytes >= 0"); + table.HasCheckConstraint("ck_content_assets_verified_size", + "verified_size_bytes is null or verified_size_bytes >= 0"); table.HasCheckConstraint( "ck_content_assets_verified_checksum", "verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'"); @@ -141,7 +142,8 @@ internal sealed class ContentImportJobConfiguration : IEntityTypeConfiguration { - table.HasCheckConstraint("ck_content_import_jobs_counts", "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0"); + table.HasCheckConstraint("ck_content_import_jobs_counts", + "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0"); }); builder.HasOne().WithMany() @@ -342,9 +344,10 @@ internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfigurat builder.ToTable(table => { table.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0"); - table.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0"); + table.HasCheckConstraint("ck_video_playback_progress_duration", + "duration_seconds is null or duration_seconds >= 0"); table.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0"); table.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0"); }); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 357ef0e..4c27338 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -161,7 +161,6 @@ internal sealed class SubjectConfiguration : IEntityTypeConfiguration .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } - } internal sealed class CategoryConfiguration : IEntityTypeConfiguration @@ -184,4 +183,4 @@ internal sealed class CategoryConfiguration : IEntityTypeConfiguration .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs index 115fa7a..fddb689 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs @@ -24,7 +24,8 @@ internal sealed class ProductConfiguration : IEntityTypeConfiguration builder.Property(entity => entity.DetailImages).IsJson("[]"); builder.Property(entity => entity.IsActive).HasDefaultValue(true); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.Type, entity.IsActive, entity.SortOrder }); + builder.HasIndex(entity => new + { entity.TenantId, entity.RegionId, entity.Type, entity.IsActive, entity.SortOrder }); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.RegionId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) @@ -47,7 +48,8 @@ internal sealed class SvipPlanConfiguration : IEntityTypeConfiguration builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.IsActive, entity.SortOrder }); builder.ToTable(table => { - table.HasCheckConstraint("ck_svip_plans_price", "price_cents >= 0 and (original_price_cents is null or original_price_cents >= 0)"); + table.HasCheckConstraint("ck_svip_plans_price", + "price_cents >= 0 and (original_price_cents is null or original_price_cents >= 0)"); table.HasCheckConstraint("ck_svip_plans_days", "days >= 0"); }); builder.HasOne().WithMany() @@ -208,7 +210,8 @@ internal sealed class CodeBatchConfiguration : IEntityTypeConfiguration { table.HasCheckConstraint("ck_code_batches_counts", "total_count >= 0"); - table.HasCheckConstraint("ck_code_batches_amounts", "default_unit_price_cents >= 0 and cost_price_cents >= 0"); + table.HasCheckConstraint("ck_code_batches_amounts", + "default_unit_price_cents >= 0 and cost_price_cents >= 0"); }); } } @@ -368,8 +371,10 @@ internal sealed class CommerceReconciliationBatchConfiguration : IEntityTypeConf builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt }); builder.ToTable(table => { - table.HasCheckConstraint("ck_commerce_reconciliation_batches_counts", "total_count >= 0 and matched_count >= 0 and mismatch_count >= 0 and missing_local_count >= 0 and missing_provider_count >= 0 and duplicate_count >= 0 and ignored_count >= 0"); - table.HasCheckConstraint("ck_commerce_reconciliation_batches_amounts", "amount_cents >= 0 and refund_amount_cents >= 0"); + table.HasCheckConstraint("ck_commerce_reconciliation_batches_counts", + "total_count >= 0 and matched_count >= 0 and mismatch_count >= 0 and missing_local_count >= 0 and missing_provider_count >= 0 and duplicate_count >= 0 and ignored_count >= 0"); + table.HasCheckConstraint("ck_commerce_reconciliation_batches_amounts", + "amount_cents >= 0 and refund_amount_cents >= 0"); }); builder.HasOne().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull); } @@ -402,7 +407,8 @@ internal sealed class CommerceReconciliationItemConfiguration : IEntityTypeConfi builder.ToTable(table => { table.HasCheckConstraint("ck_commerce_reconciliation_items_row", "row_no > 0"); - table.HasCheckConstraint("ck_commerce_reconciliation_items_amounts", "amount_cents >= 0 and refund_amount_cents >= 0"); + table.HasCheckConstraint("ck_commerce_reconciliation_items_amounts", + "amount_cents >= 0 and refund_amount_cents >= 0"); }); builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany() @@ -453,7 +459,8 @@ internal sealed class CommerceReconciliationIssueConfiguration : IEntityTypeConf builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.Status, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.OrderNo, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.PaymentId, entity.CreatedAt }); - builder.ToTable(table => table.HasCheckConstraint("ck_commerce_reconciliation_issues_amounts", "amount_cents >= 0 and refund_amount_cents >= 0")); + builder.ToTable(table => table.HasCheckConstraint("ck_commerce_reconciliation_issues_amounts", + "amount_cents >= 0 and refund_amount_cents >= 0")); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.BatchId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) @@ -480,7 +487,8 @@ internal sealed class CommerceReconciliationIssueConfiguration : IEntityTypeConf } } -internal sealed class CommerceReconciliationIssueEventConfiguration : IEntityTypeConfiguration +internal sealed class + CommerceReconciliationIssueEventConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -517,7 +525,8 @@ internal sealed class CommerceAdjustmentVoucherConfiguration : IEntityTypeConfig builder.HasIndex(entity => new { entity.TenantId, entity.VoucherNo }).IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.IssueId, entity.CreatedAt }); - builder.ToTable(table => table.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0")); + builder.ToTable(table => + table.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0")); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.IssueId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) @@ -547,7 +556,8 @@ internal sealed class CommerceAdjustmentVoucherConfiguration : IEntityTypeConfig } } -internal sealed class CommerceAdjustmentVoucherEventConfiguration : IEntityTypeConfiguration +internal sealed class + CommerceAdjustmentVoucherEventConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -566,4 +576,4 @@ internal sealed class CommerceAdjustmentVoucherEventConfiguration : IEntityTypeC .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs b/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs index edf9655..d442d9b 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs @@ -72,4 +72,4 @@ internal static class ConfigurationSupport : Enum.Parse(value.Replace("_", string.Empty), true)) .HasMaxLength(maxLength); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/ContentConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/ContentConfigurations.cs index 1e0adae..df99c1d 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/ContentConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/ContentConfigurations.cs @@ -74,10 +74,7 @@ internal sealed class ContentNodeConfiguration : IEntityTypeConfiguration entity.Path).HasMethod("gist"); builder.HasIndex(entity => new { entity.TenantId, entity.MarkerType }) .HasFilter("marker_type is not null"); - builder.ToTable(table => - { - table.HasCheckConstraint("ck_content_nodes_depth", "depth >= 0"); - }); + builder.ToTable(table => { table.HasCheckConstraint("ck_content_nodes_depth", "depth >= 0"); }); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.EntryId }) @@ -264,4 +261,4 @@ internal sealed class PracticeBlueprintConfiguration : IEntityTypeConfiguration< .HasForeignKey(entity => entity.UpdatedBy) .OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/ContentPlatformConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/ContentPlatformConfigurations.cs index 449ada3..a10fa1d 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/ContentPlatformConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/ContentPlatformConfigurations.cs @@ -31,7 +31,8 @@ internal sealed class ContentAssetAccessEventConfiguration : IEntityTypeConfigur builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt }) .HasFilter("user_id is not null"); builder.HasIndex(entity => new { entity.TenantId, entity.AccessType, entity.Result, entity.CreatedAt }); - builder.ToTable(table => table.HasCheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0")); + builder.ToTable(table => table.HasCheckConstraint("ck_content_asset_access_events_expiry", + "expires_in_seconds is null or expires_in_seconds > 0")); builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.AssetId }) @@ -41,7 +42,8 @@ internal sealed class ContentAssetAccessEventConfiguration : IEntityTypeConfigur } } -internal sealed class ContentAssetSecurityScanEventConfiguration : IEntityTypeConfiguration +internal sealed class + ContentAssetSecurityScanEventConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -131,4 +133,4 @@ internal sealed class AiRecommendationReportConfiguration : IEntityTypeConfigura .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/GrowthConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/GrowthConfigurations.cs index 5fa7cfe..3781f39 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/GrowthConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/GrowthConfigurations.cs @@ -23,7 +23,8 @@ internal sealed class ReferralTrackConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.LegacyId }).IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.LeadId, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.RefCode, entity.EventType, entity.CreatedAt }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId) + .OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.TargetUserId).OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.LeadId }) @@ -66,9 +67,12 @@ internal sealed class ReferralLeadConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.StudentUserId }).IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.ReferrerUserId, entity.BoundAt }); builder.HasIndex(entity => new { entity.TenantId, entity.AssignedToUserId, entity.Status, entity.BoundAt }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.StudentUserId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId).OnDelete(DeleteBehavior.SetNull); - builder.HasOne().WithMany().HasForeignKey(entity => entity.AssignedToUserId).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.StudentUserId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.AssignedToUserId) + .OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.AssignedBy).OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.FirstTrackId }) @@ -190,7 +194,8 @@ internal sealed class TenantCommissionSettingConfiguration : IEntityTypeConfigur builder.HasIndex(entity => entity.TenantId).IsUnique(); builder.ToTable(table => { - table.HasCheckConstraint("ck_tenant_commission_settings_default_rate", "default_rate >= 0 and default_rate <= 1"); + table.HasCheckConstraint("ck_tenant_commission_settings_default_rate", + "default_rate >= 0 and default_rate <= 1"); table.HasCheckConstraint("ck_tenant_commission_settings_min_settlement", "min_settlement_cents >= 0"); }); builder.HasOne().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull); @@ -219,11 +224,14 @@ internal sealed class CommissionSettlementConfiguration : IEntityTypeConfigurati builder.ToTable(table => { table.HasCheckConstraint("ck_commission_settlements_counts", "source_count >= 0 and paid_user_count >= 0"); - table.HasCheckConstraint("ck_commission_settlements_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); - table.HasCheckConstraint("ck_commission_settlements_rates", "default_rate >= 0 and default_rate <= 1 and (effective_rate is null or (effective_rate >= 0 and effective_rate <= 1))"); + table.HasCheckConstraint("ck_commission_settlements_amounts", + "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + table.HasCheckConstraint("ck_commission_settlements_rates", + "default_rate >= 0 and default_rate <= 1 and (effective_rate is null or (effective_rate >= 0 and effective_rate <= 1))"); table.HasCheckConstraint("ck_commission_settlements_period", "period_end >= period_start"); }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId) + .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(entity => entity.GeneratedBy).OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.ReviewedBy).OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.PaidBy).OnDelete(DeleteBehavior.SetNull); @@ -247,15 +255,19 @@ internal sealed class CommissionSettlementItemConfiguration : IEntityTypeConfigu builder.HasIndex(entity => new { entity.TenantId, entity.SettlementId }); builder.ToTable(table => { - table.HasCheckConstraint("ck_commission_settlement_items_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); - table.HasCheckConstraint("ck_commission_settlement_items_rate", "commission_rate >= 0 and commission_rate <= 1"); + table.HasCheckConstraint("ck_commission_settlement_items_amounts", + "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + table.HasCheckConstraint("ck_commission_settlement_items_rate", + "commission_rate >= 0 and commission_rate <= 1"); }); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.SettlementId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.SetNull); - builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(entity => entity.StudentUserId).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ReferrerUserId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.StudentUserId) + .OnDelete(DeleteBehavior.SetNull); } } @@ -289,7 +301,8 @@ internal sealed class CommissionSettlementProofConfiguration : IEntityTypeConfig } } -internal sealed class CommissionSettlementExportEventConfiguration : IEntityTypeConfiguration +internal sealed class + CommissionSettlementExportEventConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -310,4 +323,4 @@ internal sealed class CommissionSettlementExportEventConfiguration : IEntityType .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(entity => entity.ExportedBy).OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs index c40e59a..4db52a6 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs @@ -94,4 +94,4 @@ internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs index cadd20c..1bba8bb 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs @@ -112,7 +112,8 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration entity.LegacyQuestionId).HasMaxLength(64); builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64); builder.Property(entity => entity.SelectedOptions).IsJson("[]"); - builder.Property(entity => entity.GradingStatus).HasSnakeCaseEnum().HasDefaultValue(AnswerGradingStatus.PendingReview); + builder.Property(entity => entity.GradingStatus).HasSnakeCaseEnum() + .HasDefaultValue(AnswerGradingStatus.PendingReview); builder.Property(entity => entity.AwardedScore).HasPrecision(8, 2); builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200); builder.Property(entity => entity.RequestHash).HasMaxLength(64); @@ -288,4 +289,4 @@ internal sealed class RecentPracticeConfiguration : IEntityTypeConfiguration entity.UserId) .OnDelete(DeleteBehavior.Cascade); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/LearningReportConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/LearningReportConfigurations.cs index 1a071b8..9016dc9 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/LearningReportConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/LearningReportConfigurations.cs @@ -203,7 +203,8 @@ internal sealed class PracticeSessionReportConfiguration : IEntityTypeConfigurat .HasFilter("blueprint_id is not null"); builder.ToTable(table => { - table.HasCheckConstraint("ck_practice_session_reports_counts", "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + table.HasCheckConstraint("ck_practice_session_reports_counts", + "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); table.HasCheckConstraint("ck_practice_session_reports_scores", "score >= 0 and total_score >= 0"); table.HasCheckConstraint("ck_practice_session_reports_accuracy", "accuracy >= 0 and accuracy <= 1"); table.HasCheckConstraint("ck_practice_session_reports_duration", "duration_seconds >= 0"); @@ -244,7 +245,8 @@ internal sealed class PracticeSessionReportSectionConfiguration : IEntityTypeCon builder.HasIndex(entity => new { entity.TenantId, entity.ReportId, entity.SortOrder }); builder.ToTable(table => { - table.HasCheckConstraint("ck_practice_session_report_sections_counts", "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + table.HasCheckConstraint("ck_practice_session_report_sections_counts", + "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); table.HasCheckConstraint("ck_practice_session_report_sections_scores", "score >= 0 and total_score >= 0"); table.HasCheckConstraint("ck_practice_session_report_sections_accuracy", "accuracy >= 0 and accuracy <= 1"); }); @@ -269,7 +271,8 @@ internal sealed class DashboardDailyStatConfiguration : IEntityTypeConfiguration builder.Property(entity => entity.LegacyId).HasMaxLength(64); builder.Property(entity => entity.LegacyRegionId).HasMaxLength(64); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId }).IsUnique().AreNullsDistinct(false); + builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId }).IsUnique() + .AreNullsDistinct(false); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.RegionId }) @@ -288,11 +291,12 @@ internal sealed class RevenueDailyStatConfiguration : IEntityTypeConfiguration entity.LegacyRegionId).HasMaxLength(64); builder.Property(entity => entity.SaleType).HasMaxLength(50); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId, entity.SaleType }).IsUnique().AreNullsDistinct(false); + builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId, entity.SaleType }) + .IsUnique().AreNullsDistinct(false); builder.HasOne().WithMany() .HasForeignKey(entity => new { entity.TenantId, entity.RegionId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs index 3a6912c..02b983e 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs @@ -212,7 +212,8 @@ internal sealed class PlatformBackendRoleConfiguration : IEntityTypeConfiguratio } } -internal sealed class PlatformBackendRolePermissionConfiguration : IEntityTypeConfiguration +internal sealed class + PlatformBackendRolePermissionConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -285,7 +286,8 @@ internal sealed class AuthorizationScopeVersionConfiguration : IEntityTypeConfig } } -internal sealed class AuthorizationCacheInvalidationConfiguration : IEntityTypeConfiguration +internal sealed class + AuthorizationCacheInvalidationConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -297,7 +299,8 @@ internal sealed class AuthorizationCacheInvalidationConfiguration : IEntityTypeC value => value == null ? null : Enum.Parse(value, true)); builder.Property(entity => entity.LastError).HasMaxLength(2000); builder.HasIndex(entity => new { entity.ProcessedAt, entity.CreatedAt }); - builder.HasIndex(entity => new { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version }); + builder.HasIndex(entity => new + { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version }); } } @@ -491,6 +494,7 @@ internal sealed class TenantThemeConfigConfiguration : IEntityTypeConfiguration< .HasPrincipalKey(entity => entity.Code) .OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.PublishedBy).OnDelete(DeleteBehavior.SetNull); - builder.HasOne().WithMany().HasForeignKey(entity => entity.DraftUpdatedBy).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.DraftUpdatedBy) + .OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs index 4b56008..b9454bd 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs @@ -24,7 +24,8 @@ internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfigurati builder.Property(entity => entity.BankName).HasMaxLength(200); builder.Property(entity => entity.BankAccountMasked).HasMaxLength(100); builder.Property(entity => entity.Metadata).IsJson("{}"); - builder.HasOne().WithOne().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithOne().HasForeignKey(entity => entity.TenantId) + .OnDelete(DeleteBehavior.Cascade); } } @@ -40,7 +41,8 @@ internal sealed class TenantBillingPolicyConfiguration : IEntityTypeConfiguratio builder.ToTable(table => table.HasCheckConstraint( "ck_tenant_billing_policies_renewal_lead_days", "renewal_lead_days between 1 and 90")); - builder.HasOne().WithOne().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithOne().HasForeignKey(entity => entity.TenantId) + .OnDelete(DeleteBehavior.Cascade); } } @@ -79,7 +81,8 @@ internal sealed class PlatformOperationIdempotencyConfiguration : IEntityTypeCon } } -internal sealed class PlatformBillingInvoiceReminderConfiguration : IEntityTypeConfiguration +internal sealed class + PlatformBillingInvoiceReminderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -89,7 +92,8 @@ internal sealed class PlatformBillingInvoiceReminderConfiguration : IEntityTypeC builder.Property(entity => entity.Channel).HasSnakeCaseEnum(); builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.Metadata).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceId, entity.ReminderType, entity.Channel, entity.ReminderDate }).IsUnique(); + builder.HasIndex(entity => new + { entity.TenantId, entity.InvoiceId, entity.ReminderType, entity.Channel, entity.ReminderDate }).IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.ReminderDate }); builder.HasIndex(entity => new { entity.InvoiceId, entity.ReminderDate }); builder.ToTable(table => @@ -143,15 +147,20 @@ internal sealed class PlatformAuditAlertConfiguration : IEntityTypeConfiguration builder.HasIndex(entity => new { entity.Status, entity.Severity, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt }); builder.HasIndex(entity => entity.AuditLogId); - builder.HasOne().WithMany().HasForeignKey(entity => entity.RuleId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(entity => entity.AuditLogId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.RuleId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.AuditLogId) + .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(entity => entity.AcknowledgedBy).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.AcknowledgedBy) + .OnDelete(DeleteBehavior.SetNull); builder.HasOne().WithMany().HasForeignKey(entity => entity.ResolvedBy).OnDelete(DeleteBehavior.SetNull); } } -internal sealed class PlatformBillingDunningNotificationChannelConfiguration : IEntityTypeConfiguration +internal sealed class + PlatformBillingDunningNotificationChannelConfiguration : IEntityTypeConfiguration< + PlatformBillingDunningNotificationChannel> { public void Configure(EntityTypeBuilder builder) { @@ -162,21 +171,27 @@ internal sealed class PlatformBillingDunningNotificationChannelConfiguration : I builder.Property(entity => entity.Provider).HasSnakeCaseEnum(); builder.Property(entity => entity.WebhookUrl).HasMaxLength(2048); builder.Property(entity => entity.SecretRef).HasMaxLength(300); - builder.Property(entity => entity.ReminderTypes).HasColumnType("text[]").HasDefaultValueSql("array['overdue', 'final_notice']::text[]"); - builder.Property(entity => entity.ReminderChannels).HasColumnType("text[]").HasDefaultValueSql("array['internal']::text[]"); + builder.Property(entity => entity.ReminderTypes).HasColumnType("text[]") + .HasDefaultValueSql("array['overdue', 'final_notice']::text[]"); + builder.Property(entity => entity.ReminderChannels).HasColumnType("text[]") + .HasDefaultValueSql("array['internal']::text[]"); builder.Property(entity => entity.TenantIds).HasColumnType("uuid[]").HasDefaultValueSql("'{}'::uuid[]"); builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => entity.ChannelCode).IsUnique(); builder.HasIndex(entity => new { entity.Enabled, entity.MinReminderLevel, entity.ChannelCode }); builder.ToTable(table => { - table.HasCheckConstraint("ck_platform_billing_dunning_channels_level", "min_reminder_level between 1 and 20"); - table.HasCheckConstraint("ck_platform_billing_dunning_channels_timeout", "timeout_seconds between 1 and 60"); + table.HasCheckConstraint("ck_platform_billing_dunning_channels_level", + "min_reminder_level between 1 and 20"); + table.HasCheckConstraint("ck_platform_billing_dunning_channels_timeout", + "timeout_seconds between 1 and 60"); }); } } -internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEntityTypeConfiguration +internal sealed class + PlatformBillingDunningNotificationEventConfiguration : IEntityTypeConfiguration< + PlatformBillingDunningNotificationEvent> { public void Configure(EntityTypeBuilder builder) { @@ -192,7 +207,8 @@ internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEn builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt }); builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt }); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_dunning_events_attempts", "attempts >= 0")); + builder.ToTable(table => + table.HasCheckConstraint("ck_platform_billing_dunning_events_attempts", "attempts >= 0")); builder.HasOne().WithMany() .HasForeignKey(entity => entity.ChannelId) .HasConstraintName("fk_platform_billing_dunning_events_channel") @@ -243,7 +259,8 @@ internal sealed class PlatformPaymentChannelConfiguration : IEntityTypeConfigura builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => new { entity.AppId, entity.Provider }).IsUnique(); builder.HasIndex(entity => new { entity.Status, entity.Priority, entity.Provider }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.AppId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.AppId) + .OnDelete(DeleteBehavior.Cascade); } } @@ -262,7 +279,8 @@ internal sealed class PlatformApprovalPolicyConfiguration : IEntityTypeConfigura { table.HasCheckConstraint("ck_platform_approval_policies_version", "version > 0"); table.HasCheckConstraint("ck_platform_approval_policies_expiry", "expires_after_hours between 1 and 720"); - table.HasCheckConstraint("ck_platform_approval_policies_threshold", "amount_threshold_cents is null or amount_threshold_cents > 0"); + table.HasCheckConstraint("ck_platform_approval_policies_threshold", + "amount_threshold_cents is null or amount_threshold_cents > 0"); }); } } @@ -296,7 +314,8 @@ internal sealed class PlatformApprovalRequestConfiguration : IEntityTypeConfigur } } -internal sealed class PlatformConfigurationDefinitionConfiguration : IEntityTypeConfiguration +internal sealed class + PlatformConfigurationDefinitionConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -326,10 +345,12 @@ internal sealed class PlatformConfigurationVersionConfiguration : IEntityTypeCon builder.Property(entity => entity.Reason).HasMaxLength(1000); builder.HasIndex(entity => new { entity.DefinitionId, entity.Environment, entity.Version }).IsUnique(); builder.HasIndex(entity => new { entity.DefinitionId, entity.Environment, entity.Status }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.DefinitionId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(entity => entity.DefinitionId) + .OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(entity => entity.PublishedBy).OnDelete(DeleteBehavior.Restrict); - builder.HasOne().WithMany().HasForeignKey(entity => entity.RolledBackFromVersionId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany() + .HasForeignKey(entity => entity.RolledBackFromVersionId).OnDelete(DeleteBehavior.Restrict); } } @@ -365,8 +386,10 @@ internal sealed class PlatformNotificationDeliveryConfiguration : IEntityTypeCon builder.Property(entity => entity.Metadata).IsJson("{}"); builder.HasIndex(entity => new { entity.TemplateId, entity.RecipientUserId, entity.IdempotencyKey }).IsUnique(); builder.HasIndex(entity => new { entity.Status, entity.CreatedAt }); - builder.HasOne().WithMany().HasForeignKey(entity => entity.TemplateId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne().WithMany().HasForeignKey(entity => entity.RecipientUserId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(entity => entity.TemplateId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(entity => entity.RecipientUserId) + .OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/PocketBaseImportConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PocketBaseImportConfigurations.cs index 2c521c2..ab9e487 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PocketBaseImportConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PocketBaseImportConfigurations.cs @@ -62,4 +62,4 @@ internal sealed class PocketBaseImportIssueConfiguration : IEntityTypeConfigurat .OnDelete(DeleteBehavior.Cascade); builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs index 18b1dfb..4ceb27c 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PointConfigurations.cs @@ -40,7 +40,8 @@ internal sealed class PointActivityClaimConfiguration : IEntityTypeConfiguration builder.Property(entity => entity.SourceType).HasMaxLength(100); builder.Property(entity => entity.Metadata).IsJson("{}"); builder.Property(entity => entity.ClaimedAt).HasDefaultValueSql("now()"); - builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.TaskId, entity.SourceType, entity.SourceId }) + builder.HasIndex(entity => new + { entity.TenantId, entity.UserId, entity.TaskId, entity.SourceType, entity.SourceId }) .IsUnique() .HasFilter("source_type is not null and source_id is not null"); builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt }); @@ -49,10 +50,7 @@ internal sealed class PointActivityClaimConfiguration : IEntityTypeConfiguration .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade); - builder.ToTable(table => - { - table.HasCheckConstraint("ck_point_activity_claims_points", "points > 0"); - }); + builder.ToTable(table => { table.HasCheckConstraint("ck_point_activity_claims_points", "points > 0"); }); } } @@ -109,4 +107,4 @@ internal sealed class PointExchangeOrderConfiguration : IEntityTypeConfiguration table.HasCheckConstraint("ck_point_exchange_orders_points_cost", "points_cost > 0"); }); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs index 3f21b7b..577ba85 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs @@ -106,4 +106,4 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration entity.CreatedBy) .OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs index 42d9811..b645761 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/SaasBillingConfigurations.cs @@ -19,7 +19,8 @@ internal sealed class SaasFeatureConfiguration : IEntityTypeConfiguration value.Status).HasSnakeCaseEnum(); builder.HasIndex(value => value.Code).IsUnique(); builder.HasIndex(value => new { value.Status, value.Category, value.SortOrder }); - builder.ToTable(table => table.HasCheckConstraint("ck_saas_features_reference_price", "reference_price_cents >= 0")); + builder.ToTable(table => + table.HasCheckConstraint("ck_saas_features_reference_price", "reference_price_cents >= 0")); } } @@ -71,11 +72,13 @@ internal sealed class SaasOfferingVersionConfiguration : IEntityTypeConfiguratio builder.Property(value => value.Metadata).IsJson("{}"); builder.HasIndex(value => new { value.OfferingId, value.Version }).IsUnique(); builder.HasIndex(value => new { value.OfferingId, value.Status, value.EffectiveAt }); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingId) + .OnDelete(DeleteBehavior.Cascade); builder.ToTable(table => { table.HasCheckConstraint("ck_saas_offering_versions_version", "version > 0"); - table.HasCheckConstraint("ck_saas_offering_versions_amount", "original_amount_cents >= 0 and amount_cents >= 0 and amount_cents <= original_amount_cents"); + table.HasCheckConstraint("ck_saas_offering_versions_amount", + "original_amount_cents >= 0 and amount_cents >= 0 and amount_cents <= original_amount_cents"); }); } } @@ -87,8 +90,10 @@ internal sealed class SaasOfferingVersionFeatureConfiguration : IEntityTypeConfi builder.ConfigureEntity("saas_offering_version_features"); builder.Property(value => value.FeatureCode).HasMaxLength(120); builder.HasIndex(value => new { value.OfferingVersionId, value.FeatureCode }).IsUnique(); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasPrincipalKey(value => value.Code) + .HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict); } } @@ -105,8 +110,10 @@ internal sealed class SaasFeatureLimitDefinitionConfiguration : IEntityTypeConfi builder.Property(value => value.Kind).HasSnakeCaseEnum(); builder.HasIndex(value => value.MetricCode).IsUnique(); builder.HasIndex(value => new { value.FeatureCode, value.Kind }); - builder.HasOne().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Cascade); - builder.ToTable(table => table.HasCheckConstraint("ck_saas_feature_limit_warning", "warning_percent between 1 and 100")); + builder.HasOne().WithMany().HasPrincipalKey(value => value.Code) + .HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Cascade); + builder.ToTable(table => + table.HasCheckConstraint("ck_saas_feature_limit_warning", "warning_percent between 1 and 100")); } } @@ -117,8 +124,10 @@ internal sealed class SaasOfferingVersionLimitConfiguration : IEntityTypeConfigu builder.ConfigureEntity("saas_offering_version_limits"); builder.Property(value => value.MetricCode).HasMaxLength(120); builder.HasIndex(value => new { value.OfferingVersionId, value.MetricCode }).IsUnique(); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasPrincipalKey(value => value.MetricCode) + .HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict); builder.ToTable(table => table.HasCheckConstraint("ck_saas_offering_version_limits_value", "limit_value >= 0")); } } @@ -133,10 +142,14 @@ internal sealed class TenantSaasSubscriptionConfiguration : IEntityTypeConfigura builder.Property(value => value.LifecycleVersion).IsConcurrencyToken(); builder.Property(value => value.Metadata).IsJson("{}"); builder.HasIndex(value => new { value.TenantId, value.Status, value.CurrentPeriodEnd }); - builder.HasIndex(value => value.TenantId).IsUnique().HasFilter("status in ('trial', 'active', 'past_due', 'suspended')"); - builder.HasOne().WithMany().HasForeignKey(value => value.BaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne().WithMany().HasForeignKey(value => value.ScheduledBaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscriptions_period", "current_period_end > current_period_start and current_period_start >= starts_at")); + builder.HasIndex(value => value.TenantId).IsUnique() + .HasFilter("status in ('trial', 'active', 'past_due', 'suspended')"); + builder.HasOne().WithMany().HasForeignKey(value => value.BaseOfferingVersionId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(value => value.ScheduledBaseOfferingVersionId) + .OnDelete(DeleteBehavior.Restrict); + builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscriptions_period", + "current_period_end > current_period_start and current_period_start >= starts_at")); } } @@ -161,12 +174,14 @@ internal sealed class TenantSaasSubscriptionItemConfiguration : IEntityTypeConfi .HasForeignKey(value => new { value.TenantId, value.SubscriptionId }) .HasPrincipalKey(value => new { value.TenantId, value.Id }) .OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId) + .OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany() .HasForeignKey(value => new { value.TenantId, value.SourceOrderItemId }) .HasPrincipalKey(value => new { value.TenantId, value.Id }) .OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscription_items_period", "ends_at > starts_at")); + builder.ToTable(table => + table.HasCheckConstraint("ck_tenant_saas_subscription_items_period", "ends_at > starts_at")); } } @@ -180,7 +195,8 @@ internal sealed class TenantFeatureOverrideConfiguration : IEntityTypeConfigurat builder.Property(value => value.Mode).HasSnakeCaseEnum(); builder.Property(value => value.Reason).HasMaxLength(1000); builder.HasIndex(value => new { value.TenantId, value.FeatureCode }).IsUnique(); - builder.HasOne().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasPrincipalKey(value => value.Code) + .HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict); } } @@ -192,8 +208,10 @@ internal sealed class TenantFeatureUsageConfiguration : IEntityTypeConfiguration builder.ConfigureTimestamps(); builder.Property(value => value.MetricCode).HasMaxLength(120); builder.Property(value => value.Version).IsConcurrencyToken(); - builder.HasIndex(value => new { value.TenantId, value.MetricCode, value.PeriodStart, value.PeriodEnd }).IsUnique(); - builder.HasOne().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(value => new { value.TenantId, value.MetricCode, value.PeriodStart, value.PeriodEnd }) + .IsUnique(); + builder.HasOne().WithMany().HasPrincipalKey(value => value.MetricCode) + .HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict); builder.ToTable(table => { table.HasCheckConstraint("ck_tenant_feature_usage_values", "used_value >= 0 and limit_value_snapshot >= 0"); @@ -218,7 +236,8 @@ internal sealed class PlatformBillingQuoteConfiguration : IEntityTypeConfigurati builder.HasIndex(value => new { value.TenantId, value.QuoteNo }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.Status, value.ExpiresAt }); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quotes_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents")); + builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quotes_amounts", + "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents")); } } @@ -232,9 +251,12 @@ internal sealed class PlatformBillingQuoteItemConfiguration : IEntityTypeConfigu builder.Property(value => value.Snapshot).IsJson("{}"); builder.HasIndex(value => new { value.TenantId, value.QuoteId, value.OfferingVersionId }).IsUnique(); builder.HasOne().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quote_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0")); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId) + .OnDelete(DeleteBehavior.Restrict); + builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quote_items_amounts", + "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0")); } } @@ -253,8 +275,10 @@ internal sealed class PlatformBillingOrderConfiguration : IEntityTypeConfigurati builder.HasIndex(value => new { value.TenantId, value.OrderNo }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.Status, value.CreatedAt }); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_orders_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents")); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); + builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_orders_amounts", + "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents")); } } @@ -268,9 +292,12 @@ internal sealed class PlatformBillingOrderItemConfiguration : IEntityTypeConfigu builder.Property(value => value.Snapshot).IsJson("{}"); builder.HasIndex(value => new { value.TenantId, value.OrderId, value.OfferingVersionId }).IsUnique(); builder.HasOne().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_order_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0")); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(value => value.OfferingVersionId) + .OnDelete(DeleteBehavior.Restrict); + builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_order_items_amounts", + "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0")); } } @@ -290,7 +317,8 @@ internal sealed class PlatformBillingPaymentConfiguration : IEntityTypeConfigura builder.HasIndex(value => new { value.TenantId, value.PaymentNo }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status }); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_payments_amount", "amount_cents >= 0")); } } @@ -309,7 +337,9 @@ internal sealed class PlatformBillingPaymentEventConfiguration : IEntityTypeConf builder.HasIndex(value => new { value.TenantId, value.Provider, value.ProviderEventId }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.PaymentId, value.CreatedAt }); builder.HasOne().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasForeignKey(value => new { value.TenantId, value.PaymentId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade); } } @@ -330,8 +360,11 @@ internal sealed class PlatformBillingRefundConfiguration : IEntityTypeConfigurat builder.HasIndex(value => new { value.TenantId, value.RefundNo }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status }); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany() + .HasForeignKey(value => new { value.TenantId, value.PaymentId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_refunds_amount", "amount_cents > 0")); } } @@ -348,7 +381,9 @@ internal sealed class PlatformBillingInvoiceConfiguration : IEntityTypeConfigura builder.Property(value => value.BillingProfileSnapshot).IsJson("{}"); builder.HasIndex(value => new { value.TenantId, value.InvoiceNo }).IsUnique(); builder.HasIndex(value => new { value.TenantId, value.Status, value.DueDate }); - builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); - builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_invoices_amount", "total_amount_cents >= 0")); + builder.HasOne().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }) + .HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict); + builder.ToTable(table => + table.HasCheckConstraint("ck_platform_billing_invoices_amount", "total_amount_cents >= 0")); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs index fde54a0..e966f4a 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/ScorelineConfigurations.cs @@ -41,7 +41,8 @@ internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration entity.MajorName).HasMaxLength(300); builder.Property(entity => entity.FieldValues).IsJson("{}"); builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year }); + builder.HasIndex(entity => new + { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year }); builder.HasIndex(entity => new { entity.TenantId, entity.Year }); builder.HasIndex(entity => new { entity.TenantId, entity.Year, entity.SchoolName, entity.MajorName, entity.Id }) .IsDescending(false, true, false, false, false); @@ -62,4 +63,4 @@ internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/StudyContentConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/StudyContentConfigurations.cs index 4945298..3c6e047 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/StudyContentConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/StudyContentConfigurations.cs @@ -114,7 +114,8 @@ internal sealed class UserWordProgressConfiguration : IEntityTypeConfiguration= 0"); table.HasCheckConstraint("ck_user_word_progress_correct_streak", "correct_streak >= 0"); - table.HasCheckConstraint("ck_user_word_progress_ease_factor", "ease_factor >= 1.30 and ease_factor <= 3.00"); + table.HasCheckConstraint("ck_user_word_progress_ease_factor", + "ease_factor >= 1.30 and ease_factor <= 3.00"); }); builder.HasOne().WithMany() @@ -319,4 +320,4 @@ internal sealed class SubjectShareConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Cascade); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/TaxonomyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TaxonomyConfigurations.cs index be5f49d..6d02757 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TaxonomyConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TaxonomyConfigurations.cs @@ -50,4 +50,4 @@ internal sealed class QuestionTaxonomyAssignmentConfiguration : IEntityTypeConfi .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs index bcc8169..819e0d9 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs @@ -156,4 +156,4 @@ internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfigurati table.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0"); }); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs index 55e3a0c..cf51d9c 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs @@ -37,7 +37,8 @@ internal sealed class TenantSecretConfiguration : IEntityTypeConfiguration entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.EncryptionKeyId).HasMaxLength(100); builder.HasIndex(entity => new { entity.TenantId, entity.SecretRef }).IsUnique(); - builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.SecretKey }).IsUnique(); + builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.SecretKey }) + .IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.Status }); builder.ToTable(table => table.HasCheckConstraint( "ck_tenant_secrets_encryption_envelope", @@ -66,10 +67,7 @@ internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguratio builder.HasIndex(entity => new { entity.TenantId, entity.Phone, entity.Purpose }) .IsUnique() .HasFilter("consumed_at is null and status in ('pending', 'sent')"); - builder.ToTable(table => - { - table.HasCheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0"); - }); + builder.ToTable(table => { table.HasCheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0"); }); builder.HasOne().WithMany() .HasForeignKey(entity => entity.TenantId) @@ -303,7 +301,8 @@ internal sealed class TenantClassMemberConfiguration : IEntityTypeConfiguration< builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.JoinedAt).HasDefaultValueSql("now()"); builder.Property(entity => entity.Metadata).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.UserId, entity.MemberType }).IsUnique(); + builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.UserId, entity.MemberType }) + .IsUnique(); builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.Status, entity.MemberType }); builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Status, entity.MemberType }); @@ -353,7 +352,8 @@ internal sealed class TenantStudentFollowupConfiguration : IEntityTypeConfigurat builder.Property(entity => entity.Priority).HasSnakeCaseEnum(); builder.Property(entity => entity.Status).HasSnakeCaseEnum(); builder.Property(entity => entity.Metadata).IsJson("{}"); - builder.HasIndex(entity => new { entity.TenantId, entity.StudentUserId, entity.Status, entity.DueAt, entity.CreatedAt }); + builder.HasIndex(entity => new + { entity.TenantId, entity.StudentUserId, entity.Status, entity.DueAt, entity.CreatedAt }); builder.HasIndex(entity => new { entity.TenantId, entity.AssignedToUserId, entity.Status, entity.DueAt }) .HasFilter("assigned_to_user_id is not null"); builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.Status, entity.DueAt }) @@ -373,4 +373,4 @@ internal sealed class TenantStudentFollowupConfiguration : IEntityTypeConfigurat .HasForeignKey(entity => entity.CompletedBy) .OnDelete(DeleteBehavior.SetNull); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Migrations/MigrationBuilderTenantGuardExtensions.cs b/Tiku.Infrastructure/Persistence/Migrations/MigrationBuilderTenantGuardExtensions.cs index dfe4147..9dc45ed 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/MigrationBuilderTenantGuardExtensions.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/MigrationBuilderTenantGuardExtensions.cs @@ -28,4 +28,4 @@ internal static class MigrationBuilderTenantGuardExtensions { migrationBuilder.Sql(PostgreSqlSaasCatalogConstraintSql.DropOfferingVersionImmutabilityGuards); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs b/Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs index 4910fb1..90d0066 100644 --- a/Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs +++ b/Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs @@ -1,6 +1,5 @@ using System.Text; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Tiku.Infrastructure.Persistence; @@ -11,43 +10,25 @@ internal static class ModelBuilderExtensions { foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { - foreach (var property in entityType.GetProperties()) - { - property.SetColumnName(ToSnakeCase(property.Name)); - } + foreach (var property in entityType.GetProperties()) property.SetColumnName(ToSnakeCase(property.Name)); foreach (var key in entityType.GetKeys()) - { if (key.GetName() is { } keyName) - { key.SetName(ToSnakeCase(keyName)); - } - } foreach (var foreignKey in entityType.GetForeignKeys()) - { if (foreignKey.GetConstraintName() is { } constraintName) - { foreignKey.SetConstraintName(ToSnakeCase(constraintName)); - } - } foreach (var index in entityType.GetIndexes()) - { if (index.GetDatabaseName() is { } indexName) - { index.SetDatabaseName(ToSnakeCase(indexName)); - } - } } } public static string ToSnakeCase(string value) { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } + if (string.IsNullOrWhiteSpace(value)) return value; var builder = new StringBuilder(value.Length + 8); for (var index = 0; index < value.Length; index++) @@ -55,10 +36,8 @@ internal static class ModelBuilderExtensions var character = value[index]; if (char.IsUpper(character) && index > 0 && (char.IsLower(value[index - 1]) || - index + 1 < value.Length && char.IsLower(value[index + 1]))) - { + (index + 1 < value.Length && char.IsLower(value[index + 1])))) builder.Append('_'); - } builder.Append(char.ToLowerInvariant(character)); } @@ -70,4 +49,4 @@ internal static class ModelBuilderExtensions internal sealed class SnakeCaseEnumConverter() : ValueConverter( value => ModelBuilderExtensions.ToSnakeCase(value.ToString()), value => Enum.Parse(value.Replace("_", string.Empty), true)) - where TEnum : struct, Enum; + where TEnum : struct, Enum; \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs b/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs index 8f65b3c..8926e37 100644 --- a/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs +++ b/Tiku.Infrastructure/Persistence/Modules/Catalog/TikuDbContext.Catalog.cs @@ -1,21 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -32,4 +16,4 @@ public sealed partial class TikuDbContext public DbSet QuestionTaxonomyAssignments => Set(); public DbSet ScorelineFields => Set(); public DbSet ScorelineRecords => Set(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs b/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs index ac6abaf..428d570 100644 --- a/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs +++ b/Tiku.Infrastructure/Persistence/Modules/CommerceGrowth/TikuDbContext.CommerceGrowth.cs @@ -1,21 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -37,9 +22,15 @@ public sealed partial class TikuDbContext public DbSet CommerceReconciliationBatches => Set(); public DbSet CommerceReconciliationItems => Set(); public DbSet CommerceReconciliationIssues => Set(); - public DbSet CommerceReconciliationIssueEvents => Set(); + + public DbSet CommerceReconciliationIssueEvents => + Set(); + public DbSet CommerceAdjustmentVouchers => Set(); - public DbSet CommerceAdjustmentVoucherEvents => Set(); + + public DbSet CommerceAdjustmentVoucherEvents => + Set(); + public DbSet PointActivityTasks => Set(); public DbSet PointActivityClaims => Set(); public DbSet PointExchangeItems => Set(); @@ -56,5 +47,7 @@ public sealed partial class TikuDbContext public DbSet CommissionSettlements => Set(); public DbSet CommissionSettlementItems => Set(); public DbSet CommissionSettlementProofs => Set(); - public DbSet CommissionSettlementExportEvents => Set(); -} + + public DbSet CommissionSettlementExportEvents => + Set(); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs b/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs index 818e8bc..4e55a8f 100644 --- a/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs +++ b/Tiku.Infrastructure/Persistence/Modules/Content/TikuDbContext.Content.cs @@ -1,21 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -52,4 +37,4 @@ public sealed partial class TikuDbContext public DbSet TenantQuestionBankPreferences => Set(); public DbSet TenantQuestionReferences => Set(); public DbSet AiRecommendationReports => Set(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs b/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs index d0a0c11..e98f402 100644 --- a/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs +++ b/Tiku.Infrastructure/Persistence/Modules/Learning/TikuDbContext.Learning.cs @@ -1,21 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Import; using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -38,4 +22,4 @@ public sealed partial class TikuDbContext public DbSet PracticeSessionReportSections => Set(); public DbSet DashboardDailyStats => Set(); public DbSet RevenueDailyStats => Set(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs b/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs index 3e90898..8234861 100644 --- a/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs +++ b/Tiku.Infrastructure/Persistence/Modules/Operations/TikuDbContext.Operations.cs @@ -1,21 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -36,7 +21,10 @@ public sealed partial class TikuDbContext public DbSet PlatformBackendRoleMenus => Set(); public DbSet PlatformBackendUserRoles => Set(); public DbSet AuthorizationScopeVersions => Set(); - public DbSet AuthorizationCacheInvalidations => Set(); + + public DbSet AuthorizationCacheInvalidations => + Set(); + public DbSet BackgroundJobs => Set(); public DbSet TenantLifecycleOperations => Set(); public DbSet WorkerHeartbeats => Set(); @@ -50,17 +38,28 @@ public sealed partial class TikuDbContext public DbSet TenantBillingPolicies => Set(); public DbSet TenantOwnerActivationGrants => Set(); public DbSet PlatformOperationIdempotencies => Set(); - public DbSet PlatformBillingInvoiceReminders => Set(); + + public DbSet PlatformBillingInvoiceReminders => + Set(); + public DbSet PlatformAuditAlertRules => Set(); public DbSet PlatformAuditAlerts => Set(); - public DbSet PlatformBillingDunningNotificationChannels => Set(); - public DbSet PlatformBillingDunningNotificationEvents => Set(); + + public DbSet PlatformBillingDunningNotificationChannels => + Set(); + + public DbSet PlatformBillingDunningNotificationEvents => + Set(); + public DbSet PlatformPaymentApps => Set(); public DbSet PlatformPaymentChannels => Set(); public DbSet PlatformApprovalPolicies => Set(); public DbSet PlatformApprovalRequests => Set(); - public DbSet PlatformConfigurationDefinitions => Set(); + + public DbSet PlatformConfigurationDefinitions => + Set(); + public DbSet PlatformConfigurationVersions => Set(); public DbSet PlatformNotificationTemplates => Set(); public DbSet PlatformNotificationDeliveries => Set(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs b/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs index 3d476a0..9752dd5 100644 --- a/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs +++ b/Tiku.Infrastructure/Persistence/Modules/PlatformBilling/TikuDbContext.PlatformBilling.cs @@ -1,21 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; -using Tiku.Domain.Identity; using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -43,5 +28,4 @@ public sealed partial class TikuDbContext public DbSet PocketBaseImportRuns => Set(); public DbSet PocketBaseRawRecords => Set(); public DbSet PocketBaseImportIssues => Set(); - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs b/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs index 29e1c9b..497d94f 100644 --- a/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs +++ b/Tiku.Infrastructure/Persistence/Modules/PlatformCore/TikuDbContext.PlatformCore.cs @@ -1,20 +1,6 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using System.Reflection; -using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; -using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; +using Microsoft.EntityFrameworkCore; using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -23,7 +9,6 @@ public sealed partial class TikuDbContext { public DbSet Tenants => Set(); public new DbSet Users => Set(); - public DbSet DataProtectionKeys => Set(); public DbSet UserIdentities => Set(); public DbSet TenantMemberships => Set(); public DbSet TenantDomains => Set(); @@ -46,4 +31,5 @@ public sealed partial class TikuDbContext public DbSet TenantStudentNotes => Set(); public DbSet TenantStudentFollowups => Set(); public DbSet StudentProfiles => Set(); -} + public DbSet DataProtectionKeys => Set(); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/PostgreSqlSaasCatalogConstraintSql.cs b/Tiku.Infrastructure/Persistence/PostgreSqlSaasCatalogConstraintSql.cs index 116a2f3..9a62f2b 100644 --- a/Tiku.Infrastructure/Persistence/PostgreSqlSaasCatalogConstraintSql.cs +++ b/Tiku.Infrastructure/Persistence/PostgreSqlSaasCatalogConstraintSql.cs @@ -3,222 +3,222 @@ namespace Tiku.Infrastructure.Persistence; internal static class PostgreSqlSaasCatalogConstraintSql { public const string CreateOfferingVersionImmutabilityGuards = """ - create or replace function tiku_guard_saas_offering_version_immutable() - returns trigger - language plpgsql - as $$ - begin - if tg_op = 'DELETE' then - if old.status in ('published', 'retired') then - raise exception 'published or retired SaaS offering versions cannot be deleted' - using errcode = '23514', - constraint = 'ck_saas_offering_versions_published_immutable'; - end if; + create or replace function tiku_guard_saas_offering_version_immutable() + returns trigger + language plpgsql + as $$ + begin + if tg_op = 'DELETE' then + if old.status in ('published', 'retired') then + raise exception 'published or retired SaaS offering versions cannot be deleted' + using errcode = '23514', + constraint = 'ck_saas_offering_versions_published_immutable'; + end if; - return old; - end if; + return old; + end if; - if old.status not in ('published', 'retired') then - return new; - end if; + if old.status not in ('published', 'retired') then + return new; + end if; - if old.status = 'published' - and new.status = 'retired' - and new.retired_at is not null - and new.id is not distinct from old.id - and new.offering_id is not distinct from old.offering_id - and new.version is not distinct from old.version - and new.billing_cycle is not distinct from old.billing_cycle - and new.original_amount_cents is not distinct from old.original_amount_cents - and new.amount_cents is not distinct from old.amount_cents - and new.currency is not distinct from old.currency - and new.effective_at is not distinct from old.effective_at - and new.published_at is not distinct from old.published_at - and new.metadata is not distinct from old.metadata - and new.created_at is not distinct from old.created_at then - return new; - end if; + if old.status = 'published' + and new.status = 'retired' + and new.retired_at is not null + and new.id is not distinct from old.id + and new.offering_id is not distinct from old.offering_id + and new.version is not distinct from old.version + and new.billing_cycle is not distinct from old.billing_cycle + and new.original_amount_cents is not distinct from old.original_amount_cents + and new.amount_cents is not distinct from old.amount_cents + and new.currency is not distinct from old.currency + and new.effective_at is not distinct from old.effective_at + and new.published_at is not distinct from old.published_at + and new.metadata is not distinct from old.metadata + and new.created_at is not distinct from old.created_at then + return new; + end if; - raise exception 'published or retired SaaS offering versions are immutable' - using errcode = '23514', - constraint = 'ck_saas_offering_versions_published_immutable'; - end; - $$; + raise exception 'published or retired SaaS offering versions are immutable' + using errcode = '23514', + constraint = 'ck_saas_offering_versions_published_immutable'; + end; + $$; - do $guard$ - begin - if to_regclass('saas_offering_versions') is not null then - execute 'create trigger trg_saas_offering_versions_published_immutable - before update or delete on saas_offering_versions - for each row execute function tiku_guard_saas_offering_version_immutable()'; - end if; - end - $guard$; - """; + do $guard$ + begin + if to_regclass('saas_offering_versions') is not null then + execute 'create trigger trg_saas_offering_versions_published_immutable + before update or delete on saas_offering_versions + for each row execute function tiku_guard_saas_offering_version_immutable()'; + end if; + end + $guard$; + """; public const string CreateOfferingVersionChildrenImmutabilityGuards = """ - create or replace function tiku_guard_saas_offering_version_child_immutable() - returns trigger - language plpgsql - as $$ - declare - version_status text; - begin - if tg_op = 'DELETE' then - select status into version_status - from saas_offering_versions - where id = old.offering_version_id; + create or replace function tiku_guard_saas_offering_version_child_immutable() + returns trigger + language plpgsql + as $$ + declare + version_status text; + begin + if tg_op = 'DELETE' then + select status into version_status + from saas_offering_versions + where id = old.offering_version_id; - if version_status is distinct from 'draft' then - raise exception 'published or retired SaaS offering version children are immutable' - using errcode = '23514', - constraint = 'ck_saas_offering_versions_published_immutable'; - end if; + if version_status is distinct from 'draft' then + raise exception 'published or retired SaaS offering version children are immutable' + using errcode = '23514', + constraint = 'ck_saas_offering_versions_published_immutable'; + end if; - return old; - end if; + return old; + end if; - if tg_op = 'UPDATE' and old.offering_version_id <> new.offering_version_id then - select status into version_status - from saas_offering_versions - where id = old.offering_version_id; + if tg_op = 'UPDATE' and old.offering_version_id <> new.offering_version_id then + select status into version_status + from saas_offering_versions + where id = old.offering_version_id; - if version_status is distinct from 'draft' then - raise exception 'published or retired SaaS offering version children are immutable' - using errcode = '23514', - constraint = 'ck_saas_offering_versions_published_immutable'; - end if; - end if; + if version_status is distinct from 'draft' then + raise exception 'published or retired SaaS offering version children are immutable' + using errcode = '23514', + constraint = 'ck_saas_offering_versions_published_immutable'; + end if; + end if; - select status into version_status - from saas_offering_versions - where id = new.offering_version_id; + select status into version_status + from saas_offering_versions + where id = new.offering_version_id; - if version_status is distinct from 'draft' then - raise exception 'published or retired SaaS offering version children are immutable' - using errcode = '23514', - constraint = 'ck_saas_offering_versions_published_immutable'; - end if; + if version_status is distinct from 'draft' then + raise exception 'published or retired SaaS offering version children are immutable' + using errcode = '23514', + constraint = 'ck_saas_offering_versions_published_immutable'; + end if; - return new; - end; - $$; + return new; + end; + $$; - do $guard$ - begin - if to_regclass('saas_offering_version_features') is not null then - execute 'create trigger trg_saas_offering_version_features_published_immutable - before insert or update or delete on saas_offering_version_features - for each row execute function tiku_guard_saas_offering_version_child_immutable()'; - end if; + do $guard$ + begin + if to_regclass('saas_offering_version_features') is not null then + execute 'create trigger trg_saas_offering_version_features_published_immutable + before insert or update or delete on saas_offering_version_features + for each row execute function tiku_guard_saas_offering_version_child_immutable()'; + end if; - if to_regclass('saas_offering_version_limits') is not null then - execute 'create trigger trg_saas_offering_version_limits_published_immutable - before insert or update or delete on saas_offering_version_limits - for each row execute function tiku_guard_saas_offering_version_child_immutable()'; - end if; - end - $guard$; - """; + if to_regclass('saas_offering_version_limits') is not null then + execute 'create trigger trg_saas_offering_version_limits_published_immutable + before insert or update or delete on saas_offering_version_limits + for each row execute function tiku_guard_saas_offering_version_child_immutable()'; + end if; + end + $guard$; + """; public const string CreateSubscriptionOfferingTypeGuards = """ - create or replace function tiku_guard_saas_subscription_offering_type() - returns trigger - language plpgsql - as $$ - declare - offering_type text; - source_version_id uuid; - begin - if tg_table_name = 'tenant_saas_subscriptions' then - select o.type into offering_type - from saas_offering_versions v - join saas_offerings o on o.id = v.offering_id - where v.id = new.base_offering_version_id; + create or replace function tiku_guard_saas_subscription_offering_type() + returns trigger + language plpgsql + as $$ + declare + offering_type text; + source_version_id uuid; + begin + if tg_table_name = 'tenant_saas_subscriptions' then + select o.type into offering_type + from saas_offering_versions v + join saas_offerings o on o.id = v.offering_id + where v.id = new.base_offering_version_id; - if offering_type is distinct from 'base_plan' then - raise exception 'subscription base offering version must belong to a base plan' - using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan'; - end if; + if offering_type is distinct from 'base_plan' then + raise exception 'subscription base offering version must belong to a base plan' + using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan'; + end if; - if new.scheduled_base_offering_version_id is not null then - select o.type into offering_type - from saas_offering_versions v - join saas_offerings o on o.id = v.offering_id - where v.id = new.scheduled_base_offering_version_id; - if offering_type is distinct from 'base_plan' then - raise exception 'scheduled subscription offering version must belong to a base plan' - using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan'; - end if; - end if; - return new; - end if; + if new.scheduled_base_offering_version_id is not null then + select o.type into offering_type + from saas_offering_versions v + join saas_offerings o on o.id = v.offering_id + where v.id = new.scheduled_base_offering_version_id; + if offering_type is distinct from 'base_plan' then + raise exception 'scheduled subscription offering version must belong to a base plan' + using errcode = '23514', constraint = 'ck_tenant_saas_subscription_base_plan'; + end if; + end if; + return new; + end if; - select o.type into offering_type - from saas_offering_versions v - join saas_offerings o on o.id = v.offering_id - where v.id = new.offering_version_id; - if offering_type is distinct from new.item_type::text then - raise exception 'subscription item type must match its offering type' - using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_offering_type'; - end if; + select o.type into offering_type + from saas_offering_versions v + join saas_offerings o on o.id = v.offering_id + where v.id = new.offering_version_id; + if offering_type is distinct from new.item_type::text then + raise exception 'subscription item type must match its offering type' + using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_offering_type'; + end if; - if new.source_order_item_id is not null then - select offering_version_id into source_version_id - from platform_billing_order_items - where tenant_id = new.tenant_id and id = new.source_order_item_id; - if source_version_id is distinct from new.offering_version_id then - raise exception 'subscription item must match its order item snapshot' - using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_order_snapshot'; - end if; - end if; - return new; - end; - $$; + if new.source_order_item_id is not null then + select offering_version_id into source_version_id + from platform_billing_order_items + where tenant_id = new.tenant_id and id = new.source_order_item_id; + if source_version_id is distinct from new.offering_version_id then + raise exception 'subscription item must match its order item snapshot' + using errcode = '23514', constraint = 'ck_tenant_saas_subscription_item_order_snapshot'; + end if; + end if; + return new; + end; + $$; - do $guard$ - begin - if to_regclass('tenant_saas_subscriptions') is not null then - execute 'create trigger trg_tenant_saas_subscriptions_base_plan - before insert or update on tenant_saas_subscriptions - for each row execute function tiku_guard_saas_subscription_offering_type()'; - end if; - if to_regclass('tenant_saas_subscription_items') is not null then - execute 'create trigger trg_tenant_saas_subscription_items_offering_type - before insert or update on tenant_saas_subscription_items - for each row execute function tiku_guard_saas_subscription_offering_type()'; - end if; - end - $guard$; - """; + do $guard$ + begin + if to_regclass('tenant_saas_subscriptions') is not null then + execute 'create trigger trg_tenant_saas_subscriptions_base_plan + before insert or update on tenant_saas_subscriptions + for each row execute function tiku_guard_saas_subscription_offering_type()'; + end if; + if to_regclass('tenant_saas_subscription_items') is not null then + execute 'create trigger trg_tenant_saas_subscription_items_offering_type + before insert or update on tenant_saas_subscription_items + for each row execute function tiku_guard_saas_subscription_offering_type()'; + end if; + end + $guard$; + """; public const string DropOfferingVersionImmutabilityGuards = """ - do $guard$ - begin - if to_regclass('saas_offering_version_features') is not null then - execute 'drop trigger if exists trg_saas_offering_version_features_published_immutable on saas_offering_version_features'; - end if; + do $guard$ + begin + if to_regclass('saas_offering_version_features') is not null then + execute 'drop trigger if exists trg_saas_offering_version_features_published_immutable on saas_offering_version_features'; + end if; - if to_regclass('saas_offering_version_limits') is not null then - execute 'drop trigger if exists trg_saas_offering_version_limits_published_immutable on saas_offering_version_limits'; - end if; + if to_regclass('saas_offering_version_limits') is not null then + execute 'drop trigger if exists trg_saas_offering_version_limits_published_immutable on saas_offering_version_limits'; + end if; - if to_regclass('saas_offering_versions') is not null then - execute 'drop trigger if exists trg_saas_offering_versions_published_immutable on saas_offering_versions'; - end if; + if to_regclass('saas_offering_versions') is not null then + execute 'drop trigger if exists trg_saas_offering_versions_published_immutable on saas_offering_versions'; + end if; - if to_regclass('tenant_saas_subscription_items') is not null then - execute 'drop trigger if exists trg_tenant_saas_subscription_items_offering_type on tenant_saas_subscription_items'; - end if; + if to_regclass('tenant_saas_subscription_items') is not null then + execute 'drop trigger if exists trg_tenant_saas_subscription_items_offering_type on tenant_saas_subscription_items'; + end if; - if to_regclass('tenant_saas_subscriptions') is not null then - execute 'drop trigger if exists trg_tenant_saas_subscriptions_base_plan on tenant_saas_subscriptions'; - end if; - end - $guard$; + if to_regclass('tenant_saas_subscriptions') is not null then + execute 'drop trigger if exists trg_tenant_saas_subscriptions_base_plan on tenant_saas_subscriptions'; + end if; + end + $guard$; - drop function if exists tiku_guard_saas_offering_version_child_immutable(); - drop function if exists tiku_guard_saas_offering_version_immutable(); - drop function if exists tiku_guard_saas_subscription_offering_type(); - """; -} + drop function if exists tiku_guard_saas_offering_version_child_immutable(); + drop function if exists tiku_guard_saas_offering_version_immutable(); + drop function if exists tiku_guard_saas_subscription_offering_type(); + """; +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs b/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs index d5dc0d5..539dee6 100644 --- a/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs +++ b/Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs @@ -3,87 +3,87 @@ namespace Tiku.Infrastructure.Persistence; internal static class PostgreSqlTenantConstraintSql { public const string CreateTenantQuestionReferenceGuard = """ - create or replace function tiku_guard_tenant_question_reference() - returns trigger - language plpgsql - as $$ - declare - owner_mode text; - begin - select mode into owner_mode - from tenants - where id = new.question_owner_tenant_id; + create or replace function tiku_guard_tenant_question_reference() + returns trigger + language plpgsql + as $$ + declare + owner_mode text; + begin + select mode into owner_mode + from tenants + where id = new.question_owner_tenant_id; - if owner_mode = 'platform_owned' then - if new.source <> 'platform' then - raise exception 'tenant question reference to platform question must use platform source' - using errcode = '23514', - constraint = 'ck_tenant_question_references_platform_or_self_owner'; - end if; + if owner_mode = 'platform_owned' then + if new.source <> 'platform' then + raise exception 'tenant question reference to platform question must use platform source' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end if; - return new; - end if; + return new; + end if; - if new.question_owner_tenant_id = new.tenant_id then - if new.source <> 'tenant' then - raise exception 'tenant question reference to tenant-owned question must use tenant source' - using errcode = '23514', - constraint = 'ck_tenant_question_references_platform_or_self_owner'; - end if; + if new.question_owner_tenant_id = new.tenant_id then + if new.source <> 'tenant' then + raise exception 'tenant question reference to tenant-owned question must use tenant source' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end if; - return new; - end if; + return new; + end if; - raise exception 'tenant question reference cannot point to another tenant private question' - using errcode = '23514', - constraint = 'ck_tenant_question_references_platform_or_self_owner'; - end; - $$; + raise exception 'tenant question reference cannot point to another tenant private question' + using errcode = '23514', + constraint = 'ck_tenant_question_references_platform_or_self_owner'; + end; + $$; - create trigger trg_tenant_question_references_platform_or_self_owner - before insert or update of tenant_id, question_owner_tenant_id, question_id, source - on tenant_question_references - for each row - execute function tiku_guard_tenant_question_reference(); - """; + create trigger trg_tenant_question_references_platform_or_self_owner + before insert or update of tenant_id, question_owner_tenant_id, question_id, source + on tenant_question_references + for each row + execute function tiku_guard_tenant_question_reference(); + """; public const string CreateTaxonomyParentGuard = """ - create or replace function tiku_guard_taxonomy_parent_owner() - returns trigger - language plpgsql - as $$ - declare - parent_owner_mode text; - begin - if new.parent_owner_tenant_id is null and new.parent_id is null then - return new; - end if; + create or replace function tiku_guard_taxonomy_parent_owner() + returns trigger + language plpgsql + as $$ + declare + parent_owner_mode text; + begin + if new.parent_owner_tenant_id is null and new.parent_id is null then + return new; + end if; - select mode into parent_owner_mode - from tenants - where id = new.parent_owner_tenant_id; + select mode into parent_owner_mode + from tenants + where id = new.parent_owner_tenant_id; - if parent_owner_mode = 'platform_owned' or new.parent_owner_tenant_id = new.tenant_id then - return new; - end if; + if parent_owner_mode = 'platform_owned' or new.parent_owner_tenant_id = new.tenant_id then + return new; + end if; - raise exception 'taxonomy parent must belong to platform or current tenant' - using errcode = '23514', - constraint = 'ck_taxonomy_nodes_parent_platform_or_self_owner'; - end; - $$; + raise exception 'taxonomy parent must belong to platform or current tenant' + using errcode = '23514', + constraint = 'ck_taxonomy_nodes_parent_platform_or_self_owner'; + end; + $$; - create trigger trg_taxonomy_nodes_parent_platform_or_self_owner - before insert or update of tenant_id, parent_owner_tenant_id, parent_id - on taxonomy_nodes - for each row - execute function tiku_guard_taxonomy_parent_owner(); - """; + create trigger trg_taxonomy_nodes_parent_platform_or_self_owner + before insert or update of tenant_id, parent_owner_tenant_id, parent_id + on taxonomy_nodes + for each row + execute function tiku_guard_taxonomy_parent_owner(); + """; public const string DropTenantGuards = """ - drop trigger if exists trg_tenant_question_references_platform_or_self_owner on tenant_question_references; - drop function if exists tiku_guard_tenant_question_reference(); - drop trigger if exists trg_taxonomy_nodes_parent_platform_or_self_owner on taxonomy_nodes; - drop function if exists tiku_guard_taxonomy_parent_owner(); - """; -} + drop trigger if exists trg_tenant_question_references_platform_or_self_owner on tenant_question_references; + drop function if exists tiku_guard_tenant_question_reference(); + drop trigger if exists trg_taxonomy_nodes_parent_platform_or_self_owner on taxonomy_nodes; + drop function if exists tiku_guard_taxonomy_parent_owner(); + """; +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/TenantIsolationSaveChangesInterceptor.cs b/Tiku.Infrastructure/Persistence/TenantIsolationSaveChangesInterceptor.cs index beac9a8..c40f811 100644 --- a/Tiku.Infrastructure/Persistence/TenantIsolationSaveChangesInterceptor.cs +++ b/Tiku.Infrastructure/Persistence/TenantIsolationSaveChangesInterceptor.cs @@ -25,19 +25,13 @@ public sealed class TenantIsolationSaveChangesInterceptor(ITenantContext tenantC private void Enforce(DbContext? dbContext) { - if (dbContext is null) - { - return; - } + if (dbContext is null) return; foreach (var entry in dbContext.ChangeTracker.Entries() .Where(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)) { var tenantProperty = entry.Metadata.FindProperty("TenantId"); - if (tenantProperty?.ClrType != typeof(Guid)) - { - continue; - } + if (tenantProperty?.ClrType != typeof(Guid)) continue; var property = entry.Property("TenantId"); var currentTenantId = (Guid)(property.CurrentValue ?? Guid.Empty); @@ -45,11 +39,9 @@ public sealed class TenantIsolationSaveChangesInterceptor(ITenantContext tenantC if (entry.State == EntityState.Added) { if (!tenantContext.TenantId.HasValue && !tenantContext.IsSystem) - { throw new TenantIsolationException( entry.Metadata.ClrType, "Cannot add tenant-owned data without a resolved tenant."); - } if (currentTenantId == Guid.Empty && tenantContext.TenantId.HasValue) { @@ -58,32 +50,26 @@ public sealed class TenantIsolationSaveChangesInterceptor(ITenantContext tenantC } if (!tenantContext.IsSystem && currentTenantId != tenantContext.TenantId) - { throw new TenantIsolationException( entry.Metadata.ClrType, "Cannot add data for another tenant."); - } continue; } if (property.IsModified || currentTenantId != originalTenantId) - { throw new TenantIsolationException( entry.Metadata.ClrType, "Tenant ownership cannot be changed."); - } if (!tenantContext.IsSystem && (!tenantContext.TenantId.HasValue || originalTenantId != tenantContext.TenantId.Value)) - { throw new TenantIsolationException( entry.Metadata.ClrType, "Cannot modify or delete data owned by another tenant."); - } } } } public sealed class TenantIsolationException(Type entityType, string message) - : InvalidOperationException($"Tenant isolation rejected {entityType.Name}: {message}"); + : InvalidOperationException($"Tenant isolation rejected {entityType.Name}: {message}"); \ No newline at end of file diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index 7b5b965..2d728bf 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -1,21 +1,11 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; using System.Reflection; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; -using Tiku.Domain.Catalog; -using Tiku.Domain.Commerce; using Tiku.Domain.Common; -using Tiku.Domain.Content; -using Tiku.Domain.Growth; using Tiku.Domain.Identity; -using Tiku.Domain.Import; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Persistence; @@ -40,6 +30,7 @@ public sealed partial class TikuDbContext( context.InitializeSystem(null, "Direct DbContext construction for model tooling"); return context; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -64,10 +55,7 @@ public sealed partial class TikuDbContext( foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { var tenantProperty = entityType.FindProperty("TenantId"); - if (tenantProperty?.ClrType != typeof(Guid) || entityType.BaseType is not null) - { - continue; - } + if (tenantProperty?.ClrType != typeof(Guid) || entityType.BaseType is not null) continue; applyMethod.MakeGenericMethod(entityType.ClrType).Invoke(this, [modelBuilder]); } @@ -89,57 +77,38 @@ public sealed partial class TikuDbContext( foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { var tenantProperty = entityType.FindProperty("TenantId"); - if (tenantProperty?.ClrType != typeof(Guid)) - { - continue; - } + if (tenantProperty?.ClrType != typeof(Guid)) continue; if (!typeof(ITenantOwned).IsAssignableFrom(entityType.ClrType)) - { throw new InvalidOperationException( $"Tenant entity '{entityType.ClrType.Name}' must implement {nameof(ITenantOwned)}."); - } if (!entityType.GetDeclaredQueryFilters().Any()) - { throw new InvalidOperationException( $"Tenant entity '{entityType.ClrType.Name}' does not have a tenant query filter."); - } foreach (var index in entityType.GetDeclaredIndexes().Where(index => index.IsUnique)) - { if (index.Properties.All(property => property.Name != "TenantId") && index.FindAnnotation("Tiku:GlobalUnique")?.Value is not true) - { invalidUniqueIndexes.Add( $"{entityType.ClrType.Name}({string.Join(",", index.Properties.Select(property => property.Name))})"); - } - } foreach (var foreignKey in entityType.GetDeclaredForeignKeys().Where(foreignKey => typeof(ITenantOwned).IsAssignableFrom(foreignKey.PrincipalEntityType.ClrType))) - { if (foreignKey.PrincipalKey.Properties.All(property => property.Name != "TenantId")) - { invalidTenantForeignKeys.Add( $"{entityType.ClrType.Name}->{foreignKey.PrincipalEntityType.ClrType.Name}"); - } - } } if (invalidUniqueIndexes.Count > 0) - { throw new InvalidOperationException( $"Unique indexes on tenant entities must include TenantId: {string.Join("; ", invalidUniqueIndexes)}"); - } if (invalidTenantForeignKeys.Count > 0) - { throw new InvalidOperationException( $"Foreign keys between tenant entities must use a tenant-qualified principal key: {string.Join("; ", invalidTenantForeignKeys)}"); - } } public override int SaveChanges(bool acceptAllChangesOnSuccess) @@ -165,25 +134,15 @@ public sealed partial class TikuDbContext( var now = DateTimeOffset.UtcNow; foreach (var entry in ChangeTracker.Entries().Where(entry => entry.State == EntityState.Modified)) - { if (entry.Property(user => user.Status).IsModified || entry.Property(user => user.PasswordHash).IsModified) - { entry.Entity.SecurityStamp = Guid.NewGuid().ToString("N"); - } - } foreach (var entry in ChangeTracker.Entries()) { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedAt = now; - } + if (entry.State == EntityState.Added) entry.Entity.CreatedAt = now; - if (entry.State is EntityState.Added or EntityState.Modified) - { - entry.Entity.UpdatedAt = now; - } + if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.UpdatedAt = now; } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs b/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs index d922fb9..a607139 100644 --- a/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs +++ b/Tiku.Infrastructure/PlatformAdmin/AuditAndAlerts/PlatformAdminService.AuditAndAlerts.cs @@ -1,22 +1,7 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Identity; -using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -34,7 +19,8 @@ internal sealed partial class PlatformAdminService if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); - logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); + logs = logs.Where(log => + log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); } return new PlatformAuditLogList(await logs @@ -65,9 +51,7 @@ internal sealed partial class PlatformAdminService { var alerts = dbContext.PlatformAuditAlerts.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) - { alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status)); - } return new PlatformAuditAlertList(await alerts .OrderByDescending(alert => alert.LastSeenAt) @@ -84,8 +68,10 @@ internal sealed partial class PlatformAdminService await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); return await ExecuteSystemAsync("platform audit alert status update", async dbContext => { - var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken) - ?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found"); + var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, + cancellationToken) + ?? throw new PlatformAdminException("Platform audit alert was not found.", + "audit_alert_not_found"); alert.Status = command.Status; alert.ResolutionNote = Normalize(command.ResolutionNote); if (command.Status == PlatformAuditAlertStatus.Acknowledged) @@ -99,11 +85,10 @@ internal sealed partial class PlatformAdminService alert.ResolvedAt ??= DateTimeOffset.UtcNow; } - AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote }); + AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, + new { alert.Status, command.ResolutionNote }); await dbContext.SaveChangesAsync(cancellationToken); return alert; }, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs b/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs index 3fa6f80..174a9a5 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Dashboard/PlatformAdminService.Dashboard.cs @@ -1,22 +1,6 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Identity; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -30,17 +14,17 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform overview", async dbContext => { var row = await dbContext.Database.SqlQuery($""" - SELECT - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount", - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount", - (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount", - (SELECT count(*)::integer FROM orders) AS "OrderCount", - (SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount", - (SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents", - (SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount", - (SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount", - (SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount" - """).SingleAsync(cancellationToken); + SELECT + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount", + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount", + (SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount", + (SELECT count(*)::integer FROM orders) AS "OrderCount", + (SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount", + (SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents", + (SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount", + (SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount", + (SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount" + """).SingleAsync(cancellationToken); return new PlatformOverview( row.TenantCount, row.ActiveTenantCount, @@ -66,6 +50,4 @@ internal sealed partial class PlatformAdminService public int QuestionCount { get; init; } public int LearningActiveUserCount { get; init; } } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs b/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs index 2bbb2ec..2e3ce6b 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Dunning/PlatformAdminService.Dunning.cs @@ -1,22 +1,7 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Identity; -using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -65,8 +50,10 @@ internal sealed partial class PlatformAdminService { var code = NormalizeCode(command.ChannelCode); var channel = command.ChannelId.HasValue - ? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) - : await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); + ? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync( + item => item.Id == command.ChannelId.Value, cancellationToken) + : await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync( + item => item.ChannelCode == code, cancellationToken); if (channel is null) { channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code }; @@ -108,8 +95,11 @@ internal sealed partial class PlatformAdminService await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => { - var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found"); + var channel = + await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync( + item => item.Id == command.ChannelId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning channel was not found.", + "dunning_channel_not_found"); channel.Enabled = false; AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new { @@ -131,9 +121,7 @@ internal sealed partial class PlatformAdminService { var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) - { events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); - } return new PlatformBillingDunningEventList(await events .OrderByDescending(item => item.CreatedAt) @@ -152,8 +140,9 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform dunning event detail", async dbContext => { var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", + "dunning_event_not_found"); return ToDunningEventItem(item); }, cancellationToken); } @@ -166,8 +155,10 @@ internal sealed partial class PlatformAdminService await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning event retry", async dbContext => { - var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync( + item => item.Id == command.EventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", + "dunning_event_not_found"); item.Status = PlatformBillingDunningNotificationStatus.Pending; item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; @@ -187,14 +178,20 @@ internal sealed partial class PlatformAdminService public Task AcknowledgeBillingDunningEventAsync( PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, - CancellationToken cancellationToken = default) => - ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken); + CancellationToken cancellationToken = default) + { + return ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, + cancellationToken); + } public Task IgnoreBillingDunningEventAsync( PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, - CancellationToken cancellationToken = default) => - ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken); + CancellationToken cancellationToken = default) + { + return ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, + cancellationToken); + } private async Task ResolveBillingDunningEventAsync( PlatformAdminActor actor, @@ -206,13 +203,14 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform dunning event resolve", async dbContext => { var item = await dbContext.PlatformBillingDunningNotificationEvents - .SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken) - ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + .SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", + "dunning_event_not_found"); if (item.Status == PlatformBillingDunningNotificationStatus.Processing || - item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored) - { - throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid"); - } + item.Status is PlatformBillingDunningNotificationStatus.Acknowledged + or PlatformBillingDunningNotificationStatus.Ignored) + throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", + "dunning_event_status_invalid"); var fromStatus = item.Status; item.Status = status; item.NextAttemptAt = null; @@ -226,6 +224,4 @@ internal sealed partial class PlatformAdminService return ToDunningEventItem(item); }, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs index 9424110..5c4ae52 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdminService.Foundation.cs @@ -1,22 +1,17 @@ -using System.Text.Json; using System.Security.Cryptography; using System.Text; -using Microsoft.AspNetCore.Identity; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -29,19 +24,19 @@ internal sealed partial class PlatformAdminService { var access = await currentAccessContext.GetAsync(cancellationToken); if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode)) - { throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied"); - } } - private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) => - exception.InnerException is PostgresException + private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) + { + return exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: { } constraintName } && constraintName.StartsWith( "ix_platform_operation_idempotencies_actor_user_id_scope_", StringComparison.Ordinal); + } private async Task ProvisioningReplayResultAsync( TikuDbContext dbContext, @@ -51,7 +46,7 @@ internal sealed partial class PlatformAdminService var tenant = await dbContext.Tenants.AsNoTracking() .SingleAsync(value => value.Id == tenantId, cancellationToken); var ownerId = tenant.OwnerUserId - ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); + ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken); var primaryDomain = await dbContext.TenantDomains.AsNoTracking() .SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken); @@ -86,29 +81,22 @@ internal sealed partial class PlatformAdminService CancellationToken cancellationToken) { if (tenant.OwnerUserId is not { } ownerId) - { return new PlatformOwnerActivationStatus("domain_pending", null, null); - } var activated = await dbContext.Users.AsNoTracking().AnyAsync(value => value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken); - if (activated) - { - return new PlatformOwnerActivationStatus("activated", null, null); - } + if (activated) return new PlatformOwnerActivationStatus("activated", null, null); var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && value.ConsumedAt == null && value.RevokedAt == null) .OrderByDescending(value => value.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (grant is not null) - { return new PlatformOwnerActivationStatus( grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired", grant.Id, grant.ExpiresAt); - } var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => - value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, + value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, cancellationToken); return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null); } @@ -133,21 +121,22 @@ internal sealed partial class PlatformAdminService nameof(PlatformAdminService), reason, Guid.NewGuid().ToString("N"), - IsGlobal: true), + true), async (provider, _) => await operation(provider, provider.GetRequiredService()), cancellationToken); } - private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) + private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, + CancellationToken cancellationToken) { - var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken); - if (!exists) - { - throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); - } + var exists = + await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, + cancellationToken); + if (!exists) throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); } - private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) + private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, + object details) { dbContext.AuditLogs.Add(new AuditLog { @@ -159,8 +148,10 @@ internal sealed partial class PlatformAdminService }); } - private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) => - new( + private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, + DateTimeOffset? subscriptionExpiresAt) + { + return new PlatformTenantItem( tenant.Id, tenant.Slug, tenant.Name, @@ -172,9 +163,11 @@ internal sealed partial class PlatformAdminService domainCount, tenant.CreatedAt, tenant.UpdatedAt); + } - private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) => - new( + private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) + { + return new PlatformTenantDomainItem( domain.Id, domain.TenantId, domain.Host, @@ -189,17 +182,21 @@ internal sealed partial class PlatformAdminService null, null, null); + } - private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) => - ToDomainItem(domain) with + private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) + { + return ToDomainItem(domain) with { VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}", VerificationToken = domain.VerificationToken, CnameTarget = domains.AllowedCnameTargets.FirstOrDefault() }; + } - private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) => - new( + private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) + { + return new PlatformTenantSubscriptionItem( subscription.Id, subscription.TenantId, subscription.BaseOfferingVersionId, @@ -209,9 +206,11 @@ internal sealed partial class PlatformAdminService subscription.CurrentPeriodEnd, subscription.CancelAtPeriodEnd, subscription.Metadata); + } - private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) => - new( + private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) + { + return new TenantBillingProfileItem( profile.TenantId, profile.BillingName, profile.TaxId, @@ -224,9 +223,11 @@ internal sealed partial class PlatformAdminService profile.BankName, profile.BankAccountMasked, profile.Metadata); + } - private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) => - new( + private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) + { + return new TenantBillingPolicyItem( policy.TenantId, policy.CollectionMode, policy.DefaultPaymentProvider, @@ -234,9 +235,12 @@ internal sealed partial class PlatformAdminService policy.RenewalLeadDays, policy.CreatedAt, policy.UpdatedAt); + } - private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) => - new( + private static PlatformBillingDunningChannelItem ToDunningChannelItem( + PlatformBillingDunningNotificationChannel channel) + { + return new PlatformBillingDunningChannelItem( channel.Id, channel.ChannelCode, channel.Name, @@ -253,9 +257,11 @@ internal sealed partial class PlatformAdminService channel.Metadata, channel.CreatedAt, channel.UpdatedAt); + } - private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) => - new( + private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) + { + return new PlatformBillingDunningEventItem( item.Id, item.TenantId, item.ChannelId, @@ -275,9 +281,11 @@ internal sealed partial class PlatformAdminService item.Metadata, item.CreatedAt, item.UpdatedAt); + } - private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) => - new( + private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) + { + return new PlatformStaffItem( user.Id, user.Name, MaskPhone(user.Phone), @@ -286,15 +294,29 @@ internal sealed partial class PlatformAdminService roleCodes, user.CreatedAt, user.UpdatedAt); + } - private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200); + private static int Limit(int? limit) + { + return Math.Clamp(limit ?? 50, 1, 200); + } - private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant(); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static string Required(string? value, string name) => - string.IsNullOrWhiteSpace(value) + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant(); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string Required(string? value, string name) + { + return string.IsNullOrWhiteSpace(value) ? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required") : value.Trim(); + } private static string ProvisioningRequestHash(CreatePlatformTenantCommand command) { @@ -343,14 +365,20 @@ internal sealed partial class PlatformAdminService } } - private static string Base64Url(byte[] value) => - Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static string Base64Url(byte[] value) + { + return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } private string BuildOwnerActivationUrl(string host, Guid activationId, string token) - => TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); + { + return TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); + } - private static JsonElement JsonObjectOrDefault(JsonElement value) => - value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); + } private static string? MaskPhone(string? phone) { @@ -372,9 +400,7 @@ internal sealed partial class PlatformAdminService { var value = Normalize(webhookUrl) ?? string.Empty; if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) - { return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}"; - } return $"{uri.Scheme}://{uri.Host}/****"; } @@ -400,11 +426,15 @@ internal sealed partial class PlatformAdminService }; } - private static TenantStatus ParseTenantStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + private static TenantStatus ParseTenantStatus(string status) + { + return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + } - private static TenantDomainStatus ParseDomainStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + private static TenantDomainStatus ParseDomainStatus(string status) + { + return Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + } private static async Task EnsureTenantOwnerRoleAsync( TikuDbContext dbContext, @@ -474,12 +504,13 @@ internal sealed partial class PlatformAdminService DataScope = JsonSerializer.SerializeToElement(new { mode = "all" }) }; dbContext.TenantBackendRoles.Add(role); - dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission - { - TenantId = tenantId, - RoleId = role.Id, - PermissionCode = code - })); + dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => + new TenantBackendRolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionCode = code + })); dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, @@ -488,12 +519,22 @@ internal sealed partial class PlatformAdminService }); } - private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) + { + return Enum.TryParse(status, true, out var value) + ? value + : throw InvalidStatus(status); + } - private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) => - Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) + { + return Enum.TryParse(status, true, out var value) + ? value + : throw InvalidStatus(status); + } - private static PlatformAdminException InvalidStatus(string status) => - new($"Unsupported status '{status}'.", "invalid_status"); -} + private static PlatformAdminException InvalidStatus(string status) + { + return new PlatformAdminException($"Unsupported status '{status}'.", "invalid_status"); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs index 491487e..acbe1e3 100644 --- a/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/Operations/PlatformOperationsQueryService.cs @@ -78,7 +78,8 @@ internal sealed class PlatformOperationsQueryService( .Where(item => item.Status == BackgroundJobStatus.Pending) .MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken); var expired = await dbContext.BackgroundJobs.AsNoTracking() - .CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken); + .CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, + cancellationToken); return new PlatformJobMetrics( counts, oldest, @@ -96,7 +97,8 @@ internal sealed class PlatformOperationsQueryService( .Select(group => new PlatformMetricCount(group.Key.ToString(), group.Count())) .ToArrayAsync(cancellationToken); var expired = await dbContext.PlatformApprovalRequests.AsNoTracking() - .CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken); + .CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, + cancellationToken); var drafts = await dbContext.PlatformConfigurationVersions.AsNoTracking() .CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken); var notifications = await dbContext.PlatformNotificationDeliveries.AsNoTracking() @@ -105,4 +107,4 @@ internal sealed class PlatformOperationsQueryService( .ToArrayAsync(cancellationToken); return new PlatformGovernanceMetrics(approvals, expired, drafts, notifications, now); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs index eab151b..b07ece6 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs @@ -1,21 +1,6 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Identity; -using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -26,7 +11,6 @@ internal sealed partial class PlatformAdminService( IOptions provisioningOptions, IOptions domainOptions) : IPlatformAdminService { - private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value; private readonly DomainLifecycleOptions domains = domainOptions.Value; - -} + private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value; +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs index faef020..bd1a324 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformApprovalService.cs @@ -1,5 +1,5 @@ -using System.Security.Cryptography; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -60,6 +60,7 @@ internal sealed class PlatformApprovalService( item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); } + return ToItem(item); } @@ -80,7 +81,9 @@ internal sealed class PlatformApprovalService( CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformApprovalPolicyManage); - var policy = await dbContext.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code, cancellationToken) + var policy = + await dbContext.PlatformApprovalPolicies.SingleOrDefaultAsync(item => item.Code == command.Code, + cancellationToken) ?? throw Error("Approval policy was not found.", "approval_policy_not_found"); if (command.ExpiresAfterHours is < 1 or > 720 || command.AmountThresholdCents is <= 0) throw Error("Approval policy limits are invalid.", "approval_policy_invalid"); @@ -91,15 +94,22 @@ internal sealed class PlatformApprovalService( policy.Conditions = command.Conditions.Clone(); policy.Version++; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor.UserId, "platform.approval_policy.updated", "platform_approval_policies", policy.Id, new { policy.Code, policy.Version }, cancellationToken); + await AuditAsync(actor.UserId, "platform.approval_policy.updated", "platform_approval_policies", policy.Id, + new { policy.Code, policy.Version }, cancellationToken); return ToItem(policy); } - public Task ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) => - DecideAsync(actor, requestId, true, reason, cancellationToken); + public Task ApproveAsync(PlatformApprovalActor actor, Guid requestId, string reason, + CancellationToken cancellationToken = default) + { + return DecideAsync(actor, requestId, true, reason, cancellationToken); + } - public Task RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, CancellationToken cancellationToken = default) => - DecideAsync(actor, requestId, false, reason, cancellationToken); + public Task RejectAsync(PlatformApprovalActor actor, Guid requestId, string reason, + CancellationToken cancellationToken = default) + { + return DecideAsync(actor, requestId, false, reason, cancellationToken); + } public async Task CancelAsync( PlatformApprovalActor actor, @@ -116,18 +126,21 @@ internal sealed class PlatformApprovalService( item.DecidedAt = DateTimeOffset.UtcNow; item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor.UserId, "platform.approval.cancelled", "platform_approval_requests", item.Id, new { item.RequestNo }, cancellationToken); + await AuditAsync(actor.UserId, "platform.approval.cancelled", "platform_approval_requests", item.Id, + new { item.RequestNo }, cancellationToken); return ToItem(item); } public Task SubmitRefundAsync( SaasCatalogActor actor, RequestPlatformRefundCommand command, - CancellationToken cancellationToken = default) => - SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment, + CancellationToken cancellationToken = default) + { + return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment, nameof(RequestPlatformRefundCommand), "platform_billing_payments", command.PaymentId.ToString("N"), command.AmountCents, command.IdempotencyKey, command.Reason, command, async token => await billingService.RequestRefundAsync(actor, command, token), cancellationToken); + } public async Task ConfirmManualPaymentAsync( SaasCatalogActor actor, @@ -136,20 +149,20 @@ internal sealed class PlatformApprovalService( CancellationToken cancellationToken = default) { var amount = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest( - null, - SystemScopeCallerType.Platform, - nameof(PlatformApprovalService), - "Resolve payment amount for platform approval", - command.PaymentId.ToString("N"), - IsGlobal: true), - async (services, token) => await services.GetRequiredService() - .PlatformBillingPayments.AsNoTracking() - .Where(item => item.Id == command.PaymentId) - .Select(item => (int?)item.AmountCents) - .SingleOrDefaultAsync(token), - cancellationToken) - ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); + new SystemScopeRequest( + null, + SystemScopeCallerType.Platform, + nameof(PlatformApprovalService), + "Resolve payment amount for platform approval", + command.PaymentId.ToString("N"), + true), + async (services, token) => await services.GetRequiredService() + .PlatformBillingPayments.AsNoTracking() + .Where(item => item.Id == command.PaymentId) + .Select(item => (int?)item.AmountCents) + .SingleOrDefaultAsync(token), + cancellationToken) + ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.FinancialAdjustment, nameof(ConfirmManualPaymentCommand), "platform_billing_payments", command.PaymentId.ToString("N"), amount, idempotencyKey, command.Reason, command, @@ -163,22 +176,27 @@ internal sealed class PlatformApprovalService( CancellationToken cancellationToken = default) { if (command.Status != TenantStatus.Archived) - return ExecuteImmediateAsync(() => platformAdminService.UpdateTenantStatusAsync(actor, command, cancellationToken)); + return ExecuteImmediateAsync(() => + platformAdminService.UpdateTenantStatusAsync(actor, command, cancellationToken)); return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.TenantArchive, nameof(UpdatePlatformTenantStatusCommand), "tenants", command.TenantId.ToString("N"), null, idempotencyKey, command.Reason, command, - async token => await platformAdminService.UpdateTenantStatusAsync(actor, command, token), cancellationToken); + async token => await platformAdminService.UpdateTenantStatusAsync(actor, command, token), + cancellationToken); } public Task UpsertPaymentChannelAsync( PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, string idempotencyKey, - CancellationToken cancellationToken = default) => - SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.PaymentChannelChange, - nameof(UpsertPlatformPaymentChannelCommand), "platform_payment_channels", command.Id?.ToString("N") ?? command.Provider, + CancellationToken cancellationToken = default) + { + return SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.PaymentChannelChange, + nameof(UpsertPlatformPaymentChannelCommand), "platform_payment_channels", + command.Id?.ToString("N") ?? command.Provider, null, idempotencyKey, "支付渠道或密钥引用变更", command, async token => await paymentSettingsService.UpsertChannelAsync(actor, command, token), cancellationToken); + } public async Task ReplaceRoleBindingsAsync( BackofficeActor actor, @@ -189,103 +207,13 @@ internal sealed class PlatformApprovalService( var isSuperAdmin = await dbContext.PlatformBackendRoles.AsNoTracking() .AnyAsync(role => role.Id == command.RoleId && role.Code == "platform_super_admin", cancellationToken); if (!isSuperAdmin) - return await ExecuteImmediateAsync(() => backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, cancellationToken)); + return await ExecuteImmediateAsync(() => + backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, cancellationToken)); return await SubmitAsync(actor.UserId, PlatformApprovalPolicyCodes.SuperAdminGrant, nameof(ReplaceRoleBindingsCommand), "platform_backend_roles", command.RoleId.ToString("N"), null, idempotencyKey, "平台超级管理员权限变更", command, - async token => await backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, token), cancellationToken); - } - - private async Task SubmitAsync( - Guid actorUserId, - string policyCode, - string commandType, - string targetType, - string targetId, - int? amountCents, - string idempotencyKey, - string? reason, - TCommand command, - Func> execute, - CancellationToken cancellationToken) - { - idempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey) ? throw Error("Idempotency-Key is required.", "idempotency_key_required") : idempotencyKey.Trim(); - var policy = await dbContext.PlatformApprovalPolicies.AsNoTracking().SingleOrDefaultAsync(item => item.Code == policyCode, cancellationToken) - ?? throw Error("Approval policy is not configured.", "approval_policy_not_configured"); - var requiresApproval = PlatformApprovalRules.RequiresApproval( - policy.Enabled, policy.AlwaysRequireApproval, policy.AmountThresholdCents, amountCents); - if (!requiresApproval) - return await ExecuteImmediateAsync(() => execute(cancellationToken)); - - var snapshot = RedactedSnapshot(command); - var requestHash = Hash(snapshot.GetRawText()); - var existing = await dbContext.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item => - item.RequestedBy == actorUserId && item.CommandType == commandType && item.IdempotencyKey == idempotencyKey, cancellationToken); - if (existing is not null) - { - if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal)) - throw Error("Idempotency key was used with a different approval request.", "idempotency_conflict"); - return new PlatformCommandSubmission(existing.Status == PlatformApprovalRequestStatus.Succeeded ? "executed" : "pending_approval", existing.ResultSnapshot, ToItem(existing)); - } - - var item = new PlatformApprovalRequest - { - RequestNo = $"PA{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..24], - PolicyCode = policy.Code, - PolicyVersion = policy.Version, - RequestedBy = actorUserId, - RequiredPermission = policy.RequiredPermission, - CommandType = commandType, - TargetType = targetType, - TargetId = targetId, - AmountCents = amountCents, - IdempotencyKey = idempotencyKey, - RequestHash = requestHash, - RequestSnapshot = snapshot, - RequestReason = reason?.Trim(), - ExpiresAt = DateTimeOffset.UtcNow.AddHours(policy.ExpiresAfterHours) - }; - dbContext.PlatformApprovalRequests.Add(item); - await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actorUserId, "platform.approval.requested", "platform_approval_requests", item.Id, - new { item.RequestNo, item.PolicyCode, item.CommandType, item.TargetType, item.TargetId, item.AmountCents }, cancellationToken); - return new PlatformCommandSubmission("pending_approval", null, ToItem(item)); - } - - private async Task DecideAsync( - PlatformApprovalActor actor, - Guid requestId, - bool approve, - string reason, - CancellationToken cancellationToken) - { - Require(actor, BackendPermissions.PlatformApprovalDecide); - var item = await RequiredRequestAsync(requestId, cancellationToken); - var denial = PlatformApprovalRules.DecisionDenialCode(item.Status, item.RequestedBy, actor.UserId, - item.ExpiresAt, item.RequiredPermission, actor.Permissions, DateTimeOffset.UtcNow); - if (denial == "approval_request_expired") - { - item.Status = PlatformApprovalRequestStatus.Expired; - item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); - throw Error("Approval request has expired.", "approval_request_expired"); - } - if (denial == "approval_request_not_pending") - throw Error("Only a pending approval request can be changed.", denial); - if (denial == "approval_maker_checker_required") - throw Error("Requester cannot approve or reject the same request.", "approval_maker_checker_required"); - if (denial == "approval_business_permission_required") - throw Error("Approver no longer has the required business permission.", "approval_business_permission_required"); - - item.DecidedBy = actor.UserId; - item.DecidedAt = DateTimeOffset.UtcNow; - item.DecisionReason = RequiredReason(reason); - item.Status = approve ? PlatformApprovalRequestStatus.Approved : PlatformApprovalRequestStatus.Rejected; - item.ConcurrencyStamp = Guid.NewGuid(); - await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor.UserId, approve ? "platform.approval.approved" : "platform.approval.rejected", - "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode }, cancellationToken); - return ToItem(item); + async token => await backofficeService.ReplacePlatformRoleBindingsAsync(actor, command, token), + cancellationToken); } public async Task ProcessApprovedAsync(int batchSize = 20, CancellationToken cancellationToken = default) @@ -321,17 +249,125 @@ internal sealed class PlatformApprovalService( item.Status = PlatformApprovalRequestStatus.Failed; item.Error = exception.Message.Length > 4000 ? exception.Message[..4000] : exception.Message; } + item.ConcurrencyStamp = Guid.NewGuid(); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(item.DecidedBy ?? item.RequestedBy, - item.Status == PlatformApprovalRequestStatus.Succeeded ? "platform.approval.executed" : "platform.approval.execution_failed", - "platform_approval_requests", item.Id, new { item.RequestNo, item.CommandType, item.Error }, cancellationToken); + item.Status == PlatformApprovalRequestStatus.Succeeded + ? "platform.approval.executed" + : "platform.approval.execution_failed", + "platform_approval_requests", item.Id, new { item.RequestNo, item.CommandType, item.Error }, + cancellationToken); processed++; } + return processed; } - private async Task ExecuteApprovedAsync(PlatformApprovalRequest item, CancellationToken cancellationToken) + private async Task SubmitAsync( + Guid actorUserId, + string policyCode, + string commandType, + string targetType, + string targetId, + int? amountCents, + string idempotencyKey, + string? reason, + TCommand command, + Func> execute, + CancellationToken cancellationToken) + { + idempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey) + ? throw Error("Idempotency-Key is required.", "idempotency_key_required") + : idempotencyKey.Trim(); + var policy = await dbContext.PlatformApprovalPolicies.AsNoTracking() + .SingleOrDefaultAsync(item => item.Code == policyCode, cancellationToken) + ?? throw Error("Approval policy is not configured.", "approval_policy_not_configured"); + var requiresApproval = PlatformApprovalRules.RequiresApproval( + policy.Enabled, policy.AlwaysRequireApproval, policy.AmountThresholdCents, amountCents); + if (!requiresApproval) + return await ExecuteImmediateAsync(() => execute(cancellationToken)); + + var snapshot = RedactedSnapshot(command); + var requestHash = Hash(snapshot.GetRawText()); + var existing = await dbContext.PlatformApprovalRequests.AsNoTracking().SingleOrDefaultAsync(item => + item.RequestedBy == actorUserId && item.CommandType == commandType && + item.IdempotencyKey == idempotencyKey, + cancellationToken); + if (existing is not null) + { + if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal)) + throw Error("Idempotency key was used with a different approval request.", "idempotency_conflict"); + return new PlatformCommandSubmission( + existing.Status == PlatformApprovalRequestStatus.Succeeded ? "executed" : "pending_approval", + existing.ResultSnapshot, ToItem(existing)); + } + + var item = new PlatformApprovalRequest + { + RequestNo = $"PA{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..24], + PolicyCode = policy.Code, + PolicyVersion = policy.Version, + RequestedBy = actorUserId, + RequiredPermission = policy.RequiredPermission, + CommandType = commandType, + TargetType = targetType, + TargetId = targetId, + AmountCents = amountCents, + IdempotencyKey = idempotencyKey, + RequestHash = requestHash, + RequestSnapshot = snapshot, + RequestReason = reason?.Trim(), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(policy.ExpiresAfterHours) + }; + dbContext.PlatformApprovalRequests.Add(item); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actorUserId, "platform.approval.requested", "platform_approval_requests", item.Id, + new { item.RequestNo, item.PolicyCode, item.CommandType, item.TargetType, item.TargetId, item.AmountCents }, + cancellationToken); + return new PlatformCommandSubmission("pending_approval", null, ToItem(item)); + } + + private async Task DecideAsync( + PlatformApprovalActor actor, + Guid requestId, + bool approve, + string reason, + CancellationToken cancellationToken) + { + Require(actor, BackendPermissions.PlatformApprovalDecide); + var item = await RequiredRequestAsync(requestId, cancellationToken); + var denial = PlatformApprovalRules.DecisionDenialCode(item.Status, item.RequestedBy, actor.UserId, + item.ExpiresAt, item.RequiredPermission, actor.Permissions, DateTimeOffset.UtcNow); + if (denial == "approval_request_expired") + { + item.Status = PlatformApprovalRequestStatus.Expired; + item.ConcurrencyStamp = Guid.NewGuid(); + await dbContext.SaveChangesAsync(cancellationToken); + throw Error("Approval request has expired.", "approval_request_expired"); + } + + if (denial == "approval_request_not_pending") + throw Error("Only a pending approval request can be changed.", denial); + if (denial == "approval_maker_checker_required") + throw Error("Requester cannot approve or reject the same request.", "approval_maker_checker_required"); + if (denial == "approval_business_permission_required") + throw Error("Approver no longer has the required business permission.", + "approval_business_permission_required"); + + item.DecidedBy = actor.UserId; + item.DecidedAt = DateTimeOffset.UtcNow; + item.DecisionReason = RequiredReason(reason); + item.Status = approve ? PlatformApprovalRequestStatus.Approved : PlatformApprovalRequestStatus.Rejected; + item.ConcurrencyStamp = Guid.NewGuid(); + await dbContext.SaveChangesAsync(cancellationToken); + await AuditAsync(actor.UserId, approve ? "platform.approval.approved" : "platform.approval.rejected", + "platform_approval_requests", item.Id, new { item.RequestNo, item.PolicyCode }, cancellationToken); + return ToItem(item); + } + + private async Task ExecuteApprovedAsync(PlatformApprovalRequest item, + CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); scope.ServiceProvider.GetRequiredService().Load(new ClaimsPrincipal( @@ -340,16 +376,25 @@ internal sealed class PlatformApprovalService( "platform-approval"))); object result = item.CommandType switch { - nameof(RequestPlatformRefundCommand) => await scope.ServiceProvider.GetRequiredService() - .RequestRefundAsync(new SaasCatalogActor(item.RequestedBy), Deserialize(item), cancellationToken), - nameof(ConfirmManualPaymentCommand) => await scope.ServiceProvider.GetRequiredService() - .ConfirmManualPaymentAsync(new SaasCatalogActor(item.RequestedBy), Deserialize(item), cancellationToken), - nameof(UpdatePlatformTenantStatusCommand) => await scope.ServiceProvider.GetRequiredService() - .UpdateTenantStatusAsync(new PlatformAdminActor(item.RequestedBy), Deserialize(item), cancellationToken), - nameof(UpsertPlatformPaymentChannelCommand) => await scope.ServiceProvider.GetRequiredService() - .UpsertChannelAsync(new PlatformCapabilityActor(item.RequestedBy), Deserialize(item), cancellationToken), + nameof(RequestPlatformRefundCommand) => await scope.ServiceProvider + .GetRequiredService() + .RequestRefundAsync(new SaasCatalogActor(item.RequestedBy), + Deserialize(item), cancellationToken), + nameof(ConfirmManualPaymentCommand) => await scope.ServiceProvider + .GetRequiredService() + .ConfirmManualPaymentAsync(new SaasCatalogActor(item.RequestedBy), + Deserialize(item), cancellationToken), + nameof(UpdatePlatformTenantStatusCommand) => await scope.ServiceProvider + .GetRequiredService() + .UpdateTenantStatusAsync(new PlatformAdminActor(item.RequestedBy), + Deserialize(item), cancellationToken), + nameof(UpsertPlatformPaymentChannelCommand) => await scope.ServiceProvider + .GetRequiredService() + .UpsertChannelAsync(new PlatformCapabilityActor(item.RequestedBy), + Deserialize(item), cancellationToken), nameof(ReplaceRoleBindingsCommand) => await scope.ServiceProvider.GetRequiredService() - .ReplacePlatformRoleBindingsAsync(new BackofficeActor(item.RequestedBy, null, true), Deserialize(item), cancellationToken), + .ReplacePlatformRoleBindingsAsync(new BackofficeActor(item.RequestedBy, null, true), + Deserialize(item), cancellationToken), _ => throw Error("Approval command type is not supported.", "approval_command_not_supported") }; return JsonSerializer.SerializeToElement(result, JsonOptions); @@ -361,13 +406,19 @@ internal sealed class PlatformApprovalService( return new PlatformCommandSubmission("executed", JsonSerializer.SerializeToElement(result, JsonOptions), null); } - private static T Deserialize(PlatformApprovalRequest item) => - JsonSerializer.Deserialize(item.RequestSnapshot.GetRawText(), JsonOptions) - ?? throw Error("Approval request snapshot is invalid.", "approval_snapshot_invalid"); + private static T Deserialize(PlatformApprovalRequest item) + { + return JsonSerializer.Deserialize(item.RequestSnapshot.GetRawText(), JsonOptions) + ?? throw Error("Approval request snapshot is invalid.", "approval_snapshot_invalid"); + } - private async Task RequiredRequestAsync(Guid requestId, CancellationToken cancellationToken) => - await dbContext.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId, cancellationToken) - ?? throw Error("Approval request was not found.", "approval_request_not_found"); + private async Task RequiredRequestAsync(Guid requestId, + CancellationToken cancellationToken) + { + return await dbContext.PlatformApprovalRequests.SingleOrDefaultAsync(item => item.Id == requestId, + cancellationToken) + ?? throw Error("Approval request was not found.", "approval_request_not_found"); + } private async Task ExpirePendingAsync(CancellationToken cancellationToken) { @@ -380,8 +431,13 @@ internal sealed class PlatformApprovalService( .SetProperty(item => item.UpdatedAt, now), cancellationToken); } - private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) => - auditService.WriteAsync(new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), JsonSerializer.SerializeToElement(details, JsonOptions)), cancellationToken); + private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, + CancellationToken cancellationToken) + { + return auditService.WriteAsync( + new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), + JsonSerializer.SerializeToElement(details, JsonOptions)), cancellationToken); + } private static JsonElement RedactedSnapshot(T command) { @@ -393,35 +449,63 @@ internal sealed class PlatformApprovalService( private static void Redact(JsonNode? node) { if (node is JsonObject value) - { foreach (var property in value.ToArray()) { var name = property.Key; - if ((name.Contains("password", StringComparison.OrdinalIgnoreCase) || name.Contains("token", StringComparison.OrdinalIgnoreCase) || - name.Equals("secret", StringComparison.OrdinalIgnoreCase)) && !name.EndsWith("Ref", StringComparison.OrdinalIgnoreCase)) + if ((name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Equals("secret", StringComparison.OrdinalIgnoreCase)) && + !name.EndsWith("Ref", StringComparison.OrdinalIgnoreCase)) value[name] = "***"; else Redact(property.Value); } - } else if (node is JsonArray array) - foreach (var item in array) Redact(item); + foreach (var item in array) + Redact(item); + } + + private static string Hash(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + + private static string RequiredReason(string reason) + { + return string.IsNullOrWhiteSpace(reason) + ? throw Error("Decision reason is required.", "approval_reason_required") + : reason.Trim(); } - private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - private static string RequiredReason(string reason) => string.IsNullOrWhiteSpace(reason) ? throw Error("Decision reason is required.", "approval_reason_required") : reason.Trim(); private static void EnsurePending(PlatformApprovalRequest item) { if (item.Status != PlatformApprovalRequestStatus.Pending) throw Error("Only a pending approval request can be changed.", "approval_request_not_pending"); } + private static void Require(PlatformApprovalActor actor, string permission) { - if (!actor.Permissions.Contains(permission)) throw Error("Platform approval access is denied.", "platform_access_denied"); + if (!actor.Permissions.Contains(permission)) + throw Error("Platform approval access is denied.", "platform_access_denied"); } - private static PlatformApprovalException Error(string message, string code) => new(message, code); - private static PlatformApprovalRequestItem ToItem(PlatformApprovalRequest item) => new(item.Id, item.RequestNo, item.PolicyCode, item.PolicyVersion, - item.Status, item.RequestedBy, item.DecidedBy, item.RequiredPermission, item.CommandType, item.TargetType, item.TargetId, item.AmountCents, - item.RequestSnapshot, item.RequestReason, item.DecisionReason, item.Error, item.ExpiresAt, item.CreatedAt, item.UpdatedAt); - private static PlatformApprovalPolicyItem ToItem(PlatformApprovalPolicy item) => new(item.Id, item.Code, item.Name, item.RequiredPermission, - item.Enabled, item.AlwaysRequireApproval, item.AmountThresholdCents, item.Version, item.ExpiresAfterHours, item.Conditions); -} + + private static PlatformApprovalException Error(string message, string code) + { + return new PlatformApprovalException(message, code); + } + + private static PlatformApprovalRequestItem ToItem(PlatformApprovalRequest item) + { + return new PlatformApprovalRequestItem(item.Id, item.RequestNo, item.PolicyCode, item.PolicyVersion, + item.Status, item.RequestedBy, item.DecidedBy, item.RequiredPermission, item.CommandType, item.TargetType, + item.TargetId, item.AmountCents, + item.RequestSnapshot, item.RequestReason, item.DecisionReason, item.Error, item.ExpiresAt, item.CreatedAt, + item.UpdatedAt); + } + + private static PlatformApprovalPolicyItem ToItem(PlatformApprovalPolicy item) + { + return new PlatformApprovalPolicyItem(item.Id, item.Code, item.Name, item.RequiredPermission, + item.Enabled, item.AlwaysRequireApproval, item.AmountThresholdCents, item.Version, item.ExpiresAfterHours, + item.Conditions); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs index 24a0c43..c2bd8bb 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs @@ -5,8 +5,8 @@ using Tiku.Application.Backoffice; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Common; -using Tiku.Domain.Platform; using Tiku.Domain.Operations; +using Tiku.Domain.Platform; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; @@ -19,30 +19,41 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category).ThenBy(item => item.Code) + return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category) + .ThenBy(item => item.Code) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); } public async Task> GetConfigurationVersionsAsync( - PlatformApprovalActor actor, string definitionCode, string? environment, CancellationToken cancellationToken = default) + PlatformApprovalActor actor, string definitionCode, string? environment, + CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); var definitionId = await dbContext.PlatformConfigurationDefinitions.AsNoTracking() - .Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id).SingleOrDefaultAsync(cancellationToken) - ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); - var query = dbContext.PlatformConfigurationVersions.AsNoTracking().Where(item => item.DefinitionId == definitionId); - if (!string.IsNullOrWhiteSpace(environment)) query = query.Where(item => item.Environment == NormalizeEnvironment(environment)); - return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item)).ToArrayAsync(cancellationToken); + .Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id) + .SingleOrDefaultAsync(cancellationToken) + ?? throw Error("Configuration definition was not found.", + "platform_configuration_not_found"); + var query = dbContext.PlatformConfigurationVersions.AsNoTracking() + .Where(item => item.DefinitionId == definitionId); + if (!string.IsNullOrWhiteSpace(environment)) + query = query.Where(item => item.Environment == NormalizeEnvironment(environment)); + return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item)) + .ToArrayAsync(cancellationToken); } public async Task SaveConfigurationDraftAsync( - PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default) + PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, + CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var definition = await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync(item => item.Code == command.DefinitionCode, cancellationToken) + var definition = + await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync( + item => item.Code == command.DefinitionCode, cancellationToken) ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); if (!definition.AllowRuntimeManagement) - throw Error("Security-controlled configuration cannot be changed at runtime.", "platform_configuration_runtime_forbidden"); + throw Error("Security-controlled configuration cannot be changed at runtime.", + "platform_configuration_runtime_forbidden"); var environment = NormalizeEnvironment(command.Environment); ValidateValue(definition, command.Value, command.SecretRef); var nextVersion = (await dbContext.PlatformConfigurationVersions @@ -69,12 +80,15 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) - ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); + var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, + cancellationToken) + ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); if (item.Status != PlatformConfigurationVersionStatus.Draft) throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft"); - var current = await dbContext.PlatformConfigurationVersions.Where(value => value.DefinitionId == item.DefinitionId && - value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published).ToArrayAsync(cancellationToken); + var current = await dbContext.PlatformConfigurationVersions.Where(value => + value.DefinitionId == item.DefinitionId && + value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published) + .ToArrayAsync(cancellationToken); foreach (var published in current) published.Status = PlatformConfigurationVersionStatus.Retired; item.Status = PlatformConfigurationVersionStatus.Published; item.PublishedBy = actor.UserId; @@ -89,9 +103,11 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); - var source = await dbContext.PlatformConfigurationVersions.AsNoTracking().SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken) - ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); - var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item => item.DefinitionId == source.DefinitionId && item.Environment == source.Environment) + var source = await dbContext.PlatformConfigurationVersions.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken) + ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); + var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item => + item.DefinitionId == source.DefinitionId && item.Environment == source.Environment) .MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1; var rollback = new PlatformConfigurationVersion { @@ -110,13 +126,15 @@ internal sealed partial class PlatformGovernanceService( } public async Task> GetNotificationDeliveriesAsync( - PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default) + PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, + CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var values = dbContext.PlatformNotificationDeliveries.AsNoTracking(); if (status.HasValue) values = values.Where(item => item.Status == status.Value); if (!string.IsNullOrWhiteSpace(query.Search)) - values = values.Where(item => item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search)); + values = values.Where(item => + item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search)); var total = await values.CountAsync(cancellationToken); var items = await values.OrderByDescending(item => item.CreatedAt) .Skip((query.SafePage - 1) * query.SafePageSize).Take(query.SafePageSize) @@ -133,13 +151,16 @@ internal sealed partial class PlatformGovernanceService( } public async Task UpsertNotificationTemplateAsync( - PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default) + PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, + CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var code = Required(command.Code, "code").ToLowerInvariant(); var item = command.Id.HasValue - ? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) - : await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, cancellationToken); + ? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, + cancellationToken) + : await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, + cancellationToken); item ??= new PlatformNotificationTemplate { Code = code }; if (dbContext.Entry(item).State == EntityState.Detached) dbContext.PlatformNotificationTemplates.Add(item); item.Name = Required(command.Name, "name"); @@ -149,29 +170,37 @@ internal sealed partial class PlatformGovernanceService( item.Enabled = command.Enabled; item.Variables = command.Variables.Clone(); await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates", item.Id, new { item.Code, item.Channel }, cancellationToken); + await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates", + item.Id, new { item.Code, item.Channel }, cancellationToken); return ToItem(item); } public async Task> SendNotificationAsync( - PlatformApprovalActor actor, SendPlatformNotificationCommand command, CancellationToken cancellationToken = default) + PlatformApprovalActor actor, SendPlatformNotificationCommand command, + CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - var template = await dbContext.PlatformNotificationTemplates.AsNoTracking().SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken) - ?? throw Error("Notification template was not found.", "platform_notification_template_not_found"); - if (!template.Enabled) throw Error("Notification template is disabled.", "platform_notification_template_disabled"); - var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal).ToArray(); + var template = await dbContext.PlatformNotificationTemplates.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken) + ?? throw Error("Notification template was not found.", + "platform_notification_template_not_found"); + if (!template.Enabled) + throw Error("Notification template is disabled.", "platform_notification_template_disabled"); + var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal) + .ToArray(); var recipients = await (from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id - where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active - select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken); + join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id + where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active + select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken); var subject = Render(template.SubjectTemplate, command.Variables); var body = Render(template.BodyTemplate, command.Variables); var deliveries = new List(); foreach (var recipient in recipients) { - var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => item.TemplateId == template.Id && - item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey, cancellationToken); + var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => + item.TemplateId == template.Id && + item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey, + cancellationToken); if (existing) continue; var delivery = new PlatformNotificationDelivery { @@ -183,12 +212,15 @@ internal sealed partial class PlatformGovernanceService( Body = body, IdempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"), CreatedBy = actor.UserId, - Status = template.Channel == PlatformNotificationChannel.InApp ? PlatformNotificationDeliveryStatus.Sent : PlatformNotificationDeliveryStatus.Pending, + Status = template.Channel == PlatformNotificationChannel.InApp + ? PlatformNotificationDeliveryStatus.Sent + : PlatformNotificationDeliveryStatus.Pending, SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null }; dbContext.PlatformNotificationDeliveries.Add(delivery); deliveries.Add(delivery); } + await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id, new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken); @@ -199,39 +231,111 @@ internal sealed partial class PlatformGovernanceService( PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); - var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, cancellationToken) - ?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found"); - if (item.Status is not (PlatformNotificationDeliveryStatus.Failed or PlatformNotificationDeliveryStatus.Pending)) - throw Error("Only pending or failed notification delivery can be retried.", "platform_notification_not_retryable"); + var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, + cancellationToken) + ?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found"); + if (item.Status is not (PlatformNotificationDeliveryStatus.Failed + or PlatformNotificationDeliveryStatus.Pending)) + throw Error("Only pending or failed notification delivery can be retried.", + "platform_notification_not_retryable"); item.Status = PlatformNotificationDeliveryStatus.Pending; item.Attempts++; item.LastError = null; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries", item.Id, new { item.Channel, item.Attempts }, cancellationToken); + await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries", + item.Id, new { item.Channel, item.Attempts }, cancellationToken); return ToItem(item); } - private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) => - auditService.WriteAsync(new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), JsonSerializer.SerializeToElement(details)), cancellationToken); - private static void Require(PlatformApprovalActor actor, string permission) { if (!actor.Permissions.Contains(permission)) throw Error("Platform governance access is denied.", "platform_access_denied"); } - private static string Required(string? value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); - private static string NormalizeEnvironment(string value) { value = Required(value, "environment").ToLowerInvariant(); return EnvironmentPattern().IsMatch(value) ? value : throw Error("Environment code is invalid.", "platform_configuration_environment_invalid"); } + private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, + CancellationToken cancellationToken) + { + return auditService.WriteAsync( + new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), + JsonSerializer.SerializeToElement(details)), cancellationToken); + } + + private static void Require(PlatformApprovalActor actor, string permission) + { + if (!actor.Permissions.Contains(permission)) + throw Error("Platform governance access is denied.", "platform_access_denied"); + } + + private static string Required(string? value, string field) + { + return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); + } + + private static string NormalizeEnvironment(string value) + { + value = Required(value, "environment").ToLowerInvariant(); + return EnvironmentPattern().IsMatch(value) + ? value + : throw Error("Environment code is invalid.", "platform_configuration_environment_invalid"); + } + private static void ValidateValue(PlatformConfigurationDefinition definition, JsonElement? value, string? secretRef) { - if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef)) throw Error("Sensitive configuration requires a secret reference.", "platform_configuration_secret_ref_required"); - if (definition.IsSensitive && value.HasValue && value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object)) throw Error("Sensitive configuration cannot contain a plain value.", "platform_configuration_plain_secret_forbidden"); - if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef)) throw Error("Non-sensitive configuration cannot use a secret reference.", "platform_configuration_secret_ref_invalid"); - if (!definition.IsSensitive && !value.HasValue) throw Error("Configuration value is required.", "platform_configuration_value_required"); + if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef)) + throw Error("Sensitive configuration requires a secret reference.", + "platform_configuration_secret_ref_required"); + if (definition.IsSensitive && value.HasValue && + value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object)) + throw Error("Sensitive configuration cannot contain a plain value.", + "platform_configuration_plain_secret_forbidden"); + if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef)) + throw Error("Non-sensitive configuration cannot use a secret reference.", + "platform_configuration_secret_ref_invalid"); + if (!definition.IsSensitive && !value.HasValue) + throw Error("Configuration value is required.", "platform_configuration_value_required"); } - private static string Render(string template, IReadOnlyDictionary variables) => TokenPattern().Replace(template, match => - variables.TryGetValue(match.Groups[1].Value, out var value) ? value : throw Error($"Notification variable {match.Groups[1].Value} is missing.", "platform_notification_variable_missing")); - private static PlatformApprovalException Error(string message, string code) => new(message, code); - private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item) => new(item.Id, item.Code, item.Name, item.Category, item.ValueType, item.AllowRuntimeManagement, item.IsSensitive, item.Description, item.ValidationSchema); - private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item) => new(item.Id, item.DefinitionId, item.Environment, item.Version, item.Status, item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy, item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt); - private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item) => new(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate, item.BodyTemplate, item.Enabled, item.Variables, item.UpdatedAt); - private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item) => new(item.Id, item.TemplateId, item.RecipientUserId, item.RecipientRoleCode, item.Channel, item.Status, item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt); + + private static string Render(string template, IReadOnlyDictionary variables) + { + return TokenPattern().Replace(template, match => + variables.TryGetValue(match.Groups[1].Value, out var value) + ? value + : throw Error($"Notification variable {match.Groups[1].Value} is missing.", + "platform_notification_variable_missing")); + } + + private static PlatformApprovalException Error(string message, string code) + { + return new PlatformApprovalException(message, code); + } + + private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item) + { + return new PlatformConfigurationDefinitionItem(item.Id, item.Code, item.Name, item.Category, item.ValueType, + item.AllowRuntimeManagement, + item.IsSensitive, item.Description, item.ValidationSchema); + } + + private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item) + { + return new PlatformConfigurationVersionItem(item.Id, item.DefinitionId, item.Environment, item.Version, + item.Status, + item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy, + item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt); + } + + private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item) + { + return new PlatformNotificationTemplateItem(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate, + item.BodyTemplate, item.Enabled, + item.Variables, item.UpdatedAt); + } + + private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item) + { + return new PlatformNotificationDeliveryItem(item.Id, item.TemplateId, item.RecipientUserId, + item.RecipientRoleCode, item.Channel, item.Status, + item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt); + } + [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,79}$")] private static partial Regex EnvironmentPattern(); + [GeneratedRegex("\\{\\{([a-zA-Z][a-zA-Z0-9_.-]*)\\}\\}")] private static partial Regex TokenPattern(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs index b75cf50..ac0e151 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs @@ -21,6 +21,7 @@ internal sealed class PlatformQuestionBankService( ITenantExecutionScope tenantExecutionScope) : IPlatformQuestionBankService { private const int MaxPageSize = 100; + private static readonly HashSet SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase) { "choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming" @@ -29,58 +30,63 @@ internal sealed class PlatformQuestionBankService( public Task> GetBanksAsync( PlatformAdminActor actor, PlatformQuestionBankFilter filter, - CancellationToken cancellationToken = default) => - ExecuteAsync>(actor, "查询平台公共题库", async (_, dbContext, tenantId, token) => - { - var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId); - if (!string.IsNullOrWhiteSpace(filter.Keyword)) + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "查询平台公共题库", + async (_, dbContext, tenantId, token) => { - var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Name.Contains(keyword)); - } + var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword)); + } - if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) - { - var status = ParseBankStatus(filter.Status); - query = query.Where(item => item.Status == status); - } + if (!string.IsNullOrWhiteSpace(filter.Status) && + !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + var status = ParseBankStatus(filter.Status); + query = query.Where(item => item.Status == status); + } - var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token); - var entryIds = banks.Where(item => item.ContentEntryId.HasValue).Select(item => item.ContentEntryId!.Value).ToArray(); - var nodeCounts = await dbContext.ContentNodes.AsNoTracking() - .Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive) - .GroupBy(item => item.EntryId) - .Select(group => new { EntryId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.EntryId, item => item.Count, token); - var questionCounts = await dbContext.Questions.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.QuestionBankId.HasValue && item.Status != QuestionStatus.Archived) - .GroupBy(item => item.QuestionBankId!.Value) - .Select(group => new { BankId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.BankId, item => item.Count, token); - return banks.Select(item => ToBankItem( - item, - item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) ? nodes : 0, - questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray(); - }, cancellationToken); + var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token); + var entryIds = banks.Where(item => item.ContentEntryId.HasValue) + .Select(item => item.ContentEntryId!.Value).ToArray(); + var nodeCounts = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive) + .GroupBy(item => item.EntryId) + .Select(group => new { EntryId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.EntryId, item => item.Count, token); + var questionCounts = await dbContext.Questions.AsNoTracking() + .Where(item => + item.TenantId == tenantId && item.QuestionBankId.HasValue && + item.Status != QuestionStatus.Archived) + .GroupBy(item => item.QuestionBankId!.Value) + .Select(group => new { BankId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.BankId, item => item.Count, token); + return banks.Select(item => ToBankItem( + item, + item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) + ? nodes + : 0, + questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray(); + }, cancellationToken); + } public Task UpsertBankAsync( PlatformAdminActor actor, UpsertPlatformQuestionBankCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) => { - if (string.IsNullOrWhiteSpace(command.Name)) - { - throw Error("题库名称不能为空。", "question_bank_name_required"); - } + if (string.IsNullOrWhiteSpace(command.Name)) throw Error("题库名称不能为空。", "question_bank_name_required"); var bank = command.Id.HasValue - ? await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value, token) + ? await dbContext.QuestionBanks.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value, token) : null; - if (command.Id.HasValue && bank is null) - { - throw Error("公共题库不存在。", "question_bank_not_found"); - } + if (command.Id.HasValue && bank is null) throw Error("公共题库不存在。", "question_bank_not_found"); ContentEntry? trackedEntry = null; if (bank is null) @@ -128,216 +134,299 @@ internal sealed class PlatformQuestionBankService( bank.Metadata = ObjectOrDefault(command.Metadata); if (bank.ContentEntryId.HasValue) { - var entry = trackedEntry ?? await dbContext.ContentEntries.SingleAsync(item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token); + var entry = trackedEntry ?? + await dbContext.ContentEntries.SingleAsync( + item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token); entry.Name = bank.Name; entry.UpdatedBy = actor.UserId; } + AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name }); await dbContext.SaveChangesAsync(token); return ToBankItem(bank, 0, 0); }, cancellationToken); + } public Task ArchiveBankAsync( PlatformAdminActor actor, Guid bankId, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) => { var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); - if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.Status != QuestionStatus.Archived, token)) - { + if (await dbContext.Questions.AnyAsync( + item => item.TenantId == tenantId && item.QuestionBankId == bankId && + item.Status != QuestionStatus.Archived, token)) throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty"); - } - if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token)) - { + + if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token)) throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived"); - } + bank.Status = QuestionBankStatus.Archived; AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name }); await dbContext.SaveChangesAsync(token); return ToBankItem(bank, 0, 0); }, cancellationToken); + } public Task> GetNodesAsync( PlatformAdminActor actor, Guid bankId, - CancellationToken cancellationToken = default) => - ExecuteAsync>(actor, "查询公共题库内容结构", async (_, dbContext, tenantId, token) => - { - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token); - var nodes = await dbContext.ContentNodes.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId) - .OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name) - .ToArrayAsync(token); - var counts = await dbContext.Questions.AsNoTracking() - .Where(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && item.Status != QuestionStatus.Archived) - .GroupBy(item => item.ContentNodeId!.Value) - .Select(group => new { NodeId = group.Key, Count = group.Count() }) - .ToDictionaryAsync(item => item.NodeId, item => item.Count, token); - return nodes.Select(item => ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray(); - }, cancellationToken); + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "查询公共题库内容结构", + async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token); + var nodes = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId) + .OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name) + .ToArrayAsync(token); + var counts = await dbContext.Questions.AsNoTracking() + .Where(item => + item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && + item.Status != QuestionStatus.Archived) + .GroupBy(item => item.ContentNodeId!.Value) + .Select(group => new { NodeId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.NodeId, item => item.Count, token); + return nodes.Select(item => + ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray(); + }, cancellationToken); + } public Task UpsertNodeAsync( PlatformAdminActor actor, UpsertPlatformQuestionBankNodeCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) => { var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token); }, cancellationToken); + } public Task> BatchCreateNodesAsync( PlatformAdminActor actor, BatchCreatePlatformQuestionBankNodesCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync>(actor, "批量创建章节或试卷", async (_, dbContext, tenantId, token) => - { - if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper) + CancellationToken cancellationToken = default) + { + return ExecuteAsync>(actor, "批量创建章节或试卷", + async (_, dbContext, tenantId, token) => { - throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid"); - } - var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); - var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - if (names.Length is 0 or > 100) - { - throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid"); - } - var result = new List(); - var order = 0; - foreach (var name in names) - { - result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, new UpsertPlatformQuestionBankNodeCommand( - null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, JsonDefaults.Object()), token)); - } - return result; - }, cancellationToken); + if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper) + throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid"); + + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (names.Length is 0 or > 100) throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid"); + + var result = new List(); + var order = 0; + foreach (var name in names) + result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, + new UpsertPlatformQuestionBankNodeCommand( + null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, + JsonDefaults.Object()), token)); + + return result; + }, cancellationToken); + } public Task ArchiveNodeAsync( PlatformAdminActor actor, Guid nodeId, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) => { - var node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == nodeId, token) - ?? throw Error("内容节点不存在。", "question_bank_node_not_found"); - if (await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token)) - { + var node = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == nodeId, token) + ?? throw Error("内容节点不存在。", "question_bank_node_not_found"); + if (await dbContext.ContentNodes.AnyAsync( + item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token)) throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children"); - } - if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.ContentNodeId == nodeId && item.Status != QuestionStatus.Archived, token)) - { + + if (await dbContext.Questions.AnyAsync( + item => item.TenantId == tenantId && item.ContentNodeId == nodeId && + item.Status != QuestionStatus.Archived, token)) throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions"); - } + node.IsActive = false; node.UpdatedBy = actor.UserId; - var bankId = await dbContext.QuestionBanks.Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id).SingleAsync(token); + var bankId = await dbContext.QuestionBanks + .Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id) + .SingleAsync(token); AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name }); await dbContext.SaveChangesAsync(token); return ToNodeItem(node, bankId, 0); }, cancellationToken); + } public Task GetQuestionsAsync( PlatformAdminActor actor, PlatformQuestionBankFilter filter, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) => { - if (!filter.QuestionBankId.HasValue) - { - throw Error("请选择公共题库。", "question_bank_id_required"); - } + if (!filter.QuestionBankId.HasValue) throw Error("请选择公共题库。", "question_bank_id_required"); + await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token); - var query = dbContext.Questions.AsNoTracking().Where(item => item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId); + var query = dbContext.Questions.AsNoTracking().Where(item => + item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId); if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId); - if (!string.IsNullOrWhiteSpace(filter.Type)) { var type = filter.Type.Trim(); query = query.Where(item => item.Type == type); } + if (!string.IsNullOrWhiteSpace(filter.Type)) + { + var type = filter.Type.Trim(); + query = query.Where(item => item.Type == type); + } + if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty); - if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(filter.Status) && + !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) { var status = ParseQuestionStatus(filter.Status); query = query.Where(item => item.Status == status); } + if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); - query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => version.TenantId == tenantId && version.QuestionId == item.Id && version.Id == item.CurrentVersionId && version.Content != null && version.Content.Contains(keyword))); + query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => + version.TenantId == tenantId && version.QuestionId == item.Id && + version.Id == item.CurrentVersionId && version.Content != null && + version.Content.Contains(keyword))); } + var page = Math.Max(1, filter.Page); var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize); var total = await query.CountAsync(token); - var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToArrayAsync(token); - var versionIds = questions.Where(item => item.CurrentVersionId.HasValue).Select(item => item.CurrentVersionId!.Value).ToArray(); - var versions = await dbContext.QuestionVersions.AsNoTracking().Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)).ToDictionaryAsync(item => item.Id, token); - return new PlatformQuestionPage(questions.Select(item => ToQuestionItem(item, item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) ? version : null)).ToArray(), total, page, pageSize); + var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize) + .Take(pageSize).ToArrayAsync(token); + var versionIds = questions.Where(item => item.CurrentVersionId.HasValue) + .Select(item => item.CurrentVersionId!.Value).ToArray(); + var versions = await dbContext.QuestionVersions.AsNoTracking() + .Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)) + .ToDictionaryAsync(item => item.Id, token); + return new PlatformQuestionPage( + questions.Select(item => ToQuestionItem(item, + item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) + ? version + : null)).ToArray(), total, page, pageSize); }, cancellationToken); + } public Task UpsertQuestionAsync( PlatformAdminActor actor, UpsertPlatformQuestionCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) => { var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token); ValidateQuestion(command); var question = command.Id.HasValue - ? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, token) + ? await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, + token) : null; if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found"); - question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, CreatedAt = DateTimeOffset.UtcNow }; + question ??= new Question + { + TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, + CreatedAt = DateTimeOffset.UtcNow + }; if (!command.Id.HasValue) dbContext.Questions.Add(question); ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); await dbContext.SaveChangesAsync(token); - var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var nextVersion = await dbContext.QuestionVersions + .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); dbContext.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; - AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo }); + AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, + new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo }); await dbContext.SaveChangesAsync(token); return ToQuestionItem(question, version); }, cancellationToken); + } public Task ArchiveQuestionsAsync( PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) => { var ids = command.QuestionIds.Distinct().Take(500).ToArray(); if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required"); - var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)).ToArrayAsync(token); + var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)) + .ToArrayAsync(token); foreach (var question in questions) question.Status = QuestionStatus.Archived; - AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), new { questionIds = questions.Select(item => item.Id).ToArray() }); + AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), + new { questionIds = questions.Select(item => item.Id).ToArray() }); await dbContext.SaveChangesAsync(token); return questions.Length; }, cancellationToken); + } - public Task PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) => - ImportAsync(actor, command, false, cancellationToken); + public Task PreviewImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) + { + return ImportAsync(actor, command, false, cancellationToken); + } - public Task ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) => - ImportAsync(actor, command, true, cancellationToken); + public Task ExecuteImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) + { + return ImportAsync(actor, command, true, cancellationToken); + } - public Task GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "查询公共题库导入结果", async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), cancellationToken); + public Task GetImportAsync(PlatformAdminActor actor, Guid jobId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "查询公共题库导入结果", + async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), + cancellationToken); + } - public Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) => - await provider.GetRequiredService().SignUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command with { AssetType = "image", Category = "question", IsPublic = true }, token), cancellationToken); + public Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, + AssetUploadSignCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) => + await provider.GetRequiredService().SignUploadAsync( + new AssetManagementActor(tenantId, actor.UserId), + command with { AssetType = "image", Category = "question", IsPublic = true }, token), + cancellationToken); + } - public Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) => - await provider.GetRequiredService().ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), cancellationToken); + public Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, + AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) => + await provider.GetRequiredService() + .ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), + cancellationToken); + } - private async Task ImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken) + private async Task ImportAsync(PlatformAdminActor actor, + PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken) { return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) => { var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); var format = NormalizeImportFormat(command.Format); ContentNode? targetNode = null; - if (command.ContentNodeId.HasValue) targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token); - if (format == "simple" && targetNode is null) throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required"); + if (command.ContentNodeId.HasValue) + targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token); + if (format == "simple" && targetNode is null) + throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required"); var job = new ContentImportJob { @@ -354,10 +443,7 @@ internal sealed class PlatformQuestionBankService( RawPayload = command.Payload.Clone(), StartedAt = execute ? DateTimeOffset.UtcNow : null }; - if (execute) - { - dbContext.ContentImportJobs.Add(job); - } + if (execute) dbContext.ContentImportJobs.Add(job); var rows = format == "simple" ? ExtractSimpleRows(command.Payload, targetNode!.Id) @@ -386,11 +472,13 @@ internal sealed class PlatformQuestionBankService( { item.Status = ContentImportItemStatus.Invalid; item.IssuesCount = 1; - issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, validation.Value.Field, validation.Value.Message)); + issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, + validation.Value.Field, validation.Value.Message)); job.ErrorCount++; importItems.Add(item); continue; } + job.ValidCount++; item.Status = ContentImportItemStatus.Valid; if (execute) @@ -398,11 +486,14 @@ internal sealed class PlatformQuestionBankService( var node = targetNode; if (format == "structured") { - (node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, targetNode, nodeCache, token); + (node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, + targetNode, nodeCache, token); createdNodes += made; } + if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required"); - var result = await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token); + var result = + await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token); item.TargetType = "question"; item.TargetId = result.Question.Id; item.ContentHash = result.SourceHash; @@ -416,49 +507,62 @@ internal sealed class PlatformQuestionBankService( else if (result.Action == "updated") updated++; else skipped++; } + importItems.Add(item); } + job.InsertedCount = inserted; job.UpdatedCount = updated; job.SkippedCount = skipped; - job.Status = !execute ? ContentImportStatus.Preview : job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed; + job.Status = !execute ? ContentImportStatus.Preview : + job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed; job.FinishedAt = execute ? DateTimeOffset.UtcNow : null; - job.Summary = JsonSerializer.SerializeToElement(new { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount }); + job.Summary = JsonSerializer.SerializeToElement(new + { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount }); job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question)); if (execute) { dbContext.ContentImportItems.AddRange(importItems); dbContext.ContentImportIssues.AddRange(issues); - AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount }); + AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, + new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount }); await dbContext.SaveChangesAsync(token); } - var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), issues.Select(ToIssueItem).ToArray()); + + var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), + issues.Select(ToIssueItem).ToArray()); return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped); }, cancellationToken); } - private async Task ExecuteAsync(PlatformAdminActor actor, string reason, Func> action, CancellationToken cancellationToken) + private async Task ExecuteAsync(PlatformAdminActor actor, string reason, + Func> action, + CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); - if (access.UserId != actor.UserId || !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) - { + if (access.UserId != actor.UserId || + !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied"); - } var correlationId = Guid.NewGuid().ToString("N"); var platformTenantId = await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), "解析平台公共题库所属租户", correlationId, IsGlobal: true), + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), + "解析平台公共题库所属租户", correlationId, true), async (provider, token) => { var dbContext = provider.GetRequiredService(); - var tenantIds = await dbContext.Tenants.AsNoTracking().Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2).ToArrayAsync(token); + var tenantIds = await dbContext.Tenants.AsNoTracking() + .Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2) + .ToArrayAsync(token); if (tenantIds.Length != 1) - { - throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", tenantIds.Length == 0 ? "platform_question_owner_missing" : "platform_question_owner_ambiguous"); - } + throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", + tenantIds.Length == 0 + ? "platform_question_owner_missing" + : "platform_question_owner_ambiguous"); return tenantIds[0]; }, cancellationToken); return await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), reason, correlationId), + new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, + nameof(PlatformQuestionBankService), reason, correlationId), async (provider, token) => { var dbContext = provider.GetRequiredService(); @@ -466,14 +570,22 @@ internal sealed class PlatformQuestionBankService( }, cancellationToken); } - private static async Task UpsertNodeCoreAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, CancellationToken token) + private static async Task UpsertNodeCoreAsync(TikuDbContext dbContext, + PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, + CancellationToken token) { if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required"); ContentNode? parent = null; - if (command.ParentId.HasValue) parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token); - var node = command.Id.HasValue ? await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, token) : null; + if (command.ParentId.HasValue) + parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token); + var node = command.Id.HasValue + ? await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, + token) + : null; if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found"); - node ??= new ContentNode { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId }; + node ??= new ContentNode + { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId }; if (!command.Id.HasValue) dbContext.ContentNodes.Add(node); node.ParentId = parent?.Id; node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim(); @@ -487,12 +599,15 @@ internal sealed class PlatformQuestionBankService( node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper; node.Metadata = ObjectOrDefault(command.Metadata); node.UpdatedBy = actor.UserId; - AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, new { bankId = bank.Id, node.Name, node.NodeType }); + AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, + new { bankId = bank.Id, node.Name, node.NodeType }); await dbContext.SaveChangesAsync(token); return ToNodeItem(node, bank.Id, 0); } - private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection path, ContentNode? root, Dictionary cache, CancellationToken token) + private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext dbContext, + PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection path, + ContentNode? root, Dictionary cache, CancellationToken token) { var parent = root; var created = 0; @@ -502,7 +617,9 @@ internal sealed class PlatformQuestionBankService( if (!cache.TryGetValue(cacheKey, out var node)) { var parentId = parent?.Id; - node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.ParentId == parentId && item.NodeKey == part.Key, token); + node = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && + item.ParentId == parentId && item.NodeKey == part.Key, token); if (node is null) { node = new ContentNode @@ -527,32 +644,47 @@ internal sealed class PlatformQuestionBankService( await dbContext.SaveChangesAsync(token); created++; } + cache[cacheKey] = node; } + parent = node; } + if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required"); return (parent, created); } - private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, JsonElement payload, CancellationToken token) + private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync( + TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, + JsonElement payload, CancellationToken token) { var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id"); var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText()); var question = !string.IsNullOrWhiteSpace(legacyId) - ? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.LegacyId == legacyId, token) + ? await dbContext.Questions.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.LegacyId == legacyId, token) : await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id) - .Join(dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, version => (Guid?)version.Id, (item, _) => item) + .Join( + dbContext.QuestionVersions.Where(item => + item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, + version => version.Id, (item, _) => item) .SingleOrDefaultAsync(token); - if (question is not null && question.QuestionBankId != bank.Id) throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict"); - if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync(item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) return (question, "skipped", sourceHash); + if (question is not null && question.QuestionBankId != bank.Id) + throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict"); + if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync( + item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) + return (question, "skipped", sourceHash); var isNew = question is null; - question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId }; + question ??= new Question + { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId }; if (isNew) dbContext.Questions.Add(question); var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash); ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); await dbContext.SaveChangesAsync(token); - var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var nextVersion = await dbContext.QuestionVersions + .Where(item => item.TenantId == tenantId && item.QuestionId == question.Id) + .Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); dbContext.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; @@ -562,37 +694,49 @@ internal sealed class PlatformQuestionBankService( private static List ExtractSimpleRows(JsonElement payload, Guid nodeId) { - var array = payload.ValueKind == JsonValueKind.Array ? payload : payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) ? questions : default; - if (array.ValueKind != JsonValueKind.Array) throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid"); - return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)).ToList(); + var array = payload.ValueKind == JsonValueKind.Array + ? payload + : + payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) + ? + questions + : default; + if (array.ValueKind != JsonValueKind.Array) + throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid"); + return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)) + .ToList(); } private static List ExtractStructuredRows(JsonElement payload, Guid? rootNodeId) { - if (payload.ValueKind != JsonValueKind.Object) throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid"); - if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid"); + if (payload.ValueKind != JsonValueKind.Object) + throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid"); + if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") + throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid"); var rows = new List(); WalkStructured(payload, [], rows, rootNodeId); if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty"); return rows; } - private static void WalkStructured(JsonElement current, List path, List rows, Guid? rootNodeId) + private static void WalkStructured(JsonElement current, List path, List rows, + Guid? rootNodeId) { if (current.ValueKind != JsonValueKind.Object) return; if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array) - { - foreach (var question in questions.EnumerateArray()) rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId)); - } + foreach (var question in questions.EnumerateArray()) + rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId)); string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"]; foreach (var property in childProperties) { - if (!current.TryGetProperty(property, out var children) || children.ValueKind != JsonValueKind.Array) continue; + if (!current.TryGetProperty(property, out var children) || + children.ValueKind != JsonValueKind.Array) continue; foreach (var child in children.EnumerateArray()) { if (child.ValueKind != JsonValueKind.Object) continue; var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点"; - var key = GetString(child, "code") ?? GetString(child, "key") ?? GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}"; + var key = GetString(child, "code") ?? GetString(child, "key") ?? + GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}"; var type = property switch { "subjects" => ContentNodeType.Subject, @@ -600,7 +744,8 @@ internal sealed class PlatformQuestionBankService( "papers" => ContentNodeType.Paper, _ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category) }; - var next = new List(path) { new(key, name, type, GetInt(child, "order") ?? path.Count) }; + var next = new List(path) + { new(key, name, type, GetInt(child, "order") ?? path.Count) }; WalkStructured(child, next, rows, rootNodeId); } } @@ -609,7 +754,9 @@ internal sealed class PlatformQuestionBankService( private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。"); - if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && string.IsNullOrWhiteSpace(GetString(payload, "title"))) return ("question_content_required", "content", "题干不能为空。"); + if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && + string.IsNullOrWhiteSpace(GetString(payload, "title"))) + return ("question_content_required", "content", "题干不能为空。"); var type = GetString(payload, "type") ?? "choice"; if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。"); return null; @@ -627,12 +774,11 @@ internal sealed class PlatformQuestionBankService( command.CorrectOptionIndex, command.CorrectOptionIndices, command.AnswerText)) - { throw Error("发布题目必须提供有效的标准答案。", "question_grading_rule_invalid"); - } } - private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, Guid nodeId) + private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, + Guid nodeId) { question.QuestionBankId = command.QuestionBankId; question.EntryId = entryId; @@ -647,75 +793,241 @@ internal sealed class PlatformQuestionBankService( question.Status = ParseQuestionStatus(command.Status); } - private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, UpsertPlatformQuestionCommand command) => new() + private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, + UpsertPlatformQuestionCommand command) { - TenantId = tenantId, - QuestionId = questionId, - VersionNo = versionNo, - Content = Normalize(command.Content), - Options = ArrayOrDefault(command.Options), - CorrectOptionIndex = command.CorrectOptionIndex, - CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices), - AnswerText = Normalize(command.AnswerText), - Explanation = Normalize(command.Explanation), - SubQuestions = ArrayOrDefault(command.SubQuestions), - CodeLang = Normalize(command.CodeLang), - CodeTemplate = Normalize(command.CodeTemplate), - SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)), - CreatedBy = actor.UserId - }; + return new QuestionVersion + { + TenantId = tenantId, + QuestionId = questionId, + VersionNo = versionNo, + Content = Normalize(command.Content), + Options = ArrayOrDefault(command.Options), + CorrectOptionIndex = command.CorrectOptionIndex, + CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices), + AnswerText = Normalize(command.AnswerText), + Explanation = Normalize(command.Explanation), + SubQuestions = ArrayOrDefault(command.SubQuestions), + CodeLang = Normalize(command.CodeLang), + CodeTemplate = Normalize(command.CodeTemplate), + SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)), + CreatedBy = actor.UserId + }; + } - private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, string? legacyId, string sourceHash) => new( - null, bankId, nodeId, legacyId, GetString(payload, "type") ?? "choice", GetString(payload, "typeLabel"), GetInt(payload, "difficulty"), - GetElement(payload, "tags", JsonDefaults.Array()), GetString(payload, "content") ?? GetString(payload, "title"), GetElement(payload, "options", JsonDefaults.Array()), - GetInt(payload, "correctOptionIndex"), GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), GetString(payload, "answerText") ?? GetString(payload, "answer"), - GetString(payload, "explanation"), GetElement(payload, "subQuestions", JsonDefaults.Array()), GetString(payload, "codeLang"), GetString(payload, "codeTemplate"), - GetString(payload, "mediaUrl"), GetString(payload, "status") ?? "published", GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash); + private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, + string? legacyId, string sourceHash) + { + return new UpsertPlatformQuestionCommand( + null, bankId, nodeId, legacyId, GetString(payload, "type") ?? "choice", GetString(payload, "typeLabel"), + GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), + GetString(payload, "content") ?? GetString(payload, "title"), + GetElement(payload, "options", JsonDefaults.Array()), + GetInt(payload, "correctOptionIndex"), GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), + GetString(payload, "answerText") ?? GetString(payload, "answer"), + GetString(payload, "explanation"), GetElement(payload, "subQuestions", JsonDefaults.Array()), + GetString(payload, "codeLang"), GetString(payload, "codeTemplate"), + GetString(payload, "mediaUrl"), GetString(payload, "status") ?? "published", + GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash); + } - private static async Task RequireBankAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token) => - await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == bankId, token) ?? throw Error("公共题库不存在。", "question_bank_not_found"); + private static async Task RequireBankAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, + CancellationToken token) + { + return await dbContext.QuestionBanks.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.Id == bankId, token) ?? + throw Error("公共题库不存在。", "question_bank_not_found"); + } - private static async Task RequireBankWithEntryAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token) + private static async Task RequireBankWithEntryAsync(TikuDbContext dbContext, Guid tenantId, + Guid bankId, CancellationToken token) { var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing"); return bank; } - private static async Task RequireNodeAsync(TikuDbContext dbContext, Guid tenantId, QuestionBank bank, Guid nodeId, CancellationToken token) => - await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); - - private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) => new(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, questionCount, item.Metadata, item.CreatedAt, item.UpdatedAt); - private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) => new(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, item.NodeType, item.Depth, item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); - private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) => new(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, item.ContentNodeId!.Value, item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.Tags, version?.Content, version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, item.MediaUrl, item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); - - private static async Task LoadImportDetailAsync(TikuDbContext dbContext, Guid tenantId, Guid jobId, CancellationToken token) + private static async Task RequireNodeAsync(TikuDbContext dbContext, Guid tenantId, QuestionBank bank, + Guid nodeId, CancellationToken token) { - var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? throw Error("导入任务不存在。", "question_import_not_found"); - var items = await dbContext.ContentImportItems.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToImportItem(item)).ToArrayAsync(token); - var issues = await dbContext.ContentImportIssues.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToIssueItem(item)).ToArrayAsync(token); + return await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && + item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); + } + + private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) + { + return new PlatformQuestionBankItem(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, + questionCount, item.Metadata, + item.CreatedAt, item.UpdatedAt); + } + + private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) + { + return new PlatformQuestionBankNodeItem(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, + item.NodeType, item.Depth, + item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); + } + + private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) + { + return new PlatformQuestionItem(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, + item.ContentNodeId!.Value, + item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.Tags, version?.Content, + version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, + version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, + version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, item.MediaUrl, + item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); + } + + private static async Task LoadImportDetailAsync(TikuDbContext dbContext, Guid tenantId, + Guid jobId, CancellationToken token) + { + var job = await dbContext.ContentImportJobs.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? + throw Error("导入任务不存在。", "question_import_not_found"); + var items = await dbContext.ContentImportItems.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) + .Select(item => ToImportItem(item)).ToArrayAsync(token); + var issues = await dbContext.ContentImportIssues.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo) + .Select(item => ToIssueItem(item)).ToArrayAsync(token); return new ContentImportJobDetail(ToJobItem(job), items, issues); } - private static ContentImportJobItem ToJobItem(ContentImportJob item) => new(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, item.TargetContentNodeId, item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, item.FinishedAt, item.CreatedAt, item.UpdatedAt); - private static ContentImportItemModel ToImportItem(ContentImportItem item) => new(item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, item.TargetType, item.TargetId, item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount); - private static ContentImportIssueModel ToIssueItem(ContentImportIssue item) => new(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details); - private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, string field, string message) => new() { TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, Code = code, FieldPath = field, Message = message }; + private static ContentImportJobItem ToJobItem(ContentImportJob item) + { + return new ContentImportJobItem(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, + item.TargetContentNodeId, + item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, + item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, + item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, + item.FinishedAt, item.CreatedAt, item.UpdatedAt); + } - private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) => dbContext.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = "question_bank", TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) }); - private static PlatformAdminException Error(string message, string code) => new(message, code); - private static string NormalizeImportFormat(string? value) => value?.Trim().ToLowerInvariant() switch { "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", _ => throw Error("导入格式不受支持。", "question_import_format_invalid") }; - private static QuestionBankStatus ParseBankStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active }; - private static QuestionStatus ParseQuestionStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, _ => QuestionStatus.Published }; - private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) => value?.Trim().ToLowerInvariant() switch { "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback }; - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static JsonElement ObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); - private static JsonElement ArrayOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); - private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); - private static string? GetString(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; - private static int? GetInt(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : null; - private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) ? property.Clone() : fallback; + private static ContentImportItemModel ToImportItem(ContentImportItem item) + { + return new ContentImportItemModel(item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, + item.TargetType, item.TargetId, + item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount); + } + + private static ContentImportIssueModel ToIssueItem(ContentImportIssue item) + { + return new ContentImportIssueModel(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, + item.FieldPath, item.Message, + item.Details); + } + + private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, + string field, string message) + { + return new ContentImportIssue + { + TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, + Code = code, FieldPath = field, Message = message + }; + } + + private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, + object details) + { + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, Action = action, TargetType = "question_bank", + TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) + }); + } + + private static PlatformAdminException Error(string message, string code) + { + return new PlatformAdminException(message, code); + } + + private static string NormalizeImportFormat(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", + _ => throw Error("导入格式不受支持。", "question_import_format_invalid") + }; + } + + private static QuestionBankStatus ParseBankStatus(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active + }; + } + + private static QuestionStatus ParseQuestionStatus(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, + _ => QuestionStatus.Published + }; + } + + private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) + { + return value?.Trim().ToLowerInvariant() switch + { + "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, + "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback + }; + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static JsonElement ObjectOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); + } + + private static JsonElement ArrayOrDefault(JsonElement value) + { + return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); + } + + private static string Hash(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + + private static string? GetString(JsonElement value, string name) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static int? GetInt(JsonElement value, string name) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && + property.TryGetInt32(out var result) + ? result + : null; + } + + private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) + { + return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) + ? property.Clone() + : fallback; + } private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order); - private sealed record ImportRow(int RowNo, JsonElement Question, IReadOnlyCollection Path, Guid? TargetNodeId); -} + + private sealed record ImportRow( + int RowNo, + JsonElement Question, + IReadOnlyCollection Path, + Guid? TargetNodeId); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs index 65753c1..cda46e8 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformTenantCapabilitiesService.cs @@ -1,11 +1,9 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Tiku.Application.Growth; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Application.Tenancy; -using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Operations; @@ -20,8 +18,9 @@ internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecut public Task> GetConfigsAsync( PlatformCapabilityActor actor, PlatformCapabilityQuery query, - CancellationToken cancellationToken = default) => - ExecuteAsync("platform crm configs list", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm configs list", async (services, token) => { var db = services.GetRequiredService(); var values = @@ -42,21 +41,24 @@ internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecut .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); + } public Task UpsertConfigAsync( PlatformCapabilityActor actor, UpsertPlatformCrmConfigCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("platform crm config upsert", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm config upsert", async (services, token) => { var db = services.GetRequiredService(); var tenantName = await db.Tenants - .Where(tenant => tenant.Id == command.TenantId) - .Select(tenant => tenant.Name) - .SingleOrDefaultAsync(token) - ?? throw Error("Tenant was not found.", "tenant_not_found"); + .Where(tenant => tenant.Id == command.TenantId) + .Select(tenant => tenant.Name) + .SingleOrDefaultAsync(token) + ?? throw Error("Tenant was not found.", "tenant_not_found"); var item = command.Id.HasValue - ? await db.CrmConfigs.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + ? await db.CrmConfigs.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) : await db.CrmConfigs.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, token); if (item is null) { @@ -75,55 +77,64 @@ internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecut item.AssignmentPool = command.AssignmentPool ?? JsonDefaults.Array(); item.AssignmentConfig = command.AssignmentConfig ?? JsonDefaults.Object(); item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, new { item.Enabled, item.Url, item.AssignmentMode }); + AddAudit(db, actor, command.TenantId, "platform.crm.config.upserted", "crm_config", item.Id, + new { item.Enabled, item.Url, item.AssignmentMode }); await db.SaveChangesAsync(token); return ToCrmConfigItem(item, tenantName); }, cancellationToken); + } public Task> GetLeadsAsync( PlatformCapabilityActor actor, PlatformCapabilityQuery query, - CancellationToken cancellationToken = default) => - ExecuteAsync("platform crm leads list", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm leads list", async (services, token) => { var db = services.GetRequiredService(); var values = db.CrmWebhookQueue.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending)); - var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == ParseEnum(query.Status, CrmWebhookQueueStatus.Pending)); + var items = await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); + } public Task RetryLeadAsync( PlatformCapabilityActor actor, PlatformCrmLeadRetryCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("platform crm lead retry", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm lead retry", async (services, token) => { var db = services.GetRequiredService(); var item = await db.CrmWebhookQueue.SingleOrDefaultAsync(value => value.Id == command.QueueId, token) - ?? throw Error("CRM queue item was not found.", "crm_queue_not_found"); - if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded or CrmWebhookQueueStatus.Retrying)) - { + ?? throw Error("CRM queue item was not found.", "crm_queue_not_found"); + if (item.Status is not (CrmWebhookQueueStatus.Failed or CrmWebhookQueueStatus.Discarded + or CrmWebhookQueueStatus.Retrying)) throw Error("Only failed CRM queue items can be retried.", "crm_queue_retry_invalid"); - } item.Status = CrmWebhookQueueStatus.Retrying; item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, new { command.Note }); + AddAudit(db, actor, item.TenantId, "platform.crm.lead.retry", "crm_webhook_queue", item.Id, + new { command.Note }); await db.SaveChangesAsync(token); return item; }, cancellationToken); + } public Task> GetLogsAsync( PlatformCapabilityActor actor, Guid? tenantId, Guid? queueId, int limit, - CancellationToken cancellationToken = default) => - ExecuteAsync("platform crm logs list", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform crm logs list", async (services, token) => { var db = services.GetRequiredService(); var values = db.CrmWebhookLogs.AsNoTracking(); @@ -140,33 +151,80 @@ internal sealed class PlatformCrmAdminService(ITenantExecutionScope tenantExecut var items = await values.OrderByDescending(value => value.CreatedAt).Take(Limit(limit)).ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); + } - private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) => - new(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, config.SecretRef, config.FormName, config.ExamType, - config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, config.AssignmentConfig, config.UpdatedAt); + private static PlatformCrmConfigItem ToCrmConfigItem(CrmConfig config, string tenantName) + { + return new PlatformCrmConfigItem(config.Id, config.TenantId, tenantName, config.Enabled, config.Url, + config.SecretRef, + config.FormName, config.ExamType, + config.TimeoutSeconds, config.DelaySeconds, config.AssignmentMode, config.AssignmentPool, + config.AssignmentConfig, config.UpdatedAt); + } - private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformCrmAdminService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } - private static bool IsEnabledStatus(string value) => value.Equals("active", StringComparison.OrdinalIgnoreCase) || value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常"; - private static PlatformCapabilityException Error(string message, string code) => new(message, code); - private static int Limit(int value) => Math.Clamp(value, 1, 500); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static T ParseEnum(string? value, T fallback) where T : struct, Enum => Enum.TryParse(NormalizeEnum(value), true, out var parsed) ? parsed : fallback; - private static string? NormalizeEnum(string? value) => value?.Trim().Replace("-", "_", StringComparison.Ordinal); - private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) => - db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); + private static bool IsEnabledStatus(string value) + { + return value.Equals("active", StringComparison.OrdinalIgnoreCase) || + value.Equals("enabled", StringComparison.OrdinalIgnoreCase) || value == "正常"; + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(NormalizeEnum(value), true, out var parsed) ? parsed : fallback; + } + + private static string? NormalizeEnum(string? value) + { + return value?.Trim().Replace("-", "_", StringComparison.Ordinal); + } + + private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, + TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) + }); + } } internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformSmsAdminService { - public Task> GetChannelsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms channels list", async (services, token) => + public Task> GetChannelsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channels list", async (services, token) => { var db = services.GetRequiredService(); - var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero); + var monthStart = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, 0, 0, 0, + TimeSpan.Zero); var sentCounts = await db.SmsSendLogs.AsNoTracking() - .Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && value.ChannelId.HasValue) + .Where(value => value.CreatedAt >= monthStart && value.Status == SmsSendLogStatus.Sent && + value.ChannelId.HasValue) .GroupBy(value => value.ChannelId!.Value) .Select(group => new { ChannelId = group.Key, Count = group.Count() }) .ToDictionaryAsync(value => value.ChannelId, value => value.Count, token); @@ -175,21 +233,31 @@ internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecut join tenant in db.Tenants.AsNoTracking() on channel.TenantId equals tenant.Id select new { channel, tenant.Name }; if (query.TenantId.HasValue) values = values.Where(value => value.channel.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); - var rows = await values.OrderBy(value => value.channel.Priority).ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - return new PlatformTenantCapabilityList(rows.Select(value => ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray()); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.channel.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); + var rows = await values.OrderBy(value => value.channel.Priority) + .ThenByDescending(value => value.channel.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + return new PlatformTenantCapabilityList(rows.Select(value => + ToChannelItem(value.channel, value.Name, sentCounts.GetValueOrDefault(value.channel.Id))).ToArray()); }, cancellationToken); + } - public Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms channel upsert", async (services, token) => + public Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsChannelCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channel upsert", async (services, token) => { var db = services.GetRequiredService(); var tenantName = await TenantNameAsync(db, command.TenantId, token); var provider = NormalizeCode(command.Provider); var scene = NormalizeCode(command.Scene); var item = command.Id.HasValue - ? await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) - : await db.SmsChannels.SingleOrDefaultAsync(value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, token); + ? await db.SmsChannels.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + : await db.SmsChannels.SingleOrDefaultAsync( + value => value.TenantId == command.TenantId && value.Provider == provider && value.Scene == scene, + token); if (item is null) { item = new SmsChannel { TenantId = command.TenantId }; @@ -207,57 +275,81 @@ internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecut item.ConfigPublic = command.ConfigPublic; item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, new { item.Provider, item.Scene, item.Status }); + AddAudit(db, actor, command.TenantId, "platform.sms.channel.upserted", "sms_channels", item.Id, + new { item.Provider, item.Scene, item.Status }); await db.SaveChangesAsync(token); return ToChannelItem(item, tenantName, 0); }, cancellationToken); + } - public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms channel disable", async (services, token) => + public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms channel disable", async (services, token) => { var db = services.GetRequiredService(); var item = await db.SmsChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) - ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); + ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); item.Status = TenantExternalProviderStatus.Disabled; item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, new { item.Provider, item.Scene }); + AddAudit(db, actor, item.TenantId, "platform.sms.channel.disabled", "sms_channels", item.Id, + new { item.Provider, item.Scene }); await db.SaveChangesAsync(token); return ToChannelItem(item, await TenantNameAsync(db, item.TenantId, token), 0); }, cancellationToken); + } - public Task> GetTemplatesAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms templates list", async (services, token) => + public Task> GetTemplatesAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms templates list", async (services, token) => { var db = services.GetRequiredService(); var sentCounts = await db.SmsSendLogs.AsNoTracking() .Where(value => value.TemplateId.HasValue) .GroupBy(value => value.TemplateId!.Value) - .Select(group => new { TemplateId = group.Key, Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) }) + .Select(group => new + { + TemplateId = group.Key, Sent = group.Count(value => value.Status == SmsSendLogStatus.Sent), + Failed = group.Count(value => value.Status == SmsSendLogStatus.Failed) + }) .ToDictionaryAsync(value => value.TemplateId, token); var values = from template in db.SmsTemplates.AsNoTracking() - join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals new { channel.TenantId, ChannelId = channel.Id } + join channel in db.SmsChannels.AsNoTracking() on new { template.TenantId, template.ChannelId } equals + new { channel.TenantId, ChannelId = channel.Id } join tenant in db.Tenants.AsNoTracking() on template.TenantId equals tenant.Id select new { template, channel.Name, TenantName = tenant.Name }; if (query.TenantId.HasValue) values = values.Where(value => value.template.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active)); - var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.template.Status == ParseEnum(query.Status, SmsTemplateStatus.Active)); + var rows = await values.OrderByDescending(value => value.template.UpdatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); return new PlatformTenantCapabilityList(rows.Select(value => { var counts = sentCounts.GetValueOrDefault(value.template.Id); - return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, counts?.Failed ?? 0); + return ToTemplateItem(value.template, value.TenantName, value.Name, counts?.Sent ?? 0, + counts?.Failed ?? 0); }).ToArray()); }, cancellationToken); + } - public Task UpsertTemplateAsync(PlatformCapabilityActor actor, UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms template upsert", async (services, token) => + public Task UpsertTemplateAsync(PlatformCapabilityActor actor, + UpsertPlatformSmsTemplateCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms template upsert", async (services, token) => { var db = services.GetRequiredService(); - var channel = await db.SmsChannels.AsNoTracking().SingleOrDefaultAsync(value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token) - ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); + var channel = await db.SmsChannels.AsNoTracking() + .SingleOrDefaultAsync( + value => value.Id == command.ChannelId && value.TenantId == command.TenantId, token) + ?? throw Error("SMS channel was not found.", "sms_channel_not_found"); var item = command.Id.HasValue - ? await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) - : await db.SmsTemplates.SingleOrDefaultAsync(value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token); + ? await db.SmsTemplates.SingleOrDefaultAsync( + value => value.Id == command.Id.Value && value.TenantId == command.TenantId, token) + : await db.SmsTemplates.SingleOrDefaultAsync( + value => value.TenantId == command.TenantId && value.Code == NormalizeCode(command.Code), token); if (item is null) { item = new SmsTemplate { TenantId = command.TenantId }; @@ -275,84 +367,167 @@ internal sealed class PlatformSmsAdminService(ITenantExecutionScope tenantExecut item.Remark = Normalize(command.Remark); item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status }); + AddAudit(db, actor, command.TenantId, "platform.sms.template.upserted", "sms_templates", item.Id, + new { item.Code, item.AuditStatus, item.Status }); await db.SaveChangesAsync(token); return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channel.Name, 0, 0); }, cancellationToken); + } - public Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) => - ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, "platform.sms.template.review_submitted", cancellationToken); + public Task SubmitTemplateReviewAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default) + { + return ChangeTemplateAsync(actor, templateId, SmsTemplateAuditStatus.PendingReview, null, + "platform.sms.template.review_submitted", cancellationToken); + } - public Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, CancellationToken cancellationToken = default) => - ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, "platform.sms.template.disabled", cancellationToken); + public Task DisableTemplateAsync(PlatformCapabilityActor actor, Guid templateId, + CancellationToken cancellationToken = default) + { + return ChangeTemplateAsync(actor, templateId, null, SmsTemplateStatus.Disabled, + "platform.sms.template.disabled", cancellationToken); + } - public Task> GetLogsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync("platform sms logs list", async (services, token) => + public Task> GetLogsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform sms logs list", async (services, token) => { var db = services.GetRequiredService(); var values = db.SmsSendLogs.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent)); - return new PlatformTenantCapabilityList(await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token)); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == ParseEnum(query.Status, SmsSendLogStatus.Sent)); + return new PlatformTenantCapabilityList(await values.OrderByDescending(value => value.CreatedAt) + .Take(Limit(query.Limit)).ToArrayAsync(token)); }, cancellationToken); + } - private Task ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, CancellationToken cancellationToken) => - ExecuteAsync(action, async (services, token) => + private Task ChangeTemplateAsync(PlatformCapabilityActor actor, Guid templateId, + SmsTemplateAuditStatus? auditStatus, SmsTemplateStatus? status, string action, + CancellationToken cancellationToken) + { + return ExecuteAsync(action, async (services, token) => { var db = services.GetRequiredService(); var item = await db.SmsTemplates.SingleOrDefaultAsync(value => value.Id == templateId, token) - ?? throw Error("SMS template was not found.", "sms_template_not_found"); - var channelName = await db.SmsChannels.AsNoTracking().Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId).Select(value => value.Name).SingleAsync(token); + ?? throw Error("SMS template was not found.", "sms_template_not_found"); + var channelName = await db.SmsChannels.AsNoTracking() + .Where(value => value.TenantId == item.TenantId && value.Id == item.ChannelId) + .Select(value => value.Name).SingleAsync(token); if (auditStatus.HasValue) { item.AuditStatus = auditStatus.Value; item.SubmittedAt = DateTimeOffset.UtcNow; } + if (status.HasValue) { item.Status = status.Value; item.DisabledAt = DateTimeOffset.UtcNow; } + item.UpdatedAt = DateTimeOffset.UtcNow; - AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, new { item.Code, item.AuditStatus, item.Status }); + AddAudit(db, actor, item.TenantId, action, "sms_templates", item.Id, + new { item.Code, item.AuditStatus, item.Status }); await db.SaveChangesAsync(token); return ToTemplateItem(item, await TenantNameAsync(db, item.TenantId, token), channelName, 0, 0); }, cancellationToken); + } - private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformSmsAdminService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } - private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) => - new(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, channel.Signature, channel.Scene, channel.Status, - channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, channel.Metadata, channel.UpdatedAt); - private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, int sent, int failed) => - new(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, template.Code, template.Name, template.Type, - template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, sent, failed, template.UpdatedAt); - private static async Task TenantNameAsync(TikuDbContext db, Guid tenantId, CancellationToken token) => - await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name).SingleOrDefaultAsync(token) - ?? throw Error("Tenant was not found.", "tenant_not_found"); - private static PlatformCapabilityException Error(string message, string code) => new(message, code); - private static int Limit(int value) => Math.Clamp(value, 1, 500); - private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static T ParseEnum(string? value, T fallback) where T : struct, Enum => Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; - private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, string targetType, Guid targetId, object details) => - db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); + private static PlatformSmsChannelItem ToChannelItem(SmsChannel channel, string tenantName, int sentThisMonth) + { + return new PlatformSmsChannelItem(channel.Id, channel.TenantId, tenantName, channel.Provider, channel.Name, + channel.Signature, + channel.Scene, channel.Status, + channel.SecretRef, channel.Priority, channel.MonthlyQuota, sentThisMonth, channel.ConfigPublic, + channel.Metadata, channel.UpdatedAt); + } + + private static PlatformSmsTemplateItem ToTemplateItem(SmsTemplate template, string tenantName, string channelName, + int sent, int failed) + { + return new PlatformSmsTemplateItem(template.Id, template.TenantId, tenantName, template.ChannelId, channelName, + template.Code, + template.Name, template.Type, + template.AuditStatus, template.Status, template.ProviderTemplateCode, template.Content, template.Remark, + sent, failed, template.UpdatedAt); + } + + private static async Task TenantNameAsync(TikuDbContext db, Guid tenantId, CancellationToken token) + { + return await db.Tenants.Where(value => value.Id == tenantId).Select(value => value.Name) + .SingleOrDefaultAsync(token) + ?? throw Error("Tenant was not found.", "tenant_not_found"); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } + + private static void AddAudit(TikuDbContext db, PlatformCapabilityActor actor, Guid tenantId, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + TenantId = tenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, + TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) + }); + } } -internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) : IPlatformPaymentSettingsService +internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenantExecutionScope) + : IPlatformPaymentSettingsService { - public Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) => - ExecuteAsync>("platform payment apps list", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformPaymentApps.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active)); - return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token); - }, cancellationToken); + public Task> GetAppsAsync(PlatformCapabilityActor actor, string? status, + int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment apps list", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformPaymentApps.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(status)) + values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentAppStatus.Active)); + return await values.OrderBy(value => value.AppCode).Take(Limit(limit)).ToArrayAsync(token); + }, cancellationToken); + } - public Task UpsertAppAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("platform payment app upsert", async (services, token) => + public Task UpsertAppAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentAppCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment app upsert", async (services, token) => { var db = services.GetRequiredService(); var code = NormalizeCode(command.AppCode); @@ -371,30 +546,43 @@ internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenan item.Description = Normalize(command.Description); item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, new { item.AppCode, item.Status }); + AddPlatformAudit(db, actor, "platform.payment.app.upserted", "platform_payment_apps", item.Id, + new { item.AppCode, item.Status }); await db.SaveChangesAsync(token); return item; }, cancellationToken); + } - public Task> GetChannelsAsync(PlatformCapabilityActor actor, Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) => - ExecuteAsync>("platform payment channels list", async (services, token) => + public Task> GetChannelsAsync(PlatformCapabilityActor actor, + Guid? appId, string? status, int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment channels list", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformPaymentChannels.AsNoTracking(); + if (appId.HasValue) values = values.Where(value => value.AppId == appId); + if (!string.IsNullOrWhiteSpace(status)) + values = values.Where(value => + value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active)); + return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)) + .ToArrayAsync(token); + }, cancellationToken); + } + + public Task UpsertChannelAsync(PlatformCapabilityActor actor, + UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment channel upsert", async (services, token) => { var db = services.GetRequiredService(); - var values = db.PlatformPaymentChannels.AsNoTracking(); - if (appId.HasValue) values = values.Where(value => value.AppId == appId); - if (!string.IsNullOrWhiteSpace(status)) values = values.Where(value => value.Status == ParseEnum(status, PlatformPaymentChannelStatus.Active)); - return await values.OrderBy(value => value.Priority).ThenBy(value => value.Provider).Take(Limit(limit)).ToArrayAsync(token); - }, cancellationToken); - - public Task UpsertChannelAsync(PlatformCapabilityActor actor, UpsertPlatformPaymentChannelCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("platform payment channel upsert", async (services, token) => - { - var db = services.GetRequiredService(); - if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) throw Error("Platform payment app was not found.", "platform_payment_app_not_found"); + if (!await db.PlatformPaymentApps.AnyAsync(value => value.Id == command.AppId, token)) + throw Error("Platform payment app was not found.", "platform_payment_app_not_found"); var provider = NormalizeCode(command.Provider); var item = command.Id.HasValue ? await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == command.Id.Value, token) - : await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.AppId == command.AppId && value.Provider == provider, token); + : await db.PlatformPaymentChannels.SingleOrDefaultAsync( + value => value.AppId == command.AppId && value.Provider == provider, token); if (item is null) { item = new PlatformPaymentChannel { AppId = command.AppId, Provider = provider }; @@ -410,76 +598,144 @@ internal sealed class PlatformPaymentSettingsService(ITenantExecutionScope tenan item.ConfigPublic = command.ConfigPublic; item.Metadata = command.Metadata; item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, new { item.Provider, item.Status, item.SecretRef }); + AddPlatformAudit(db, actor, "platform.payment.channel.upserted", "platform_payment_channels", item.Id, + new { item.Provider, item.Status, item.SecretRef }); await db.SaveChangesAsync(token); return item; }, cancellationToken); + } - public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, CancellationToken cancellationToken = default) => - ExecuteAsync("platform payment channel disable", async (services, token) => + public Task DisableChannelAsync(PlatformCapabilityActor actor, Guid channelId, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform payment channel disable", async (services, token) => { var db = services.GetRequiredService(); var item = await db.PlatformPaymentChannels.SingleOrDefaultAsync(value => value.Id == channelId, token) - ?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found"); + ?? throw Error("Platform payment channel was not found.", "platform_payment_channel_not_found"); item.Status = PlatformPaymentChannelStatus.Disabled; item.UpdatedAt = DateTimeOffset.UtcNow; - AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, new { item.Provider }); + AddPlatformAudit(db, actor, "platform.payment.channel.disabled", "platform_payment_channels", item.Id, + new { item.Provider }); await db.SaveChangesAsync(token); return item; }, cancellationToken); + } - public Task> GetEventsAsync(PlatformCapabilityActor actor, string? status, int limit, CancellationToken cancellationToken = default) => - ExecuteAsync>("platform payment events list", async (services, token) => - await services.GetRequiredService().PlatformBillingPaymentEvents.AsNoTracking() - .OrderByDescending(value => value.CreatedAt) - .Take(Limit(limit)) - .ToArrayAsync(token), cancellationToken); + public Task> GetEventsAsync(PlatformCapabilityActor actor, + string? status, int limit, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("platform payment events list", + async (services, token) => + await services.GetRequiredService().PlatformBillingPaymentEvents.AsNoTracking() + .OrderByDescending(value => value.CreatedAt) + .Take(Limit(limit)) + .ToArrayAsync(token), cancellationToken); + } - public Task GetRebateSummaryAsync(PlatformCapabilityActor actor, CancellationToken cancellationToken = default) => - ExecuteAsync("platform rebate summary", async (services, token) => + public Task GetRebateSummaryAsync(PlatformCapabilityActor actor, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform rebate summary", async (services, token) => { var db = services.GetRequiredService(); var settlements = db.CommissionSettlements.AsNoTracking(); var gross = await settlements.SumAsync(value => (int?)value.GrossAmountCents, token) ?? 0; var commission = await settlements.SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid).SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var pending = await settlements.Where(value => value.Status == CommissionSettlementStatus.Approved || value.Status == CommissionSettlementStatus.PendingReview).SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; - var exceptions = await settlements.CountAsync(value => value.Status == CommissionSettlementStatus.Rejected || value.Status == CommissionSettlementStatus.Cancelled, token); + var paid = await settlements.Where(value => value.Status == CommissionSettlementStatus.Paid) + .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; + var pending = await settlements + .Where(value => + value.Status == CommissionSettlementStatus.Approved || + value.Status == CommissionSettlementStatus.PendingReview) + .SumAsync(value => (int?)value.CommissionAmountCents, token) ?? 0; + var exceptions = await settlements.CountAsync( + value => value.Status == CommissionSettlementStatus.Rejected || + value.Status == CommissionSettlementStatus.Cancelled, token); return new PlatformRebateSummary(gross, commission, pending, paid, exceptions); }, cancellationToken); + } - private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - private static PlatformCapabilityException Error(string message, string code) => new(message, code); - private static int Limit(int value) => Math.Clamp(value, 1, 500); - private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static T ParseEnum(string? value, T fallback) where T : struct, Enum => Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; - private static void AddPlatformAudit(TikuDbContext db, PlatformCapabilityActor actor, string action, string targetType, Guid targetId, object details) => - db.AuditLogs.Add(new AuditLog { ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(details) }); + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformPaymentSettingsService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } + + private static void AddPlatformAudit(TikuDbContext db, PlatformCapabilityActor actor, string action, + string targetType, Guid targetId, object details) + { + db.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), + Details = JsonSerializer.SerializeToElement(details) + }); + } } -internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) : IPlatformTenantPaymentAdminService +internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope tenantExecutionScope) + : IPlatformTenantPaymentAdminService { - public Task> GetAppsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync("platform tenant payment apps list", async (services, token) => + public Task> GetAppsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment apps list", async (services, token) => { var db = services.GetRequiredService(); var values = db.TenantExternalProviders.AsNoTracking() .Where(value => value.Capability == TenantExternalProviderCapability.Payment); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); - var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority).Take(Limit(query.Limit)) - .Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, value.Metadata, value.CreatedAt, value.UpdatedAt)) + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.Status == ParseEnum(query.Status, TenantExternalProviderStatus.Active)); + var items = await values.OrderBy(value => value.TenantId).ThenBy(value => value.Priority) + .Take(Limit(query.Limit)) + .Select(value => new TenantExternalProviderItem(value.Id, value.Capability, value.Provider, + value.Status, value.DisplayName, value.SecretRef, value.Priority, value.ConfigPublic, + value.Metadata, value.CreatedAt, value.UpdatedAt)) .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); + } - public Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("platform tenant payment app upsert", async (services, token) => + public Task UpsertAppAsync(PlatformCapabilityActor actor, Guid tenantId, + UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment app upsert", async (services, token) => { - if (command.Capability != TenantExternalProviderCapability.Payment) throw Error("Tenant payment capability is required.", "tenant_payment_capability_required"); - var item = await services.GetRequiredService().UpsertProviderAsync(tenantId, command, token); + if (command.Capability != TenantExternalProviderCapability.Payment) + throw Error("Tenant payment capability is required.", "tenant_payment_capability_required"); + var item = await services.GetRequiredService() + .UpsertProviderAsync(tenantId, command, token); services.GetRequiredService().AuditLogs.Add(new AuditLog { TenantId = tenantId, @@ -492,9 +748,12 @@ internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope te await services.GetRequiredService().SaveChangesAsync(token); return item; }, cancellationToken); + } - public Task> GetEventsAsync(PlatformCapabilityActor actor, PlatformCapabilityQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync("platform tenant payment events list", async (services, token) => + public Task> GetEventsAsync(PlatformCapabilityActor actor, + PlatformCapabilityQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync("platform tenant payment events list", async (services, token) => { var db = services.GetRequiredService(); var values = db.PaymentEvents.AsNoTracking(); @@ -517,10 +776,30 @@ internal sealed class PlatformTenantPaymentAdminService(ITenantExecutionScope te .ToArrayAsync(token); return new PlatformTenantCapabilityList(items); }, cancellationToken); + } - private Task ExecuteAsync(string reason, Func> operation, CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync(new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); - private static PlatformCapabilityException Error(string message, string code) => new(message, code); - private static int Limit(int value) => Math.Clamp(value, 1, 500); - private static T ParseEnum(string? value, T fallback) where T : struct, Enum => Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) ? parsed : fallback; -} + private Task ExecuteAsync(string reason, + Func> operation, CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformTenantPaymentAdminService), + reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } + + private static PlatformCapabilityException Error(string message, string code) + { + return new PlatformCapabilityException(message, code); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 500); + } + + private static T ParseEnum(string? value, T fallback) where T : struct, Enum + { + return Enum.TryParse(value?.Trim().Replace("-", "_", StringComparison.Ordinal), true, out var parsed) + ? parsed + : fallback; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs b/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs index 6761650..711d86e 100644 --- a/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs +++ b/Tiku.Infrastructure/PlatformAdmin/StaffAndAccess/PlatformAdminService.StaffAndAccess.cs @@ -1,22 +1,10 @@ -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -38,7 +26,9 @@ internal sealed partial class PlatformAdminService .ToArrayAsync(cancellationToken); var items = roleRows .GroupBy(row => row.user.Id) - .Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())) + .Select(group => ToStaffItem(group.First().user, + group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal) + .ToArray())) .OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString()) .Take(Limit(query.Limit)) .ToArray(); @@ -57,8 +47,8 @@ internal sealed partial class PlatformAdminService var user = command.UserId.HasValue ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken) : await dbContext.Users.SingleOrDefaultAsync(item => - (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || - (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), + (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || + (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), cancellationToken); if (user is null) { @@ -89,20 +79,21 @@ internal sealed partial class PlatformAdminService } var roleIds = command.RoleIds.Distinct().ToArray(); - var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); + var roleCount = + await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); if (roleCount != roleIds.Length) - { throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found"); - } await dbContext.SaveChangesAsync(cancellationToken); - await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken); + await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id) + .ExecuteDeleteAsync(cancellationToken); dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole { UserId = user.Id, RoleId = roleId })); - AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); + AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, + new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); await dbContext.SaveChangesAsync(cancellationToken); var invalidator = provider.GetRequiredService(); await invalidator.InvalidateUserAsync(user.Id, cancellationToken); @@ -124,10 +115,11 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) => { var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken) - ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); + ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); var from = user.Status; user.Status = command.Status; - AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason }); + AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, + new { From = from, To = command.Status, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); await provider.GetRequiredService() .InvalidateUserAsync(user.Id, cancellationToken); @@ -140,6 +132,4 @@ internal sealed partial class PlatformAdminService return ToStaffItem(user, roleCodes); }, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs b/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs index d6e8b56..ef95350 100644 --- a/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs +++ b/Tiku.Infrastructure/PlatformAdmin/TenantDomains/PlatformAdminService.TenantDomains.cs @@ -1,22 +1,9 @@ using System.Text.Json; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; -using Tiku.Domain.Identity; using Tiku.Domain.Operations; -using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -32,9 +19,7 @@ internal sealed partial class PlatformAdminService { var domains = dbContext.TenantDomains.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) - { domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status)); - } if (!string.IsNullOrWhiteSpace(query.Search)) { @@ -58,9 +43,12 @@ internal sealed partial class PlatformAdminService await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform domain recheck", async dbContext => { - var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) + var domain = + await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) ?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found"); - domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending; + domain.Status = domain.Status == TenantDomainStatus.Disabled + ? TenantDomainStatus.Disabled + : TenantDomainStatus.Pending; domain.LastCheckedAt = DateTimeOffset.UtcNow; domain.LastFailureReason = null; dbContext.BackgroundJobs.Add(new BackgroundJob @@ -70,11 +58,10 @@ internal sealed partial class PlatformAdminService Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }), MaxRetries = 3 }); - AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host }); + AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, + new { domain.Id, domain.Host }); await dbContext.SaveChangesAsync(cancellationToken); return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value); }, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs index 7ceef54..cccbc1c 100644 --- a/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs +++ b/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/PlatformAdminService.TenantProvisioning.cs @@ -1,22 +1,15 @@ -using System.Text.Json; using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Npgsql; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Tenancy; -using Tiku.Application.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; @@ -42,9 +35,7 @@ internal sealed partial class PlatformAdminService } if (!string.IsNullOrWhiteSpace(query.Status)) - { tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status)); - } var rows = await tenants .OrderByDescending(tenant => tenant.CreatedAt) @@ -61,7 +52,8 @@ internal sealed partial class PlatformAdminService }) .ToArrayAsync(cancellationToken); - return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); + return new PlatformTenantList(rows + .Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); }, cancellationToken); } @@ -74,8 +66,9 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform tenant detail", async dbContext => { var tenant = await dbContext.Tenants.AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, + cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); var domains = await dbContext.TenantDomains.AsNoTracking() .Where(domain => domain.TenantId == tenantId) .OrderByDescending(domain => domain.IsPrimary) @@ -114,20 +107,20 @@ internal sealed partial class PlatformAdminService { var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() .SingleOrDefaultAsync(value => - value.ActorUserId == actor.UserId && - value.Scope == "platform.tenant.create" && - value.IdempotencyKey == idempotencyKey, + value.ActorUserId == actor.UserId && + value.Scope == "platform.tenant.create" && + value.IdempotencyKey == idempotencyKey, cancellationToken); if (existingRequest is not null) { if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { throw new PlatformAdminException( "Idempotency key was already used with a different request.", "idempotency_conflict"); - } - return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); + return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, + cancellationToken); } + var tenantId = Guid.NewGuid(); dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency { @@ -140,57 +133,54 @@ internal sealed partial class PlatformAdminService await dbContext.SaveChangesAsync(cancellationToken); var slug = NormalizeCode(command.Slug); if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken)) - { throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists"); - } var ownerEmail = Normalize(command.OwnerEmail); var ownerPhone = Normalize(command.OwnerPhone); var ownerIdentifier = ownerEmail ?? ownerPhone; if (ownerIdentifier is null) - { - throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required"); - } + throw new PlatformAdminException("Owner email or phone is required.", + "tenant_owner_identifier_required"); if (await dbContext.Users.AnyAsync(user => (ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) || (ownerPhone != null && user.Phone == ownerPhone), cancellationToken)) - { throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists"); - } SaasOfferingVersion? initialVersion = null; if (command.InitialOfferingVersionId.HasValue) { initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking() - .SingleOrDefaultAsync(value => - value.Id == command.InitialOfferingVersionId && - value.Status == SaasOfferingVersionStatus.Published, - cancellationToken) - ?? throw new PlatformAdminException("Initial offering version was not found or published.", "saas_offering_version_not_found"); + .SingleOrDefaultAsync(value => + value.Id == command.InitialOfferingVersionId && + value.Status == SaasOfferingVersionStatus.Published, + cancellationToken) + ?? throw new PlatformAdminException( + "Initial offering version was not found or published.", + "saas_offering_version_not_found"); var offeringType = await dbContext.SaasOfferings.AsNoTracking() .Where(value => value.Id == initialVersion.OfferingId) .Select(value => value.Type) .SingleAsync(cancellationToken); if (offeringType != SaasOfferingType.BasePlan) - { - throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required"); - } + throw new PlatformAdminException("Initial offering must be a base plan.", + "saas_base_offering_required"); } else { var now = DateTimeOffset.UtcNow; initialVersion = await ( - from offering in dbContext.SaasOfferings.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id equals version.OfferingId - where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) && - offering.Type == SaasOfferingType.BasePlan && - offering.Status == SaasOfferingStatus.Active && - version.Status == SaasOfferingVersionStatus.Published && - (version.EffectiveAt == null || version.EffectiveAt <= now) - orderby version.Version descending - select version).FirstOrDefaultAsync(cancellationToken) - ?? throw new PlatformAdminException( - "The default base offering does not have an effective published version.", - "default_offering_unavailable"); + from offering in dbContext.SaasOfferings.AsNoTracking() + join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id + equals version.OfferingId + where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) && + offering.Type == SaasOfferingType.BasePlan && + offering.Status == SaasOfferingStatus.Active && + version.Status == SaasOfferingVersionStatus.Published && + (version.EffectiveAt == null || version.EffectiveAt <= now) + orderby version.Version descending + select version).FirstOrDefaultAsync(cancellationToken) + ?? throw new PlatformAdminException( + "The default base offering does not have an effective published version.", + "default_offering_unavailable"); } var tenant = new Tenant @@ -228,11 +218,9 @@ internal sealed partial class PlatformAdminService var userManager = provider.GetRequiredService>(); var createOwner = await userManager.CreateAsync(owner); if (!createOwner.Succeeded) - { throw new PlatformAdminException( string.Join("; ", createOwner.Errors.Select(error => error.Description)), "tenant_owner_password_invalid"); - } tenant.OwnerUserId = owner.Id; dbContext.TenantMemberships.Add(new TenantMembership { @@ -247,10 +235,9 @@ internal sealed partial class PlatformAdminService AllowExternalStudentSelfRegistration = false }); var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost); - if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, cancellationToken)) - { + if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, + cancellationToken)) throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); - } dbContext.TenantDomains.Add(primaryDomain); dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name)); await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken); @@ -291,7 +278,8 @@ internal sealed partial class PlatformAdminService EndsAt = subscriptionExpiresAt.Value }); } - AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); + AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, + new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); await dbContext.SaveChangesAsync(cancellationToken); return new PlatformTenantProvisioningResult( ToTenantItem(tenant, 1, subscriptionExpiresAt), @@ -313,11 +301,9 @@ internal sealed partial class PlatformAdminService value.IdempotencyKey == idempotencyKey, cancellationToken); if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { throw new PlatformAdminException( "Idempotency key was already used with a different request.", "idempotency_conflict"); - } return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); }, cancellationToken); } @@ -330,15 +316,14 @@ internal sealed partial class PlatformAdminService { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Primary domain replacement reason is required.", "domain_change_reason_required"); - } + throw new PlatformAdminException("Primary domain replacement reason is required.", + "domain_change_reason_required"); return await ExecuteSystemAsync("platform primary domain replace", async dbContext => { var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => - value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); var existing = await dbContext.TenantDomains .Where(value => value.TenantId == tenant.Id && value.IsPrimary) .ToArrayAsync(cancellationToken); @@ -352,15 +337,13 @@ internal sealed partial class PlatformAdminService await dbContext.TenantOwnerActivationGrants .Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null) .ExecuteUpdateAsync(setters => setters - .SetProperty(value => value.RevokedAt, now) - .SetProperty(value => value.RevokedBy, actor.UserId) - .SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason), + .SetProperty(value => value.RevokedAt, now) + .SetProperty(value => value.RevokedBy, actor.UserId) + .SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason), cancellationToken); var next = CreatePrimaryDomain(tenant.Id, command.Host); if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken)) - { throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists"); - } dbContext.TenantDomains.Add(next); AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id, new { next.Id, next.Host, command.Reason }); @@ -377,9 +360,8 @@ internal sealed partial class PlatformAdminService await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key"); if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Owner activation issuance reason is required.", "owner_activation_reason_required"); - } + throw new PlatformAdminException("Owner activation issuance reason is required.", + "owner_activation_reason_required"); return await ExecuteSystemAsync("platform owner activation link issue", async dbContext => { @@ -389,44 +371,42 @@ internal sealed partial class PlatformAdminService cancellationToken); var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking() .SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId && - value.Scope == "platform.tenant.owner_activation.issue" && - value.IdempotencyKey == idempotencyKey, cancellationToken); + value.Scope == "platform.tenant.owner_activation.issue" && + value.IdempotencyKey == idempotencyKey, cancellationToken); var requestHash = OwnerActivationRequestHash(command); if (existingRequest is not null) { if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { throw new PlatformAdminException( "Idempotency key was already used with a different request.", "idempotency_conflict"); - } return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken); } var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value => - value.Id == command.TenantId && value.Status == TenantStatus.Active && - value.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active"); + value.Id == command.TenantId && value.Status == TenantStatus.Active && + value.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active"); var ownerId = tenant.OwnerUserId - ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); + ?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found"); var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken); if (owner.PasswordHash is not null || !owner.ForcePasswordChange) - { throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated"); - } var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value => - value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active, - cancellationToken) - ?? throw new PlatformAdminException("The primary domain is not active.", "primary_domain_not_active"); + value.TenantId == tenant.Id && value.IsPrimary && + value.Status == TenantDomainStatus.Active, + cancellationToken) + ?? throw new PlatformAdminException("The primary domain is not active.", + "primary_domain_not_active"); var now = DateTimeOffset.UtcNow; var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => value.TenantId == tenant.Id && - (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) && + (value.Status == TenantSaasSubscriptionStatus.Trial || + value.Status == TenantSaasSubscriptionStatus.Active) && value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken); if (!subscriptionActive) - { - throw new PlatformAdminException("An active trial or subscription is required.", "subscription_inactive"); - } + throw new PlatformAdminException("An active trial or subscription is required.", + "subscription_inactive"); var current = await dbContext.TenantOwnerActivationGrants .Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && @@ -434,9 +414,8 @@ internal sealed partial class PlatformAdminService .OrderByDescending(value => value.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting) - { - throw new PlatformAdminException("An owner activation link is already active.", "owner_activation_already_issued"); - } + throw new PlatformAdminException("An owner activation link is already active.", + "owner_activation_already_issued"); if (current is not null) { current.RevokedAt = now; @@ -466,7 +445,10 @@ internal sealed partial class PlatformAdminService ResourceId = grant.Id }); AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id, - new { grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason }); + new + { + grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason + }); await dbContext.SaveChangesAsync(cancellationToken); return new PlatformOwnerActivationLinkResult( grant.Id, @@ -485,15 +467,17 @@ internal sealed partial class PlatformAdminService return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) => { var tenant = await dbContext.Tenants - .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) - ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + .SingleOrDefaultAsync( + item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, + cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); var fromStatus = tenant.Status; tenant.Status = command.Status; AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new { FromStatus = fromStatus, ToStatus = tenant.Status, - BillingStatus = tenant.BillingStatus, + tenant.BillingStatus, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); @@ -501,7 +485,8 @@ internal sealed partial class PlatformAdminService .InvalidateAsync(tenant.Id, cancellationToken); await provider.GetRequiredService() .InvalidateTenantAsync(tenant.Id, cancellationToken); - var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); + var domainCount = + await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); var expiresAt = await dbContext.TenantSaasSubscriptions .Where(subscription => subscription.TenantId == tenant.Id) .OrderByDescending(subscription => subscription.CurrentPeriodEnd) @@ -540,7 +525,8 @@ internal sealed partial class PlatformAdminService profile.BankName = Normalize(command.BankName); profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked); profile.Metadata = JsonObjectOrDefault(command.Metadata); - AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType }); + AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, + new { profile.BillingName, profile.InvoiceType }); await dbContext.SaveChangesAsync(cancellationToken); return ToBillingProfileItem(profile); }, cancellationToken); @@ -556,8 +542,8 @@ internal sealed partial class PlatformAdminService { await RequireTenantAsync(dbContext, tenantId, cancellationToken); var policy = await dbContext.TenantBillingPolicies.AsNoTracking() - .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken) - ?? new TenantBillingPolicy { TenantId = tenantId }; + .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken) + ?? new TenantBillingPolicy { TenantId = tenantId }; return ToBillingPolicyItem(policy); }, cancellationToken); } @@ -572,9 +558,8 @@ internal sealed partial class PlatformAdminService { await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); if (string.IsNullOrWhiteSpace(command.Reason)) - { - throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required"); - } + throw new PlatformAdminException("Billing policy change reason is required.", + "platform_billing_reason_required"); var policy = await dbContext.TenantBillingPolicies .SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken); if (policy is null) @@ -582,6 +567,7 @@ internal sealed partial class PlatformAdminService policy = new TenantBillingPolicy { TenantId = command.TenantId }; dbContext.TenantBillingPolicies.Add(policy); } + policy.CollectionMode = command.CollectionMode; policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider); policy.AutoGenerateRenewal = command.AutoGenerateRenewal; @@ -598,6 +584,4 @@ internal sealed partial class PlatformAdminService return ToBillingPolicyItem(policy); }, cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs b/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs index 0cd1b66..cd53801 100644 --- a/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs +++ b/Tiku.Infrastructure/PlatformBilling/CommercialBillingProcessor.cs @@ -37,14 +37,9 @@ internal sealed class CommercialBillingProcessor( public async Task ProcessDueAsync(CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return 0; - } + if (!options.Value.Enabled) return 0; if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue) - { throw new InvalidOperationException("Commercial billing discovery requires a global system context."); - } var processed = 0; processed += await ProcessRenewalsAsync(cancellationToken); @@ -58,24 +53,27 @@ internal sealed class CommercialBillingProcessor( { var now = DateTimeOffset.UtcNow; var candidates = await ( - from subscription in directoryDbContext.TenantSaasSubscriptions.AsNoTracking() - join policy in directoryDbContext.TenantBillingPolicies.AsNoTracking() on subscription.TenantId equals policy.TenantId - where policy.AutoGenerateRenewal && - (subscription.Status == TenantSaasSubscriptionStatus.Active || subscription.Status == TenantSaasSubscriptionStatus.Trial) && - subscription.CurrentPeriodEnd <= now.AddDays(policy.RenewalLeadDays) - orderby subscription.CurrentPeriodEnd - select new { subscription.TenantId, SubscriptionId = subscription.Id }) + from subscription in directoryDbContext.TenantSaasSubscriptions.AsNoTracking() + join policy in directoryDbContext.TenantBillingPolicies.AsNoTracking() on subscription.TenantId equals + policy.TenantId + where policy.AutoGenerateRenewal && + (subscription.Status == TenantSaasSubscriptionStatus.Active || + subscription.Status == TenantSaasSubscriptionStatus.Trial) && + subscription.CurrentPeriodEnd <= now.AddDays(policy.RenewalLeadDays) + orderby subscription.CurrentPeriodEnd + select new { subscription.TenantId, SubscriptionId = subscription.Id }) .Take(BatchSize()) .ToArrayAsync(cancellationToken); foreach (var candidate in candidates) - { await tenantExecutionScope.ExecuteAsync( Scope(candidate.TenantId, "Generate subscription renewal receivable", candidate.SubscriptionId), async (services, token) => { var db = services.GetRequiredService(); - var subscription = await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId, token); + var subscription = + await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId, + token); var key = $"auto-renewal:{subscription.Id:N}:{subscription.CurrentPeriodEnd:yyyyMMddHHmmss}"; var existingOrder = await db.PlatformBillingOrders.SingleOrDefaultAsync(value => value.TenantId == subscription.TenantId && value.IdempotencyKey == key, token); @@ -83,13 +81,15 @@ internal sealed class CommercialBillingProcessor( if (existingOrder is null) { var ownerId = await db.Tenants.AsNoTracking() - .Where(value => value.Id == subscription.TenantId) - .Select(value => value.OwnerUserId) - .SingleAsync(token) - ?? throw new PlatformBillingException("Tenant owner is required for renewal billing.", "tenant_owner_not_found"); + .Where(value => value.Id == subscription.TenantId) + .Select(value => value.OwnerUserId) + .SingleAsync(token) + ?? throw new PlatformBillingException( + "Tenant owner is required for renewal billing.", "tenant_owner_not_found"); orderView = await services.GetRequiredService() .RenewSubscriptionAsync(new TenantBillingActor(ownerId, subscription.TenantId), key, token); - existingOrder = await db.PlatformBillingOrders.SingleAsync(value => value.Id == orderView.Id, token); + existingOrder = + await db.PlatformBillingOrders.SingleAsync(value => value.Id == orderView.Id, token); existingOrder.ExpiresAt = subscription.CurrentPeriodEnd; } else @@ -108,7 +108,6 @@ internal sealed class CommercialBillingProcessor( if (!await db.PlatformBillingInvoices.AnyAsync(value => value.TenantId == subscription.TenantId && value.OrderId == existingOrder.Id, token)) - { db.PlatformBillingInvoices.Add(new PlatformBillingInvoice { TenantId = subscription.TenantId, @@ -121,11 +120,9 @@ internal sealed class CommercialBillingProcessor( IssuedAt = now, BillingProfileSnapshot = await BillingProfileSnapshotAsync(db, subscription.TenantId, token) }); - } await db.SaveChangesAsync(token); }, cancellationToken); - } return candidates.Length; } @@ -134,7 +131,8 @@ internal sealed class CommercialBillingProcessor( var today = DateOnly.FromDateTime(DateTime.UtcNow); var candidates = await directoryDbContext.PlatformBillingInvoices.AsNoTracking() .Where(value => value.DueDate != null && - (value.Status == PlatformBillingInvoiceStatus.Issued || value.Status == PlatformBillingInvoiceStatus.Overdue) && + (value.Status == PlatformBillingInvoiceStatus.Issued || + value.Status == PlatformBillingInvoiceStatus.Overdue) && value.DueDate <= today.AddDays(7)) .OrderBy(value => value.DueDate) .Select(value => new { value.TenantId, InvoiceId = value.Id }) @@ -142,27 +140,27 @@ internal sealed class CommercialBillingProcessor( .ToArrayAsync(cancellationToken); var created = 0; foreach (var candidate in candidates) - { created += await tenantExecutionScope.ExecuteAsync( Scope(candidate.TenantId, "Generate billing reminder", candidate.InvoiceId), async (services, token) => { var db = services.GetRequiredService(); - var invoice = await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token); + var invoice = + await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token); var dueDate = invoice.DueDate!.Value; if (dueDate < today && invoice.Status == PlatformBillingInvoiceStatus.Issued) - { invoice.Status = PlatformBillingInvoiceStatus.Overdue; - } var schedule = ReminderFor(today, dueDate); if (schedule is null || await db.PlatformBillingInvoiceReminders.AnyAsync(value => value.TenantId == invoice.TenantId && value.InvoiceId == invoice.Id && - value.ReminderType == schedule.Value.Type && value.Channel == PlatformBillingInvoiceReminderChannel.Internal && + value.ReminderType == schedule.Value.Type && + value.Channel == PlatformBillingInvoiceReminderChannel.Internal && value.ReminderDate == today, token)) { await db.SaveChangesAsync(token); return 0; } + var reminder = new PlatformBillingInvoiceReminder { TenantId = invoice.TenantId, @@ -181,7 +179,6 @@ internal sealed class CommercialBillingProcessor( var ownerId = await db.Tenants.AsNoTracking().Where(value => value.Id == invoice.TenantId) .Select(value => value.OwnerUserId).SingleAsync(token); if (ownerId.HasValue) - { await services.GetRequiredService().UpsertInAppAsync( new InAppNotificationRequest( invoice.TenantId, @@ -194,7 +191,6 @@ internal sealed class CommercialBillingProcessor( SourceId: invoice.Id, DedupeKey: $"billing:{invoice.Id:N}:{schedule.Value.Type}:{today:yyyyMMdd}"), token); - } var channels = await db.PlatformBillingDunningNotificationChannels.AsNoTracking() .Where(value => value.Enabled && value.MinReminderLevel <= schedule.Value.Level && (value.TenantIds.Length == 0 || value.TenantIds.Contains(invoice.TenantId))) @@ -202,7 +198,6 @@ internal sealed class CommercialBillingProcessor( foreach (var channel in channels.Where(value => value.ReminderTypes.Length == 0 || value.ReminderTypes.Contains(schedule.Value.Type.ToString().ToLowerInvariant()))) - { db.PlatformBillingDunningNotificationEvents.Add(new PlatformBillingDunningNotificationEvent { TenantId = invoice.TenantId, @@ -222,12 +217,10 @@ internal sealed class CommercialBillingProcessor( reminder.Message }) }); - } await db.SaveChangesAsync(token); return 1; }, cancellationToken); - } return created; } @@ -240,45 +233,48 @@ internal sealed class CommercialBillingProcessor( .Take(BatchSize()) .ToArrayAsync(cancellationToken); foreach (var candidate in candidates) - { await tenantExecutionScope.ExecuteAsync( Scope(candidate.TenantId, "Execute approved SaaS refund", candidate.RefundId), async (services, token) => { var db = services.GetRequiredService(); - var refund = await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token); - var payment = await db.PlatformBillingPayments.SingleAsync(value => value.Id == refund.PaymentId, token); + var refund = + await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token); + var payment = + await db.PlatformBillingPayments.SingleAsync(value => value.Id == refund.PaymentId, token); var order = await db.PlatformBillingOrders.SingleAsync(value => value.Id == refund.OrderId, token); try { - var result = await services.GetRequiredService().CreateRefundAsync( - payment.Provider, - new CreateRefundProviderRequest( - refund.TenantId, - order.OrderNo, - refund.RefundNo, - payment.ProviderTradeNo, - refund.AmountCents, - refund.Reason, - JsonSerializer.SerializeToElement(new { refund.SubscriptionEffect })), - token); + var result = await services.GetRequiredService() + .CreateRefundAsync( + payment.Provider, + new CreateRefundProviderRequest( + refund.TenantId, + order.OrderNo, + refund.RefundNo, + payment.ProviderTradeNo, + refund.AmountCents, + refund.Reason, + JsonSerializer.SerializeToElement(new { refund.SubscriptionEffect })), + token); if (!result.Succeeded) - { - throw new PlatformBillingException("Refund provider did not accept the refund.", "platform_billing_refund_provider_failed"); - } + throw new PlatformBillingException("Refund provider did not accept the refund.", + "platform_billing_refund_provider_failed"); refund.Status = PlatformBillingRefundStatus.Succeeded; refund.ProviderRefundNo = result.ProviderRefundNo; refund.CompletedAt = DateTimeOffset.UtcNow; refund.LastError = null; var totalRefunded = await db.PlatformBillingRefunds.AsNoTracking() .Where(value => value.TenantId == refund.TenantId && value.PaymentId == payment.Id && - value.Status == PlatformBillingRefundStatus.Succeeded && value.Id != refund.Id) + value.Status == PlatformBillingRefundStatus.Succeeded && + value.Id != refund.Id) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; if (totalRefunded + refund.AmountCents >= payment.AmountCents) { payment.Status = PlatformBillingPaymentStatus.Refunded; order.Status = PlatformBillingOrderStatus.Refunded; } + await ApplyRefundEffectAsync(db, refund, token); db.AuditLogs.Add(new AuditLog { @@ -287,7 +283,8 @@ internal sealed class CommercialBillingProcessor( Action = "platform.saas.refund.succeeded", TargetType = "platform_billing_refunds", TargetId = refund.Id.ToString(), - Details = JsonSerializer.SerializeToElement(new { refund.AmountCents, refund.SubscriptionEffect }) + Details = JsonSerializer.SerializeToElement(new + { refund.AmountCents, refund.SubscriptionEffect }) }); } catch (Exception exception) @@ -295,11 +292,12 @@ internal sealed class CommercialBillingProcessor( refund.Status = PlatformBillingRefundStatus.Failed; refund.LastError = Truncate(exception.Message, 2000); } + await db.SaveChangesAsync(token); - await services.GetRequiredService().InvalidateAsync(refund.TenantId, token); + await services.GetRequiredService() + .InvalidateAsync(refund.TenantId, token); }, cancellationToken); - } return candidates.Length; } @@ -316,14 +314,15 @@ internal sealed class CommercialBillingProcessor( .Take(BatchSize()) .ToArrayAsync(cancellationToken); foreach (var candidate in candidates) - { await tenantExecutionScope.ExecuteAsync( Scope(candidate.TenantId, "Dispatch billing dunning notification", candidate.EventId), async (services, token) => { var db = services.GetRequiredService(); - var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync(value => value.Id == candidate.EventId, token); - var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(value => value.Id == item.ChannelId, token); + var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync( + value => value.Id == candidate.EventId, token); + var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking() + .SingleAsync(value => value.Id == item.ChannelId, token); item.Status = PlatformBillingDunningNotificationStatus.Processing; item.Attempts++; item.LastAttemptAt = DateTimeOffset.UtcNow; @@ -348,6 +347,7 @@ internal sealed class CommercialBillingProcessor( Encoding.UTF8.GetBytes(item.RequestPayload.GetRawText()))).ToLowerInvariant(); request.Headers.Add("X-Tiku-Signature", $"sha256={signature}"); } + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(channel.TimeoutSeconds, 1, 60))); using var response = await httpClient.SendAsync(request, timeout.Token); @@ -355,9 +355,8 @@ internal sealed class CommercialBillingProcessor( item.LastHttpCode = (int)response.StatusCode; item.LastResponseSummary = summary; if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Dunning webhook returned {(int)response.StatusCode}.", null, response.StatusCode); - } + throw new HttpRequestException($"Dunning webhook returned {(int)response.StatusCode}.", + null, response.StatusCode); item.Status = PlatformBillingDunningNotificationStatus.Sent; item.SentAt = DateTimeOffset.UtcNow; item.NextAttemptAt = null; @@ -374,35 +373,33 @@ internal sealed class CommercialBillingProcessor( else { item.Status = PlatformBillingDunningNotificationStatus.Retrying; - item.NextAttemptAt = DateTimeOffset.UtcNow.Add(RetryDelays[Math.Min(item.Attempts - 1, RetryDelays.Length - 1)]); + item.NextAttemptAt = + DateTimeOffset.UtcNow.Add( + RetryDelays[Math.Min(item.Attempts - 1, RetryDelays.Length - 1)]); } } + await db.SaveChangesAsync(token); }, cancellationToken); - } return candidates.Length; } - private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund, CancellationToken cancellationToken) + private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund, + CancellationToken cancellationToken) { - if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) - { - return; - } + if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) return; var subscription = await db.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => value.TenantId == refund.TenantId, cancellationToken); - if (subscription is null) - { - return; - } + if (subscription is null) return; if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.CancelAtPeriodEnd) { subscription.CancelAtPeriodEnd = true; subscription.CancelledAt = DateTimeOffset.UtcNow; return; } + subscription.Status = TenantSaasSubscriptionStatus.Cancelled; subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = DateTimeOffset.UtcNow; @@ -411,7 +408,8 @@ internal sealed class CommercialBillingProcessor( tenant.BillingStatus = BillingStatus.Cancelled; var items = await db.TenantSaasSubscriptionItems .Where(value => value.TenantId == refund.TenantId && value.SubscriptionId == subscription.Id && - (value.Status == TenantSaasSubscriptionItemStatus.Active || value.Status == TenantSaasSubscriptionItemStatus.Scheduled)) + (value.Status == TenantSaasSubscriptionItemStatus.Active || + value.Status == TenantSaasSubscriptionItemStatus.Scheduled)) .ToArrayAsync(cancellationToken); foreach (var item in items) { @@ -423,14 +421,11 @@ internal sealed class CommercialBillingProcessor( private async Task ValidateWebhookAsync(string value, CancellationToken cancellationToken) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || uri.IsLoopback) - { - throw new PlatformBillingException("Dunning webhook must be a non-loopback HTTPS URL.", "dunning_webhook_rejected"); - } + throw new PlatformBillingException("Dunning webhook must be a non-loopback HTTPS URL.", + "dunning_webhook_rejected"); if (options.Value.AllowedWebhookHosts.Length == 0 || !options.Value.AllowedWebhookHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) - { throw new PlatformBillingException("Dunning webhook host is not allowlisted.", "dunning_webhook_rejected"); - } IPAddress[] addresses; try { @@ -440,42 +435,37 @@ internal sealed class CommercialBillingProcessor( } catch (SocketException) { - throw new PlatformBillingException("Dunning webhook host could not be resolved.", "dunning_webhook_rejected"); + throw new PlatformBillingException("Dunning webhook host could not be resolved.", + "dunning_webhook_rejected"); } + if (addresses.Length == 0 || addresses.Any(IsPrivate)) - { - throw new PlatformBillingException("Dunning webhook cannot resolve to a private or reserved address.", "dunning_webhook_rejected"); - } + throw new PlatformBillingException("Dunning webhook cannot resolve to a private or reserved address.", + "dunning_webhook_rejected"); return uri; } private static bool IsPrivate(IPAddress address) { - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None)) - { return true; - } var bytes = address.GetAddressBytes(); if (address.AddressFamily == AddressFamily.InterNetwork) - { return bytes[0] == 0 || bytes[0] == 10 || bytes[0] == 127 || bytes[0] >= 224 || (bytes[0] == 100 && bytes[1] is >= 64 and <= 127) || (bytes[0] == 169 && bytes[1] == 254) || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || (bytes[0] == 192 && bytes[1] == 168) || (bytes[0] == 198 && bytes[1] is 18 or 19); - } return address.AddressFamily != AddressFamily.InterNetworkV6 || address.IsIPv6LinkLocal || address.IsIPv6Multicast || address.IsIPv6SiteLocal || (bytes[0] & 0xfe) == 0xfc; } - private static (PlatformBillingInvoiceReminderType Type, int Level, string Message)? ReminderFor(DateOnly today, DateOnly dueDate) + private static (PlatformBillingInvoiceReminderType Type, int Level, string Message)? ReminderFor(DateOnly today, + DateOnly dueDate) { var days = dueDate.DayNumber - today.DayNumber; return days switch @@ -488,27 +478,47 @@ internal sealed class CommercialBillingProcessor( }; } - private static async Task BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId, CancellationToken cancellationToken) + private static async Task BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId, + CancellationToken cancellationToken) { - var profile = await db.TenantBillingProfiles.AsNoTracking().SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken); - return profile is null ? JsonDefaults.Object() : JsonSerializer.SerializeToElement(new - { - profile.BillingName, - profile.TaxId, - profile.ContactName, - profile.ContactPhone, - profile.ContactEmail, - profile.InvoiceTitle, - profile.InvoiceType - }); + var profile = await db.TenantBillingProfiles.AsNoTracking() + .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken); + return profile is null + ? JsonDefaults.Object() + : JsonSerializer.SerializeToElement(new + { + profile.BillingName, + profile.TaxId, + profile.ContactName, + profile.ContactPhone, + profile.ContactEmail, + profile.InvoiceTitle, + profile.InvoiceType + }); } - private SystemScopeRequest Scope(Guid tenantId, string reason, Guid correlationId) => - new(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor), reason, correlationId.ToString("N")); + private SystemScopeRequest Scope(Guid tenantId, string reason, Guid correlationId) + { + return new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor), + reason, + correlationId.ToString("N")); + } + + private int BatchSize() + { + return Math.Clamp(options.Value.BatchSize, 1, 1000); + } + + private static string Number(string prefix) + { + return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; + } + + private static string Truncate(string value, int length) + { + return value.Length <= length ? value : value[..length]; + } - private int BatchSize() => Math.Clamp(options.Value.BatchSize, 1, 1000); - private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; - private static string Truncate(string value, int length) => value.Length <= length ? value : value[..length]; private static string RedactResponseSummary(string value) { var redacted = Regex.Replace( @@ -531,16 +541,14 @@ internal sealed class CommercialBillingProcessor( TimeSpan.FromMilliseconds(100)); return Truncate(redacted, 500); } + private static string GetSecret(JsonElement value, params string[] names) { foreach (var name in names) - { if (value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString())) - { return property.GetString()!; - } - } + throw new PlatformBillingException("Dunning signing secret is missing.", "dunning_secret_missing"); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs index 341b963..4dd7956 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -13,92 +15,133 @@ namespace Tiku.Infrastructure.PlatformBilling; internal sealed class PlatformBillingAdminService( ITenantExecutionScope tenantExecutionScope) : IPlatformBillingAdminService { - public Task> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS orders", async (services, token) => + public Task> GetOrdersAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS orders", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingOrders.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); }, cancellationToken); + } - public Task> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS payments", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformBillingPayments.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - }, cancellationToken); + public Task> GetPaymentsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS payments", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformBillingPayments.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); + }, cancellationToken); + } - public Task> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS refunds", async (services, token) => + public Task> GetRefundsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS refunds", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingRefunds.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); }, cancellationToken); + } - public Task> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS invoices", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformBillingInvoices.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - }, cancellationToken); + public Task> GetInvoicesAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS invoices", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformBillingInvoices.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); + }, cancellationToken); + } - public Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS usage", async (services, token) => + public Task> GetUsageAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS usage", async (services, token) => { var db = services.GetRequiredService(); var values = db.TenantFeatureUsages.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode).Take(Limit(query.Limit)).ToArrayAsync(token); + return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode) + .Take(Limit(query.Limit)).ToArrayAsync(token); }, cancellationToken); + } - public Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS invoice reminders", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.PlatformBillingInvoiceReminders.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.ReminderDate).ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - }, cancellationToken); + public Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS invoice reminders", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.PlatformBillingInvoiceReminders.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => + value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.ReminderDate) + .ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); + }, cancellationToken); + } - public Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) => - ExecuteAsync>("list SaaS subscriptions", async (services, token) => - { - var db = services.GetRequiredService(); - var values = db.TenantSaasSubscriptions.AsNoTracking(); - if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); - if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); - return await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); - }, cancellationToken); + public Task> GetSubscriptionsAsync(SaasCatalogActor actor, + PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) + { + return ExecuteAsync>("list SaaS subscriptions", + async (services, token) => + { + var db = services.GetRequiredService(); + var values = db.TenantSaasSubscriptions.AsNoTracking(); + if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + values = values.Where(value => value.Status == Parse(query.Status)); + return await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) + .ToArrayAsync(token); + }, cancellationToken); + } public Task ConfirmManualPaymentAsync( SaasCatalogActor actor, ConfirmManualPaymentCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("confirm manual SaaS payment", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("confirm manual SaaS payment", async (services, token) => { var db = services.GetRequiredService(); - var payment = await db.PlatformBillingPayments.AsNoTracking().SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) - ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); + var payment = await db.PlatformBillingPayments.AsNoTracking() + .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) + ?? throw Error("Platform billing payment was not found.", + "platform_billing_payment_not_found"); if (payment.Provider != "manual") - { - throw Error("Only a manual payment can be confirmed by an operator.", "platform_billing_manual_payment_required"); - } + throw Error("Only a manual payment can be confirmed by an operator.", + "platform_billing_manual_payment_required"); + if (string.IsNullOrWhiteSpace(command.Reason)) - { throw Error("Manual payment confirmation reason is required.", "platform_billing_reason_required"); - } + return await services.GetRequiredService().MarkPaidAsync( payment.Id, $"manual-{payment.Id:N}", @@ -109,24 +152,31 @@ internal sealed class PlatformBillingAdminService( actor.UserId, token); }, cancellationToken); + } public Task UpsertFeatureOverrideAsync( SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("upsert tenant feature override", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("upsert tenant feature override", async (services, token) => { var db = services.GetRequiredService(); var featureCode = command.FeatureCode.Trim().ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(command.Reason)) throw Error("Override reason is required.", "platform_billing_reason_required"); - if (!await db.Tenants.AnyAsync(value => value.Id == command.TenantId, token)) throw Error("Tenant was not found.", "tenant_not_found"); - if (!await db.SaasFeatures.AnyAsync(value => value.Code == featureCode && !value.IsCore, token)) throw Error("SaaS feature was not found.", "saas_feature_not_found"); - var item = await db.TenantFeatureOverrides.SingleOrDefaultAsync(value => value.TenantId == command.TenantId && value.FeatureCode == featureCode, token); + if (string.IsNullOrWhiteSpace(command.Reason)) + throw Error("Override reason is required.", "platform_billing_reason_required"); + if (!await db.Tenants.AnyAsync(value => value.Id == command.TenantId, token)) + throw Error("Tenant was not found.", "tenant_not_found"); + if (!await db.SaasFeatures.AnyAsync(value => value.Code == featureCode && !value.IsCore, token)) + throw Error("SaaS feature was not found.", "saas_feature_not_found"); + var item = await db.TenantFeatureOverrides.SingleOrDefaultAsync( + value => value.TenantId == command.TenantId && value.FeatureCode == featureCode, token); if (item is null) { item = new TenantFeatureOverride { TenantId = command.TenantId, FeatureCode = featureCode }; db.TenantFeatureOverrides.Add(item); } + item.Mode = command.Mode; item.ExpiresAt = command.ExpiresAt; item.Reason = command.Reason.Trim(); @@ -137,19 +187,22 @@ internal sealed class PlatformBillingAdminService( Action = "platform.saas.feature_override.updated", TargetType = "tenant_feature_overrides", TargetId = item.Id.ToString(), - Details = JsonSerializer.SerializeToElement(new { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason }) + Details = JsonSerializer.SerializeToElement(new + { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason }) }); await db.SaveChangesAsync(token); await services.GetRequiredService() .InvalidateAsync(command.TenantId, token); return item; }, cancellationToken); + } public Task GrantTrialAsync( SaasCatalogActor actor, GrantTenantTrialCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("grant tenant SaaS trial", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("grant tenant SaaS trial", async (services, token) => { var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); @@ -161,27 +214,30 @@ internal sealed class PlatformBillingAdminService( { var requestHash = Hash($"{command.TenantId:N}|{command.BaseOfferingVersionId:N}|{command.TrialDays}"); if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) - { throw Error("Idempotency key was used with a different trial request.", "idempotency_conflict"); - } - return await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == existingRequest.ResourceId, token); + + return await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == existingRequest.ResourceId, + token); } + if (await db.TenantSaasSubscriptions.AnyAsync(value => value.TenantId == command.TenantId && - value.Status != TenantSaasSubscriptionStatus.Cancelled && - value.Status != TenantSaasSubscriptionStatus.Expired, token)) - { + value.Status != TenantSaasSubscriptionStatus + .Cancelled && + value.Status != TenantSaasSubscriptionStatus.Expired, + token)) throw Error("Tenant already has an effective subscription.", "tenant_saas_subscription_exists"); - } + var version = await ( - from value in db.SaasOfferingVersions.AsNoTracking() - join offering in db.SaasOfferings.AsNoTracking() on value.OfferingId equals offering.Id - where value.Id == command.BaseOfferingVersionId && - value.Status == SaasOfferingVersionStatus.Published && - offering.Type == SaasOfferingType.BasePlan - select value).SingleOrDefaultAsync(token) - ?? throw Error("Published base offering version was not found.", "saas_offering_version_not_found"); + from value in db.SaasOfferingVersions.AsNoTracking() + join offering in db.SaasOfferings.AsNoTracking() on value.OfferingId equals offering.Id + where value.Id == command.BaseOfferingVersionId && + value.Status == SaasOfferingVersionStatus.Published && + offering.Type == SaasOfferingType.BasePlan + select value).SingleOrDefaultAsync(token) + ?? throw Error("Published base offering version was not found.", + "saas_offering_version_not_found"); var tenant = await db.Tenants.SingleOrDefaultAsync(value => value.Id == command.TenantId, token) - ?? throw Error("Tenant was not found.", "tenant_not_found"); + ?? throw Error("Tenant was not found.", "tenant_not_found"); var now = DateTimeOffset.UtcNow; var end = now.AddDays(Math.Clamp(command.TrialDays, 1, 365)); var subscription = new TenantSaasSubscription @@ -219,30 +275,44 @@ internal sealed class PlatformBillingAdminService( await services.GetRequiredService().InvalidateAsync(tenant.Id, token); return subscription; }, cancellationToken); + } - public Task SuspendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) => - ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken); + public Task SuspendSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) + { + return ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken); + } - public Task ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) => - ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken); + public Task ResumeSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) + { + return ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken); + } - public Task CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) => - ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken); + public Task CancelSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) + { + return ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken); + } - public Task ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) => - ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken); + public Task ExtendSubscriptionAsync(SaasCatalogActor actor, + ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) + { + return ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken); + } public Task RequestRefundAsync( SaasCatalogActor actor, RequestPlatformRefundCommand command, - CancellationToken cancellationToken = default) => - ExecuteAsync("request SaaS refund", async (services, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync("request SaaS refund", async (services, token) => { var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); var payment = await db.PlatformBillingPayments.AsNoTracking() - .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) - ?? throw Error("Payment was not found.", "platform_billing_payment_not_found"); + .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) + ?? throw Error("Payment was not found.", "platform_billing_payment_not_found"); var reason = Required(command.Reason, "reason"); var existing = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.TenantId == payment.TenantId && value.IdempotencyKey == key, token); @@ -251,24 +321,23 @@ internal sealed class PlatformBillingAdminService( if (existing.PaymentId != command.PaymentId || existing.AmountCents != command.AmountCents || existing.SubscriptionEffect != command.SubscriptionEffect || !string.Equals(existing.Reason, reason, StringComparison.Ordinal)) - { throw Error("Idempotency key was used with a different refund request.", "idempotency_conflict"); - } + return existing; } + if (payment.Status != PlatformBillingPaymentStatus.Succeeded) - { throw Error("Only a succeeded payment can be refunded.", "platform_billing_payment_not_refundable"); - } + var refunded = await db.PlatformBillingRefunds.AsNoTracking() .Where(value => value.TenantId == payment.TenantId && value.PaymentId == payment.Id && value.Status != PlatformBillingRefundStatus.Cancelled && value.Status != PlatformBillingRefundStatus.Failed) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; if (command.AmountCents <= 0 || refunded + command.AmountCents > payment.AmountCents) - { - throw Error("Refund amount exceeds the refundable payment balance.", "platform_billing_refund_amount_invalid"); - } + throw Error("Refund amount exceeds the refundable payment balance.", + "platform_billing_refund_amount_invalid"); + var refund = new PlatformBillingRefund { TenantId = payment.TenantId, @@ -287,39 +356,52 @@ internal sealed class PlatformBillingAdminService( await db.SaveChangesAsync(token); return refund; }, cancellationToken); + } - public Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) => - ReviewRefundAsync(actor, command, true, cancellationToken); + public Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default) + { + return ReviewRefundAsync(actor, command, true, cancellationToken); + } - public Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) => - ReviewRefundAsync(actor, command, false, cancellationToken); + public Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default) + { + return ReviewRefundAsync(actor, command, false, cancellationToken); + } - public Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) => - ExecuteAsync("retry SaaS refund", async (services, token) => + public Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("retry SaaS refund", async (services, token) => { var db = services.GetRequiredService(); - var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) + var refund = + await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); if (refund.Status != PlatformBillingRefundStatus.Failed) - { throw Error("Only a failed refund can be retried.", "platform_billing_refund_not_retryable"); - } + refund.Status = PlatformBillingRefundStatus.Processing; refund.LastError = null; AddAudit(db, actor.UserId, refund.TenantId, "platform.saas.refund.retried", refund.Id, command.Reason); await db.SaveChangesAsync(token); return refund; }, cancellationToken); + } - public Task GetCommercialMetricsAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default) => - ExecuteAsync("get SaaS commercial metrics", async (services, token) => + public Task GetCommercialMetricsAsync(SaasCatalogActor actor, + CancellationToken cancellationToken = default) + { + return ExecuteAsync("get SaaS commercial metrics", async (services, token) => { var db = services.GetRequiredService(); var now = DateTimeOffset.UtcNow; var periodStart = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); var subscriptions = await ( from subscription in db.TenantSaasSubscriptions.AsNoTracking() - join version in db.SaasOfferingVersions.AsNoTracking() on subscription.BaseOfferingVersionId equals version.Id + join version in db.SaasOfferingVersions.AsNoTracking() on subscription.BaseOfferingVersionId equals + version.Id select new { subscription.Status, version.AmountCents, version.BillingCycle }).ToArrayAsync(token); var mrr = subscriptions.Where(value => value.Status == TenantSaasSubscriptionStatus.Active) .Sum(value => value.BillingCycle switch @@ -333,10 +415,13 @@ internal sealed class PlatformBillingAdminService( .Where(value => value.Status == PlatformBillingPaymentStatus.Succeeded && value.PaidAt >= periodStart) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; var refunded = await db.PlatformBillingRefunds.AsNoTracking() - .Where(value => value.Status == PlatformBillingRefundStatus.Succeeded && value.CompletedAt >= periodStart) + .Where(value => + value.Status == PlatformBillingRefundStatus.Succeeded && value.CompletedAt >= periodStart) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; var outstanding = await db.PlatformBillingInvoices.AsNoTracking() - .Where(value => value.Status == PlatformBillingInvoiceStatus.Issued || value.Status == PlatformBillingInvoiceStatus.Overdue) + .Where(value => + value.Status == PlatformBillingInvoiceStatus.Issued || + value.Status == PlatformBillingInvoiceStatus.Overdue) .SumAsync(value => (int?)value.TotalAmountCents, token) ?? 0; var overdue = await db.PlatformBillingInvoices.AsNoTracking() .Where(value => value.Status == PlatformBillingInvoiceStatus.Overdue) @@ -351,18 +436,23 @@ internal sealed class PlatformBillingAdminService( outstanding, overdue, refunded, - subscriptions.Count(value => value.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired)); + subscriptions.Count(value => + value.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired)); }, cancellationToken); + } private Task ChangeSubscriptionStatusAsync( SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, string action, - CancellationToken cancellationToken) => - ExecuteAsync($"{action} SaaS subscription", async (services, token) => + CancellationToken cancellationToken) + { + return ExecuteAsync($"{action} SaaS subscription", async (services, token) => { var db = services.GetRequiredService(); - var subscription = await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId, token) + var subscription = + await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId, + token) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); var tenant = await db.Tenants.SingleAsync(value => value.Id == subscription.TenantId, token); var items = await db.TenantSaasSubscriptionItems @@ -372,60 +462,76 @@ internal sealed class PlatformBillingAdminService( switch (action) { case "suspended": - if (subscription.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired) + if (subscription.Status is TenantSaasSubscriptionStatus.Cancelled + or TenantSaasSubscriptionStatus.Expired) throw Error("Subscription cannot be suspended.", "tenant_saas_subscription_status_invalid"); subscription.Status = TenantSaasSubscriptionStatus.Suspended; tenant.BillingStatus = BillingStatus.Suspended; break; case "resumed": if (subscription.Status != TenantSaasSubscriptionStatus.Suspended) - throw Error("Only a suspended subscription can be resumed.", "tenant_saas_subscription_status_invalid"); + throw Error("Only a suspended subscription can be resumed.", + "tenant_saas_subscription_status_invalid"); subscription.Status = subscription.CurrentPeriodEnd > DateTimeOffset.UtcNow ? TenantSaasSubscriptionStatus.Active : TenantSaasSubscriptionStatus.PastDue; - tenant.BillingStatus = subscription.Status == TenantSaasSubscriptionStatus.Active ? BillingStatus.Active : BillingStatus.PastDue; + tenant.BillingStatus = subscription.Status == TenantSaasSubscriptionStatus.Active + ? BillingStatus.Active + : BillingStatus.PastDue; break; case "cancelled": subscription.Status = TenantSaasSubscriptionStatus.Cancelled; subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = DateTimeOffset.UtcNow; tenant.BillingStatus = BillingStatus.Cancelled; - foreach (var item in items.Where(value => value.Status is TenantSaasSubscriptionItemStatus.Active or TenantSaasSubscriptionItemStatus.Scheduled)) + foreach (var item in items.Where(value => + value.Status is TenantSaasSubscriptionItemStatus.Active + or TenantSaasSubscriptionItemStatus.Scheduled)) { item.Status = TenantSaasSubscriptionItemStatus.Cancelled; - item.EndsAt = DateTimeOffset.UtcNow > item.StartsAt ? DateTimeOffset.UtcNow : item.StartsAt.AddTicks(1); + item.EndsAt = DateTimeOffset.UtcNow > item.StartsAt + ? DateTimeOffset.UtcNow + : item.StartsAt.AddTicks(1); } + break; case "extended": var days = Math.Clamp(command.ExtendDays ?? 0, 1, 3650); subscription.CurrentPeriodEnd = subscription.CurrentPeriodEnd.AddDays(days); - foreach (var item in items.Where(value => value.Status is TenantSaasSubscriptionItemStatus.Active or TenantSaasSubscriptionItemStatus.Scheduled)) + foreach (var item in items.Where(value => + value.Status is TenantSaasSubscriptionItemStatus.Active + or TenantSaasSubscriptionItemStatus.Scheduled)) item.EndsAt = item.EndsAt.AddDays(days); break; default: throw new ArgumentOutOfRangeException(nameof(action)); } + subscription.LifecycleVersion++; - AddAudit(db, actor.UserId, subscription.TenantId, $"platform.saas.subscription.{action}", subscription.Id, command.Reason); + AddAudit(db, actor.UserId, subscription.TenantId, $"platform.saas.subscription.{action}", subscription.Id, + command.Reason); await db.SaveChangesAsync(token); - await services.GetRequiredService().InvalidateAsync(subscription.TenantId, token); + await services.GetRequiredService() + .InvalidateAsync(subscription.TenantId, token); return subscription; }, cancellationToken); + } private Task ReviewRefundAsync( SaasCatalogActor actor, ReviewPlatformRefundCommand command, bool approve, - CancellationToken cancellationToken) => - ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) => + CancellationToken cancellationToken) + { + return ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) => { var db = services.GetRequiredService(); - var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) + var refund = + await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); if (refund.Status != PlatformBillingRefundStatus.Requested) - { throw Error("Refund is not awaiting review.", "platform_billing_refund_status_invalid"); - } + refund.Status = approve ? PlatformBillingRefundStatus.Processing : PlatformBillingRefundStatus.Cancelled; refund.ReviewedBy = actor.UserId; refund.ReviewedAt = DateTimeOffset.UtcNow; @@ -437,34 +543,67 @@ internal sealed class PlatformBillingAdminService( await db.SaveChangesAsync(token); return refund; }, cancellationToken); + } - private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId, string? reason) => + private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId, + string? reason) + { db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actorUserId, Action = action, - TargetType = action.Contains("refund", StringComparison.Ordinal) ? "platform_billing_refunds" : "tenant_saas_subscriptions", + TargetType = action.Contains("refund", StringComparison.Ordinal) + ? "platform_billing_refunds" + : "tenant_saas_subscriptions", TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason }) }); + } - private static string Required(string? value, string field) => - string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); - private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; - private static string Hash(string value) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + private static string Required(string? value, string field) + { + return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); + } + + private static string Number(string prefix) + { + return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; + } + + private static string Hash(string value) + { + return Convert + .ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))) + .ToLowerInvariant(); + } private Task ExecuteAsync( string reason, Func> operation, - CancellationToken cancellationToken) => - tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason, Guid.NewGuid().ToString("N"), true), + CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason, + Guid.NewGuid().ToString("N"), true), operation, cancellationToken); + } - private static T Parse(string value) where T : struct, Enum => - Enum.TryParse(value, true, out var parsed) ? parsed : throw Error("Status is invalid.", "status_invalid"); - private static int Limit(int value) => Math.Clamp(value, 1, 200); - private static PlatformBillingException Error(string message, string code) => new(message, code); -} + private static T Parse(string value) where T : struct, Enum + { + return Enum.TryParse(value, true, out var parsed) + ? parsed + : throw Error("Status is invalid.", "status_invalid"); + } + + private static int Limit(int value) + { + return Math.Clamp(value, 1, 200); + } + + private static PlatformBillingException Error(string message, string code) + { + return new PlatformBillingException(message, code); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs index 101b5dd..2eb4d40 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingNotificationService.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Commerce; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; -using Tiku.Domain.Platform; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; @@ -12,33 +11,37 @@ internal sealed class PlatformBillingNotificationService( ITenantExecutionScope tenantExecutionScope, IPlatformBillingPaymentGateway paymentGateway) : IPlatformBillingNotificationService { - public async Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default) + public async Task ProcessAsync(PlatformBillingNotification notification, + CancellationToken cancellationToken = default) { var parsed = await paymentGateway.ParseNotificationAsync( notification.Provider, - new PaymentNotificationRequest(Guid.Empty, notification.Provider, notification.Headers, notification.RawBody, notification.Body), + new PaymentNotificationRequest(Guid.Empty, notification.Provider, notification.Headers, + notification.RawBody, notification.Body), cancellationToken); if (!parsed.SignatureValid || !parsed.Paid) - { throw Error("Payment notification was not a valid paid event.", "platform_billing_notification_invalid"); - } await tenantExecutionScope.ExecuteAsync( - new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingNotificationService), "Settle platform billing notification", parsed.EventId, true), + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingNotificationService), + "Settle platform billing notification", parsed.EventId, true), async (services, token) => { var db = services.GetRequiredService(); - var order = await db.PlatformBillingOrders.AsNoTracking().SingleOrDefaultAsync(value => value.OrderNo == parsed.OrderNo, token) - ?? throw Error("Platform billing order was not found.", "platform_billing_order_not_found"); + var order = await db.PlatformBillingOrders.AsNoTracking() + .SingleOrDefaultAsync(value => value.OrderNo == parsed.OrderNo, token) + ?? throw Error("Platform billing order was not found.", "platform_billing_order_not_found"); if (order.TotalAmountCents != parsed.AmountCents) - { - throw Error("Payment notification amount does not match the order.", "platform_billing_payment_amount_mismatch"); - } + throw Error("Payment notification amount does not match the order.", + "platform_billing_payment_amount_mismatch"); var payment = await db.PlatformBillingPayments.AsNoTracking() - .Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id && value.Provider == parsed.Provider) - .OrderByDescending(value => value.CreatedAt) - .FirstOrDefaultAsync(token) - ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); + .Where(value => + value.TenantId == order.TenantId && value.OrderId == order.Id && + value.Provider == parsed.Provider) + .OrderByDescending(value => value.CreatedAt) + .FirstOrDefaultAsync(token) + ?? throw Error("Platform billing payment was not found.", + "platform_billing_payment_not_found"); await services.GetRequiredService().MarkPaidAsync( payment.Id, parsed.EventId, @@ -52,5 +55,8 @@ internal sealed class PlatformBillingNotificationService( cancellationToken); } - private static PlatformBillingException Error(string message, string code) => new(message, code); -} + private static PlatformBillingException Error(string message, string code) + { + return new PlatformBillingException(message, code); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs index 49e5a52..10d087a 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingPaymentGateway.cs @@ -16,23 +16,29 @@ internal sealed class PlatformBillingPaymentGateway( public Task CreatePaymentAsync( string provider, CreatePaymentProviderRequest request, - CancellationToken cancellationToken = default) => - ExecuteAsync(provider, request.OrderNo, async (resolvedProvider, account, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(provider, request.OrderNo, async (resolvedProvider, account, token) => await resolvedProvider.CreatePaymentAsync(account, request, token), cancellationToken); + } public Task ParseNotificationAsync( string provider, PaymentNotificationRequest request, - CancellationToken cancellationToken = default) => - ExecuteAsync(provider, "payment-notification", async (resolvedProvider, account, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(provider, "payment-notification", async (resolvedProvider, account, token) => await resolvedProvider.ParsePaymentNotificationAsync(account, request, token), cancellationToken); + } public Task CreateRefundAsync( string provider, CreateRefundProviderRequest request, - CancellationToken cancellationToken = default) => - ExecuteAsync(provider, request.RefundNo, async (resolvedProvider, account, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(provider, request.RefundNo, async (resolvedProvider, account, token) => await resolvedProvider.CreateRefundAsync(account, request, token), cancellationToken); + } private Task ExecuteAsync( string provider, @@ -47,7 +53,7 @@ internal sealed class PlatformBillingPaymentGateway( nameof(PlatformBillingPaymentGateway), "Resolve the platform-owned SaaS billing payment account", correlationId, - IsGlobal: true), + true), async (services, token) => { var dbContext = services.GetRequiredService(); @@ -56,14 +62,14 @@ internal sealed class PlatformBillingPaymentGateway( .Select(value => value.Id) .SingleOrDefaultAsync(token); if (platformTenantId == Guid.Empty) - { - throw new PaymentProviderException("Platform-owned tenant is missing.", "platform_payment_owner_missing"); - } + throw new PaymentProviderException("Platform-owned tenant is missing.", + "platform_payment_owner_missing"); var normalized = NormalizeProvider(provider); var resolvedProvider = services.GetServices() - .SingleOrDefault(value => value.Provider == normalized) - ?? throw new PaymentProviderException("Payment provider is not supported.", "payment_provider_not_supported"); + .SingleOrDefault(value => value.Provider == normalized) + ?? throw new PaymentProviderException("Payment provider is not supported.", + "payment_provider_not_supported"); PaymentProviderAccount account; if (normalized == PaymentProviders.Manual) { @@ -77,10 +83,13 @@ internal sealed class PlatformBillingPaymentGateway( else { var channel = await dbContext.PlatformPaymentChannels.AsNoTracking() - .Where(value => value.Provider == normalized && value.Status == PlatformPaymentChannelStatus.Active) - .OrderBy(value => value.Priority) - .FirstOrDefaultAsync(token) - ?? throw new PaymentProviderException("Platform payment channel is missing.", "platform_payment_channel_missing"); + .Where(value => + value.Provider == normalized && + value.Status == PlatformPaymentChannelStatus.Active) + .OrderBy(value => value.Priority) + .FirstOrDefaultAsync(token) + ?? throw new PaymentProviderException("Platform payment channel is missing.", + "platform_payment_channel_missing"); account = new PaymentProviderAccount( platformTenantId, normalized, @@ -102,7 +111,8 @@ internal sealed class PlatformBillingPaymentGateway( "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, "ali_pay" => PaymentProviders.Alipay, PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized, - _ => throw new PaymentProviderException("Payment provider is not supported.", "payment_provider_not_supported") + _ => throw new PaymentProviderException("Payment provider is not supported.", + "payment_provider_not_supported") }; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs b/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs index 4b9c05e..3e4d6ab 100644 --- a/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs +++ b/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs @@ -2,9 +2,9 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; +using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -24,22 +24,20 @@ internal sealed class PlatformBillingSettlementService( Guid? actorUserId, CancellationToken cancellationToken = default) { - var payment = await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, cancellationToken) + var payment = + await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, + cancellationToken) ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); if (await dbContext.PlatformBillingPaymentEvents.AnyAsync(value => value.TenantId == payment.TenantId && value.Provider == payment.Provider && value.ProviderEventId == providerEventId, cancellationToken)) - { return payment; - } var order = await dbContext.PlatformBillingOrders.SingleAsync(value => value.TenantId == payment.TenantId && value.Id == payment.OrderId, cancellationToken); if (payment.AmountCents != order.TotalAmountCents) - { throw Error("Payment amount does not match the order.", "platform_billing_payment_amount_mismatch"); - } dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent { @@ -55,10 +53,11 @@ internal sealed class PlatformBillingSettlementService( await dbContext.SaveChangesAsync(cancellationToken); return payment; } - if (payment.Status != PlatformBillingPaymentStatus.Pending || order.Status != PlatformBillingOrderStatus.PendingPayment) - { - throw Error("Payment or order status does not allow settlement.", "platform_billing_payment_status_invalid"); - } + + if (payment.Status != PlatformBillingPaymentStatus.Pending || + order.Status != PlatformBillingOrderStatus.PendingPayment) + throw Error("Payment or order status does not allow settlement.", + "platform_billing_payment_status_invalid"); payment.Status = PlatformBillingPaymentStatus.Succeeded; payment.ProviderTradeNo = providerTradeNo; @@ -72,7 +71,7 @@ internal sealed class PlatformBillingSettlementService( .Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id) .ToArrayAsync(cancellationToken); var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan) - ?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing"); + ?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing"); var baseVersion = await dbContext.SaasOfferingVersions.AsNoTracking() .SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken); var subscription = await dbContext.TenantSaasSubscriptions @@ -132,6 +131,7 @@ internal sealed class PlatformBillingSettlementService( existing.Status = TenantSaasSubscriptionItemStatus.Cancelled; existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } + dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, @@ -158,6 +158,7 @@ internal sealed class PlatformBillingSettlementService( existing.Status = TenantSaasSubscriptionItemStatus.Cancelled; existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } + dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, @@ -189,6 +190,7 @@ internal sealed class PlatformBillingSettlementService( }; dbContext.PlatformBillingInvoices.Add(invoice); } + invoice.Status = PlatformBillingInvoiceStatus.Paid; invoice.PaidAt = now; dbContext.AuditLogs.Add(new AuditLog @@ -198,7 +200,8 @@ internal sealed class PlatformBillingSettlementService( Action = "platform_billing.payment.settled", TargetType = "platform_billing_orders", TargetId = order.Id.ToString(), - Details = JsonSerializer.SerializeToElement(new { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose }) + Details = JsonSerializer.SerializeToElement(new + { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose }) }); await dbContext.SaveChangesAsync(cancellationToken); await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken); @@ -224,14 +227,20 @@ internal sealed class PlatformBillingSettlementService( }); } - private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle) => cycle switch + private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle) { - PlatformBillingCycle.Monthly => start.AddMonths(1), - PlatformBillingCycle.Quarterly => start.AddMonths(3), - PlatformBillingCycle.Yearly => start.AddYears(1), - PlatformBillingCycle.OneTime => start.AddYears(100), - _ => throw new ArgumentOutOfRangeException(nameof(cycle)) - }; + return cycle switch + { + PlatformBillingCycle.Monthly => start.AddMonths(1), + PlatformBillingCycle.Quarterly => start.AddMonths(3), + PlatformBillingCycle.Yearly => start.AddYears(1), + PlatformBillingCycle.OneTime => start.AddYears(100), + _ => throw new ArgumentOutOfRangeException(nameof(cycle)) + }; + } - private static PlatformBillingException Error(string message, string code) => new(message, code); -} + private static PlatformBillingException Error(string message, string code) + { + return new PlatformBillingException(message, code); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs b/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs index 2f0a71a..6414109 100644 --- a/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs +++ b/Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs @@ -37,29 +37,30 @@ internal sealed class SaasCatalogAdminService( var metricCode = Normalize(command.MetricCode); var featureCode = Normalize(command.FeatureCode); if (command.WarningPercent != 80 || !command.IsHardLimit) - { - throw Error("Phase nine supports hard limits with an 80 percent warning threshold.", "saas_feature_limit_policy_invalid"); - } + throw Error("Phase nine supports hard limits with an 80 percent warning threshold.", + "saas_feature_limit_policy_invalid"); if (!await dbContext.SaasFeatures.AnyAsync(value => value.Code == featureCode && value.Status == SaasFeatureStatus.Active, cancellationToken)) - { throw Error("SaaS feature was not found or active.", "saas_feature_not_found"); - } var item = command.Id.HasValue - ? await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) - : await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.MetricCode == metricCode, cancellationToken); + ? await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.Id == command.Id, + cancellationToken) + : await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.MetricCode == metricCode, + cancellationToken); if (item is null) { item = new SaasFeatureLimitDefinition { MetricCode = metricCode }; dbContext.SaasFeatureLimitDefinitions.Add(item); } - else if (await dbContext.SaasOfferingVersionLimits.AnyAsync(value => value.MetricCode == item.MetricCode, cancellationToken) && + else if (await dbContext.SaasOfferingVersionLimits.AnyAsync(value => value.MetricCode == item.MetricCode, + cancellationToken) && (!string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal) || !string.Equals(item.FeatureCode, featureCode, StringComparison.Ordinal) || item.Kind != command.Kind)) { - throw Error("A limit definition referenced by offering versions cannot change identity or kind.", "saas_feature_limit_locked"); + throw Error("A limit definition referenced by offering versions cannot change identity or kind.", + "saas_feature_limit_locked"); } item.MetricCode = metricCode; @@ -82,13 +83,9 @@ internal sealed class SaasCatalogAdminService( { var code = Normalize(command.Code); if (!SaasFeatureCatalog.All.Contains(code)) - { throw Error("Feature code is not part of the application catalog.", "saas_feature_code_unknown"); - } if (command.ReferencePriceCents < 0) - { throw Error("Reference price cannot be negative.", "saas_feature_price_invalid"); - } var item = command.Id.HasValue ? await dbContext.SaasFeatures.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) @@ -98,10 +95,9 @@ internal sealed class SaasCatalogAdminService( item = new SaasFeature { Code = code }; dbContext.SaasFeatures.Add(item); } + if (item.IsCore) - { throw Error("Core features cannot be changed through the product catalog.", "saas_core_feature_locked"); - } item.Code = code; item.Name = Required(command.Name, "name"); @@ -112,7 +108,8 @@ internal sealed class SaasCatalogAdminService( item.Status = command.Status; item.SortOrder = command.SortOrder; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor, "platform.saas.feature.upserted", "saas_features", item.Id, new { item.Code, item.Status }, cancellationToken); + await AuditAsync(actor, "platform.saas.feature.upserted", "saas_features", item.Id, + new { item.Code, item.Status }, cancellationToken); return item; } @@ -130,7 +127,8 @@ internal sealed class SaasCatalogAdminService( item = new SaasOffering { Code = code }; dbContext.SaasOfferings.Add(item); } - else if (item.Type != command.Type && await dbContext.SaasOfferingVersions.AnyAsync(value => value.OfferingId == item.Id, cancellationToken)) + else if (item.Type != command.Type && + await dbContext.SaasOfferingVersions.AnyAsync(value => value.OfferingId == item.Id, cancellationToken)) { throw Error("Offering type cannot change after versions exist.", "saas_offering_type_locked"); } @@ -142,7 +140,8 @@ internal sealed class SaasCatalogAdminService( item.Description = Clean(command.Description); item.SortOrder = command.SortOrder; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor, "platform.saas.offering.upserted", "saas_offerings", item.Id, new { item.Code, item.Type, item.Status }, cancellationToken); + await AuditAsync(actor, "platform.saas.offering.upserted", "saas_offerings", item.Id, + new { item.Code, item.Type, item.Status }, cancellationToken); return item; } @@ -151,24 +150,22 @@ internal sealed class SaasCatalogAdminService( UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default) { - var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.OfferingId, cancellationToken) + var offering = + await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.OfferingId, + cancellationToken) ?? throw Error("SaaS offering was not found.", "saas_offering_not_found"); if (command.AmountCents < 0 || command.OriginalAmountCents < command.AmountCents) - { throw Error("Offering version price is invalid.", "saas_offering_price_invalid"); - } - var featureCodes = command.FeatureCodes.Select(Normalize).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + var featureCodes = command.FeatureCodes.Select(Normalize).Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal).ToArray(); if (offering.Type == SaasOfferingType.BasePlan && featureCodes.Length == 0) - { throw Error("A base plan must contain at least one sellable feature.", "saas_base_plan_features_required"); - } var validFeatures = await dbContext.SaasFeatures.CountAsync(value => - featureCodes.Contains(value.Code) && value.Status == SaasFeatureStatus.Active && !value.IsCore, cancellationToken); + featureCodes.Contains(value.Code) && value.Status == SaasFeatureStatus.Active && !value.IsCore, + cancellationToken); if (validFeatures != featureCodes.Length) - { throw Error("One or more features are unavailable.", "saas_feature_unavailable"); - } var metricCodes = command.Limits.Keys.Select(Normalize).Distinct(StringComparer.Ordinal).ToArray(); var limitDefinitions = await dbContext.SaasFeatureLimitDefinitions.AsNoTracking() @@ -177,23 +174,19 @@ internal sealed class SaasCatalogAdminService( if (command.Limits.Any(value => value.Value < 0) || limitDefinitions.Length != metricCodes.Length || limitDefinitions.Any(value => !featureCodes.Contains(value.FeatureCode))) - { throw Error("One or more feature limits are invalid.", "saas_feature_limit_invalid"); - } SaasOfferingVersion version; if (command.Id.HasValue) { - version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) - ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); + version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == command.Id, + cancellationToken) + ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); if (version.Status != SaasOfferingVersionStatus.Draft) - { throw Error("Published offering versions are immutable.", "saas_offering_version_immutable"); - } if (version.OfferingId != command.OfferingId) - { - throw Error("Offering version cannot move to another offering.", "saas_offering_version_offering_locked"); - } + throw Error("Offering version cannot move to another offering.", + "saas_offering_version_offering_locked"); } else { @@ -211,8 +204,10 @@ internal sealed class SaasCatalogAdminService( version.EffectiveAt = command.EffectiveAt; version.Metadata = ObjectOrEmpty(command.Metadata); - await dbContext.SaasOfferingVersionFeatures.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken); - await dbContext.SaasOfferingVersionLimits.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken); + await dbContext.SaasOfferingVersionFeatures.Where(value => value.OfferingVersionId == version.Id) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.SaasOfferingVersionLimits.Where(value => value.OfferingVersionId == version.Id) + .ExecuteDeleteAsync(cancellationToken); dbContext.SaasOfferingVersionFeatures.AddRange(featureCodes.Select(code => new SaasOfferingVersionFeature { OfferingVersionId = version.Id, @@ -225,7 +220,8 @@ internal sealed class SaasCatalogAdminService( LimitValue = value.Value })); await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor, "platform.saas.offering_version.saved", "saas_offering_versions", version.Id, new { offering.Code, version.Version }, cancellationToken); + await AuditAsync(actor, "platform.saas.offering_version.saved", "saas_offering_versions", version.Id, + new { offering.Code, version.Version }, cancellationToken); return (await LoadVersionsAsync(version.Id, cancellationToken)).Single(); } @@ -235,19 +231,20 @@ internal sealed class SaasCatalogAdminService( CancellationToken cancellationToken = default) { var version = await RequireDraftAsync(versionId, cancellationToken); - var hasFeatures = await dbContext.SaasOfferingVersionFeatures.AnyAsync(value => value.OfferingVersionId == versionId, cancellationToken); - if (!hasFeatures) - { - throw Error("Offering version has no features.", "saas_offering_version_empty"); - } + var hasFeatures = + await dbContext.SaasOfferingVersionFeatures.AnyAsync(value => value.OfferingVersionId == versionId, + cancellationToken); + if (!hasFeatures) throw Error("Offering version has no features.", "saas_offering_version_empty"); version.Status = SaasOfferingVersionStatus.Published; version.PublishedAt = DateTimeOffset.UtcNow; version.EffectiveAt ??= version.PublishedAt; - var offering = await dbContext.SaasOfferings.SingleAsync(value => value.Id == version.OfferingId, cancellationToken); + var offering = + await dbContext.SaasOfferings.SingleAsync(value => value.Id == version.OfferingId, cancellationToken); offering.Status = SaasOfferingStatus.Active; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor, "platform.saas.offering_version.published", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken); + await AuditAsync(actor, "platform.saas.offering_version.published", "saas_offering_versions", version.Id, + new { version.Version }, cancellationToken); return (await LoadVersionsAsync(version.Id, cancellationToken)).Single(); } @@ -256,10 +253,15 @@ internal sealed class SaasCatalogAdminService( Guid versionId, CancellationToken cancellationToken = default) { - var source = await dbContext.SaasOfferingVersions.AsNoTracking().SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) - ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); - var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => value.OfferingVersionId == versionId).Select(value => value.FeatureCode).ToArrayAsync(cancellationToken); - var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => value.OfferingVersionId == versionId).ToDictionaryAsync(value => value.MetricCode, value => value.LimitValue, cancellationToken); + var source = await dbContext.SaasOfferingVersions.AsNoTracking() + .SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) + ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); + var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + .Where(value => value.OfferingVersionId == versionId).Select(value => value.FeatureCode) + .ToArrayAsync(cancellationToken); + var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + .Where(value => value.OfferingVersionId == versionId).ToDictionaryAsync(value => value.MetricCode, + value => value.LimitValue, cancellationToken); return await UpsertDraftVersionAsync(actor, new UpsertSaasOfferingVersionCommand( null, source.OfferingId, @@ -278,73 +280,103 @@ internal sealed class SaasCatalogAdminService( Guid versionId, CancellationToken cancellationToken = default) { - var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) + var version = + await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); if (version.Status != SaasOfferingVersionStatus.Published) - { throw Error("Only a published offering version can be retired.", "saas_offering_version_status_invalid"); - } version.Status = SaasOfferingVersionStatus.Retired; version.RetiredAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); - await AuditAsync(actor, "platform.saas.offering_version.retired", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken); + await AuditAsync(actor, "platform.saas.offering_version.retired", "saas_offering_versions", version.Id, + new { version.Version }, cancellationToken); return (await LoadVersionsAsync(version.Id, cancellationToken)).Single(); } - private async Task LoadVersionsAsync(Guid? versionId, CancellationToken cancellationToken) + private async Task LoadVersionsAsync(Guid? versionId, + CancellationToken cancellationToken) { var query = from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - select new { Version = version, Offering = offering }; - if (versionId.HasValue) - { - query = query.Where(value => value.Version.Id == versionId); - } - var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code).ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken); + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + select new { Version = version, Offering = offering }; + if (versionId.HasValue) query = query.Where(value => value.Version.Id == versionId); + var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code) + .ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken); var ids = rows.Select(value => value.Version.Id).ToArray(); - var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); - var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); + var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); + var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); return rows.Select(row => new SaasOfferingVersionItem( - row.Version.Id, - row.Offering.Id, - row.Offering.Code, - row.Offering.Name, - row.Offering.Type, - row.Version.Version, - row.Version.Status, - row.Version.BillingCycle, - row.Version.OriginalAmountCents, - row.Version.AmountCents, - row.Version.Currency, - row.Version.EffectiveAt, - row.Version.PublishedAt, - features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(), - limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal))) + row.Version.Id, + row.Offering.Id, + row.Offering.Code, + row.Offering.Name, + row.Offering.Type, + row.Version.Version, + row.Version.Status, + row.Version.BillingCycle, + row.Version.OriginalAmountCents, + row.Version.AmountCents, + row.Version.Currency, + row.Version.EffectiveAt, + row.Version.PublishedAt, + features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode) + .Order(StringComparer.Ordinal).ToArray(), + limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, + value => value.LimitValue, StringComparer.Ordinal))) .ToArray(); } private async Task RequireDraftAsync(Guid versionId, CancellationToken cancellationToken) { - var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) + var version = + await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); return version.Status == SaasOfferingVersionStatus.Draft ? version : throw Error("Published offering versions are immutable.", "saas_offering_version_immutable"); } - private Task AuditAsync(SaasCatalogActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) => - auditService.WriteAsync(new BackofficeOperationAuditCommand( + private Task AuditAsync(SaasCatalogActor actor, string action, string targetType, Guid targetId, object details, + CancellationToken cancellationToken) + { + return auditService.WriteAsync(new BackofficeOperationAuditCommand( null, actor.UserId, action, targetType, targetId.ToString(), JsonSerializer.SerializeToElement(details)), cancellationToken); + } - private static PlatformBillingException Error(string message, string code) => new(message, code); - private static string Normalize(string value) => Required(value, "code").ToLowerInvariant(); - private static string NormalizeCurrency(string value) => Required(value, "currency").ToUpperInvariant(); - private static string Required(string value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); - private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static JsonElement ObjectOrEmpty(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); -} + private static PlatformBillingException Error(string message, string code) + { + return new PlatformBillingException(message, code); + } + + private static string Normalize(string value) + { + return Required(value, "code").ToLowerInvariant(); + } + + private static string NormalizeCurrency(string value) + { + return Required(value, "currency").ToUpperInvariant(); + } + + private static string Required(string value, string field) + { + return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); + } + + private static string? Clean(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static JsonElement ObjectOrEmpty(JsonElement value) + { + return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs b/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs index 6a2b67b..39f7f9f 100644 --- a/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs +++ b/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; @@ -24,15 +23,10 @@ internal sealed class SaasSubscriptionLifecycleService( DateTimeOffset? asOf = null, CancellationToken cancellationToken = default) { - if (!options.Enabled) - { - return 0; - } + if (!options.Enabled) return 0; if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue) - { throw new InvalidOperationException( "SaaS subscription lifecycle discovery requires a global system context."); - } var effectiveAt = asOf ?? DateTimeOffset.UtcNow; var tenantIds = await directoryDbContext.TenantSaasSubscriptions.AsNoTracking() @@ -60,10 +54,7 @@ internal sealed class SaasSubscriptionLifecycleService( $"saas-subscription-lifecycle-{tenantId:N}-{effectiveAt:yyyyMMddHHmmss}"), (provider, token) => ProcessTenantAsync(provider, tenantId, effectiveAt, token), cancellationToken); - if (wasChanged) - { - changed++; - } + if (wasChanged) changed++; } return changed; @@ -79,16 +70,13 @@ internal sealed class SaasSubscriptionLifecycleService( var subscription = await dbContext.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => - value.TenantId == tenantId && - value.CurrentPeriodEnd <= asOf && - (value.Status == TenantSaasSubscriptionStatus.Trial || - value.Status == TenantSaasSubscriptionStatus.Active || - value.Status == TenantSaasSubscriptionStatus.PastDue), + value.TenantId == tenantId && + value.CurrentPeriodEnd <= asOf && + (value.Status == TenantSaasSubscriptionStatus.Trial || + value.Status == TenantSaasSubscriptionStatus.Active || + value.Status == TenantSaasSubscriptionStatus.PastDue), cancellationToken); - if (subscription is null) - { - return false; - } + if (subscription is null) return false; var previousStatus = subscription.Status; var previousBaseVersionId = subscription.BaseOfferingVersionId; @@ -105,7 +93,7 @@ internal sealed class SaasSubscriptionLifecycleService( subscription.CancelledAt ??= asOf; subscription.ScheduledBaseOfferingVersionId = null; tenant.BillingStatus = BillingStatus.Cancelled; - EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true); + EndItems(items, subscription.CurrentPeriodEnd, true); } else if (subscription.ScheduledBaseOfferingVersionId is { } scheduledVersionId) { @@ -124,14 +112,17 @@ internal sealed class SaasSubscriptionLifecycleService( dbContext.ChangeTracker.Clear(); return false; } + throw new InvalidOperationException( $"Scheduled base offering version '{scheduledVersionId}' has no matching subscription item."); } transition = "scheduled_plan_activated"; var nextPeriodStart = subscription.CurrentPeriodEnd; - EndItems(items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Active), nextPeriodStart, cancelScheduled: false); - var scheduledItems = items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Scheduled).ToArray(); + EndItems(items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Active), nextPeriodStart, + false); + var scheduledItems = items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Scheduled) + .ToArray(); foreach (var item in scheduledItems) { item.Status = TenantSaasSubscriptionItemStatus.Active; @@ -153,7 +144,7 @@ internal sealed class SaasSubscriptionLifecycleService( subscription.Status = TenantSaasSubscriptionStatus.Expired; subscription.CancelledAt ??= asOf; tenant.BillingStatus = BillingStatus.Cancelled; - EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true); + EndItems(items, subscription.CurrentPeriodEnd, true); } else if (subscription.Status == TenantSaasSubscriptionStatus.Active) { @@ -168,7 +159,7 @@ internal sealed class SaasSubscriptionLifecycleService( subscription.Status = TenantSaasSubscriptionStatus.Expired; subscription.CancelledAt ??= asOf; tenant.BillingStatus = BillingStatus.Cancelled; - EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true); + EndItems(items, subscription.CurrentPeriodEnd, true); } else { @@ -216,20 +207,14 @@ internal sealed class SaasSubscriptionLifecycleService( bool cancelScheduled) { foreach (var item in items) - { if (item.Status == TenantSaasSubscriptionItemStatus.Active) { item.Status = TenantSaasSubscriptionItemStatus.Expired; - if (periodEnd > item.StartsAt && item.EndsAt > periodEnd) - { - item.EndsAt = periodEnd; - } + if (periodEnd > item.StartsAt && item.EndsAt > periodEnd) item.EndsAt = periodEnd; } else if (cancelScheduled && item.Status == TenantSaasSubscriptionItemStatus.Scheduled) { item.Status = TenantSaasSubscriptionItemStatus.Cancelled; } - } } - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs index 982ab14..df1c608 100644 --- a/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs +++ b/Tiku.Infrastructure/PlatformBilling/TenantBillingService.cs @@ -5,6 +5,7 @@ using Tiku.Application.Commerce; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Common; +using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Infrastructure.Persistence; @@ -43,11 +44,9 @@ internal sealed class TenantBillingService( { var idempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"); var existingQuote = await dbContext.PlatformBillingQuotes.AsNoTracking() - .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey, cancellationToken); - if (existingQuote is not null) - { - return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken); - } + .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey, + cancellationToken); + if (existingQuote is not null) return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken); var requestedIds = new[] { command.BaseOfferingVersionId } .Concat(command.AddOnOfferingVersionIds) @@ -55,24 +54,22 @@ internal sealed class TenantBillingService( .ToArray(); var now = DateTimeOffset.UtcNow; var versions = await ( - from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - where requestedIds.Contains(version.Id) && - version.Status == SaasOfferingVersionStatus.Published && - offering.Status == SaasOfferingStatus.Active && - (version.EffectiveAt == null || version.EffectiveAt <= now) - select new { Version = version, Offering = offering }) + from version in dbContext.SaasOfferingVersions.AsNoTracking() + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + where requestedIds.Contains(version.Id) && + version.Status == SaasOfferingVersionStatus.Published && + offering.Status == SaasOfferingStatus.Active && + (version.EffectiveAt == null || version.EffectiveAt <= now) + select new { Version = version, Offering = offering }) .ToArrayAsync(cancellationToken); if (versions.Length != requestedIds.Length || - versions.SingleOrDefault(value => value.Version.Id == command.BaseOfferingVersionId)?.Offering.Type != SaasOfferingType.BasePlan || - versions.Any(value => value.Version.Id != command.BaseOfferingVersionId && value.Offering.Type != SaasOfferingType.AddOn)) - { + versions.SingleOrDefault(value => value.Version.Id == command.BaseOfferingVersionId)?.Offering.Type != + SaasOfferingType.BasePlan || + versions.Any(value => + value.Version.Id != command.BaseOfferingVersionId && value.Offering.Type != SaasOfferingType.AddOn)) throw Error("One or more offering versions are unavailable.", "saas_offering_version_unavailable"); - } if (versions.Select(value => value.Version.Currency).Distinct(StringComparer.Ordinal).Count() != 1) - { throw Error("All quote items must use the same currency.", "platform_billing_currency_mismatch"); - } var featureRows = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) @@ -80,7 +77,8 @@ internal sealed class TenantBillingService( var limitRows = await dbContext.SaasOfferingVersionLimits.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) .ToArrayAsync(cancellationToken); - var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal).ToArray(); var limits = limitRows.GroupBy(value => value.MetricCode, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Sum(value => value.LimitValue), StringComparer.Ordinal); var originalAmount = versions.Sum(value => value.Version.OriginalAmountCents); @@ -119,8 +117,10 @@ internal sealed class TenantBillingService( offeringType = value.Offering.Type, version = value.Version.Version, value.Version.BillingCycle, - featureCodes = featureRows.Where(feature => feature.OfferingVersionId == value.Version.Id).Select(feature => feature.FeatureCode).Order(StringComparer.Ordinal), - limits = limitRows.Where(limit => limit.OfferingVersionId == value.Version.Id).ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue) + featureCodes = featureRows.Where(feature => feature.OfferingVersionId == value.Version.Id) + .Select(feature => feature.FeatureCode).Order(StringComparer.Ordinal), + limits = limitRows.Where(limit => limit.OfferingVersionId == value.Version.Id) + .ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue) }) })); await dbContext.SaveChangesAsync(cancellationToken); @@ -134,16 +134,14 @@ internal sealed class TenantBillingService( { var key = Required(command.IdempotencyKey, "idempotencyKey"); var existing = await dbContext.PlatformBillingOrders.AsNoTracking() - .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); - if (existing is not null) - { - return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken); - } + .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, + cancellationToken); + if (existing is not null) return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken); var now = DateTimeOffset.UtcNow; var quote = await dbContext.PlatformBillingQuotes.SingleOrDefaultAsync(value => - value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken) - ?? throw Error("Quote was not found.", "platform_billing_quote_not_found"); + value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken) + ?? throw Error("Quote was not found.", "platform_billing_quote_not_found"); if (quote.Status != PlatformBillingQuoteStatus.Active || quote.ExpiresAt <= now) { quote.Status = quote.ExpiresAt <= now ? PlatformBillingQuoteStatus.Expired : quote.Status; @@ -197,15 +195,13 @@ internal sealed class TenantBillingService( { var key = Required(command.IdempotencyKey, "idempotencyKey"); var existing = await dbContext.PlatformBillingPayments.AsNoTracking() - .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); - if (existing is not null) - { - return ToPaymentView(existing); - } + .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, + cancellationToken); + if (existing is not null) return ToPaymentView(existing); var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value => - value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken) - ?? throw Error("Order was not found.", "platform_billing_order_not_found"); + value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken) + ?? throw Error("Order was not found.", "platform_billing_order_not_found"); if (order.Status != PlatformBillingOrderStatus.PendingPayment || order.ExpiresAt <= DateTimeOffset.UtcNow) { if (order.ExpiresAt <= DateTimeOffset.UtcNow && order.Status == PlatformBillingOrderStatus.PendingPayment) @@ -213,6 +209,7 @@ internal sealed class TenantBillingService( order.Status = PlatformBillingOrderStatus.Expired; await dbContext.SaveChangesAsync(cancellationToken); } + throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid"); } @@ -285,15 +282,15 @@ internal sealed class TenantBillingService( .Select(value => value.OrderNo) .ToArrayAsync(cancellationToken); var result = new List(); - foreach (var orderNo in orderNos) - { - result.Add(await LoadOrderAsync(actor.TenantId, orderNo, cancellationToken)); - } + foreach (var orderNo in orderNos) result.Add(await LoadOrderAsync(actor.TenantId, orderNo, cancellationToken)); return result; } - public Task GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default) => - LoadOrderAsync(actor.TenantId, orderNo, cancellationToken); + public Task GetOrderAsync(TenantBillingActor actor, string orderNo, + CancellationToken cancellationToken = default) + { + return LoadOrderAsync(actor.TenantId, orderNo, cancellationToken); + } public async Task GetSubscriptionAsync( TenantBillingActor actor, @@ -318,17 +315,23 @@ internal sealed class TenantBillingService( .FirstOrDefaultAsync(cancellationToken); var currentAmount = current is null ? 0 - : await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents).SingleAsync(cancellationToken); - var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == command.BaseOfferingVersionId).Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken) - ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); + : await dbContext.SaasOfferingVersions.AsNoTracking() + .Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents) + .SingleAsync(cancellationToken); + var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking() + .Where(value => value.Id == command.BaseOfferingVersionId) + .Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken) + ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); var purpose = current is null ? PlatformBillingOrderPurpose.NewSubscription : requestedAmount >= currentAmount ? PlatformBillingOrderPurpose.Upgrade : PlatformBillingOrderPurpose.Downgrade; var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand( - command.BaseOfferingVersionId, command.AddOnOfferingVersionIds, purpose, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); - return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken); + command.BaseOfferingVersionId, command.AddOnOfferingVersionIds, purpose, + $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); + return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), + cancellationToken); } public async Task RenewSubscriptionAsync( @@ -337,10 +340,10 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() - .Where(value => value.TenantId == actor.TenantId) - .OrderByDescending(value => value.UpdatedAt) - .FirstOrDefaultAsync(cancellationToken) - ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); + .Where(value => value.TenantId == actor.TenantId) + .OrderByDescending(value => value.UpdatedAt) + .FirstOrDefaultAsync(cancellationToken) + ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); var addOns = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == actor.TenantId && value.SubscriptionId == subscription.Id && value.ItemType == TenantSaasSubscriptionItemType.AddOn && @@ -348,8 +351,10 @@ internal sealed class TenantBillingService( .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand( - subscription.BaseOfferingVersionId, addOns, PlatformBillingOrderPurpose.Renewal, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); - return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken); + subscription.BaseOfferingVersionId, addOns, PlatformBillingOrderPurpose.Renewal, + $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); + return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), + cancellationToken); } public async Task CancelSubscriptionAsync( @@ -357,34 +362,40 @@ internal sealed class TenantBillingService( CancellationToken cancellationToken = default) { var subscription = await dbContext.TenantSaasSubscriptions - .Where(value => value.TenantId == actor.TenantId) - .OrderByDescending(value => value.UpdatedAt) - .FirstOrDefaultAsync(cancellationToken) - ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); + .Where(value => value.TenantId == actor.TenantId) + .OrderByDescending(value => value.UpdatedAt) + .FirstOrDefaultAsync(cancellationToken) + ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); subscription.CancelAtPeriodEnd = true; subscription.CancelledAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); return await ToSubscriptionViewAsync(subscription, cancellationToken); } - public Task> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default) => - featureAccessService.GetQuotaSummaryAsync(actor.TenantId, cancellationToken); + public Task> GetUsageAsync(TenantBillingActor actor, + CancellationToken cancellationToken = default) + { + return featureAccessService.GetQuotaSummaryAsync(actor.TenantId, cancellationToken); + } public async Task> GetInvoicesAsync( TenantBillingActor actor, int limit, - CancellationToken cancellationToken = default) => - await dbContext.PlatformBillingInvoices.AsNoTracking() + CancellationToken cancellationToken = default) + { + return await dbContext.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) .ToArrayAsync(cancellationToken); + } public async Task> GetReceivablesAsync( TenantBillingActor actor, int limit, - CancellationToken cancellationToken = default) => - await dbContext.PlatformBillingInvoices.AsNoTracking() + CancellationToken cancellationToken = default) + { + return await dbContext.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) @@ -399,6 +410,7 @@ internal sealed class TenantBillingService( value.IssuedAt, value.PaidAt)) .ToArrayAsync(cancellationToken); + } public async Task CancelOrderAsync( TenantBillingActor actor, @@ -407,12 +419,10 @@ internal sealed class TenantBillingService( { var normalized = Required(orderNo, "orderNo"); var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value => - value.TenantId == actor.TenantId && value.OrderNo == normalized, + value.TenantId == actor.TenantId && value.OrderNo == normalized, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); if (order.Status != PlatformBillingOrderStatus.PendingPayment) - { throw Error("Only a pending order can be cancelled.", "platform_billing_order_not_cancellable"); - } order.Status = PlatformBillingOrderStatus.Cancelled; order.CancelledAt = DateTimeOffset.UtcNow; var receivables = await dbContext.PlatformBillingInvoices @@ -420,11 +430,8 @@ internal sealed class TenantBillingService( (value.Status == PlatformBillingInvoiceStatus.Draft || value.Status == PlatformBillingInvoiceStatus.Issued)) .ToArrayAsync(cancellationToken); - foreach (var receivable in receivables) - { - receivable.Status = PlatformBillingInvoiceStatus.Void; - } - dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog + foreach (var receivable in receivables) receivable.Status = PlatformBillingInvoiceStatus.Void; + dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, @@ -439,16 +446,20 @@ internal sealed class TenantBillingService( public async Task> GetRefundsAsync( TenantBillingActor actor, int limit, - CancellationToken cancellationToken = default) => - await dbContext.PlatformBillingRefunds.AsNoTracking() + CancellationToken cancellationToken = default) + { + return await dbContext.PlatformBillingRefunds.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) .ToArrayAsync(cancellationToken); + } - private async Task LoadQuoteAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) + private async Task LoadQuoteAsync(Guid tenantId, Guid quoteId, + CancellationToken cancellationToken) { - var quote = await dbContext.PlatformBillingQuotes.AsNoTracking().SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken); + var quote = await dbContext.PlatformBillingQuotes.AsNoTracking() + .SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken); var items = await LoadQuoteItemsAsync(tenantId, quote.Id, cancellationToken); return new PlatformBillingQuoteView( quote.Id, @@ -465,39 +476,53 @@ internal sealed class TenantBillingService( ReadLongDictionary(quote.LimitSnapshot)); } - private async Task LoadOrderAsync(Guid tenantId, string orderNo, CancellationToken cancellationToken) + private async Task LoadOrderAsync(Guid tenantId, string orderNo, + CancellationToken cancellationToken) { var normalized = Required(orderNo, "orderNo"); - var order = await dbContext.PlatformBillingOrders.AsNoTracking().SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized, cancellationToken) - ?? throw Error("Order was not found.", "platform_billing_order_not_found"); + var order = await dbContext.PlatformBillingOrders.AsNoTracking() + .SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized, + cancellationToken) + ?? throw Error("Order was not found.", "platform_billing_order_not_found"); var items = await ( - from item in dbContext.PlatformBillingOrderItems.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - where item.TenantId == tenantId && item.OrderId == order.Id - orderby item.ItemType, offering.Code - select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents)) + from item in dbContext.PlatformBillingOrderItems.AsNoTracking() + join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version + .Id + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + where item.TenantId == tenantId && item.OrderId == order.Id + orderby item.ItemType, offering.Code + select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, + item.ItemType, item.UnitAmountCents, item.AmountCents)) .ToArrayAsync(cancellationToken); - return new PlatformBillingOrderView(order.Id, order.OrderNo, order.Purpose, order.Status, order.TotalAmountCents, order.Currency, order.ExpiresAt, order.PaidAt, items); + return new PlatformBillingOrderView(order.Id, order.OrderNo, order.Purpose, order.Status, + order.TotalAmountCents, order.Currency, order.ExpiresAt, order.PaidAt, items); } - private async Task LoadQuoteItemsAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) => - await ( - from item in dbContext.PlatformBillingQuoteItems.AsNoTracking() - join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - where item.TenantId == tenantId && item.QuoteId == quoteId - orderby item.ItemType, offering.Code - select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents)) + private async Task LoadQuoteItemsAsync(Guid tenantId, Guid quoteId, + CancellationToken cancellationToken) + { + return await ( + from item in dbContext.PlatformBillingQuoteItems.AsNoTracking() + join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version + .Id + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + where item.TenantId == tenantId && item.QuoteId == quoteId + orderby item.ItemType, offering.Code + select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, + item.ItemType, item.UnitAmountCents, item.AmountCents)) .ToArrayAsync(cancellationToken); + } - private async Task ToSubscriptionViewAsync(TenantSaasSubscription subscription, CancellationToken cancellationToken) + private async Task ToSubscriptionViewAsync(TenantSaasSubscription subscription, + CancellationToken cancellationToken) { var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() - .Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Active) + .Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id && + value.Status == TenantSaasSubscriptionItemStatus.Active) .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); - if (!versionIds.Contains(subscription.BaseOfferingVersionId)) versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; + if (!versionIds.Contains(subscription.BaseOfferingVersionId)) + versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) @@ -516,27 +541,33 @@ internal sealed class TenantBillingService( features); } - private async Task LoadPublishedVersionsAsync(DateTimeOffset now, CancellationToken cancellationToken) + private async Task LoadPublishedVersionsAsync(DateTimeOffset now, + CancellationToken cancellationToken) { var rows = await ( - from version in dbContext.SaasOfferingVersions.AsNoTracking() - join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id - where version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active && - (version.EffectiveAt == null || version.EffectiveAt <= now) - orderby offering.SortOrder, offering.Code, version.Version descending - select new { Version = version, Offering = offering }) + from version in dbContext.SaasOfferingVersions.AsNoTracking() + join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id + where version.Status == SaasOfferingVersionStatus.Published && + offering.Status == SaasOfferingStatus.Active && + (version.EffectiveAt == null || version.EffectiveAt <= now) + orderby offering.SortOrder, offering.Code, version.Version descending + select new { Version = version, Offering = offering }) .ToArrayAsync(cancellationToken); var latest = rows.GroupBy(value => value.Offering.Id).Select(group => group.First()).ToArray(); var ids = latest.Select(value => value.Version.Id).ToArray(); - var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); - var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); + var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() + .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); + var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() + .Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); return latest.Select(row => new SaasOfferingVersionItem( - row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type, - row.Version.Version, row.Version.Status, row.Version.BillingCycle, - row.Version.OriginalAmountCents, row.Version.AmountCents, row.Version.Currency, - row.Version.EffectiveAt, row.Version.PublishedAt, - features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(), - limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal))) + row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type, + row.Version.Version, row.Version.Status, row.Version.BillingCycle, + row.Version.OriginalAmountCents, row.Version.AmountCents, row.Version.Currency, + row.Version.EffectiveAt, row.Version.PublishedAt, + features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode) + .Order(StringComparer.Ordinal).ToArray(), + limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, + value => value.LimitValue, StringComparer.Ordinal))) .ToArray(); } @@ -544,23 +575,44 @@ internal sealed class TenantBillingService( { var baseUrl = configuration["PlatformBilling:PublicBaseUrl"]?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) - { throw Error("Platform billing public base URL is not configured.", "platform_billing_public_url_missing"); - } return $"{baseUrl}/api/integrations/platform-billing/callbacks/{provider}"; } - private static PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value) => - new(value.Id, value.PaymentNo, value.Provider, value.Method, value.Status, value.AmountCents, value.ClientPayload); + private static PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value) + { + return new PlatformBillingPaymentView(value.Id, value.PaymentNo, value.Provider, value.Method, value.Status, + value.AmountCents, + value.ClientPayload); + } - private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; - private static string Required(string? value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); - private static string NormalizeProvider(string value) => Required(value, "provider").ToLowerInvariant().Replace('-', '_'); - private static PlatformBillingException Error(string message, string code) => new(message, code); + private static string Number(string prefix) + { + return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; + } - private static string[] ReadStringArray(JsonElement value) => value.ValueKind == JsonValueKind.Array - ? value.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).ToArray() - : []; + private static string Required(string? value, string field) + { + return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); + } + + private static string NormalizeProvider(string value) + { + return Required(value, "provider").ToLowerInvariant().Replace('-', '_'); + } + + private static PlatformBillingException Error(string message, string code) + { + return new PlatformBillingException(message, code); + } + + private static string[] ReadStringArray(JsonElement value) + { + return value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()!).ToArray() + : []; + } private static IReadOnlyDictionary ReadLongDictionary(JsonElement value) { @@ -568,4 +620,4 @@ internal sealed class TenantBillingService( return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _)) .ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Points/PointService.cs b/Tiku.Infrastructure/Points/PointService.cs index 00d0101..2dd7a04 100644 --- a/Tiku.Infrastructure/Points/PointService.cs +++ b/Tiku.Infrastructure/Points/PointService.cs @@ -68,17 +68,15 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService var taskKey = NormalizeKey(command.TaskKey, "task_key_required"); var now = DateTimeOffset.UtcNow; var task = await dbContext.PointActivityTasks - .SingleOrDefaultAsync(item => - item.TenantId == actor.TenantId && - item.TaskKey == taskKey, - cancellationToken) - ?? throw new PointException("Point activity task was not found.", "point_task_not_found"); + .SingleOrDefaultAsync(item => + item.TenantId == actor.TenantId && + item.TaskKey == taskKey, + cancellationToken) + ?? throw new PointException("Point activity task was not found.", "point_task_not_found"); if (task.Status != PointActivityTaskStatus.Active || - task.StartsAt is not null && task.StartsAt > now || - task.EndsAt is not null && task.EndsAt <= now) - { + (task.StartsAt is not null && task.StartsAt > now) || + (task.EndsAt is not null && task.EndsAt <= now)) throw new PointException("Point activity task is not claimable.", "point_task_inactive"); - } if (!string.IsNullOrWhiteSpace(command.SourceType) && command.SourceId.HasValue) { @@ -94,10 +92,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService item.Status == PointActivityClaimStatus.Claimed) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); - if (existing is not null) - { - return ToClaimItem(existing); - } + if (existing is not null) return ToClaimItem(existing); } var claimedCount = await dbContext.PointActivityClaims.CountAsync( @@ -108,9 +103,8 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService item.Status == PointActivityClaimStatus.Claimed, cancellationToken); if (claimedCount >= task.MaxClaimsPerUser) - { - throw new PointException("Point activity task claim limit has been reached.", "point_task_claim_limit_reached"); - } + throw new PointException("Point activity task claim limit has been reached.", + "point_task_claim_limit_reached"); var claim = new PointActivityClaim { @@ -165,9 +159,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService (item.StartsAt == null || item.StartsAt <= now) && (item.EndsAt == null || item.EndsAt > now)); if (query.RegionId.HasValue) - { items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value); - } var result = await items .OrderBy(item => item.SortOrder) @@ -188,33 +180,24 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService : null; var now = DateTimeOffset.UtcNow; var item = await dbContext.PointExchangeItems - .SingleOrDefaultAsync(entry => - entry.TenantId == actor.TenantId && - entry.Id == command.ItemId, - cancellationToken) - ?? throw new PointException("Point exchange item was not found.", "point_exchange_item_not_found"); + .SingleOrDefaultAsync(entry => + entry.TenantId == actor.TenantId && + entry.Id == command.ItemId, + cancellationToken) + ?? throw new PointException("Point exchange item was not found.", "point_exchange_item_not_found"); if (item.Status != PointExchangeItemStatus.Active || - item.StartsAt is not null && item.StartsAt > now || - item.EndsAt is not null && item.EndsAt <= now) - { + (item.StartsAt is not null && item.StartsAt > now) || + (item.EndsAt is not null && item.EndsAt <= now)) throw new PointException("Point exchange item is not available.", "point_exchange_item_inactive"); - } if (item.Stock is <= 0) - { throw new PointException("Point exchange item is sold out.", "point_exchange_item_sold_out"); - } var balance = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints; if (balance < item.PointsCost) - { throw new PointException("Point balance is insufficient.", "insufficient_points"); - } - if (item.Stock.HasValue) - { - item.Stock -= 1; - } + if (item.Stock.HasValue) item.Stock -= 1; var order = new PointExchangeOrder { @@ -250,15 +233,10 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService }) }); if (item.ItemType == PointExchangeItemType.Entitlement) - { await GrantEntitlementAsync(actor, item, order, now, cancellationToken); - } await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return ToExchangeOrderItem(order); } @@ -272,9 +250,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) - { orders = orders.Where(item => item.Status == ParseExchangeOrderStatus(query.Status)); - } var result = await orders .OrderByDescending(item => item.CreatedAt) @@ -349,14 +325,11 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService private async Task AssertActiveMemberAsync(PointActor actor, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync(item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.Status == MembershipStatus.Active, + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Status == MembershipStatus.Active, cancellationToken); - if (!exists) - { - throw new PointException("Current user is not a member of the tenant.", "tenant_access_denied"); - } + if (!exists) throw new PointException("Current user is not a member of the tenant.", "tenant_access_denied"); } private static PointTaskItem ToTaskItem(PointActivityTask task, int claimedCount) @@ -435,13 +408,16 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService true, out var parsed) ? parsed - : throw new PointException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); + : throw new PointException("Point exchange order status is invalid.", + "invalid_point_exchange_order_status"); } - private static string NormalizeEnum(string? value) => - string.Concat((value ?? string.Empty).Split( + private static string NormalizeEnum(string? value) + { + return string.Concat((value ?? string.Empty).Split( ['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } private static string NormalizeKey(string? value, string code) { @@ -461,9 +437,7 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(propertyName, out var property)) - { return null; - } return property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value) ? value @@ -478,4 +452,4 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService CultureInfo.InvariantCulture, $"PX{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Profile/ProfileService.cs b/Tiku.Infrastructure/Profile/ProfileService.cs index a9f8e89..1f11739 100644 --- a/Tiku.Infrastructure/Profile/ProfileService.cs +++ b/Tiku.Infrastructure/Profile/ProfileService.cs @@ -1,5 +1,6 @@ -using System.Text.Json; using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Profile; using Tiku.Domain.Catalog; @@ -8,6 +9,8 @@ using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Profile; @@ -36,50 +39,37 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { var profile = await EnsureProfileAsync(actor, cancellationToken); var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken) - ?? throw new ProfileException("Current user was not found.", "profile_user_not_found"); + ?? throw new ProfileException("Current user was not found.", "profile_user_not_found"); - if (!string.IsNullOrWhiteSpace(command.Name)) - { - user.Name = command.Name.Trim(); - } + if (!string.IsNullOrWhiteSpace(command.Name)) user.Name = command.Name.Trim(); if (command.AvatarPreset is not null) { var avatarPreset = command.AvatarPreset.Trim(); if (!AllowedAvatarPresets.Contains(avatarPreset)) - { throw new ProfileException("Avatar preset must be male or female.", "invalid_avatar_preset"); - } profile.AvatarPreset = avatarPreset.ToLowerInvariant(); } await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", + cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", + cancellationToken); profile.RegionId = command.RegionId ?? profile.RegionId; profile.SelectedSchoolId = command.SelectedSchoolId ?? profile.SelectedSchoolId; profile.SelectedMajorId = command.SelectedMajorId ?? profile.SelectedMajorId; - if (command.Stats.HasValue) - { - profile.Stats = JsonObjectOrDefault(command.Stats.Value); - } + if (command.Stats.HasValue) profile.Stats = JsonObjectOrDefault(command.Stats.Value); - if (command.Progress.HasValue) - { - profile.Progress = JsonObjectOrDefault(command.Progress.Value); - } + if (command.Progress.HasValue) profile.Progress = JsonObjectOrDefault(command.Progress.Value); if (command.ModuleSelections.HasValue) - { profile.ModuleSelections = JsonObjectOrDefault(command.ModuleSelections.Value); - } if (command.RecentActivities.HasValue) - { profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value); - } await dbContext.SaveChangesAsync(cancellationToken); return await BuildProfileItemAsync(actor, profile, null, cancellationToken); @@ -97,7 +87,8 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService item.TenantId == actor.TenantId && item.IsActive && (profile.RegionId == null || item.RegionId == null || item.RegionId == profile.RegionId) && - (profile.SelectedSchoolId == null || item.SchoolId == null || item.SchoolId == profile.SelectedSchoolId)) + (profile.SelectedSchoolId == null || item.SchoolId == null || + item.SchoolId == profile.SelectedSchoolId)) .OrderBy(item => item.ExamAt == null) .ThenBy(item => item.ExamAt) .ThenBy(item => item.SortOrder) @@ -107,18 +98,20 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService return new ExamCountdownList( items.Select(item => new ExamCountdownItem( - item.Id, - item.LegacyId, - item.RegionId, - item.SchoolId, - item.ExamName, - item.ExamAt, - item.ExamType, - item.Description, - item.Metadata, - item.SortOrder, - item.IsActive, - item.ExamAt.HasValue ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays) : null)) + item.Id, + item.LegacyId, + item.RegionId, + item.SchoolId, + item.ExamName, + item.ExamAt, + item.ExamType, + item.Description, + item.Metadata, + item.SortOrder, + item.IsActive, + item.ExamAt.HasValue + ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays) + : null)) .ToArray(), profile.RegionId, profile.SelectedSchoolId); @@ -133,9 +126,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService var notifications = dbContext.UserNotifications.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) - { notifications = notifications.Where(item => item.Status == ParseNotificationStatus(query.Status)); - } var limit = Math.Clamp(query.Limit ?? 50, 1, 100); var items = await notifications @@ -153,15 +144,11 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken = default) { if (command.NotificationIds.Count is 0 or > 100) - { throw new ProfileException("Notification ids must contain 1 to 100 items.", "invalid_notification_ids"); - } var status = ParseNotificationStatus(command.Status ?? "read"); if (status == NotificationStatus.Unread) - { throw new ProfileException("Notification status cannot be set to unread.", "invalid_notification_status"); - } var notifications = await dbContext.UserNotifications .Where(item => @@ -241,14 +228,10 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService var feedbacks = dbContext.Reports.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) - { feedbacks = feedbacks.Where(item => item.Status == ParseReportStatus(query.Status)); - } if (!string.IsNullOrWhiteSpace(query.Type)) - { feedbacks = feedbacks.Where(item => item.Type == ParseReportType(query.Type)); - } return await feedbacks .OrderByDescending(item => item.CreatedAt) @@ -264,7 +247,8 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { await EnsureProfileAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Description); - await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", + cancellationToken); var feedback = new Report { @@ -328,14 +312,15 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService } var task = await dbContext.PointActivityTasks - .Where(item => - item.TenantId == actor.TenantId && - item.Status == PointActivityTaskStatus.Active && - (item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login")) - .OrderByDescending(item => item.TaskKey == "daily_check_in") - .ThenBy(item => item.SortOrder) - .FirstOrDefaultAsync(cancellationToken) - ?? throw new ProfileException("Daily check-in point task was not configured.", "check_in_task_not_found"); + .Where(item => + item.TenantId == actor.TenantId && + item.Status == PointActivityTaskStatus.Active && + (item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login")) + .OrderByDescending(item => item.TaskKey == "daily_check_in") + .ThenBy(item => item.SortOrder) + .FirstOrDefaultAsync(cancellationToken) + ?? throw new ProfileException("Daily check-in point task was not configured.", + "check_in_task_not_found"); var now = DateTimeOffset.UtcNow; var claim = new PointActivityClaim { @@ -394,15 +379,9 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService events = events.Where(item => item.SourceType == sourceType); } - if (query.From.HasValue) - { - events = events.Where(item => item.CreatedAt >= query.From.Value); - } + if (query.From.HasValue) events = events.Where(item => item.CreatedAt >= query.From.Value); - if (query.To.HasValue) - { - events = events.Where(item => item.CreatedAt <= query.To.Value); - } + if (query.To.HasValue) events = events.Where(item => item.CreatedAt <= query.To.Value); var items = await events .OrderByDescending(item => item.CreatedAt) @@ -425,22 +404,18 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService CancellationToken cancellationToken) { var profile = await dbContext.StudentProfiles - .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken); - if (profile is not null) - { - return profile; - } + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, + cancellationToken); + if (profile is not null) return profile; var membershipExists = await dbContext.TenantMemberships.AnyAsync( membership => membership.TenantId == actor.TenantId && membership.UserId == actor.UserId && - membership.Status == Domain.Tenancy.MembershipStatus.Active, + membership.Status == MembershipStatus.Active, cancellationToken); if (!membershipExists) - { throw new ProfileException("Current user is not a tenant member.", "profile_access_denied"); - } profile = new StudentProfile { @@ -550,20 +525,14 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService Guid? id, string code, CancellationToken cancellationToken) - where TEntity : Tiku.Domain.Common.TenantEntity + where TEntity : TenantEntity { - if (!id.HasValue) - { - return; - } + if (!id.HasValue) return; var exists = await dbContext.Set().AnyAsync( entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); - if (!exists) - { - throw new ProfileException("Profile reference was not found.", code); - } + if (!exists) throw new ProfileException("Profile reference was not found.", code); } private static JsonElement JsonObjectOrDefault(JsonElement value) @@ -635,11 +604,9 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService private static TEnum ParseEnum(string value, string code) where TEnum : struct, Enum { - var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); - if (Enum.TryParse(normalized, true, out var parsed)) - { - return parsed; - } + var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal); + if (Enum.TryParse(normalized, true, out var parsed)) return parsed; throw new ProfileException("Profile enum value is invalid.", code); } @@ -673,9 +640,9 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService private static Guid CreateDeterministicGuid(string value) { - var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); Span guidBytes = stackalloc byte[16]; bytes.AsSpan(0, 16).CopyTo(guidBytes); return new Guid(guidBytes); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs b/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs index 09dd60a..f75406f 100644 --- a/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs +++ b/Tiku.Infrastructure/QuestionBanks/PublicQuestionAccessPolicy.cs @@ -22,10 +22,8 @@ public sealed class PublicQuestionAccessPolicy( cancellationToken)).Allowed; if (!tenantIsActive || !subscriptionIsActive) - { throw new PublicQuestionAccessDeniedException( "public_question_subscription_required", "An active trial or subscription is required to start public question practice."); - } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs index 60f49b0..605e3bf 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs @@ -29,25 +29,26 @@ public sealed class QuestionBankQueryService( var tenantItems = filter.Source == QuestionSource.Platform ? [] : await dbContext.QuestionBanks - .AsNoTracking() - .Where(bank => - bank.TenantId == filter.TenantId && - bank.Status == QuestionBankStatus.Active && - (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) && - (string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim()))) - .OrderBy(bank => bank.Name) - .ThenBy(bank => bank.CreatedAt) - .Take(limit) - .Select(bank => new QuestionBankCatalogItem( - bank.Id, - bank.RegionId, - bank.Name, - QuestionSource.Tenant, - bank.Status, - bank.Metadata)) - .ToArrayAsync(cancellationToken); + .AsNoTracking() + .Where(bank => + bank.TenantId == filter.TenantId && + bank.Status == QuestionBankStatus.Active && + (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) && + (string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim()))) + .OrderBy(bank => bank.Name) + .ThenBy(bank => bank.CreatedAt) + .Take(limit) + .Select(bank => new QuestionBankCatalogItem( + bank.Id, + bank.RegionId, + bank.Name, + QuestionSource.Tenant, + bank.Status, + bank.Metadata)) + .ToArrayAsync(cancellationToken); - var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) + var platformItems = filter.Source == QuestionSource.Tenant || + !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) ? [] : await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( @@ -58,13 +59,15 @@ public sealed class QuestionBankQueryService( var systemDbContext = provider.GetRequiredService(); return await systemDbContext.QuestionBanks.AsNoTracking() .Join( - systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), + systemDbContext.Tenants.AsNoTracking() + .Where(tenant => tenant.Mode == TenantMode.PlatformOwned), bank => bank.TenantId, tenant => tenant.Id, (bank, tenant) => bank) .Where(bank => bank.Status == QuestionBankStatus.Active && - (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) && + (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || + bank.RegionId == null) && (string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim()))) .OrderBy(bank => bank.Name) .Take(limit) @@ -100,7 +103,8 @@ public sealed class QuestionBankQueryService( .Take(limit), QuestionSource.Tenant) .ToArrayAsync(cancellationToken); - var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) + var platformItems = filter.Source == QuestionSource.Tenant || + !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) ? [] : await GetPlatformQuestionsAsync(filter, limit, cancellationToken); return new CatalogList(tenantItems.Concat(platformItems).Take(limit).ToArray()); @@ -110,10 +114,7 @@ public sealed class QuestionBankQueryService( QuestionBankFilter filter, CancellationToken cancellationToken = default) { - if (!filter.QuestionId.HasValue) - { - throw new QuestionBankRequiredFieldException("questionId is required."); - } + if (!filter.QuestionId.HasValue) throw new QuestionBankRequiredFieldException("questionId is required."); var result = await GetQuestionsAsync(filter with { Limit = 2 }, cancellationToken); return result.Items.SingleOrDefault() @@ -124,15 +125,13 @@ public sealed class QuestionBankQueryService( QuestionBankFilter filter, CancellationToken cancellationToken = default) { - if (!filter.QuestionId.HasValue) - { - throw new QuestionBankRequiredFieldException("questionId is required."); - } + if (!filter.QuestionId.HasValue) throw new QuestionBankRequiredFieldException("questionId is required."); if (filter.Source == QuestionSource.Platform) { await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken); - var platformItems = await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken); + var platformItems = + await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken); return new CatalogList(platformItems); } @@ -145,10 +144,7 @@ public sealed class QuestionBankQueryService( question.Status == QuestionStatus.Published, cancellationToken); - if (!questionExists) - { - throw new QuestionBankNotFoundException("Question was not found."); - } + if (!questionExists) throw new QuestionBankNotFoundException("Question was not found."); var items = await dbContext.QuestionVersions .AsNoTracking() @@ -189,49 +185,28 @@ public sealed class QuestionBankQueryService( query = query.Where(question => question.TenantId == filter.TenantId); if (filter.QuestionBankId.HasValue) - { query = query.Where(question => question.QuestionBankId == filter.QuestionBankId.Value); - } - if (filter.SubjectId.HasValue) - { - query = query.Where(question => question.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(question => question.SubjectId == filter.SubjectId.Value); - if (filter.CategoryId.HasValue) - { - query = query.Where(question => question.CategoryId == filter.CategoryId.Value); - } + if (filter.CategoryId.HasValue) query = query.Where(question => question.CategoryId == filter.CategoryId.Value); - if (filter.NodeId.HasValue) - { - query = query.Where(question => question.NodeId == filter.NodeId.Value); - } + if (filter.NodeId.HasValue) query = query.Where(question => question.NodeId == filter.NodeId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(question => question.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(question => question.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(question => question.ContentNodeId == filter.ContentNodeId.Value); - } if (filter.CollectionId.HasValue) - { query = query.Where(question => question.PrimaryCollectionId == filter.CollectionId.Value || dbContext.QuestionCollectionItems.Any(item => item.TenantId == question.TenantId && item.QuestionId == question.Id && item.CollectionId == filter.CollectionId.Value)); - } - if (filter.QuestionIds?.Count > 0) - { - query = query.Where(question => filter.QuestionIds.Contains(question.Id)); - } + if (filter.QuestionIds?.Count > 0) query = query.Where(question => filter.QuestionIds.Contains(question.Id)); if (!string.IsNullOrWhiteSpace(filter.Type)) { @@ -259,10 +234,7 @@ public sealed class QuestionBankQueryService( private static IQueryable ApplyKeyword(IQueryable query, string? keyword) where T : class { - if (string.IsNullOrWhiteSpace(keyword)) - { - return query; - } + if (string.IsNullOrWhiteSpace(keyword)) return query; var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, nameof(QuestionBank.Name)).Contains(trimmed)); @@ -357,7 +329,8 @@ public sealed class QuestionBankQueryService( (!filter.NodeId.HasValue || question.NodeId == filter.NodeId.Value) && (!filter.EntryId.HasValue || question.EntryId == filter.EntryId.Value) && (!filter.ContentNodeId.HasValue || question.ContentNodeId == filter.ContentNodeId.Value) && - (filter.QuestionIds == null || filter.QuestionIds.Count == 0 || filter.QuestionIds.Contains(question.Id)) && + (filter.QuestionIds == null || filter.QuestionIds.Count == 0 || + filter.QuestionIds.Contains(question.Id)) && (string.IsNullOrWhiteSpace(filter.Type) || question.Type == filter.Type.Trim())); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -396,10 +369,7 @@ public sealed class QuestionBankQueryService( tenant => tenant.Id, (question, tenant) => new { question.TenantId, question.Id }) .SingleOrDefaultAsync(token); - if (platformQuestion is null) - { - throw new QuestionBankNotFoundException("Question was not found."); - } + if (platformQuestion is null) throw new QuestionBankNotFoundException("Question was not found."); return await systemDbContext.QuestionVersions.AsNoTracking() .Where(version => @@ -429,4 +399,4 @@ public sealed class QuestionBankQueryService( { return Math.Clamp(limit ?? defaultLimit, 1, maxLimit); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs index 1a24974..aad03a0 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs @@ -23,7 +23,8 @@ public sealed class QuestionReferenceService( var ownerTenantId = locator.Source switch { QuestionSource.Tenant => await ResolveTenantQuestionAsync(tenantId, locator.QuestionId, cancellationToken), - QuestionSource.Platform => await ResolvePlatformQuestionAsync(tenantId, locator.QuestionId, cancellationToken), + QuestionSource.Platform => await ResolvePlatformQuestionAsync(tenantId, locator.QuestionId, + cancellationToken), _ => throw new QuestionLocatorException("question_source_invalid", "Question source is invalid.") }; @@ -33,10 +34,7 @@ public sealed class QuestionReferenceService( reference.QuestionOwnerTenantId == ownerTenantId && reference.QuestionId == locator.QuestionId, cancellationToken); - if (existing is not null) - { - return existing; - } + if (existing is not null) return existing; var reference = new TenantQuestionReference { @@ -96,4 +94,4 @@ public sealed class QuestionReferenceService( "question_not_found", "Platform question was not found."); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs index 8820195..3f6e17d 100644 --- a/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs +++ b/Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; @@ -20,9 +19,7 @@ public sealed partial class ScorelineQueryService( var query = dbContext.ScorelineFields.AsNoTracking() .Where(field => field.TenantId == filter.TenantId); if (filter.RegionId.HasValue) - { query = query.Where(field => field.RegionId == filter.RegionId.Value || field.RegionId == null); - } var fields = await query .OrderBy(field => field.SortOrder) @@ -90,25 +87,13 @@ public sealed partial class ScorelineQueryService( { var query = dbContext.ScorelineRecords.AsNoTracking() .Where(record => record.TenantId == filter.TenantId); - if (filter.RegionId.HasValue) - { - query = query.Where(record => record.RegionId == filter.RegionId.Value); - } + if (filter.RegionId.HasValue) query = query.Where(record => record.RegionId == filter.RegionId.Value); - if (filter.SchoolId.HasValue) - { - query = query.Where(record => record.SchoolId == filter.SchoolId.Value); - } + if (filter.SchoolId.HasValue) query = query.Where(record => record.SchoolId == filter.SchoolId.Value); - if (filter.MajorId.HasValue) - { - query = query.Where(record => record.MajorId == filter.MajorId.Value); - } + if (filter.MajorId.HasValue) query = query.Where(record => record.MajorId == filter.MajorId.Value); - if (filter.Year.HasValue) - { - query = query.Where(record => record.Year == filter.Year.Value); - } + if (filter.Year.HasValue) query = query.Where(record => record.Year == filter.Year.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -126,14 +111,12 @@ public sealed partial class ScorelineQueryService( foreach (var dynamicFilter in filters ?? []) { if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey)) - { - throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid"); - } + throw new ScorelineQueryException("Scoreline field filter key is invalid.", + "scoreline_field_filter_key_invalid"); if (dynamicFilter.Operator is not ("field" or "min" or "max")) - { - throw new ScorelineQueryException("Scoreline field filter operator is invalid.", "scoreline_field_filter_operator_invalid"); - } + throw new ScorelineQueryException("Scoreline field filter operator is invalid.", + "scoreline_field_filter_operator_invalid"); } } @@ -152,24 +135,19 @@ public sealed partial class ScorelineQueryService( private static ScorelineCursorPosition? DecodeCursor(string? cursor) { - if (string.IsNullOrWhiteSpace(cursor)) - { - return null; - } + if (string.IsNullOrWhiteSpace(cursor)) return null; try { var normalized = cursor.Replace('-', '+').Replace('_', '/'); - normalized = normalized.PadRight(normalized.Length + ((4 - normalized.Length % 4) % 4), '='); + normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '='); using var document = JsonDocument.Parse(Convert.FromBase64String(normalized)); var root = document.RootElement; if (root.GetProperty("v").GetInt32() != 1 || !root.TryGetProperty("year", out var year) || !root.TryGetProperty("id", out var id) || !Guid.TryParse(id.GetString(), out var recordId)) - { throw new FormatException(); - } return new ScorelineCursorPosition( year.GetInt32(), @@ -177,16 +155,20 @@ public sealed partial class ScorelineQueryService( ReadNullableString(root, "majorName"), recordId); } - catch (Exception exception) when (exception is FormatException or JsonException or InvalidOperationException or KeyNotFoundException) + catch (Exception exception) when (exception is FormatException or JsonException or InvalidOperationException + or KeyNotFoundException) { - throw new ScorelineQueryException("Scoreline cursor is invalid or unsupported.", "scoreline_cursor_invalid"); + throw new ScorelineQueryException("Scoreline cursor is invalid or unsupported.", + "scoreline_cursor_invalid"); } } - private static string? ReadNullableString(JsonElement root, string name) => - root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + private static string? ReadNullableString(JsonElement root, string name) + { + return root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } private static ScorelineFieldItem ToFieldItem(ScorelineField field) { @@ -229,4 +211,4 @@ public sealed partial class ScorelineQueryService( [GeneratedRegex("^[A-Za-z][A-Za-z0-9_]{0,63}$")] private static partial Regex FieldKeyRegex(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs b/Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs index be5efc5..0fab6bc 100644 --- a/Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs +++ b/Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs @@ -14,6 +14,36 @@ internal sealed record ScorelineQueryResult(int? Total, IReadOnlyList filter.field_key) = 'number' AND + CASE filter.operator + WHEN 'min' THEN (r.field_values ->> filter.field_key)::numeric >= filter.value::numeric + WHEN 'max' THEN (r.field_values ->> filter.field_key)::numeric <= filter.value::numeric + ELSE (r.field_values ->> filter.field_key)::numeric = filter.value::numeric + END + WHEN lower(filter.field_type) IN ('boolean', 'bool') THEN + filter.operator = 'field' AND + jsonb_typeof(r.field_values -> filter.field_key) = 'boolean' AND + (r.field_values ->> filter.field_key)::boolean = filter.value::boolean + WHEN lower(filter.field_type) IN ('text', 'textarea', 'string') THEN + filter.operator = 'field' AND + jsonb_typeof(r.field_values -> filter.field_key) = 'string' AND + strpos(lower(r.field_values ->> filter.field_key), lower(filter.value)) > 0 + ELSE + filter.operator = 'field' AND + lower(r.field_values ->> filter.field_key) = lower(filter.value) + END + ) + ) + """; + internal async Task ExecutePageAsync( ScorelineFilter filter, int page, @@ -28,8 +58,10 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) ScorelineFilter filter, int pageSize, ScorelineCursorPosition? cursor, - CancellationToken cancellationToken) => - ExecuteAsync(filter, pageSize + 1, null, cursor, false, cancellationToken); + CancellationToken cancellationToken) + { + return ExecuteAsync(filter, pageSize + 1, null, cursor, false, cancellationToken); + } private async Task ExecuteAsync( ScorelineFilter filter, @@ -51,21 +83,16 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) if (includeCount) { if (!await reader.NextResultAsync(cancellationToken) || !await reader.ReadAsync(cancellationToken)) - { throw new InvalidOperationException("Scoreline count result was missing."); - } total = reader.GetInt32(0); } if (!await reader.NextResultAsync(cancellationToken)) - { throw new InvalidOperationException("Scoreline item result was missing."); - } var items = new List(limit); while (await reader.ReadAsync(cancellationToken)) - { items.Add(new ScorelineRecordItem( reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetString(1), @@ -76,7 +103,6 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) reader.IsDBNull(6) ? null : reader.GetString(6), reader.IsDBNull(7) ? null : reader.GetString(7), JsonDocument.Parse(reader.GetString(8)).RootElement.Clone())); - } return new ScorelineQueryResult(total, items); } @@ -93,38 +119,35 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) var operation = reader.GetString(0); var value = reader.GetString(2); if (reader.IsDBNull(3)) - { - throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed"); - } + throw new ScorelineQueryException("Scoreline field filter is not enabled.", + "scoreline_field_filter_not_allowed"); var fieldType = reader.GetString(3).Trim().ToLowerInvariant(); if (operation is "min" or "max") { if (!IsNumericType(fieldType)) - { - throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid"); - } + throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", + "scoreline_field_range_type_invalid"); if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _)) - { - throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid"); - } + throw new ScorelineQueryException("Scoreline range filter value must be numeric.", + "scoreline_field_range_value_invalid"); } else if (IsNumericType(fieldType) && !decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _)) { - throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid"); + throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", + "scoreline_field_value_invalid"); } else if (fieldType is "boolean" or "bool" && !bool.TryParse(value, out _)) { - throw new ScorelineQueryException("Scoreline boolean filter value must be true or false.", "scoreline_field_value_invalid"); + throw new ScorelineQueryException("Scoreline boolean filter value must be true or false.", + "scoreline_field_value_invalid"); } } if (validated != filters.Count) - { throw new InvalidOperationException("Scoreline filter validation result was incomplete."); - } } private static string BuildSql( @@ -141,21 +164,17 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) .AppendLine("SELECT operator, field_key, value, field_type FROM allowed_filters ORDER BY ordinal;"); if (includeCount) - { sql.Append(validationCte).AppendLine() .Append("SELECT count(*)::integer FROM scoreline_records r WHERE ") .Append(baseWhere).Append(dynamicWhere).AppendLine(";"); - } sql.Append(validationCte).AppendLine() - .Append("SELECT r.id, r.legacy_id, r.region_id, r.school_id, r.major_id, r.year, r.school_name, r.major_name, r.field_values::text ") + .Append( + "SELECT r.id, r.legacy_id, r.region_id, r.school_id, r.major_id, r.year, r.school_name, r.major_name, r.field_values::text ") .Append("FROM scoreline_records r WHERE ").Append(baseWhere).Append(dynamicWhere) .AppendLine(" ORDER BY r.year DESC, r.school_name ASC NULLS LAST, r.major_name ASC NULLS LAST, r.id ASC") .Append(" LIMIT @limit"); - if (includeOffset) - { - sql.Append(" OFFSET @offset"); - } + if (includeOffset) sql.Append(" OFFSET @offset"); sql.Append(';'); return sql.ToString(); @@ -165,86 +184,55 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) { var requested = filters.Count == 0 ? "SELECT NULL::text AS operator, NULL::text AS field_key, NULL::text AS value, NULL::integer AS ordinal WHERE FALSE" - : "VALUES " + string.Join(", ", filters.Select((_, index) => $"(@filter_operator_{index}, @filter_key_{index}, @filter_value_{index}, {index})")); + : "VALUES " + string.Join(", ", + filters.Select((_, index) => + $"(@filter_operator_{index}, @filter_key_{index}, @filter_value_{index}, {index})")); return $$""" - WITH requested_filters(operator, field_key, value, ordinal) AS ({{requested}}), - allowed_filters AS ( - SELECT requested.operator, requested.field_key, requested.value, requested.ordinal, configured.field_type - FROM requested_filters requested - LEFT JOIN LATERAL ( - SELECT field.field_type - FROM scoreline_fields field - WHERE field.tenant_id = @tenant_id - AND field.is_filter - AND field.field_key = requested.field_key - AND (@region_id IS NULL OR field.region_id = @region_id OR field.region_id IS NULL) - ORDER BY (field.region_id = @region_id) DESC NULLS LAST, field.sort_order, field.id - LIMIT 1 - ) configured ON TRUE - ) - """; + WITH requested_filters(operator, field_key, value, ordinal) AS ({{requested}}), + allowed_filters AS ( + SELECT requested.operator, requested.field_key, requested.value, requested.ordinal, configured.field_type + FROM requested_filters requested + LEFT JOIN LATERAL ( + SELECT field.field_type + FROM scoreline_fields field + WHERE field.tenant_id = @tenant_id + AND field.is_filter + AND field.field_key = requested.field_key + AND (@region_id IS NULL OR field.region_id = @region_id OR field.region_id IS NULL) + ORDER BY (field.region_id = @region_id) DESC NULLS LAST, field.sort_order, field.id + LIMIT 1 + ) configured ON TRUE + ) + """; } private static string BuildBaseWhere(bool includeCursor) { var where = """ - r.tenant_id = @tenant_id - AND (@region_id IS NULL OR r.region_id = @region_id) - AND (@school_id IS NULL OR r.school_id = @school_id) - AND (@major_id IS NULL OR r.major_id = @major_id) - AND (@year IS NULL OR r.year = @year) - AND (@keyword IS NULL OR r.school_name ILIKE @keyword ESCAPE '\' OR r.major_name ILIKE @keyword ESCAPE '\') - """; - if (!includeCursor) - { - return where; - } + r.tenant_id = @tenant_id + AND (@region_id IS NULL OR r.region_id = @region_id) + AND (@school_id IS NULL OR r.school_id = @school_id) + AND (@major_id IS NULL OR r.major_id = @major_id) + AND (@year IS NULL OR r.year = @year) + AND (@keyword IS NULL OR r.school_name ILIKE @keyword ESCAPE '\' OR r.major_name ILIKE @keyword ESCAPE '\') + """; + if (!includeCursor) return where; return where + """ - AND ( - r.year < @cursor_year OR - (r.year = @cursor_year AND ( - (@cursor_school IS NOT NULL AND (r.school_name > @cursor_school OR r.school_name IS NULL)) OR - (r.school_name IS NOT DISTINCT FROM @cursor_school AND ( - (@cursor_major IS NOT NULL AND (r.major_name > @cursor_major OR r.major_name IS NULL)) OR - (r.major_name IS NOT DISTINCT FROM @cursor_major AND r.id > @cursor_id) - )) - )) - ) - """; + AND ( + r.year < @cursor_year OR + (r.year = @cursor_year AND ( + (@cursor_school IS NOT NULL AND (r.school_name > @cursor_school OR r.school_name IS NULL)) OR + (r.school_name IS NOT DISTINCT FROM @cursor_school AND ( + (@cursor_major IS NOT NULL AND (r.major_name > @cursor_major OR r.major_name IS NULL)) OR + (r.major_name IS NOT DISTINCT FROM @cursor_major AND r.id > @cursor_id) + )) + )) + ) + """; } - private const string DynamicWhere = """ - - AND NOT EXISTS ( - SELECT 1 - FROM allowed_filters filter - WHERE filter.field_type IS NULL OR NOT ( - CASE - WHEN lower(filter.field_type) IN ('number', 'integer', 'decimal', 'float') THEN - jsonb_typeof(r.field_values -> filter.field_key) = 'number' AND - CASE filter.operator - WHEN 'min' THEN (r.field_values ->> filter.field_key)::numeric >= filter.value::numeric - WHEN 'max' THEN (r.field_values ->> filter.field_key)::numeric <= filter.value::numeric - ELSE (r.field_values ->> filter.field_key)::numeric = filter.value::numeric - END - WHEN lower(filter.field_type) IN ('boolean', 'bool') THEN - filter.operator = 'field' AND - jsonb_typeof(r.field_values -> filter.field_key) = 'boolean' AND - (r.field_values ->> filter.field_key)::boolean = filter.value::boolean - WHEN lower(filter.field_type) IN ('text', 'textarea', 'string') THEN - filter.operator = 'field' AND - jsonb_typeof(r.field_values -> filter.field_key) = 'string' AND - strpos(lower(r.field_values ->> filter.field_key), lower(filter.value)) > 0 - ELSE - filter.operator = 'field' AND - lower(r.field_values ->> filter.field_key) = lower(filter.value) - END - ) - ) - """; - private static void AddParameters( NpgsqlCommand command, ScorelineFilter filter, @@ -261,15 +249,13 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) ? DBNull.Value : $"%{EscapeLike(filter.Keyword.Trim())}%"; command.Parameters.AddWithValue("limit", limit); - if (offset.HasValue) - { - command.Parameters.AddWithValue("offset", offset.Value); - } + if (offset.HasValue) command.Parameters.AddWithValue("offset", offset.Value); if (cursor is not null) { command.Parameters.AddWithValue("cursor_year", cursor.Year); - command.Parameters.Add("cursor_school", NpgsqlDbType.Text).Value = (object?)cursor.SchoolName ?? DBNull.Value; + command.Parameters.Add("cursor_school", NpgsqlDbType.Text).Value = + (object?)cursor.SchoolName ?? DBNull.Value; command.Parameters.Add("cursor_major", NpgsqlDbType.Text).Value = (object?)cursor.MajorName ?? DBNull.Value; command.Parameters.AddWithValue("cursor_id", cursor.Id); } @@ -282,11 +268,15 @@ public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource) } } - private static string EscapeLike(string value) => - value.Replace("\\", "\\\\", StringComparison.Ordinal) + private static string EscapeLike(string value) + { + return value.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("%", "\\%", StringComparison.Ordinal) .Replace("_", "\\_", StringComparison.Ordinal); + } - private static bool IsNumericType(string fieldType) => - fieldType is "number" or "integer" or "decimal" or "float"; -} + private static bool IsNumericType(string fieldType) + { + return fieldType is "number" or "integer" or "decimal" or "float"; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs b/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs index c625a24..17dda4d 100644 --- a/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs +++ b/Tiku.Infrastructure/Security/AuthorizationCacheInvalidationProcessor.cs @@ -10,10 +10,7 @@ internal sealed class AuthorizationCacheInvalidationProcessor( { public async Task ProcessPendingAsync(int batchSize = 100, CancellationToken cancellationToken = default) { - if (!cache.IsConfigured) - { - return 0; - } + if (!cache.IsConfigured) return 0; var items = await dbContext.AuthorizationCacheInvalidations .Where(item => item.ProcessedAt == null) .OrderBy(item => item.CreatedAt) @@ -21,7 +18,6 @@ internal sealed class AuthorizationCacheInvalidationProcessor( .ToArrayAsync(cancellationToken); var processed = 0; foreach (var item in items) - { try { switch (item.TargetType) @@ -36,14 +32,17 @@ internal sealed class AuthorizationCacheInvalidationProcessor( await cache.InvalidateTenantAsync(item.TenantId.Value, cancellationToken); break; case "membership" when item.TenantId.HasValue && item.UserId.HasValue: - await cache.InvalidateMembershipAsync(item.TenantId.Value, item.UserId.Value, cancellationToken); + await cache.InvalidateMembershipAsync(item.TenantId.Value, item.UserId.Value, + cancellationToken); break; case "scope" when item.Realm.HasValue && item.Version.HasValue: - await cache.SetAuthorizationVersionAsync(item.Realm.Value, item.TenantId, item.Version.Value, cancellationToken); + await cache.SetAuthorizationVersionAsync(item.Realm.Value, item.TenantId, item.Version.Value, + cancellationToken); break; default: throw new InvalidOperationException($"Invalid authorization cache invalidation {item.Id}."); } + item.ProcessedAt = DateTimeOffset.UtcNow; item.LastError = null; item.AttemptCount++; @@ -54,8 +53,8 @@ internal sealed class AuthorizationCacheInvalidationProcessor( item.AttemptCount++; item.LastError = exception.Message; } - } + await dbContext.SaveChangesAsync(cancellationToken); return processed; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs b/Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs index 43688ad..483ef8c 100644 --- a/Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs +++ b/Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs @@ -7,23 +7,53 @@ public static class AuthorizationCacheTelemetry public const string MeterName = "Tiku.Security.AuthorizationCache"; private static readonly Meter Meter = new(MeterName, "1.0.0"); private static readonly Counter ReadCounter = Meter.CreateCounter("tiku.authorization_cache.reads"); - private static readonly Counter FallbackCounter = Meter.CreateCounter("tiku.authorization_cache.postgres_fallbacks"); - private static readonly Counter InvalidationCounter = Meter.CreateCounter("tiku.authorization_cache.invalidations"); - private static readonly Counter VersionMismatchCounter = Meter.CreateCounter("tiku.authorization_cache.version_mismatches"); - private static readonly Counter ShadowMismatchCounter = Meter.CreateCounter("tiku.authorization_cache.shadow_mismatches"); - private static readonly Histogram RedisDuration = Meter.CreateHistogram("tiku.authorization_cache.redis.duration", "ms"); - public static void Read(string source, bool hit) => ReadCounter.Add(1, - new KeyValuePair("source", source), - new KeyValuePair("hit", hit)); - public static void PostgresFallback() => FallbackCounter.Add(1); - public static void Invalidated(string target, bool succeeded) => InvalidationCounter.Add(1, - new KeyValuePair("target", target), - new KeyValuePair("succeeded", succeeded)); - public static void VersionMismatch() => VersionMismatchCounter.Add(1); + private static readonly Counter FallbackCounter = + Meter.CreateCounter("tiku.authorization_cache.postgres_fallbacks"); + + private static readonly Counter InvalidationCounter = + Meter.CreateCounter("tiku.authorization_cache.invalidations"); + + private static readonly Counter VersionMismatchCounter = + Meter.CreateCounter("tiku.authorization_cache.version_mismatches"); + + private static readonly Counter ShadowMismatchCounter = + Meter.CreateCounter("tiku.authorization_cache.shadow_mismatches"); + + private static readonly Histogram RedisDuration = + Meter.CreateHistogram("tiku.authorization_cache.redis.duration", "ms"); + + public static void Read(string source, bool hit) + { + ReadCounter.Add(1, + new KeyValuePair("source", source), + new KeyValuePair("hit", hit)); + } + + public static void PostgresFallback() + { + FallbackCounter.Add(1); + } + + public static void Invalidated(string target, bool succeeded) + { + InvalidationCounter.Add(1, + new KeyValuePair("target", target), + new KeyValuePair("succeeded", succeeded)); + } + + public static void VersionMismatch() + { + VersionMismatchCounter.Add(1); + } + public static void ShadowCompared(bool match) { if (!match) ShadowMismatchCounter.Add(1); } - public static void RecordRedisDuration(double milliseconds) => RedisDuration.Record(milliseconds); -} + + public static void RecordRedisDuration(double milliseconds) + { + RedisDuration.Record(milliseconds); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs b/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs index a4a9539..640b83c 100644 --- a/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs +++ b/Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs @@ -10,23 +10,32 @@ internal sealed class AuthorizationStateInvalidator( TikuDbContext dbContext, IAccessSecurityCache cache) : IAuthorizationStateInvalidator { - public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => - ExecuteAsync("session", null, null, sessionId, null, null, + public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + return ExecuteAsync("session", null, null, sessionId, null, null, token => cache.InvalidateSessionAsync(sessionId, token), cancellationToken); + } - public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => - ExecuteAsync("user", null, userId, null, null, null, + public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return ExecuteAsync("user", null, userId, null, null, null, token => cache.InvalidateUserAsync(userId, token), cancellationToken); + } - public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => - ExecuteAsync("tenant", tenantId, null, null, AuthRealm.Tenant, null, + public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + return ExecuteAsync("tenant", tenantId, null, null, AuthRealm.Tenant, null, token => cache.InvalidateTenantAsync(tenantId, token), cancellationToken); + } - public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => - ExecuteAsync("membership", tenantId, userId, null, AuthRealm.Tenant, null, + public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + return ExecuteAsync("membership", tenantId, userId, null, AuthRealm.Tenant, null, token => cache.InvalidateMembershipAsync(tenantId, userId, token), cancellationToken); + } - public async Task BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) + public async Task BumpScopeAsync(AuthRealm realm, Guid? tenantId, + CancellationToken cancellationToken = default) { var updated = await dbContext.AuthorizationScopeVersions .Where(item => item.Realm == realm && item.TenantId == tenantId) @@ -43,6 +52,7 @@ internal sealed class AuthorizationStateInvalidator( }); await dbContext.SaveChangesAsync(cancellationToken); } + var version = await dbContext.AuthorizationScopeVersions.AsNoTracking() .Where(item => item.Realm == realm && item.TenantId == tenantId) .Select(item => item.Version) @@ -57,10 +67,7 @@ internal sealed class AuthorizationStateInvalidator( AuthRealm? realm, long? version, Func operation, CancellationToken cancellationToken) { - if (!cache.IsConfigured) - { - return; - } + if (!cache.IsConfigured) return; var invalidation = new AuthorizationCacheInvalidation { TargetType = targetType, @@ -86,10 +93,7 @@ internal sealed class AuthorizationStateInvalidator( invalidation.LastError = exception.Message; await dbContext.SaveChangesAsync(CancellationToken.None); AuthorizationCacheTelemetry.Invalidated(targetType, false); - if (dbContext.Database.CurrentTransaction is not null) - { - return; - } + if (dbContext.Database.CurrentTransaction is not null) return; throw new AuthorizationSecurityUnavailableException(exception); } } @@ -97,9 +101,28 @@ internal sealed class AuthorizationStateInvalidator( internal sealed class NullAuthorizationStateInvalidator : IAuthorizationStateInvalidator { - public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) => Task.FromResult(1L); -} + public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) + { + return Task.FromResult(1L); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/CurrentAccessContext.cs b/Tiku.Infrastructure/Security/CurrentAccessContext.cs index 8518453..dc702c4 100644 --- a/Tiku.Infrastructure/Security/CurrentAccessContext.cs +++ b/Tiku.Infrastructure/Security/CurrentAccessContext.cs @@ -1,9 +1,9 @@ -using Microsoft.EntityFrameworkCore; using System.Collections.Concurrent; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using Tiku.Application.Security; using Tiku.Application.Auth; +using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; @@ -32,30 +32,24 @@ internal sealed class CurrentAccessContext( private async Task LoadAsync(CancellationToken cancellationToken) { - if (!currentUser.IsAuthenticated || currentUser.UserId is not { } userId) - { - return Empty(); - } + if (!currentUser.IsAuthenticated || currentUser.UserId is not { } userId) return Empty(); var validatedSession = requestSecurityState.ValidatedSession; var isValidated = validatedSession is not null && validatedSession.UserId == userId && validatedSession.TenantId == tenantContext.TenantId; if (isValidated && cacheOptions.Value.Mode == AuthorizationCacheMode.Active) - { return await LoadCachedSnapshotAsync( userId, tenantContext.TenantId, validatedSession!.Realm, validatedSession.AuthorizationVersion, cancellationToken); - } if (!isValidated) { var isUserActive = await dbContext.Users.AsNoTracking() .AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken); if (!isUserActive) - { return new CurrentAccessSnapshot( userId, tenantContext.TenantId, @@ -64,7 +58,6 @@ internal sealed class CurrentAccessContext( new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); - } } if (tenantContext.TenantId is not { } tenantId) @@ -91,7 +84,6 @@ internal sealed class CurrentAccessContext( membership.Status == MembershipStatus.Active, cancellationToken); if (!isActiveMember) - { return new CurrentAccessSnapshot( userId, tenantId, @@ -100,7 +92,6 @@ internal sealed class CurrentAccessContext( new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); - } } var tenantRoles = await ( @@ -121,11 +112,12 @@ internal sealed class CurrentAccessContext( on binding.PermissionCode equals permission.Code where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && - (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) + (permission.Area == BackendPermissionArea.Tenant || + permission.Area == BackendPermissionArea.Both) select binding.PermissionCode) .Distinct() .ToArrayAsync(cancellationToken)) - .ToHashSet(StringComparer.Ordinal); + .ToHashSet(StringComparer.Ordinal); return new CurrentAccessSnapshot( userId, @@ -140,29 +132,33 @@ internal sealed class CurrentAccessContext( private async Task> LoadPlatformPermissionsAsync(Guid userId, CancellationToken cancellationToken) { return (await ( - from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() - join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id - join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding.RoleId - join permission in dbContext.BackendPermissions.AsNoTracking() - on binding.PermissionCode equals permission.Code - where userRole.UserId == userId && - role.Status == BackendRoleStatus.Active && - (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both) - select binding.PermissionCode) - .Distinct() - .ToArrayAsync(cancellationToken)) + from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding + .RoleId + join permission in dbContext.BackendPermissions.AsNoTracking() + on binding.PermissionCode equals permission.Code + where userRole.UserId == userId && + role.Status == BackendRoleStatus.Active && + (permission.Area == BackendPermissionArea.Platform || + permission.Area == BackendPermissionArea.Both) + select binding.PermissionCode) + .Distinct() + .ToArrayAsync(cancellationToken)) .ToHashSet(StringComparer.Ordinal); } private async Task LoadCachedSnapshotAsync( Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken) { - var localKey = $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}"; + var localKey = + $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}"; if (memoryCache.TryGetValue(localKey, out var local) && local is not null) { AuthorizationCacheTelemetry.Read("l1_snapshot", true); return local; } + AuthorizationCacheTelemetry.Read("l1_snapshot", false); try @@ -175,10 +171,8 @@ internal sealed class CurrentAccessContext( TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); return distributed.Snapshot; } - if (distributed is not null) - { - AuthorizationCacheTelemetry.VersionMismatch(); - } + + if (distributed is not null) AuthorizationCacheTelemetry.VersionMismatch(); } catch (Exception exception) when (exception is not OperationCanceledException) { @@ -199,6 +193,7 @@ internal sealed class CurrentAccessContext( { SnapshotFlights.TryRemove(new KeyValuePair>>(localKey, flight)); } + memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); try { @@ -209,6 +204,7 @@ internal sealed class CurrentAccessContext( { // PostgreSQL remains authoritative; a later request can refill Redis. } + return snapshot; } @@ -216,27 +212,27 @@ internal sealed class CurrentAccessContext( Guid userId, Guid? tenantId, CancellationToken cancellationToken) { if (tenantId is null) - { return new CurrentAccessSnapshot( userId, null, true, false, new HashSet(StringComparer.Ordinal), await LoadPlatformPermissionsAsync(userId, cancellationToken), CurrentDataScope.Self); - } var roles = await ( from userRole in dbContext.TenantBackendUserRoles.AsNoTracking() join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id - where userRole.TenantId == tenantId && userRole.UserId == userId && role.Status == BackendRoleStatus.Active + where userRole.TenantId == tenantId && userRole.UserId == userId && + role.Status == BackendRoleStatus.Active select new { role.Id, role.DataScope }) .ToArrayAsync(cancellationToken); var roleIds = roles.Select(role => role.Id).ToArray(); var permissions = roleIds.Length == 0 ? new HashSet(StringComparer.Ordinal) : (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking() - join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code - where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && - (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) - select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken)) - .ToHashSet(StringComparer.Ordinal); + join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals + permission.Code + where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && + (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) + select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken)) + .ToHashSet(StringComparer.Ordinal); return new CurrentAccessSnapshot( userId, tenantId, true, true, permissions, new HashSet(StringComparer.Ordinal), CurrentDataScope.Merge(roles.Select(role => role.DataScope))); @@ -253,4 +249,4 @@ internal sealed class CurrentAccessContext( new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs b/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs index 4a896bb..ec3fd37 100644 --- a/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs +++ b/Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs @@ -22,12 +22,10 @@ public sealed class DataProtectionKeyRingOptions if (string.IsNullOrWhiteSpace(CertificatePath)) { if (requireCertificate) - { throw new InvalidOperationException( "Data Protection certificate is required outside Development. " + "Configure Security:DataProtection:CertificatePath or " + "TIKU_DATA_PROTECTION_CERTIFICATE_PATH."); - } return null; } @@ -36,8 +34,7 @@ public sealed class DataProtectionKeyRingOptions { var certificate = X509CertificateLoader.LoadPkcs12FromFile( Path.GetFullPath(CertificatePath.Trim()), - CertificatePassword, - X509KeyStorageFlags.DefaultKeySet); + CertificatePassword); if (!certificate.HasPrivateKey) { certificate.Dispose(); @@ -59,4 +56,4 @@ public sealed class DataProtectionKeyRingOptions exception); } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs b/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs index 497eb62..8db41d5 100644 --- a/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs +++ b/Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs @@ -11,21 +11,13 @@ internal static class DataScopeQueryableExtensions Expression>? selfPredicate, Expression>? restrictedPredicate) { - if (scope.Mode == DataScopeMode.All) - { - return query; - } + if (scope.Mode == DataScopeMode.All) return query; Expression>? predicate = null; - if (scope.IncludesSelf && selfPredicate is not null) - { - predicate = selfPredicate; - } + if (scope.IncludesSelf && selfPredicate is not null) predicate = selfPredicate; if (scope.Mode == DataScopeMode.Restricted && restrictedPredicate is not null) - { predicate = predicate is null ? restrictedPredicate : OrElse(predicate, restrictedPredicate); - } return predicate is null ? query.Where(_ => false) : query.Where(predicate); } @@ -40,8 +32,12 @@ internal static class DataScopeQueryableExtensions return Expression.Lambda>(Expression.OrElse(leftBody, rightBody), parameter); } - private sealed class ReplaceParameterVisitor(ParameterExpression source, ParameterExpression target) : ExpressionVisitor + private sealed class ReplaceParameterVisitor(ParameterExpression source, ParameterExpression target) + : ExpressionVisitor { - protected override Expression VisitParameter(ParameterExpression node) => node == source ? target : base.VisitParameter(node); + protected override Expression VisitParameter(ParameterExpression node) + { + return node == source ? target : base.VisitParameter(node); + } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/FeatureAccessService.cs b/Tiku.Infrastructure/Security/FeatureAccessService.cs index 84ee7a6..ce58fe8 100644 --- a/Tiku.Infrastructure/Security/FeatureAccessService.cs +++ b/Tiku.Infrastructure/Security/FeatureAccessService.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; using Tiku.Domain.Platform; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; @@ -40,22 +39,18 @@ internal sealed class FeatureAccessService( { var requested = permissionCodes.Distinct(StringComparer.Ordinal).ToArray(); var permissions = await ( - from permission in dbContext.BackendPermissions.AsNoTracking() - join module in dbContext.PermissionModules.AsNoTracking() - on permission.PermissionModuleCode equals module.Code - where requested.Contains(permission.Code) - select new { permission.Code, module.RequiredFeatureCode }) + from permission in dbContext.BackendPermissions.AsNoTracking() + join module in dbContext.PermissionModules.AsNoTracking() + on permission.PermissionModuleCode equals module.Code + where requested.Contains(permission.Code) + select new { permission.Code, module.RequiredFeatureCode }) .ToArrayAsync(cancellationToken); var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken); var allowed = new HashSet(StringComparer.Ordinal); foreach (var permission in permissions) - { if (permission.RequiredFeatureCode is null || snapshot.Evaluate(permission.RequiredFeatureCode, operation).Allowed) - { allowed.Add(permission.Code); - } - } return allowed; } @@ -66,61 +61,61 @@ internal sealed class FeatureAccessService( { var now = DateTimeOffset.UtcNow; var rows = await dbContext.Database.SqlQuery($""" - WITH current_subscription AS ( - SELECT subscription.id, - subscription.base_offering_version_id, - subscription.current_period_start, - subscription.current_period_end - FROM tenant_saas_subscriptions AS subscription - WHERE subscription.tenant_id = {tenantId} - AND subscription.status IN ('trial', 'active') - AND subscription.starts_at <= {now} - AND subscription.current_period_end > {now} - ORDER BY subscription.updated_at DESC - LIMIT 1 - ), - version_ids AS ( - SELECT subscription.base_offering_version_id AS offering_version_id - FROM current_subscription AS subscription - UNION - SELECT item.offering_version_id - FROM tenant_saas_subscription_items AS item - INNER JOIN current_subscription AS subscription - ON subscription.id = item.subscription_id - WHERE item.tenant_id = {tenantId} - AND item.status = 'active' - AND item.starts_at <= {now} - AND item.ends_at > {now} - ), - quota_limits AS ( - SELECT definition.metric_code, - sum(definition.limit_value)::bigint AS limit_value - FROM saas_offering_version_limits AS definition - WHERE definition.offering_version_id IN ( - SELECT version.offering_version_id FROM version_ids AS version) - GROUP BY definition.metric_code - ) - SELECT limits.metric_code AS "MetricCode", - COALESCE(usage.used_value, 0)::bigint AS "Used", - limits.limit_value AS "Limit", - COALESCE(usage.period_start, subscription.current_period_start) AS "PeriodStart", - COALESCE(usage.period_end, subscription.current_period_end) AS "PeriodEnd" - FROM quota_limits AS limits - CROSS JOIN current_subscription AS subscription - LEFT JOIN LATERAL ( - SELECT current_usage.used_value, - current_usage.period_start, - current_usage.period_end - FROM tenant_feature_usage AS current_usage - WHERE current_usage.tenant_id = {tenantId} - AND current_usage.metric_code = limits.metric_code - AND current_usage.period_start <= {now} - AND current_usage.period_end > {now} - ORDER BY current_usage.period_start DESC - LIMIT 1 - ) AS usage ON TRUE - ORDER BY limits.metric_code - """) + WITH current_subscription AS ( + SELECT subscription.id, + subscription.base_offering_version_id, + subscription.current_period_start, + subscription.current_period_end + FROM tenant_saas_subscriptions AS subscription + WHERE subscription.tenant_id = {tenantId} + AND subscription.status IN ('trial', 'active') + AND subscription.starts_at <= {now} + AND subscription.current_period_end > {now} + ORDER BY subscription.updated_at DESC + LIMIT 1 + ), + version_ids AS ( + SELECT subscription.base_offering_version_id AS offering_version_id + FROM current_subscription AS subscription + UNION + SELECT item.offering_version_id + FROM tenant_saas_subscription_items AS item + INNER JOIN current_subscription AS subscription + ON subscription.id = item.subscription_id + WHERE item.tenant_id = {tenantId} + AND item.status = 'active' + AND item.starts_at <= {now} + AND item.ends_at > {now} + ), + quota_limits AS ( + SELECT definition.metric_code, + sum(definition.limit_value)::bigint AS limit_value + FROM saas_offering_version_limits AS definition + WHERE definition.offering_version_id IN ( + SELECT version.offering_version_id FROM version_ids AS version) + GROUP BY definition.metric_code + ) + SELECT limits.metric_code AS "MetricCode", + COALESCE(usage.used_value, 0)::bigint AS "Used", + limits.limit_value AS "Limit", + COALESCE(usage.period_start, subscription.current_period_start) AS "PeriodStart", + COALESCE(usage.period_end, subscription.current_period_end) AS "PeriodEnd" + FROM quota_limits AS limits + CROSS JOIN current_subscription AS subscription + LEFT JOIN LATERAL ( + SELECT current_usage.used_value, + current_usage.period_start, + current_usage.period_end + FROM tenant_feature_usage AS current_usage + WHERE current_usage.tenant_id = {tenantId} + AND current_usage.metric_code = limits.metric_code + AND current_usage.period_start <= {now} + AND current_usage.period_end > {now} + ORDER BY current_usage.period_start DESC + LIMIT 1 + ) AS usage ON TRUE + ORDER BY limits.metric_code + """) .ToArrayAsync(cancellationToken); var result = new List(rows.Length); foreach (var row in rows) @@ -146,26 +141,19 @@ internal sealed class FeatureAccessService( long amount, CancellationToken cancellationToken = default) { - if (amount <= 0) - { - throw new ArgumentOutOfRangeException(nameof(amount)); - } + if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount)); var normalized = Normalize(metricCode); var now = DateTimeOffset.UtcNow; var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken); - if (subscription is null) - { - return false; - } + if (subscription is null) return false; - var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken); + var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, + cancellationToken); if (!limits.TryGetValue(normalized, out var limit)) - { // Quotas are opt-in per offering version. A missing metric means that the // subscription does not cap this operation, rather than a zero allowance. return true; - } var updated = await dbContext.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && @@ -175,22 +163,18 @@ internal sealed class FeatureAccessService( .ExecuteUpdateAsync(setters => setters .SetProperty(value => value.UsedValue, value => value.UsedValue + amount) .SetProperty(value => value.LimitValueSnapshot, limit) - .SetProperty(value => value.WarningIssued, value => value.WarningIssued || (value.UsedValue + amount) * 100 >= limit * 80) + .SetProperty(value => value.WarningIssued, + value => value.WarningIssued || (value.UsedValue + amount) * 100 >= limit * 80) .SetProperty(value => value.Version, value => value.Version + 1) .SetProperty(value => value.UpdatedAt, now), cancellationToken); - if (updated == 1) - { - return true; - } + if (updated == 1) return true; var exists = await dbContext.TenantFeatureUsages.AnyAsync(value => - value.TenantId == tenantId && value.MetricCode == normalized && - value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd, + value.TenantId == tenantId && value.MetricCode == normalized && + value.PeriodStart == subscription.CurrentPeriodStart && + value.PeriodEnd == subscription.CurrentPeriodEnd, cancellationToken); - if (exists || amount > limit) - { - return false; - } + if (exists || amount > limit) return false; dbContext.TenantFeatureUsages.Add(new TenantFeatureUsage { @@ -210,10 +194,7 @@ internal sealed class FeatureAccessService( } catch (DbUpdateException exception) { - foreach (var entry in exception.Entries) - { - entry.State = EntityState.Detached; - } + foreach (var entry in exception.Entries) entry.State = EntityState.Detached; return await dbContext.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && @@ -232,18 +213,12 @@ internal sealed class FeatureAccessService( long amount, CancellationToken cancellationToken = default) { - if (amount <= 0) - { - throw new ArgumentOutOfRangeException(nameof(amount)); - } + if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount)); var normalized = Normalize(metricCode); var now = DateTimeOffset.UtcNow; var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken); - if (subscription is null) - { - return; - } + if (subscription is null) return; await dbContext.TenantFeatureUsages .Where(value => value.TenantId == tenantId && value.MetricCode == normalized && value.PeriodStart == subscription.CurrentPeriodStart && @@ -286,25 +261,30 @@ internal sealed class FeatureAccessService( value.StartsAt <= now && value.EndsAt > now) .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); - if (!versionIds.Contains(baseVersionId)) - { - versionIds = [.. versionIds, baseVersionId]; - } + if (!versionIds.Contains(baseVersionId)) versionIds = [.. versionIds, baseVersionId]; return await dbContext.SaasOfferingVersionLimits.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .GroupBy(value => value.MetricCode) .Select(group => new { MetricCode = group.Key, Limit = group.Sum(value => value.LimitValue) }) - .ToDictionaryAsync(value => value.MetricCode, value => value.Limit, StringComparer.Ordinal, cancellationToken); + .ToDictionaryAsync(value => value.MetricCode, value => value.Limit, StringComparer.Ordinal, + cancellationToken); } - private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) => - new(true, null, featureCode, operation); + private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) + { + return new FeatureAccessDecision(true, null, featureCode, operation); + } - private static FeatureAccessDecision Denied(string featureCode, FeatureAccessOperation operation, string code) => - new(false, code, featureCode, operation); + private static FeatureAccessDecision Denied(string featureCode, FeatureAccessOperation operation, string code) + { + return new FeatureAccessDecision(false, code, featureCode, operation); + } - private static string Normalize(string value) => value.Trim().ToLowerInvariant(); + private static string Normalize(string value) + { + return value.Trim().ToLowerInvariant(); + } private sealed record SubscriptionProjection( Guid Id, @@ -318,4 +298,4 @@ internal sealed class FeatureAccessService( long Limit, DateTimeOffset PeriodStart, DateTimeOffset PeriodEnd); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs b/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs index 16ef46d..4a31829 100644 --- a/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs +++ b/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs @@ -45,10 +45,7 @@ internal sealed class FeatureUsageReconciliationService( public async Task ProcessDueAsync(CancellationToken cancellationToken = default) { - if (!options.Value.Enabled) - { - return 0; - } + if (!options.Value.Enabled) return 0; var now = DateTimeOffset.UtcNow; var cutoff = now.AddMinutes(-Math.Clamp(options.Value.IntervalMinutes, 1, 24 * 60)); @@ -80,7 +77,6 @@ internal sealed class FeatureUsageReconciliationService( .ToArrayAsync(cancellationToken); foreach (var tenantId in candidates) - { await ReconcileTenantAsync( new ReconcileFeatureUsageRequest( tenantId, @@ -89,7 +85,6 @@ internal sealed class FeatureUsageReconciliationService( "Periodic tenant feature usage reconciliation", $"feature-usage-periodic-{tenantId:N}-{now:yyyyMMddHHmmss}"), cancellationToken); - } return candidates.Length; } @@ -115,10 +110,7 @@ internal sealed class FeatureUsageReconciliationService( item.CurrentPeriodEnd }) .FirstOrDefaultAsync(cancellationToken); - if (subscription is null) - { - return []; - } + if (subscription is null) return []; var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() .Where(item => item.TenantId == tenantId && @@ -129,19 +121,15 @@ internal sealed class FeatureUsageReconciliationService( .Select(item => item.OfferingVersionId) .ToArrayAsync(cancellationToken); if (!versionIds.Contains(subscription.BaseOfferingVersionId)) - { versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; - } var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking() .Where(item => versionIds.Contains(item.OfferingVersionId) && CurrentMetrics.Contains(item.MetricCode)) .GroupBy(item => item.MetricCode) .Select(group => new { MetricCode = group.Key, LimitValue = group.Sum(item => item.LimitValue) }) - .ToDictionaryAsync(item => item.MetricCode, item => item.LimitValue, StringComparer.Ordinal, cancellationToken); - if (limits.Count == 0) - { - return []; - } + .ToDictionaryAsync(item => item.MetricCode, item => item.LimitValue, StringComparer.Ordinal, + cancellationToken); + if (limits.Count == 0) return []; var actual = new Dictionary(StringComparer.Ordinal) { @@ -160,7 +148,8 @@ internal sealed class FeatureUsageReconciliationService( .Distinct() .LongCountAsync(cancellationToken), [SaasQuotaMetricCatalog.PrivateQuestionCount] = await dbContext.Questions.AsNoTracking() - .LongCountAsync(item => item.TenantId == tenantId && item.Status != QuestionStatus.Archived, cancellationToken), + .LongCountAsync(item => item.TenantId == tenantId && item.Status != QuestionStatus.Archived, + cancellationToken), [SaasQuotaMetricCatalog.StorageBytes] = await dbContext.ContentAssets.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == ContentStatus.Active && @@ -204,10 +193,11 @@ internal sealed class FeatureUsageReconciliationService( trackedUsage.WarningIssued = warning; trackedUsage.Version = isNew ? 1 : trackedUsage.Version + 1; } + result.Add(new ReconciledFeatureUsage(limit.Key, actualValue, limit.Value, warning, exceeded)); } await dbContext.SaveChangesAsync(cancellationToken); return result; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/RedisAuthorizationCache.cs b/Tiku.Infrastructure/Security/RedisAuthorizationCache.cs index 83bb0ca..13f22f7 100644 --- a/Tiku.Infrastructure/Security/RedisAuthorizationCache.cs +++ b/Tiku.Infrastructure/Security/RedisAuthorizationCache.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using System.Diagnostics; +using System.Text.Json; using Microsoft.Extensions.Options; using StackExchange.Redis; using Tiku.Application.Security; @@ -13,14 +13,15 @@ internal sealed class RedisAuthorizationCache( string environmentName) : IAccessSecurityCache, IAuthorizationSnapshotCache { private const string SetVersionScript = """ - local current = redis.call('GET', KEYS[1]) - if current then - local decoded = cjson.decode(current) - if tonumber(decoded.version) > tonumber(ARGV[1]) then return 0 end - end - redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]) - return 1 - """; + local current = redis.call('GET', KEYS[1]) + if current then + local decoded = cjson.decode(current) + if tonumber(decoded.version) > tonumber(ARGV[1]) then return 0 end + end + redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]) + return 1 + """; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private readonly AuthorizationCacheOptions options = options.Value; private readonly string prefix = $"tiku:{Normalize(environmentName)}"; @@ -33,12 +34,12 @@ internal sealed class RedisAuthorizationCache( { cancellationToken.ThrowIfCancellationRequested(); var keys = lookup.Realm == AuthRealm.Tenant - ? new RedisKey[] + ? new[] { SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, lookup.TenantId), TenantKey(lookup.TenantId!.Value), MembershipKey(lookup.TenantId.Value, lookup.UserId) } - : new RedisKey[] + : new[] { SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, null), PlatformAccessKey(lookup.UserId) @@ -62,27 +63,40 @@ internal sealed class RedisAuthorizationCache( var ttl = StateTtl(); var database = connection.GetDatabase(); var writes = new List(); - Add(writes, database, state.Session is null ? default : SessionKey(state.Session.SessionId), state.Session, ttl); + Add(writes, database, state.Session is null ? default : SessionKey(state.Session.SessionId), state.Session, + ttl); Add(writes, database, state.User is null ? default : UserKey(state.User.UserId), state.User, ttl); Add(writes, database, state.Tenant is null ? default : TenantKey(state.Tenant.TenantId), state.Tenant, ttl); - Add(writes, database, state.Membership is null ? default : MembershipKey(state.Membership.TenantId, state.Membership.UserId), state.Membership, ttl); - Add(writes, database, state.PlatformAccess is null ? default : PlatformAccessKey(state.PlatformAccess.UserId), state.PlatformAccess, ttl); + Add(writes, database, + state.Membership is null ? default : MembershipKey(state.Membership.TenantId, state.Membership.UserId), + state.Membership, ttl); + Add(writes, database, state.PlatformAccess is null ? default : PlatformAccessKey(state.PlatformAccess.UserId), + state.PlatformAccess, ttl); await Task.WhenAll(writes).WaitAsync(cancellationToken); if (state.AuthorizationVersion is { } version) - { await SetAuthorizationVersionAsync( version.Realm, version.TenantId, version.Version, cancellationToken); - } } - public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => - DeleteAsync(SessionKey(sessionId), cancellationToken); - public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => - DeleteAsync(UserKey(userId), cancellationToken); - public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => - DeleteAsync(TenantKey(tenantId), cancellationToken); - public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => - DeleteAsync(MembershipKey(tenantId, userId), cancellationToken); + public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + return DeleteAsync(SessionKey(sessionId), cancellationToken); + } + + public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return DeleteAsync(UserKey(userId), cancellationToken); + } + + public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + return DeleteAsync(TenantKey(tenantId), cancellationToken); + } + + public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + return DeleteAsync(MembershipKey(tenantId, userId), cancellationToken); + } public async Task SetAuthorizationVersionAsync( AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default) @@ -90,9 +104,9 @@ internal sealed class RedisAuthorizationCache( var value = new CachedAuthorizationVersion(realm, tenantId, version); var ttl = StateTtl(); await connection.GetDatabase().ScriptEvaluateAsync( - SetVersionScript, - [VersionKey(realm, tenantId)], - [version, JsonSerializer.Serialize(value, SerializerOptions), (long)ttl.TotalMilliseconds]) + SetVersionScript, + [VersionKey(realm, tenantId)], + [version, JsonSerializer.Serialize(value, SerializerOptions), (long)ttl.TotalMilliseconds]) .WaitAsync(cancellationToken); } @@ -109,26 +123,30 @@ internal sealed class RedisAuthorizationCache( Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, - CancellationToken cancellationToken) => - SetValueAsync( + CancellationToken cancellationToken) + { + return SetValueAsync( SnapshotKey(realm, tenantId, userId), snapshot, TimeSpan.FromSeconds(Math.Max(1, options.DistributedSnapshotSeconds)), cancellationToken); + } - private async Task DeleteAsync(RedisKey key, CancellationToken cancellationToken) => + private async Task DeleteAsync(RedisKey key, CancellationToken cancellationToken) + { await connection.GetDatabase().KeyDeleteAsync(key).WaitAsync(cancellationToken); + } - private async Task SetValueAsync(RedisKey key, T value, TimeSpan ttl, CancellationToken cancellationToken) => + private async Task SetValueAsync(RedisKey key, T value, TimeSpan ttl, CancellationToken cancellationToken) + { await connection.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl) .WaitAsync(cancellationToken); + } private static void Add(List writes, IDatabase database, RedisKey key, T? value, TimeSpan ttl) { if (value is not null) - { writes.Add(database.StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl)); - } } private TimeSpan StateTtl() @@ -138,33 +156,108 @@ internal sealed class RedisAuthorizationCache( return TimeSpan.FromSeconds(seconds * (1 + Random.Shared.Next(-jitter, jitter + 1) / 100d)); } - private static T? Deserialize(RedisValue value) => value.IsNullOrEmpty - ? default - : JsonSerializer.Deserialize(value.ToString(), SerializerOptions); + private static T? Deserialize(RedisValue value) + { + return value.IsNullOrEmpty + ? default + : JsonSerializer.Deserialize(value.ToString(), SerializerOptions); + } - private RedisKey SessionKey(Guid id) => $"{prefix}:auth:session:v1:{id:N}"; - private RedisKey UserKey(Guid id) => $"{prefix}:auth:user:v1:{id:N}"; - private RedisKey TenantKey(Guid id) => $"{prefix}:auth:tenant:v1:{id:N}"; - private RedisKey MembershipKey(Guid tenantId, Guid userId) => $"{prefix}:auth:membership:v1:{tenantId:N}:{userId:N}"; - private RedisKey PlatformAccessKey(Guid userId) => $"{prefix}:auth:platform-access:v1:{userId:N}"; - private RedisKey VersionKey(AuthRealm realm, Guid? tenantId) => - $"{prefix}:authz:version:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}"; - private RedisKey SnapshotKey(AuthRealm realm, Guid? tenantId, Guid userId) => - $"{prefix}:authz:snapshot:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}"; - private static string Normalize(string value) => new(value.Trim().ToLowerInvariant() - .Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-').ToArray()); + private RedisKey SessionKey(Guid id) + { + return $"{prefix}:auth:session:v1:{id:N}"; + } + + private RedisKey UserKey(Guid id) + { + return $"{prefix}:auth:user:v1:{id:N}"; + } + + private RedisKey TenantKey(Guid id) + { + return $"{prefix}:auth:tenant:v1:{id:N}"; + } + + private RedisKey MembershipKey(Guid tenantId, Guid userId) + { + return $"{prefix}:auth:membership:v1:{tenantId:N}:{userId:N}"; + } + + private RedisKey PlatformAccessKey(Guid userId) + { + return $"{prefix}:auth:platform-access:v1:{userId:N}"; + } + + private RedisKey VersionKey(AuthRealm realm, Guid? tenantId) + { + return + $"{prefix}:authz:version:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}"; + } + + private RedisKey SnapshotKey(AuthRealm realm, Guid? tenantId, Guid userId) + { + return + $"{prefix}:authz:snapshot:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}"; + } + + private static string Normalize(string value) + { + return new string(value.Trim().ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-') + .ToArray()); + } } internal sealed class NullAuthorizationCache : IAccessSecurityCache, IAuthorizationSnapshotCache { public bool IsConfigured => false; - public Task GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default) => Task.CompletedTask; -} + + public Task GetAsync(AccessSecurityCacheLookup lookup, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + + public Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + + public Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs b/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs index 6c48096..31547cc 100644 --- a/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs +++ b/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs @@ -3,4 +3,4 @@ namespace Tiku.Infrastructure.Security; public sealed class RedisSecurityConnectionOptions { public string ConnectionString { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/RedisSecurityStore.cs b/Tiku.Infrastructure/Security/RedisSecurityStore.cs index a700172..a8c64e8 100644 --- a/Tiku.Infrastructure/Security/RedisSecurityStore.cs +++ b/Tiku.Infrastructure/Security/RedisSecurityStore.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Logging; -using StackExchange.Redis; using System.Diagnostics; using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; using Tiku.Application.Security; namespace Tiku.Infrastructure.Security; @@ -11,32 +11,39 @@ internal sealed class RedisSecurityStore( string environmentName, ILogger logger) : IRedisSecurityStore { + private const string ConsumeScript = """ + local now = redis.call('TIME') + local nowMs = now[1] * 1000 + math.floor(now[2] / 1000) + local retryAfter = 0 + for i = 1, #KEYS do + local current = tonumber(redis.call('GET', KEYS[i]) or '0') + local limit = tonumber(ARGV[(i - 1) * 2 + 1]) + if current >= limit then + local ttl = redis.call('PTTL', KEYS[i]) + if ttl > retryAfter then retryAfter = ttl end + end + end + if retryAfter > 0 then return {0, retryAfter} end + for i = 1, #KEYS do + local window = tonumber(ARGV[(i - 1) * 2 + 2]) + local value = redis.call('INCR', KEYS[i]) + if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end + end + return {1, 0} + """; + private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0"); - private static readonly Counter OperationCounter = Meter.CreateCounter("tiku.redis.security.operations"); - private static readonly Counter RejectionCounter = Meter.CreateCounter("tiku.redis.rate_limit.rejections"); + + private static readonly Counter + OperationCounter = Meter.CreateCounter("tiku.redis.security.operations"); + + private static readonly Counter RejectionCounter = + Meter.CreateCounter("tiku.redis.rate_limit.rejections"); + private static readonly Counter ErrorCounter = Meter.CreateCounter("tiku.redis.security.errors"); + private static readonly Histogram ScriptDuration = Meter.CreateHistogram( "tiku.redis.lua.duration", "ms"); - private const string ConsumeScript = """ - local now = redis.call('TIME') - local nowMs = now[1] * 1000 + math.floor(now[2] / 1000) - local retryAfter = 0 - for i = 1, #KEYS do - local current = tonumber(redis.call('GET', KEYS[i]) or '0') - local limit = tonumber(ARGV[(i - 1) * 2 + 1]) - if current >= limit then - local ttl = redis.call('PTTL', KEYS[i]) - if ttl > retryAfter then retryAfter = ttl end - end - end - if retryAfter > 0 then return {0, retryAfter} end - for i = 1, #KEYS do - local window = tonumber(ARGV[(i - 1) * 2 + 2]) - local value = redis.call('INCR', KEYS[i]) - if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end - end - return {1, 0} - """; private readonly string prefix = $"tiku:{Normalize(environmentName)}"; @@ -47,10 +54,7 @@ internal sealed class RedisSecurityStore( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - if (buckets.Count == 0) - { - return new DistributedRateLimitResult(true); - } + if (buckets.Count == 0) return new DistributedRateLimitResult(true); try { @@ -65,10 +69,7 @@ internal sealed class RedisSecurityStore( var retryMs = (long)result[1]; OperationCounter.Add(1, new KeyValuePair("operation", "rate_limit")); ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds); - if (!allowed) - { - RejectionCounter.Add(1); - } + if (!allowed) RejectionCounter.Add(1); return new DistributedRateLimitResult( allowed, retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null); @@ -96,9 +97,11 @@ internal sealed class RedisSecurityStore( } } - private static string Normalize(string value) => - string.Concat(value.Trim().ToLowerInvariant().Select(character => + private static string Normalize(string value) + { + return string.Concat(value.Trim().ToLowerInvariant().Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-')); + } } public sealed class NullRedisSecurityStore : IRedisSecurityStore @@ -107,11 +110,16 @@ public sealed class NullRedisSecurityStore : IRedisSecurityStore public Task ConsumeAsync( IReadOnlyCollection buckets, - CancellationToken cancellationToken = default) => - Task.FromResult(new DistributedRateLimitResult(true)); + CancellationToken cancellationToken = default) + { + return Task.FromResult(new DistributedRateLimitResult(true)); + } - public Task PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false); + public Task PingAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } } public sealed class RedisSecurityUnavailableException(Exception innerException) - : Exception("Redis security services are unavailable.", innerException); + : Exception("Redis security services are unavailable.", innerException); \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/RequestAccessValidator.cs b/Tiku.Infrastructure/Security/RequestAccessValidator.cs index 306b67d..4abd545 100644 --- a/Tiku.Infrastructure/Security/RequestAccessValidator.cs +++ b/Tiku.Infrastructure/Security/RequestAccessValidator.cs @@ -11,6 +11,8 @@ internal sealed class RequestAccessValidator(IAuthSessionStore sessionStore) : I Guid userId, AuthRealm realm, Guid? tenantId, - CancellationToken cancellationToken = default) => - sessionStore.ValidateAccessSessionAsync(sessionId, userId, realm, tenantId, cancellationToken); -} + CancellationToken cancellationToken = default) + { + return sessionStore.ValidateAccessSessionAsync(sessionId, userId, realm, tenantId, cancellationToken); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs b/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs index f70115f..6e7e97e 100644 --- a/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs +++ b/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs @@ -19,18 +19,21 @@ internal sealed class TenantFeatureCacheInvalidator( await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); var distributedCache = serviceProvider.GetService(); if (distributedCache is not null) - { try { await Task.WhenAll( - distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read), cancellationToken), - distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write), cancellationToken)); + distributedCache.RemoveAsync( + TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read), + cancellationToken), + distributedCache.RemoveAsync( + TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write), + cancellationToken)); } catch (Exception exception) when (exception is not OperationCanceledException) { - logger.LogWarning(exception, "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId); + logger.LogWarning(exception, + "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId); } - } } private void RemoveMemory(Guid tenantId) @@ -38,4 +41,4 @@ internal sealed class TenantFeatureCacheInvalidator( memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read)); memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write)); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs index 794ddaf..e88ed02 100644 --- a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs +++ b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs @@ -30,67 +30,55 @@ internal sealed record TenantFeatureAccessSnapshot( public FeatureAccessDecision Evaluate(string featureCode, FeatureAccessOperation operation) { var normalized = Normalize(featureCode); - if (TenantStatus != Tiku.Domain.Tenancy.TenantStatus.Active) - { - return Denied(normalized, operation, "tenant_inactive"); - } + if (TenantStatus != Domain.Tenancy.TenantStatus.Active) return Denied(normalized, operation, "tenant_inactive"); var feature = Features.SingleOrDefault(value => value.Code == normalized); - if (feature is null) - { - return Denied(normalized, operation, "feature_unavailable"); - } + if (feature is null) return Denied(normalized, operation, "feature_unavailable"); - if (feature.IsCore) - { - return Allowed(normalized, operation); - } + if (feature.IsCore) return Allowed(normalized, operation); var hasOverride = Overrides.TryGetValue(normalized, out var overrideMode); if (hasOverride && overrideMode == TenantFeatureOverrideMode.Disabled) - { return Denied(normalized, operation, "feature_disabled"); - } if (Subscription is null) - { return hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled ? Allowed(normalized, operation) : Denied(normalized, operation, "subscription_missing"); - } var now = DateTimeOffset.UtcNow; if (operation == FeatureAccessOperation.Write && (Subscription.Status is not (TenantSaasSubscriptionStatus.Trial or TenantSaasSubscriptionStatus.Active) || Subscription.StartsAt > now || Subscription.CurrentPeriodEnd <= now)) - { return Denied(normalized, operation, "subscription_read_only"); - } if (Subscription.Status == TenantSaasSubscriptionStatus.Suspended) - { return Denied(normalized, operation, "subscription_suspended"); - } - if (hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled) - { - return Allowed(normalized, operation); - } + if (hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled) return Allowed(normalized, operation); return PurchasedFeatures.Contains(normalized) ? Allowed(normalized, operation) : Denied(normalized, operation, "feature_not_purchased"); } - private static string Normalize(string value) => value.Trim().ToLowerInvariant(); + private static string Normalize(string value) + { + return value.Trim().ToLowerInvariant(); + } - private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) => - new(true, null, featureCode, operation); + private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) + { + return new FeatureAccessDecision(true, null, featureCode, operation); + } private static FeatureAccessDecision Denied( string featureCode, FeatureAccessOperation operation, - string denialCode) => new(false, denialCode, featureCode, operation); + string denialCode) + { + return new FeatureAccessDecision(false, denialCode, featureCode, operation); + } } internal interface ITenantFeatureSnapshotProvider @@ -108,12 +96,17 @@ internal sealed class TenantFeatureSnapshotProvider( ILogger logger) : ITenantFeatureSnapshotProvider { private static readonly TimeSpan MemoryDuration = TimeSpan.FromSeconds(30); + private static readonly DistributedCacheEntryOptions DistributedOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - private readonly Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion, Task Snapshot)> requestCache = []; + + private readonly + Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion, + Task Snapshot)> requestCache = []; public Task GetAsync( Guid tenantId, @@ -127,7 +120,7 @@ internal sealed class TenantFeatureSnapshotProvider( var snapshotTask = GetCoreAsync( tenantId, operation, - bypassSharedCache: cached.Snapshot is not null, + cached.Snapshot is not null, cancellationToken); requestCache[requestKey] = (saveVersion, snapshotTask); return snapshotTask; @@ -146,19 +139,17 @@ internal sealed class TenantFeatureSnapshotProvider( if (!bypassSharedCache && memoryCache.TryGetValue(cacheKey, out var memorySnapshot) && memorySnapshot is not null) - { return memorySnapshot; - } var distributedCache = serviceProvider.GetService(); if (!bypassSharedCache && distributedCache is not null) - { try { var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken); if (!string.IsNullOrWhiteSpace(cached)) { - var distributedSnapshot = JsonSerializer.Deserialize(cached, SerializerOptions); + var distributedSnapshot = + JsonSerializer.Deserialize(cached, SerializerOptions); if (distributedSnapshot is not null) { memoryCache.Set(cacheKey, distributedSnapshot, MemoryDuration); @@ -170,12 +161,10 @@ internal sealed class TenantFeatureSnapshotProvider( { logger.LogWarning(exception, "Tenant feature snapshot cache read failed; falling back to PostgreSQL."); } - } var snapshot = await LoadAsync(tenantId, operation, cancellationToken); memoryCache.Set(cacheKey, snapshot, MemoryDuration); if (distributedCache is not null) - { try { await distributedCache.SetStringAsync( @@ -188,7 +177,6 @@ internal sealed class TenantFeatureSnapshotProvider( { logger.LogWarning(exception, "Tenant feature snapshot cache write failed; continuing without Redis."); } - } return snapshot; } @@ -224,7 +212,8 @@ internal sealed class TenantFeatureSnapshotProvider( var now = DateTimeOffset.UtcNow; var overrides = await dbContext.TenantFeatureOverrides.AsNoTracking() .Where(value => value.TenantId == tenantId && (value.ExpiresAt == null || value.ExpiresAt > now)) - .ToDictionaryAsync(value => value.FeatureCode, value => value.Mode, StringComparer.Ordinal, cancellationToken); + .ToDictionaryAsync(value => value.FeatureCode, value => value.Mode, StringComparer.Ordinal, + cancellationToken); var purchased = new HashSet(StringComparer.Ordinal); if (tenant?.Subscription is { } subscription) @@ -257,6 +246,8 @@ internal sealed class TenantFeatureSnapshotProvider( purchased); } - internal static string CacheKey(Guid tenantId, FeatureAccessOperation operation) => - $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}"; -} + internal static string CacheKey(Guid tenantId, FeatureAccessOperation operation) + { + return $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}"; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs index ae4d1fe..7092517 100644 --- a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs +++ b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Net; using System.Security.Cryptography; using System.Text.RegularExpressions; using AlibabaCloud.OSS.V2.Credentials; @@ -34,21 +33,36 @@ public sealed partial class AliyunOssObjectStorageService( private Oss.Client? client; private bool disposed; - public string ConfiguredDefaultProvider() => - NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev); + public void Dispose() + { + if (disposed) return; - public string ConfiguredDefaultBucket() => - string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket) + lock (clientLock) + { + client?.Dispose(); + client = null; + disposed = true; + } + } + + public string ConfiguredDefaultProvider() + { + return NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev); + } + + public string ConfiguredDefaultBucket() + { + return string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket) ? "tenant-assets" : storageOptions.Value.DefaultBucket.Trim(); + } public string NormalizeProvider(string? value, string? fallback = null) { var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider(); if (!SupportedProviders.Contains(provider)) - { - throw new ObjectStorageException($"Unsupported storage provider: {provider}", "UNSUPPORTED_STORAGE_PROVIDER"); - } + throw new ObjectStorageException($"Unsupported storage provider: {provider}", + "UNSUPPORTED_STORAGE_PROVIDER"); return provider; } @@ -63,16 +77,12 @@ public sealed partial class AliyunOssObjectStorageService( clean.Contains('\\', StringComparison.Ordinal) || clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) || !SafeObjectKeyRegex().IsMatch(clean)) - { throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY"); - } if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal)) - { throw new ObjectStorageException( "objectKey must be scoped by tenantId prefix.", "OBJECT_KEY_TENANT_PREFIX_REQUIRED"); - } return clean; } @@ -85,9 +95,7 @@ public sealed partial class AliyunOssObjectStorageService( if (normalized.Length > 160 || normalized.Contains('\r', StringComparison.Ordinal) || normalized.Contains('\n', StringComparison.Ordinal)) - { throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE"); - } var exact = storageOptions.Value.AllowedMimeTypes .Select(item => item.Trim().ToLowerInvariant()) @@ -100,29 +108,20 @@ public sealed partial class AliyunOssObjectStorageService( if (!exact.Contains(normalized) && !prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal))) - { throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED"); - } return normalized; } public long? ValidateFileSize(long? fileSizeBytes) { - if (fileSizeBytes is null) - { - return null; - } + if (fileSizeBytes is null) return null; if (fileSizeBytes < 0) - { throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE"); - } if (fileSizeBytes > storageOptions.Value.MaxUploadBytes) - { throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE"); - } return fileSizeBytes; } @@ -131,11 +130,9 @@ public sealed partial class AliyunOssObjectStorageService( { var normalized = NormalizeProvider(provider); if (!UploadableProviders.Contains(normalized)) - { throw new ObjectStorageException( $"{normalized} does not support managed upload signing.", "UPLOAD_PROVIDER_NOT_SUPPORTED"); - } } public void AssertWritableLocation(StorageAssetLocation location) @@ -143,11 +140,9 @@ public sealed partial class AliyunOssObjectStorageService( var provider = NormalizeProvider(location.Provider); if (provider == ObjectStorageProviders.AliyunOss && (string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey))) - { throw new ObjectStorageException( $"{provider} asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED"); - } } public Task SignUploadAsync( @@ -178,9 +173,9 @@ public sealed partial class AliyunOssObjectStorageService( objectKey, "PUT", request.ExpiresIn, - mimeType: mimeType, - fileName: null, - disposition: null)); + mimeType, + null, + null)); } public Task SignDownloadAsync( @@ -191,7 +186,6 @@ public sealed partial class AliyunOssObjectStorageService( var provider = NormalizeProvider(request.Provider); if (!string.IsNullOrWhiteSpace(request.CdnUrl)) - { return Task.FromResult(new ObjectStorageSignedUrl( provider, request.Bucket, @@ -202,16 +196,12 @@ public sealed partial class AliyunOssObjectStorageService( DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "public-or-provider-managed")); - } if (string.IsNullOrWhiteSpace(request.ObjectKey)) - { throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED"); - } var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); if (provider is ObjectStorageProviders.LocalDev or ObjectStorageProviders.QiniuKodo) - { return Task.FromResult(LocalSignedUrl( provider, request.Bucket, @@ -219,24 +209,19 @@ public sealed partial class AliyunOssObjectStorageService( "GET", request.ExpiresIn, request.Disposition)); - } if (provider != ObjectStorageProviders.AliyunOss) - { throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED"); - } if (string.IsNullOrWhiteSpace(request.Bucket)) - { throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); - } return Task.FromResult(SignAliyunOss( request.Bucket, objectKey, "GET", request.ExpiresIn, - mimeType: null, + null, request.FileName, request.Disposition)); } @@ -248,9 +233,7 @@ public sealed partial class AliyunOssObjectStorageService( cancellationToken.ThrowIfCancellationRequested(); var provider = NormalizeProvider(request.Provider); if (string.IsNullOrWhiteSpace(request.ObjectKey)) - { throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED"); - } var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes); @@ -258,12 +241,11 @@ public sealed partial class AliyunOssObjectStorageService( var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant(); if (provider == ObjectStorageProviders.LocalDev) - { return new ObjectStorageMetadata( provider, request.Bucket, objectKey, - Exists: true, + true, fileSizeBytes, mimeType, checksumSha256, @@ -271,19 +253,15 @@ public sealed partial class AliyunOssObjectStorageService( DateTimeOffset.UtcNow.ToString("O"), new Dictionary(), "local-dev-declared-metadata"); - } if (provider != ObjectStorageProviders.AliyunOss) - { throw new ObjectStorageException( $"{provider} does not support upload confirmation.", "UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED"); - } if (string.IsNullOrWhiteSpace(request.Bucket)) - { - throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED"); - } + throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", + "ASSET_OBJECT_LOCATION_REQUIRED"); try { @@ -314,18 +292,12 @@ public sealed partial class AliyunOssObjectStorageService( var mimeType = ValidateMimeType(request.MimeType); var fileSizeBytes = ValidateFileSize(request.FileSizeBytes); var checksumSha256 = NormalizeChecksum(request.ChecksumSha256); - if (request.Content.CanSeek) - { - request.Content.Position = 0; - } + if (request.Content.CanSeek) request.Content.Position = 0; if (string.IsNullOrWhiteSpace(request.Bucket)) - { throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); - } if (provider == ObjectStorageProviders.LocalDev) - { return new ObjectStorageWriteResult( provider, request.Bucket, @@ -337,31 +309,19 @@ public sealed partial class AliyunOssObjectStorageService( null, new Dictionary(), "local-dev-write-placeholder"); - } if (provider != ObjectStorageProviders.AliyunOss) - { throw new ObjectStorageException( $"{provider} does not support managed server-side writes.", "WRITE_PROVIDER_NOT_SUPPORTED"); - } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); if (request.Metadata is not null) - { foreach (var (key, value) in request.Metadata) - { if (!string.IsNullOrWhiteSpace(key) && value is not null) - { metadata[key.Trim()] = value; - } - } - } - if (!string.IsNullOrWhiteSpace(checksumSha256)) - { - metadata["sha256"] = checksumSha256; - } + if (!string.IsNullOrWhiteSpace(checksumSha256)) metadata["sha256"] = checksumSha256; var result = await GetClient().PutObjectAsync(new PutObjectRequest { @@ -394,15 +354,11 @@ public sealed partial class AliyunOssObjectStorageService( var provider = NormalizeProvider(request.Provider); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); if (provider != ObjectStorageProviders.AliyunOss) - { throw new ObjectStorageException( $"{provider} does not support managed server-side reads.", "READ_PROVIDER_NOT_SUPPORTED"); - } if (string.IsNullOrWhiteSpace(request.Bucket)) - { throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); - } var result = await GetClient().GetObjectAsync( new GetObjectRequest { Bucket = request.Bucket, Key = objectKey }, @@ -414,21 +370,6 @@ public sealed partial class AliyunOssObjectStorageService( "STORAGE_OBJECT_EMPTY_RESPONSE"); } - public void Dispose() - { - if (disposed) - { - return; - } - - lock (clientLock) - { - client?.Dispose(); - client = null; - disposed = true; - } - } - private ObjectStorageSignedUrl SignAliyunOss( string bucket, string objectKey, @@ -443,25 +384,22 @@ public sealed partial class AliyunOssObjectStorageService( PresignResult result; if (method == "PUT") - { result = GetClient().Presign(new PutObjectRequest { Bucket = bucket, Key = objectKey, ContentType = mimeType }, expiresAt.UtcDateTime); - } else - { result = GetClient().Presign(new GetObjectRequest { Bucket = bucket, Key = objectKey, ResponseContentDisposition = BuildContentDisposition(fileName, disposition) }, expiresAt.UtcDateTime); - } - return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, "aliyun-oss-signature-url-v4"); + return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, + "aliyun-oss-signature-url-v4"); } private ObjectStorageSignedUrl LocalSignedUrl( @@ -495,10 +433,7 @@ public sealed partial class AliyunOssObjectStorageService( private Uri? BuildPublicUrl(string bucket, string objectKey) { var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/'); - if (string.IsNullOrWhiteSpace(baseUrl)) - { - return null; - } + if (string.IsNullOrWhiteSpace(baseUrl)) return null; var path = string.Join( '/', @@ -513,10 +448,7 @@ public sealed partial class AliyunOssObjectStorageService( { ObjectDisposedException.ThrowIf(disposed, this); - if (client is not null) - { - return client; - } + if (client is not null) return client; lock (clientLock) { @@ -528,10 +460,8 @@ public sealed partial class AliyunOssObjectStorageService( private static Oss.Client CreateClient(AliyunOssOptions currentOptions) { if (!currentOptions.IsConfigured) - { throw new ObjectStorageNotConfiguredException( "aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required."); - } var configuration = Oss.Configuration.LoadDefault(); configuration.Region = currentOptions.Region?.Trim(); @@ -562,9 +492,7 @@ public sealed partial class AliyunOssObjectStorageService( string signatureMode) { if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url)) - { throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL."); - } return new ObjectStorageSignedUrl( provider, @@ -589,15 +517,13 @@ public sealed partial class AliyunOssObjectStorageService( { var headers = NormalizeHeaders(result.Headers); foreach (var metadata in result.Metadata ?? new Dictionary()) - { headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value; - } return new ObjectStorageMetadata( ObjectStorageProviders.AliyunOss, bucket, objectKey, - Exists: true, + true, result.ContentLength, NullIfWhiteSpace(result.ContentType), FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(), @@ -610,18 +536,11 @@ public sealed partial class AliyunOssObjectStorageService( private static Dictionary NormalizeHeaders(IDictionary? headers) { var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (headers is null) - { - return normalized; - } + if (headers is null) return normalized; foreach (var (key, value) in headers) - { if (!string.IsNullOrWhiteSpace(key) && value is not null) - { normalized[key.ToLowerInvariant()] = value; - } - } return normalized; } @@ -643,22 +562,20 @@ public sealed partial class AliyunOssObjectStorageService( private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn) { if (expiresIn <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, "Signed URL expiration must be positive."); - } + throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, + "Signed URL expiration must be positive."); return DateTimeOffset.UtcNow.Add(expiresIn); } - private static string CanonicalObjectKey(string objectKey) => - ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/"); + private static string CanonicalObjectKey(string objectKey) + { + return ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/"); + } private static string? BuildContentDisposition(string? fileName, string? disposition) { - if (string.IsNullOrWhiteSpace(fileName)) - { - return null; - } + if (string.IsNullOrWhiteSpace(fileName)) return null; var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase) ? "inline" @@ -669,28 +586,25 @@ public sealed partial class AliyunOssObjectStorageService( private static string? FirstHeader(IReadOnlyDictionary headers, params string[] keys) { foreach (var key in keys) - { if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) - { return value; - } - } return null; } - private static string? CleanEtag(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"'); + private static string? CleanEtag(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"'); + } - private static string? NullIfWhiteSpace(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string? NullIfWhiteSpace(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } private static string? NormalizeChecksum(string? value) { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } + if (string.IsNullOrWhiteSpace(value)) return null; var normalized = value.Trim().ToLowerInvariant(); return normalized.Length == 64 ? normalized : null; @@ -698,16 +612,10 @@ public sealed partial class AliyunOssObjectStorageService( private static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) { - if (stream.CanSeek) - { - stream.Position = 0; - } + if (stream.CanSeek) stream.Position = 0; var hash = await SHA256.HashDataAsync(stream, cancellationToken); - if (stream.CanSeek) - { - stream.Position = 0; - } + if (stream.CanSeek) stream.Position = 0; return Convert.ToHexString(hash).ToLowerInvariant(); } @@ -717,4 +625,4 @@ public sealed partial class AliyunOssObjectStorageService( [GeneratedRegex("/{2,}")] private static partial Regex ConsecutiveSlashRegex(); -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Storage/AliyunOssOptions.cs b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs index 916b0be..6cc0c0d 100644 --- a/Tiku.Infrastructure/Storage/AliyunOssOptions.cs +++ b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs @@ -23,4 +23,4 @@ public sealed class AliyunOssOptions { return options.PresignDefaultMinutes > 0; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs index 80b4f52..47bb921 100644 --- a/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs +++ b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs @@ -9,6 +9,7 @@ public sealed class ObjectStorageOptions public string? PublicBaseUrl { get; set; } public long MaxUploadBytes { get; set; } = 1024L * 1024 * 500; public string[] AllowedMimePrefixes { get; set; } = ["image/", "video/", "audio/"]; + public string[] AllowedMimeTypes { get; set; } = [ "application/pdf", @@ -22,8 +23,10 @@ public sealed class ObjectStorageOptions public bool RequireTenantPrefix { get; set; } = true; - public static bool BeValid(ObjectStorageOptions options) => - options.MaxUploadBytes > 0 && - !string.IsNullOrWhiteSpace(options.DefaultProvider) && - !string.IsNullOrWhiteSpace(options.DefaultBucket); -} + public static bool BeValid(ObjectStorageOptions options) + { + return options.MaxUploadBytes > 0 && + !string.IsNullOrWhiteSpace(options.DefaultProvider) && + !string.IsNullOrWhiteSpace(options.DefaultBucket); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs index ef0b483..9ca8e73 100644 --- a/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs +++ b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs @@ -21,19 +21,12 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo unit.IsActive); if (filter.RegionId.HasValue) - { query = query.Where(unit => unit.RegionId == filter.RegionId.Value || unit.RegionId == null); - } - if (filter.EntryId.HasValue) - { - query = query.Where(unit => unit.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(unit => unit.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(unit => unit.ContentNodeId == filter.ContentNodeId.Value); - } query = ApplyNameKeyword(query, filter.Keyword); @@ -67,20 +60,12 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo word.TenantId == filter.TenantId && word.IsActive); - if (filter.UnitId.HasValue) - { - query = query.Where(word => word.UnitId == filter.UnitId.Value); - } + if (filter.UnitId.HasValue) query = query.Where(word => word.UnitId == filter.UnitId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(word => word.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(word => word.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(word => word.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -125,19 +110,12 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo subject.IsActive); if (filter.RegionId.HasValue) - { query = query.Where(subject => subject.RegionId == filter.RegionId.Value || subject.RegionId == null); - } - if (filter.EntryId.HasValue) - { - query = query.Where(subject => subject.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(subject => subject.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(subject => subject.ContentNodeId == filter.ContentNodeId.Value); - } query = ApplyNameKeyword(query, filter.Keyword); @@ -175,20 +153,12 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo chapter.TenantId == filter.TenantId && chapter.IsActive); - if (filter.SubjectId.HasValue) - { - query = query.Where(chapter => chapter.SubjectId == filter.SubjectId.Value); - } + if (filter.SubjectId.HasValue) query = query.Where(chapter => chapter.SubjectId == filter.SubjectId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(chapter => chapter.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(chapter => chapter.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(chapter => chapter.ContentNodeId == filter.ContentNodeId.Value); - } query = ApplyNameKeyword(query, filter.Keyword); @@ -221,20 +191,12 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo entry.TenantId == filter.TenantId && entry.IsActive); - if (filter.ChapterId.HasValue) - { - query = query.Where(entry => entry.ChapterId == filter.ChapterId.Value); - } + if (filter.ChapterId.HasValue) query = query.Where(entry => entry.ChapterId == filter.ChapterId.Value); - if (filter.EntryId.HasValue) - { - query = query.Where(entry => entry.EntryId == filter.EntryId.Value); - } + if (filter.EntryId.HasValue) query = query.Where(entry => entry.EntryId == filter.EntryId.Value); if (filter.ContentNodeId.HasValue) - { query = query.Where(entry => entry.ContentNodeId == filter.ContentNodeId.Value); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -268,10 +230,7 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo private static IQueryable ApplyNameKeyword(IQueryable query, string? keyword) where T : class { - if (string.IsNullOrWhiteSpace(keyword)) - { - return query; - } + if (string.IsNullOrWhiteSpace(keyword)) return query; var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, "Name").Contains(trimmed)); @@ -281,4 +240,4 @@ public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyCo { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/NullTenantPublicCacheInvalidator.cs b/Tiku.Infrastructure/Tenancy/NullTenantPublicCacheInvalidator.cs index 5c4bd59..29175b5 100644 --- a/Tiku.Infrastructure/Tenancy/NullTenantPublicCacheInvalidator.cs +++ b/Tiku.Infrastructure/Tenancy/NullTenantPublicCacheInvalidator.cs @@ -4,5 +4,8 @@ namespace Tiku.Infrastructure.Tenancy; internal sealed class NullTenantPublicCacheInvalidator : ITenantPublicCacheInvalidator { - public Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask; -} + public Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs index 22b778f..df5db30 100644 --- a/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs +++ b/Tiku.Infrastructure/Tenancy/PublicTenantConfigurationQuery.cs @@ -1,8 +1,8 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Operations; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; @@ -32,15 +32,21 @@ internal sealed class PublicTenantConfigurationQuery(TikuDbContext dbContext) branding?.ServiceWechat, branding?.ServiceAccountName, IsNonEmptyObject(theme?.ActiveTheme) ? theme!.ActiveTheme.Clone() : CloneOrDefault(branding?.Theme), - IsNonEmptyObject(theme?.ActivePublicAssets) ? theme!.ActivePublicAssets.Clone() : CloneOrDefault(branding?.PublicAssets), + IsNonEmptyObject(theme?.ActivePublicAssets) + ? theme!.ActivePublicAssets.Clone() + : CloneOrDefault(branding?.PublicAssets), CloneOrDefault(settings?.FeatureFlags), CloneOrDefault(settings?.AdminFeatureFlags), CloneOrDefault(settings?.PublicConfig)); } - private static bool IsNonEmptyObject(System.Text.Json.JsonElement? value) => - value is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.EnumerateObject().Any(); + private static bool IsNonEmptyObject(JsonElement? value) + { + return value is { ValueKind: JsonValueKind.Object } json && json.EnumerateObject().Any(); + } - private static System.Text.Json.JsonElement CloneOrDefault(System.Text.Json.JsonElement? value) => - value.HasValue ? value.Value.Clone() : JsonDefaults.Object(); -} + private static JsonElement CloneOrDefault(JsonElement? value) + { + return value.HasValue ? value.Value.Clone() : JsonDefaults.Object(); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantDirectory.cs b/Tiku.Infrastructure/Tenancy/TenantDirectory.cs index 7fab4ad..b4e21d2 100644 --- a/Tiku.Infrastructure/Tenancy/TenantDirectory.cs +++ b/Tiku.Infrastructure/Tenancy/TenantDirectory.cs @@ -1,8 +1,8 @@ -using Npgsql; using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Npgsql; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; @@ -14,21 +14,20 @@ public sealed class TenantDirectory( IServiceProvider serviceProvider) : ITenantDirectory { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry); public Task FindByHostAsync( string host, CancellationToken cancellationToken = default) { const string sql = """ - select t.id, t.slug, t.name, t.status, t.mode, d.host - from tenant_domains d - join tenants t on t.id = d.tenant_id - where d.host = @lookup - and d.status = 'active' - and t.status = 'active' - limit 1 - """; + select t.id, t.slug, t.name, t.status, t.mode, d.host + from tenant_domains d + join tenants t on t.id = d.tenant_id + where d.host = @lookup + and d.status = 'active' + and t.status = 'active' + limit 1 + """; return FindAsync(sql, "host", Normalize(host), cancellationToken); } @@ -37,12 +36,12 @@ public sealed class TenantDirectory( CancellationToken cancellationToken = default) { const string sql = """ - select t.id, t.slug, t.name, t.status, t.mode, null::text as host - from tenants t - where t.slug = @lookup - and t.status = 'active' - limit 1 - """; + select t.id, t.slug, t.name, t.status, t.mode, null::text as host + from tenants t + where t.slug = @lookup + and t.status = 'active' + limit 1 + """; return FindAsync(sql, "code", Normalize(tenantCode), cancellationToken); } @@ -54,13 +53,10 @@ public sealed class TenantDirectory( { var cacheKey = $"tenant-directory:v1:{kind}:{lookup}"; if (memoryCache.TryGetValue(cacheKey, out var memoryValue) && memoryValue is not null) - { return memoryValue.Entry; - } var distributedCache = serviceProvider.GetService(); if (distributedCache is not null) - { try { var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken); @@ -80,14 +76,14 @@ public sealed class TenantDirectory( { // Tenant resolution must fall back to PostgreSQL when Redis is unavailable. } - } await using var command = dataSource.CreateCommand(sql); command.Parameters.AddWithValue("lookup", lookup); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { - await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(false, null), TimeSpan.FromSeconds(20), cancellationToken); + await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(false, null), TimeSpan.FromSeconds(20), + cancellationToken); return null; } @@ -98,7 +94,8 @@ public sealed class TenantDirectory( ParseEnum(reader.GetString(3)), ParseEnum(reader.GetString(4)), reader.IsDBNull(5) ? null : reader.GetString(5)); - await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(true, result), TimeSpan.FromSeconds(300), cancellationToken); + await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(true, result), TimeSpan.FromSeconds(300), + cancellationToken); return result; } @@ -110,10 +107,7 @@ public sealed class TenantDirectory( CancellationToken cancellationToken) { memoryCache.Set(cacheKey, value, value.Found ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(20)); - if (distributedCache is null) - { - return; - } + if (distributedCache is null) return; try { @@ -135,5 +129,10 @@ public sealed class TenantDirectory( return Enum.Parse(value.Replace("_", string.Empty, StringComparison.Ordinal), true); } - private static string Normalize(string value) => value.Trim().ToLowerInvariant(); -} + private static string Normalize(string value) + { + return value.Trim().ToLowerInvariant(); + } + + private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry); +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs index 3fc3c93..1fee012 100644 --- a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs @@ -1,12 +1,13 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Tiku.Application.Tenancy; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; -using Tiku.Domain.Operations; namespace Tiku.Infrastructure.Tenancy; @@ -22,14 +23,10 @@ public sealed class DnsDomainOwnershipVerifier( CancellationToken cancellationToken = default) { if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host)) - { - return new(true, true, null); - } + return new DomainOwnershipResult(true, true, null); if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint)) - { - return new(false, false, "DNS verification is not configured."); - } + return new DomainOwnershipResult(false, false, "DNS verification is not configured."); try { @@ -37,21 +34,19 @@ public sealed class DnsDomainOwnershipVerifier( var cnameMatches = cnameAnswers.Any(answer => options.AllowedCnameTargets.Any(target => NormalizeDnsName(answer).Equals(NormalizeDnsName(target), StringComparison.OrdinalIgnoreCase))); if (!cnameMatches) - { - return new(false, true, "CNAME does not point to an allowed gateway target."); - } + return new DomainOwnershipResult(false, true, "CNAME does not point to an allowed gateway target."); var verificationName = $"{options.VerificationRecordPrefix.Trim().TrimEnd('.')}.{host}"; var txtAnswers = await QueryAsync(verificationName, "TXT", cancellationToken); var txtMatches = txtAnswers.Any(answer => answer.Trim().Trim('"').Equals(verificationToken, StringComparison.Ordinal)); return txtMatches - ? new(true, true, null) - : new(false, true, "TXT ownership token was not found."); + ? new DomainOwnershipResult(true, true, null) + : new DomainOwnershipResult(false, true, "TXT ownership token was not found."); } catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException) { - return new(false, true, $"DNS verification failed: {exception.Message}"); + return new DomainOwnershipResult(false, true, $"DNS verification failed: {exception.Message}"); } } @@ -65,10 +60,8 @@ public sealed class DnsDomainOwnershipVerifier( using var response = await httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); - if (!document.RootElement.TryGetProperty("Answer", out var answers) || answers.ValueKind != JsonValueKind.Array) - { - return []; - } + if (!document.RootElement.TryGetProperty("Answer", out var answers) || + answers.ValueKind != JsonValueKind.Array) return []; return answers.EnumerateArray() .Where(answer => answer.TryGetProperty("data", out _)) @@ -77,7 +70,10 @@ public sealed class DnsDomainOwnershipVerifier( .ToArray(); } - private static string NormalizeDnsName(string value) => value.Trim().Trim('"').TrimEnd('.'); + private static string NormalizeDnsName(string value) + { + return value.Trim().Trim('"').TrimEnd('.'); + } private static bool IsLocalhostHost(string host) { @@ -98,37 +94,31 @@ public sealed class HttpDomainGatewayProvisioner( CancellationToken cancellationToken = default) { if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host)) - { - return new(true, true, null); - } + return new DomainGatewayResult(true, true, null); if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey)) - { - return new(false, false, "Gateway TLS provisioning is not configured."); - } + return new DomainGatewayResult(false, false, "Gateway TLS provisioning is not configured."); try { using var request = new HttpRequestMessage( HttpMethod.Post, $"{options.GatewayBaseUrl.TrimEnd('/')}/domains/ensure"); - request.Headers.Authorization = new("Bearer", options.GatewayApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.GatewayApiKey); request.Content = JsonContent.Create(new { host }); using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) - { - return new(false, true, $"Gateway returned HTTP {(int)response.StatusCode}."); - } + return new DomainGatewayResult(false, true, $"Gateway returned HTTP {(int)response.StatusCode}."); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var tlsReady = document.RootElement.TryGetProperty("tlsReady", out var value) && value.GetBoolean(); return tlsReady - ? new(true, true, null) - : new(false, true, "Gateway route exists but TLS is not ready."); + ? new DomainGatewayResult(true, true, null) + : new DomainGatewayResult(false, true, "Gateway route exists but TLS is not ready."); } catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException) { - return new(false, true, $"Gateway provisioning failed: {exception.Message}"); + return new DomainGatewayResult(false, true, $"Gateway provisioning failed: {exception.Message}"); } } @@ -162,10 +152,7 @@ public sealed class TenantDomainLifecycleService( public async Task ProcessPendingAsync(CancellationToken cancellationToken = default) { - if (!options.Enabled) - { - return 0; - } + if (!options.Enabled) return 0; var domains = await dbContext.TenantDomains .Where(domain => @@ -175,15 +162,9 @@ public sealed class TenantDomainLifecycleService( .Take(Math.Clamp(options.BatchSize, 1, 500)) .ToArrayAsync(cancellationToken); - foreach (var domain in domains) - { - await ProcessAsync(domain, cancellationToken); - } + foreach (var domain in domains) await ProcessAsync(domain, cancellationToken); - if (domains.Length > 0) - { - await dbContext.SaveChangesAsync(cancellationToken); - } + if (domains.Length > 0) await dbContext.SaveChangesAsync(cancellationToken); return domains.Length; } @@ -232,4 +213,4 @@ public sealed class TenantDomainLifecycleService( domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending; domain.LastFailureReason = reason; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs b/Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs index a825be5..69ba021 100644 --- a/Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs +++ b/Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs @@ -24,9 +24,8 @@ internal static class TenantDomainProvisioning { var candidate = value.Trim().TrimEnd('.'); if (candidate.Length == 0 || candidate.Contains('/') || candidate.Contains(':') || candidate.Contains('*')) - { - throw new ArgumentException("A DNS host without scheme, port, path or wildcard is required.", nameof(value)); - } + throw new ArgumentException("A DNS host without scheme, port, path or wildcard is required.", + nameof(value)); string ascii; try @@ -40,12 +39,11 @@ internal static class TenantDomainProvisioning if (ascii.Length > 253 || ascii.Split('.').Length < 2 || ascii.Split('.').Any(label => label.Length is 0 or > 63 || - label.StartsWith('-') || label.EndsWith('-') || - label.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))) - { + label.StartsWith('-') || label.EndsWith('-') || + label.Any(character => + !char.IsAsciiLetterOrDigit(character) && character != '-'))) throw new ArgumentException("The domain host is invalid.", nameof(value)); - } return ascii; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs index 7072707..37b6198 100644 --- a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs +++ b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using Tiku.Application.Security; using System.Diagnostics; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Tiku.Application.Security; using Tiku.Domain.Operations; using Tiku.Infrastructure.Persistence; @@ -16,12 +16,14 @@ public sealed class TenantExecutionScope( public Task ExecuteAsync( SystemScopeRequest request, Func operation, - CancellationToken cancellationToken = default) => - ExecuteAsync(request, async (provider, token) => + CancellationToken cancellationToken = default) + { + return ExecuteAsync(request, async (provider, token) => { await operation(provider, token); return null; }, cancellationToken); + } public async Task ExecuteAsync( SystemScopeRequest request, @@ -50,11 +52,9 @@ public sealed class TenantExecutionScope( { var result = await operation(scope.ServiceProvider, cancellationToken); await WriteAuditAsync( - dbContext, request, "system_scope.completed", started, stopwatch.ElapsedMilliseconds, null, cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + dbContext, request, "system_scope.completed", started, stopwatch.ElapsedMilliseconds, null, + cancellationToken); + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return result; } catch (Exception exception) @@ -66,6 +66,7 @@ public sealed class TenantExecutionScope( await WriteAuditAsync( dbContext, request, "system_scope.entered", started, null, null, CancellationToken.None); } + await WriteAuditAsync( dbContext, request, "system_scope.failed", started, stopwatch.ElapsedMilliseconds, exception.GetType().Name, CancellationToken.None); @@ -83,21 +84,16 @@ public sealed class TenantExecutionScope( ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason); ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId); if (request.TargetTenantId is null && !request.IsGlobal) - { - throw new ArgumentException("System scope requires a target tenant or an explicit global declaration.", nameof(request)); - } + throw new ArgumentException("System scope requires a target tenant or an explicit global declaration.", + nameof(request)); if (request.TargetTenantId is not null && request.IsGlobal) - { throw new ArgumentException("A tenant-targeted system scope cannot also be global.", nameof(request)); - } - if (request.IsGlobal && request.CallerType is SystemScopeCallerType.Worker or SystemScopeCallerType.PublicQuestionBank) - { - throw new ArgumentException("Worker and public question bank scopes must target a tenant.", nameof(request)); - } + if (request.IsGlobal && + request.CallerType is SystemScopeCallerType.Worker or SystemScopeCallerType.PublicQuestionBank) + throw new ArgumentException("Worker and public question bank scopes must target a tenant.", + nameof(request)); if (!Enum.IsDefined(request.CallerType)) - { throw new ArgumentOutOfRangeException(nameof(request), "Unknown system scope caller type."); - } } private static async Task WriteAuditAsync( @@ -129,4 +125,4 @@ public sealed class TenantExecutionScope( }); await dbContext.SaveChangesAsync(cancellationToken); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs index 4ec6de1..eb23f23 100644 --- a/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantExternalProviderConfigService.cs @@ -26,9 +26,7 @@ internal sealed class TenantExternalProviderConfigService( item.Status == TenantExternalProviderStatus.Active); if (!string.IsNullOrWhiteSpace(normalizedProvider)) - { query = query.Where(item => item.Provider == normalizedProvider); - } var item = await query .OrderBy(item => item.Priority) @@ -36,11 +34,9 @@ internal sealed class TenantExternalProviderConfigService( .FirstOrDefaultAsync(cancellationToken); if (item is null) - { throw new TenantExternalProviderException( "Tenant external provider is not configured.", "tenant_external_provider_not_configured"); - } var secretPayload = string.IsNullOrWhiteSpace(item.SecretRef) ? JsonDocument.Parse("{}").RootElement.Clone() @@ -74,15 +70,10 @@ internal sealed class TenantExternalProviderConfigService( .AsNoTracking() .Where(item => item.TenantId == tenantId); - if (capability.HasValue) - { - query = query.Where(item => item.Capability == capability.Value); - } + if (capability.HasValue) query = query.Where(item => item.Capability == capability.Value); if (!string.IsNullOrWhiteSpace(normalizedProvider)) - { query = query.Where(item => item.Provider == normalizedProvider); - } return await query .OrderBy(item => item.Capability) @@ -136,16 +127,15 @@ internal sealed class TenantExternalProviderConfigService( { var normalized = NormalizeProvider(provider); if (string.IsNullOrWhiteSpace(normalized)) - { throw new TenantExternalProviderException("Provider is required.", "provider_required"); - } return normalized; } public static string? NormalizeProvider(string? provider) { - var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant() + .Replace("-", "_", StringComparison.Ordinal); return normalized switch { "wechat" or "wechatpay" or "wxpay" or "wx_pay" => "wechat_pay", @@ -155,8 +145,9 @@ internal sealed class TenantExternalProviderConfigService( }; } - private static TenantExternalProviderItem ToItem(TenantExternalProvider item) => - new( + private static TenantExternalProviderItem ToItem(TenantExternalProvider item) + { + return new TenantExternalProviderItem( item.Id, item.Capability, item.Provider, @@ -168,25 +159,23 @@ internal sealed class TenantExternalProviderConfigService( item.Metadata, item.CreatedAt, item.UpdatedAt); + } - private static JsonElement JsonObjectOrDefault(JsonElement element) => - element.ValueKind == JsonValueKind.Object + private static JsonElement JsonObjectOrDefault(JsonElement element) + { + return element.ValueKind == JsonValueKind.Object ? element.Clone() : JsonSerializer.SerializeToElement(new { }); + } private static void AssertPublicConfig(JsonElement element, string path) { - if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return; - } + if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return; if (element.ValueKind != JsonValueKind.Object) - { throw new TenantExternalProviderException( $"{path} must be a JSON object.", "provider_config_must_be_object"); - } foreach (var property in element.EnumerateObject()) { @@ -194,17 +183,14 @@ internal sealed class TenantExternalProviderConfigService( if (key.Contains("secret", StringComparison.Ordinal) || key.Contains("token", StringComparison.Ordinal) || key.Contains("privatekey", StringComparison.Ordinal) || - key is "key" or "apikey" or "accesskey" or "accesskeyid" or "accesskeysecret" or "appsecret" or "apiv3key") - { + key is "key" or "apikey" or "accesskey" or "accesskeyid" or "accesskeysecret" or "appsecret" + or "apiv3key") throw new TenantExternalProviderException( $"{path} cannot contain secrets.", "public_config_contains_secret"); - } if (property.Value.ValueKind == JsonValueKind.Object) - { AssertPublicConfig(property.Value, $"{path}.{property.Name}"); - } } } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs index fb3469b..e2e106f 100644 --- a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs +++ b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs @@ -62,4 +62,4 @@ internal static class TenantFrontendConfigDefaults DraftHomeModules = modules.Clone() }; } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs index c1da80c..84bdaf8 100644 --- a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs @@ -1,9 +1,9 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -using Tiku.Application.Tenancy; using Tiku.Application.Security; -using Tiku.Domain.Common; +using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -65,16 +65,14 @@ public sealed class TenantFrontendConfigService( CancellationToken cancellationToken = default) { var config = await dbContext.TenantFrontendConfigs - .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) - ?? throw new TenantFrontendConfigException( - "frontend_config_not_found", - "Save a frontend configuration draft before publishing."); + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) + ?? throw new TenantFrontendConfigException( + "frontend_config_not_found", + "Save a frontend configuration draft before publishing."); if (config.ConfigVersion != expectedVersion) - { throw new TenantFrontendConfigException( "frontend_config_version_conflict", "Frontend configuration has changed; reload it before publishing."); - } var draft = Draft(config); Validate(draft); @@ -95,35 +93,35 @@ public sealed class TenantFrontendConfigService( Guid tenantId, CancellationToken cancellationToken = default) { - if (cache.TryGetValue(CacheKey(tenantId), out var cached) && cached is not null) - { - return cached; - } + if (cache.TryGetValue(CacheKey(tenantId), out var cached) && + cached is not null) return cached; var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync( - item => item.Id == tenantId && item.Status == TenantStatus.Active, - cancellationToken) - ?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found."); + item => item.Id == tenantId && item.Status == TenantStatus.Active, + cancellationToken) + ?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found."); var now = DateTimeOffset.UtcNow; var hasActiveSubscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(item => - item.TenantId == tenantId && - (item.Status == TenantSaasSubscriptionStatus.Trial || item.Status == TenantSaasSubscriptionStatus.Active) && - item.StartsAt <= now && item.CurrentPeriodEnd > now, + item.TenantId == tenantId && + (item.Status == TenantSaasSubscriptionStatus.Trial || + item.Status == TenantSaasSubscriptionStatus.Active) && + item.StartsAt <= now && item.CurrentPeriodEnd > now, cancellationToken); if (!hasActiveSubscription) - { - throw new TenantFrontendConfigException("subscription_inactive", "An active tenant subscription is required."); - } + throw new TenantFrontendConfigException("subscription_inactive", + "An active tenant subscription is required."); var config = await dbContext.TenantFrontendConfigs.AsNoTracking() - .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) - ?? CreateDefault(tenantId, tenant.Name); + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) + ?? CreateDefault(tenantId, tenant.Name); var ownerActivated = tenant.OwnerUserId.HasValue && await dbContext.Users.AsNoTracking().AnyAsync( - user => user.Id == tenant.OwnerUserId && user.Status == Tiku.Domain.Identity.UserStatus.Active && + user => user.Id == tenant.OwnerUserId && user.Status == UserStatus.Active && !user.ForcePasswordChange && user.PasswordHash != null, cancellationToken); var siteState = !ownerActivated ? "setup_required" - : config.PublishedAt.HasValue ? "active" : "ready_to_launch"; + : config.PublishedAt.HasValue + ? "active" + : "ready_to_launch"; var result = new TenantRuntimeBootstrap( config.SchemaVersion, config.ConfigVersion, @@ -135,25 +133,28 @@ public sealed class TenantFrontendConfigService( config.PublishedFeatures.Clone(), config.PublishedNavigation.Clone(), config.PublishedHomeModules.Clone(), - (await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, cancellationToken)) - .Where(code => code != SaasFeatureCatalog.CoreBackoffice) - .Order(StringComparer.Ordinal) - .ToArray(), + (await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, + cancellationToken)) + .Where(code => code != SaasFeatureCatalog.CoreBackoffice) + .Order(StringComparer.Ordinal) + .ToArray(), (await dbContext.TenantAuthPolicies.AsNoTracking() .Where(item => item.TenantId == tenantId) .Select(item => item.AllowedStudentLoginMethods) .SingleOrDefaultAsync(cancellationToken) ?? ["password"]) - .Select(value => value.Trim().ToLowerInvariant()) - .Where(value => value.Length > 0) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .ToArray()); + .Select(value => value.Trim().ToLowerInvariant()) + .Where(value => value.Length > 0) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray()); cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration); return result; } - private static TenantFrontendConfig CreateDefault(Guid tenantId, string tenantName) => - TenantFrontendConfigDefaults.Create(tenantId, tenantName); + private static TenantFrontendConfig CreateDefault(Guid tenantId, string tenantName) + { + return TenantFrontendConfigDefaults.Create(tenantId, tenantName); + } private static TenantFrontendConfigItem ToItem(TenantFrontendConfig config) { @@ -189,19 +190,15 @@ public sealed class TenantFrontendConfigService( RequireKind(draft.HomeModules, JsonValueKind.Array, "homeModules"); foreach (var root in new[] { draft.Branding, draft.Theme, draft.Features, draft.Navigation, draft.HomeModules }) - { RejectUnsafeContent(root); - } } private static void RequireKind(JsonElement value, JsonValueKind expected, string field) { if (value.ValueKind != expected) - { throw new TenantFrontendConfigException( "frontend_config_invalid", $"Frontend configuration field '{field}' must be a JSON {expected.ToString().ToLowerInvariant()}."); - } } private static void RejectUnsafeContent(JsonElement value) @@ -212,34 +209,33 @@ public sealed class TenantFrontendConfigService( { if (property.Name.Contains("script", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("html", StringComparison.OrdinalIgnoreCase)) - { throw UnsafeConfig(); - } RejectUnsafeContent(property.Value); } } else if (value.ValueKind == JsonValueKind.Array) { - foreach (var item in value.EnumerateArray()) - { - RejectUnsafeContent(item); - } + foreach (var item in value.EnumerateArray()) RejectUnsafeContent(item); } else if (value.ValueKind == JsonValueKind.String) { var text = value.GetString() ?? string.Empty; if (text.Contains(" new( - "frontend_config_unsafe", - "Frontend configuration cannot contain HTML or executable scripts."); + private static TenantFrontendConfigException UnsafeConfig() + { + return new TenantFrontendConfigException( + "frontend_config_unsafe", + "Frontend configuration cannot contain HTML or executable scripts."); + } - private static string CacheKey(Guid tenantId) => $"tenant-runtime:{tenantId:N}"; -} + private static string CacheKey(Guid tenantId) + { + return $"tenant-runtime:{tenantId:N}"; + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs index 9106262..e9fd1a8 100644 --- a/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs @@ -28,17 +28,16 @@ internal sealed class TenantLifecycleService( Guid tenantId, CancellationToken cancellationToken = default) { - var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) - ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); + var tenant = await dbContext.Tenants.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) + ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); var blockers = new List(); if (tenant.Status == TenantStatus.Archived) blockers.Add("tenant_already_archived"); if (await dbContext.BackgroundJobs.AnyAsync(item => - item.TenantId == tenantId && - item.Status == BackgroundJobStatus.Processing, + item.TenantId == tenantId && + item.Status == BackgroundJobStatus.Processing, cancellationToken)) - { blockers.Add("processing_background_jobs"); - } var recentExport = await HasRecentExportAsync(tenantId, cancellationToken); if (!recentExport) blockers.Add("recent_successful_export_required"); return new TenantArchivePreview(tenantId, blockers.Count == 0, recentExport, blockers); @@ -82,19 +81,18 @@ internal sealed class TenantLifecycleService( CancellationToken cancellationToken = default) { var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( - item => item.TenantId == tenantId && item.Id == operationId && - item.OperationType == TenantLifecycleOperationType.Export && - item.Status == TenantLifecycleOperationStatus.Succeeded, - cancellationToken) ?? throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready."); + item => item.TenantId == tenantId && item.Id == operationId && + item.OperationType == TenantLifecycleOperationType.Export && + item.Status == TenantLifecycleOperationStatus.Succeeded, + cancellationToken) ?? + throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready."); var asset = operation.ExportAssetId.HasValue ? await dbContext.ContentAssets.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operation.ExportAssetId.Value, cancellationToken) : null; if (asset is null || string.IsNullOrWhiteSpace(asset.ObjectKey)) - { throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export asset was not found."); - } return await objectStorageService.SignDownloadAsync( new ObjectStorageDownloadSignRequest( tenantId, @@ -103,8 +101,7 @@ internal sealed class TenantLifecycleService( asset.ObjectKey, TimeSpan.FromMinutes(15), asset.CdnUrl, - asset.FileName, - "attachment"), + asset.FileName), cancellationToken); } @@ -116,16 +113,15 @@ internal sealed class TenantLifecycleService( { var preview = await PreviewArchiveAsync(tenantId, cancellationToken); if (!preview.CanArchive) - { throw new TenantLifecycleException("tenant_archive_blocked", string.Join(',', preview.Blockers)); - } var tenant = await RequireTenantAsync(tenantId, cancellationToken); var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Archive, reason); operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Archived; await dbContext.TenantDomains.Where(item => item.TenantId == tenantId) - .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), cancellationToken); + .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), + cancellationToken); dbContext.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason); await dbContext.SaveChangesAsync(cancellationToken); @@ -142,15 +138,14 @@ internal sealed class TenantLifecycleService( { var tenant = await RequireTenantAsync(tenantId, cancellationToken); if (tenant.Status != TenantStatus.Archived) - { throw new TenantLifecycleException("tenant_not_archived", "Only archived tenants can be restored."); - } var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Restore, reason); operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Suspended; await dbContext.TenantDomains.Where(item => item.TenantId == tenantId) - .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), cancellationToken); + .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), + cancellationToken); dbContext.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason); await dbContext.SaveChangesAsync(cancellationToken); @@ -168,18 +163,16 @@ internal sealed class TenantLifecycleService( await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); var tenant = await RequireTenantAsync(tenantId, cancellationToken); var target = await dbContext.TenantMemberships.SingleOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active, + item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active, cancellationToken) ?? throw new TenantLifecycleException( - "tenant_owner_target_not_active_member", - "New owner must be an existing active tenant member."); + "tenant_owner_target_not_active_member", + "New owner must be an existing active tenant member."); var previousOwnerId = tenant.OwnerUserId; if (previousOwnerId == targetUserId) - { throw new TenantLifecycleException("tenant_owner_unchanged", "Target user is already the tenant owner."); - } var previous = previousOwnerId.HasValue ? await dbContext.TenantMemberships.SingleOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == previousOwnerId.Value, + item.TenantId == tenantId && item.UserId == previousOwnerId.Value, cancellationToken) : null; if (previous is not null) previous.Role = TenantRole.TenantAdmin; @@ -193,22 +186,20 @@ internal sealed class TenantLifecycleService( if (ownerRoleId.HasValue) { if (previousOwnerId.HasValue) - { await dbContext.TenantBackendUserRoles - .Where(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value && item.RoleId == ownerRoleId.Value) + .Where(item => + item.TenantId == tenantId && item.UserId == previousOwnerId.Value && + item.RoleId == ownerRoleId.Value) .ExecuteDeleteAsync(cancellationToken); - } if (!await dbContext.TenantBackendUserRoles.AnyAsync(item => - item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value, + item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value, cancellationToken)) - { dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = targetUserId, RoleId = ownerRoleId.Value }); - } } var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.OwnerTransfer, reason); @@ -222,9 +213,8 @@ internal sealed class TenantLifecycleService( await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); if (previousOwnerId.HasValue) - { - await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value, cancellationToken); - } + await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value, + cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, targetUserId, cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); @@ -234,16 +224,18 @@ internal sealed class TenantLifecycleService( { var cutoff = DateTimeOffset.UtcNow - RecentExportWindow; return dbContext.TenantLifecycleOperations.AnyAsync(item => - item.TenantId == tenantId && - item.OperationType == TenantLifecycleOperationType.Export && - item.Status == TenantLifecycleOperationStatus.Succeeded && - item.CompletedAt >= cutoff, + item.TenantId == tenantId && + item.OperationType == TenantLifecycleOperationType.Export && + item.Status == TenantLifecycleOperationStatus.Succeeded && + item.CompletedAt >= cutoff, cancellationToken); } - private async Task RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) => - await dbContext.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? - throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); + private async Task RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) + { + return await dbContext.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? + throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); + } private async Task RevokeTenantSessionsAsync(Guid tenantId, CancellationToken cancellationToken) { @@ -253,9 +245,8 @@ internal sealed class TenantLifecycleService( .Distinct() .ToArrayAsync(cancellationToken); foreach (var userId in userIds) - { - await sessionStore.RevokeRealmAsync(userId, AuthRealm.Tenant, tenantId, "tenant_archived", cancellationToken); - } + await sessionStore.RevokeRealmAsync(userId, AuthRealm.Tenant, tenantId, "tenant_archived", + cancellationToken); } private async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken) @@ -270,15 +261,19 @@ internal sealed class TenantLifecycleService( Guid tenantId, Guid actorUserId, TenantLifecycleOperationType type, - string? reason) => new() + string? reason) + { + return new TenantLifecycleOperation { TenantId = tenantId, RequestedBy = actorUserId, OperationType = type, Reason = reason?.Trim() }; + } - private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) => + private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) + { dbContext.AuditLogs.Add(new AuditLog { TenantId = tenantId, @@ -288,24 +283,31 @@ internal sealed class TenantLifecycleService( TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason }) }); + } - private static string ToProvider(AssetStorageProvider provider) => provider switch + private static string ToProvider(AssetStorageProvider provider) { - AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, - AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, - _ => ObjectStorageProviders.ExternalUrl - }; + return provider switch + { + AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, + AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, + _ => ObjectStorageProviders.ExternalUrl + }; + } - private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation) => new( - operation.Id, - operation.TenantId, - operation.OperationType, - operation.Status, - operation.RequestedBy, - operation.TargetUserId, - operation.ExportAssetId, - operation.Reason, - operation.LastError, - operation.CreatedAt, - operation.CompletedAt); -} + private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation) + { + return new TenantLifecycleOperationItem( + operation.Id, + operation.TenantId, + operation.OperationType, + operation.Status, + operation.RequestedBy, + operation.TargetUserId, + operation.ExportAssetId, + operation.Reason, + operation.LastError, + operation.CreatedAt, + operation.CompletedAt); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs b/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs index 07a11e5..b6290aa 100644 --- a/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs +++ b/Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; using Tiku.Application.Tenancy; +using Tiku.Domain.Identity; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -15,25 +16,28 @@ internal sealed class TenantOnboardingService( Guid tenantId, CancellationToken cancellationToken = default) { - var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(value => value.Id == tenantId, cancellationToken) - ?? throw new TenantExternalProviderException("Tenant was not found.", "tenant_not_found"); + var tenant = await dbContext.Tenants.AsNoTracking() + .SingleOrDefaultAsync(value => value.Id == tenantId, cancellationToken) + ?? throw new TenantExternalProviderException("Tenant was not found.", "tenant_not_found"); var ownerActivated = tenant.OwnerUserId.HasValue && await ( from membership in dbContext.TenantMemberships.AsNoTracking() join user in dbContext.Users.AsNoTracking() on membership.UserId equals user.Id where membership.TenantId == tenantId && membership.UserId == tenant.OwnerUserId && membership.Role == TenantRole.TenantOwner && membership.Status == MembershipStatus.Active && - user.Status == Tiku.Domain.Identity.UserStatus.Active && !user.ForcePasswordChange + user.Status == UserStatus.Active && !user.ForcePasswordChange select membership.Id).AnyAsync(cancellationToken); var now = DateTimeOffset.UtcNow; var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && - (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) && + (value.Status == TenantSaasSubscriptionStatus.Trial || + value.Status == TenantSaasSubscriptionStatus.Active) && value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken); var billingPolicyConfigured = await dbContext.TenantBillingPolicies.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && value.RenewalLeadDays >= 1 && value.RenewalLeadDays <= 90 && value.DefaultPaymentProvider != string.Empty, cancellationToken); var primaryDomainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => - value.TenantId == tenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active, cancellationToken); + value.TenantId == tenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active, + cancellationToken); var frontendPublished = await dbContext.TenantFrontendConfigs.AsNoTracking().AnyAsync(value => value.TenantId == tenantId && value.PublishedAt != null, cancellationToken); var loginMethods = await dbContext.TenantAuthPolicies.AsNoTracking() @@ -42,7 +46,9 @@ internal sealed class TenantOnboardingService( .SingleOrDefaultAsync(cancellationToken) ?? ["password"]; var hasLoginMethod = loginMethods.Any(value => !string.IsNullOrWhiteSpace(value)); - var enabledFeatures = await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, cancellationToken); + var enabledFeatures = + await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, + cancellationToken); var paymentRequired = enabledFeatures.Contains(SaasFeatureCatalog.StudentStore); var storageRequired = enabledFeatures.Overlaps(new[] { @@ -66,7 +72,8 @@ internal sealed class TenantOnboardingService( new("primary_domain_active", true, primaryDomainActive, null), new("frontend_config_published", true, frontendPublished, null), new("student_login_configured", true, hasLoginMethod, string.Join(',', loginMethods)), - ProviderStep("object_storage_provider", TenantExternalProviderCapability.ObjectStorage, storageRequired, providers), + ProviderStep("object_storage_provider", TenantExternalProviderCapability.ObjectStorage, storageRequired, + providers), ProviderStep("sms_provider", TenantExternalProviderCapability.Sms, smsRequired, providers), ProviderStep("payment_provider", TenantExternalProviderCapability.Payment, paymentRequired, providers), ProviderStep("notification_provider", TenantExternalProviderCapability.Notification, false, providers) @@ -80,6 +87,9 @@ internal sealed class TenantOnboardingService( string code, TenantExternalProviderCapability capability, bool required, - IReadOnlyCollection providers) => - new(code, required, providers.Contains(capability), providers.Contains(capability) ? "active" : "missing"); -} + IReadOnlyCollection providers) + { + return new TenantOnboardingStep(code, required, providers.Contains(capability), + providers.Contains(capability) ? "active" : "missing"); + } +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs b/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs index 0d4a861..48ff817 100644 --- a/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs +++ b/Tiku.Infrastructure/TenantAdmin/Classes/TenantAdminDirectService.Classes.cs @@ -1,26 +1,12 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; -using Tiku.Domain.Identity; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -39,20 +25,15 @@ public sealed partial class TenantAdminDirectService .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); - if (filter.RegionId.HasValue) - { - query = query.Where(item => item.RegionId == filter.RegionId.Value); - } + item => classIds.Contains(item.Id) || + (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); + if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_class_status")); - } + query = query.Where(item => + item.Status == ParseEnum(filter.Status, "invalid_class_status")); else - { query = query.Where(item => item.Status != TenantRecordStatus.Archived); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -87,8 +68,9 @@ public sealed partial class TenantAdminDirectService .ToArrayAsync(cancellationToken); return new TenantAdminClassList( - items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), - Scoped: scope.Mode != DataScopeMode.All); + items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)) + .ToArray(), + scope.Mode != DataScopeMode.All); } public async Task> UpsertClassAsync( @@ -100,19 +82,17 @@ public sealed partial class TenantAdminDirectService ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id)) - { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id)) - { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } - item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; + item ??= new TenantClass + { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Code = Normalize(command.Code); @@ -123,10 +103,7 @@ public sealed partial class TenantAdminDirectService item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantClasses.Add(item); - } + if (isNew) dbContext.TenantClasses.Add(item); await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -153,12 +130,10 @@ public sealed partial class TenantAdminDirectService .ApplyDataScope( scope, entity => entity.CreatedBy == actor.UserId, - entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) + entity => classIds.Contains(entity.Id) || + (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value))) .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } + if (item is null) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); item.Status = TenantRecordStatus.Disabled; item.UpdatedBy = actor.UserId; @@ -188,11 +163,11 @@ public sealed partial class TenantAdminDirectService regionIds.Contains(tenantClass.RegionId.Value))); if (!string.IsNullOrWhiteSpace(filter.MemberType)) - { - query = query.Where(item => item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); - } + query = query.Where(item => + item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); - query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); + query = query.Where(item => + item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); var items = await query .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 : @@ -222,18 +197,19 @@ public sealed partial class TenantAdminDirectService : null; var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type"); var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"); - var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken); - await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); + var user = await ResolveUserAsync(command.User, + memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken); + await EnsureMembershipAsync(actor.TenantId, user.Id, + memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); if (memberType == TenantClassMemberType.Student) - { - await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); - } + await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), + JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => - member.TenantId == actor.TenantId && - member.ClassId == command.ClassId && - member.UserId == user.Id && - member.MemberType == memberType, + member.TenantId == actor.TenantId && + member.ClassId == command.ClassId && + member.UserId == user.Id && + member.MemberType == memberType, cancellationToken); var isNew = item is null; item ??= new TenantClassMember @@ -248,17 +224,11 @@ public sealed partial class TenantAdminDirectService item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantClassMembers.Add(item); - } + if (isNew) dbContext.TenantClassMembers.Add(item); await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } @@ -281,10 +251,7 @@ public sealed partial class TenantAdminDirectService tenantClass.RegionId.HasValue && regionIds.Contains(tenantClass.RegionId.Value))) .FirstOrDefaultAsync(cancellationToken); - if (item is null) - { - throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); - } + if (item is null) throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken); item.Status = TenantClassMemberStatus.Removed; @@ -294,6 +261,4 @@ public sealed partial class TenantAdminDirectService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs index 0997c6f..681ee81 100644 --- a/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs +++ b/Tiku.Infrastructure/TenantAdmin/Dashboard/TenantAdminDirectService.Dashboard.cs @@ -1,25 +1,10 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; -using Tiku.Application.Auth; -using Tiku.Application.Content; -using Tiku.Application.Notifications; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Identity; -using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -40,7 +25,8 @@ public sealed partial class TenantAdminDirectService .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); + item => classIds.Contains(item.Id) || + (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); var scopedStudents = dbContext.StudentProfiles.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( @@ -55,44 +41,47 @@ public sealed partial class TenantAdminDirectService item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); var studentCount = await scopedStudents.CountAsync(cancellationToken); - var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); + var classCount = + await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); var staffCount = await dbContext.TenantMemberships.AsNoTracking() .CountAsync(item => - item.TenantId == actor.TenantId && - item.Status == MembershipStatus.Active && - item.Role != TenantRole.Student, + item.TenantId == actor.TenantId && + item.Status == MembershipStatus.Active && + item.Role != TenantRole.Student, cancellationToken); var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() .CountAsync(item => - item.TenantId == actor.TenantId && - item.FinishedAt == null && - (!item.ExpiresAt.HasValue || item.ExpiresAt > now), + item.TenantId == actor.TenantId && + item.FinishedAt == null && + (!item.ExpiresAt.HasValue || item.ExpiresAt > now), cancellationToken); var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() .CountAsync(item => - item.TenantId == actor.TenantId && - (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), + item.TenantId == actor.TenantId && + (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), cancellationToken); var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() - .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); + .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, + cancellationToken); var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); var revenueCents = await scopedOrders - .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded) + .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || + item.Status == OrderStatus.Refunded) .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking() .CountAsync(item => - item.TenantId == actor.TenantId && - (item.Status == CommerceRefundStatus.Requested || - item.Status == CommerceRefundStatus.Approved || - item.Status == CommerceRefundStatus.Processing), + item.TenantId == actor.TenantId && + (item.Status == CommerceRefundStatus.Requested || + item.Status == CommerceRefundStatus.Approved || + item.Status == CommerceRefundStatus.Processing), cancellationToken); var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking() .CountAsync(item => - item.TenantId == actor.TenantId && - item.Status != ReconciliationIssueStatus.Resolved && - item.Status != ReconciliationIssueStatus.Ignored, + item.TenantId == actor.TenantId && + item.Status != ReconciliationIssueStatus.Resolved && + item.Status != ReconciliationIssueStatus.Ignored, cancellationToken); return new TenantAdminOverviewItem( @@ -109,6 +98,4 @@ public sealed partial class TenantAdminDirectService openReconciliationIssueCount, now); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs index cb4aa62..0ffcd0b 100644 --- a/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs +++ b/Tiku.Infrastructure/TenantAdmin/DomainsAndEngagement/TenantAdminDirectService.DomainsAndEngagement.cs @@ -1,26 +1,15 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; using Tiku.Application.Notifications; -using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; +using Tiku.Infrastructure.Tenancy; namespace Tiku.Infrastructure.TenantAdmin; @@ -55,16 +44,14 @@ public sealed partial class TenantAdminDirectService { throw new TenantAdminDirectException(exception.Message, "invalid_domain_host"); } + var host = generated.Host; if (command.IsPrimary) { var primaryDomains = await dbContext.TenantDomains .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) .ToArrayAsync(cancellationToken); - foreach (var domain in primaryDomains) - { - domain.IsPrimary = false; - } + foreach (var domain in primaryDomains) domain.IsPrimary = false; } var item = new TenantDomain @@ -114,7 +101,8 @@ public sealed partial class TenantAdminDirectService JsonObjectOrDefault(default)), cancellationToken); - await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken); + await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToAuthProviderItem(item)); } @@ -127,14 +115,9 @@ public sealed partial class TenantAdminDirectService await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Category)) - { query = query.Where(item => item.Category == filter.Category.Trim()); - } - if (!filter.IncludeInactive) - { - query = query.Where(item => item.IsActive); - } + if (!filter.IncludeInactive) query = query.Where(item => item.IsActive); var items = await query .OrderBy(item => item.SortOrder) @@ -153,7 +136,8 @@ public sealed partial class TenantAdminDirectService { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); - var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken); + var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, + cancellationToken); var isNew = item is null; item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.LegacyId = Normalize(command.LegacyId); @@ -169,10 +153,7 @@ public sealed partial class TenantAdminDirectService item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; - if (isNew) - { - dbContext.Badges.Add(item); - } + if (isNew) dbContext.Badges.Add(item); await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -203,21 +184,16 @@ public sealed partial class TenantAdminDirectService member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } + if (filter.UserId.HasValue) query = query.Where(item => item.UserId == filter.UserId.Value); - if (filter.BadgeId.HasValue) - { - query = query.Where(item => item.BadgeId == filter.BadgeId.Value); - } + if (filter.BadgeId.HasValue) query = query.Where(item => item.BadgeId == filter.BadgeId.Value); var grants = await query .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); - var userIds = grants.Select(item => item.UserId).OfType().Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); + var userIds = grants.Select(item => item.UserId).OfType() + .Concat(grants.Select(item => item.GrantedBy).OfType()).Distinct().ToArray(); var badgeIds = grants.Select(item => item.BadgeId).OfType().Distinct().ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) @@ -227,11 +203,14 @@ public sealed partial class TenantAdminDirectService .ToDictionaryAsync(badge => badge.Id, cancellationToken); return new CatalogList(grants.Select(grant => - ToBadgeGrantItem( - grant, - grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, - grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null, - grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray()); + ToBadgeGrantItem( + grant, + grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null, + grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) + ? grantedBy + : null, + grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)) + .ToArray()); } public async Task> GrantBadgeAsync( @@ -243,15 +222,9 @@ public sealed partial class TenantAdminDirectService var badge = await dbContext.Badges.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.BadgeId, cancellationToken); - if (badge is null) - { - throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); - } + if (badge is null) throw new TenantAdminDirectException("Badge was not found.", "badge_not_found"); - if (!badge.IsActive) - { - throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); - } + if (!badge.IsActive) throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive"); await AssertStudentAsync(actor, scope, command.UserId, cancellationToken); var grant = await dbContext.UserBadges.FirstOrDefaultAsync( @@ -270,10 +243,7 @@ public sealed partial class TenantAdminDirectService grant.Note = Normalize(command.Note) ?? grant.Note; grant.GrantedBy ??= actor.UserId; grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow; - if (isNew) - { - dbContext.UserBadges.Add(grant); - } + if (isNew) dbContext.UserBadges.Add(grant); var dedupeKey = $"badge:{grant.Id:N}"; await notificationProvider.UpsertInAppAsync( @@ -295,7 +265,8 @@ public sealed partial class TenantAdminDirectService await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); + var user = await dbContext.Users.AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken); return new ContentManagementResult(ToBadgeGrantItem(grant, user, null, badge)); } @@ -322,20 +293,14 @@ public sealed partial class TenantAdminDirectService member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } + if (filter.UserId.HasValue) query = query.Where(item => item.UserId == filter.UserId.Value); if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_notification_status")); - } + query = query.Where(item => + item.Status == ParseEnum(filter.Status, "invalid_notification_status")); if (!string.IsNullOrWhiteSpace(filter.NotificationType)) - { query = query.Where(item => item.NotificationType == filter.NotificationType.Trim()); - } var items = await query .OrderByDescending(item => item.CreatedAt) @@ -402,20 +367,14 @@ public sealed partial class TenantAdminDirectService member.UserId == item.UserId.Value && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))); - if (filter.UserId.HasValue) - { - query = query.Where(item => item.UserId == filter.UserId.Value); - } + if (filter.UserId.HasValue) query = query.Where(item => item.UserId == filter.UserId.Value); if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); - } + query = + query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_feedback_status")); if (!string.IsNullOrWhiteSpace(filter.Type)) - { query = query.Where(item => item.Type == ParseEnum(filter.Type, "invalid_feedback_type")); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -435,7 +394,9 @@ public sealed partial class TenantAdminDirectService .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(reports.Select(report => - ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray()); + ToFeedbackItem(report, + report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)) + .ToArray()); } public async Task> UpdateFeedbackAsync( @@ -463,16 +424,14 @@ public sealed partial class TenantAdminDirectService member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId)))) .FirstOrDefaultAsync(cancellationToken); - if (report is null) - { - throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); - } + if (report is null) throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found"); var fromStatus = report.Status; report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status"); report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority"); report.Resolution = Normalize(command.Resolution) ?? report.Resolution; - if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed) + if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved + or ReportStatus.Closed) { report.HandledBy = actor.UserId; report.HandledAt = DateTimeOffset.UtcNow; @@ -491,10 +450,9 @@ public sealed partial class TenantAdminDirectService await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var user = report.UserId.HasValue - ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) + ? await dbContext.Users.AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken) : null; return new ContentManagementResult(ToFeedbackItem(report, user)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs index 56c9492..26a7dae 100644 --- a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.DataAccess.cs @@ -1,41 +1,26 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; -using Tiku.Application.Auth; -using Tiku.Application.Content; -using Tiku.Application.Notifications; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; -using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { - private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) + private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, + CancellationToken cancellationToken) { User? user = null; if (command.UserId.HasValue) { - user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); - if (user is null) - { - throw new TenantAdminDirectException("User was not found.", "user_not_found"); - } + user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, + cancellationToken); + if (user is null) throw new TenantAdminDirectException("User was not found.", "user_not_found"); } else { @@ -43,17 +28,16 @@ public sealed partial class TenantAdminDirectService var email = Normalize(command.Email); var username = Normalize(command.Username); user = await dbContext.Users.FirstOrDefaultAsync(item => - (phone != null && item.Phone == phone) || - (email != null && item.Email == email) || - (username != null && item.UserName == username), + (phone != null && item.Phone == phone) || + (email != null && item.Email == email) || + (username != null && item.UserName == username), cancellationToken); if (user is null) { if (phone is null && email is null && username is null && Normalize(command.Name) is null) - { - throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required"); - } + throw new TenantAdminDirectException("userId, phone, email, username or name is required.", + "user_required"); user = new User { @@ -84,18 +68,16 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken) { var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == userId && item.Role == role, + item.TenantId == tenantId && item.UserId == userId && item.Role == role, cancellationToken); if (membership is null) { var metricCode = QuotaMetricForRole(role); if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( tenantId, metricCode, cancellationToken: cancellationToken); - } membership = new TenantMembership { TenantId = tenantId, @@ -111,22 +93,24 @@ public sealed partial class TenantAdminDirectService { var metricCode = QuotaMetricForRole(role); if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken)) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( tenantId, metricCode, cancellationToken: cancellationToken); - } } + membership.Status = MembershipStatus.Active; } return membership; } - private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student - ? SaasQuotaMetricCatalog.StudentCount - : SaasQuotaMetricCatalog.StaffCount; + private static string QuotaMetricForRole(TenantRole role) + { + return role == TenantRole.Student + ? SaasQuotaMetricCatalog.StudentCount + : SaasQuotaMetricCatalog.StaffCount; + } private Task IsUserCountedForMetricAsync( Guid tenantId, @@ -139,10 +123,7 @@ public sealed partial class TenantAdminDirectService item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active); - if (excludedMembershipId.HasValue) - { - query = query.Where(item => item.Id != excludedMembershipId.Value); - } + if (excludedMembershipId.HasValue) query = query.Where(item => item.Id != excludedMembershipId.Value); return metricCode == SaasQuotaMetricCatalog.StudentCount ? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken) @@ -162,7 +143,7 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken) { var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => - item.TenantId == tenantId && item.UserId == userId, + item.TenantId == tenantId && item.UserId == userId, cancellationToken); if (profile is null) { @@ -204,19 +185,13 @@ public sealed partial class TenantAdminDirectService var email = Normalize(row.User.Email); var name = Normalize(row.User.Name); if (row.User.UserId is null && phone is null && email is null && name is null) - { reason = "user_required"; - } else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId)) - { reason = "data_scope_denied"; - } - else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) - { + else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) reason = "region_not_found"; - } else if (row.ClassId.HasValue) - { try { await AssertClassAsync(actor, scope, row.ClassId, cancellationToken); @@ -225,7 +200,6 @@ public sealed partial class TenantAdminDirectService { reason = exception.Code; } - } items.Add(new TenantAdminStudentImportPreviewItem( rowNo, @@ -251,10 +225,10 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken) { var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => - member.TenantId == actor.TenantId && - member.ClassId == classId && - member.UserId == userId && - member.MemberType == memberType, + member.TenantId == actor.TenantId && + member.ClassId == classId && + member.UserId == userId && + member.MemberType == memberType, cancellationToken); if (item is null) { @@ -279,14 +253,13 @@ public sealed partial class TenantAdminDirectService Guid tenantId, CancellationToken cancellationToken) { - var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var settings = await dbContext.TenantSettings.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null || settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) || rulesElement.ValueKind != JsonValueKind.Array) - { return []; - } return JsonSerializer.Deserialize(rulesElement.GetRawText()) ?? []; } @@ -296,7 +269,8 @@ public sealed partial class TenantAdminDirectService IReadOnlyCollection rules, CancellationToken cancellationToken) { - var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var settings = + await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (settings is null) { settings = new TenantSettings { TenantId = tenantId }; @@ -318,10 +292,7 @@ public sealed partial class TenantAdminDirectService var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)) .Where(rule => rule.Enabled) .ToArray(); - if (rules.Length == 0) - { - return []; - } + if (rules.Length == 0) return []; var regionIds = scope.RegionIds.ToArray(); var students = await dbContext.StudentProfiles.AsNoTracking() @@ -365,7 +336,6 @@ public sealed partial class TenantAdminDirectService } if (hitRules.Count > 0) - { result.Add(new TenantSupervisionRiskStudentItem( row.Profile.UserId, row.User?.Name, @@ -373,7 +343,6 @@ public sealed partial class TenantAdminDirectService row.Profile.RegionId, hitRules.Distinct(StringComparer.Ordinal).ToArray(), reasons.Distinct(StringComparer.Ordinal).ToArray())); - } } return result; @@ -384,10 +353,7 @@ public sealed partial class TenantAdminDirectService var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student, cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } + if (!exists) throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } private async Task AssertStudentAsync( @@ -414,26 +380,17 @@ public sealed partial class TenantAdminDirectService member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .AnyAsync(cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } + if (!exists) throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) { - if (!userId.HasValue) - { - return; - } + if (!userId.HasValue) return; var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active, cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } + if (!exists) throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); } private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) @@ -477,8 +434,8 @@ public sealed partial class TenantAdminDirectService .Where(permission => tenantPermissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToArrayAsync(cancellationToken); - foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) - { + foreach (var permissionCode in + BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal)) dbContext.BackendPermissions.Add(new BackendPermission { Code = permissionCode, @@ -487,7 +444,6 @@ public sealed partial class TenantAdminDirectService PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), IsSystem = true }); - } var boundPermissionCodes = await dbContext.TenantBackendRolePermissions .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id) @@ -506,14 +462,12 @@ public sealed partial class TenantAdminDirectService if (!await dbContext.TenantBackendUserRoles.AnyAsync( binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id, cancellationToken)) - { dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = userId, RoleId = role.Id }); - } } private async Task EnsureBrandingThemeAsync( @@ -522,7 +476,8 @@ public sealed partial class TenantAdminDirectService JsonElement publicAssets, CancellationToken cancellationToken) { - var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + var branding = + await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (branding is null) { var tenantName = await dbContext.Tenants @@ -543,18 +498,12 @@ public sealed partial class TenantAdminDirectService private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) { - if (!classId.HasValue) - { - return; - } + if (!classId.HasValue) return; var exists = await dbContext.TenantClasses.AnyAsync( item => item.TenantId == tenantId && item.Id == classId.Value, cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } + if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } private async Task AssertClassAsync( @@ -563,10 +512,7 @@ public sealed partial class TenantAdminDirectService Guid? classId, CancellationToken cancellationToken) { - if (!classId.HasValue) - { - return; - } + if (!classId.HasValue) return; var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); @@ -575,12 +521,10 @@ public sealed partial class TenantAdminDirectService .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, - item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) + item => classIds.Contains(item.Id) || + (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))) .AnyAsync(cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Class was not found.", "class_not_found"); - } + if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } private async Task RequireDataScopeAsync( @@ -589,9 +533,7 @@ public sealed partial class TenantAdminDirectService { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) - { throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } return access.DataScope; } @@ -602,9 +544,7 @@ public sealed partial class TenantAdminDirectService { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (scope.Mode != DataScopeMode.All) - { throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found"); - } } private async Task AssertReferenceAsync( @@ -614,16 +554,11 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken) where TEntity : TenantEntity { - if (!id.HasValue) - { - return; - } + if (!id.HasValue) return; - var exists = await dbContext.Set().AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); - if (!exists) - { - throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); - } + var exists = await dbContext.Set() + .AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); + if (!exists) throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); } private async Task ResolveTenantEntityAsync( @@ -635,15 +570,11 @@ public sealed partial class TenantAdminDirectService where TEntity : AuditableTenantEntity { if (id.HasValue) - { - return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); - } + return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, + cancellationToken); legacyId = Normalize(legacyId); - if (legacyId is null) - { - return null; - } + if (legacyId is null) return null; return typeof(TEntity).GetProperty("LegacyId") is null ? null @@ -670,6 +601,4 @@ public sealed partial class TenantAdminDirectService }); await Task.CompletedTask.WaitAsync(cancellationToken); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs index ea6f510..6a3c269 100644 --- a/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs +++ b/Tiku.Infrastructure/TenantAdmin/Foundation/TenantAdminDirectService.MappingAndValidation.cs @@ -1,32 +1,19 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; -using Tiku.Application.Auth; -using Tiku.Application.Content; -using Tiku.Application.Notifications; -using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { - private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount) + private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, + int staffCount) { return new TenantAdminClassItem( item.Id, @@ -78,9 +65,13 @@ public sealed partial class TenantAdminDirectService profile?.RegionId, profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null, profile?.SelectedSchoolId, - profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null, + profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) + ? schoolName + : null, profile?.SelectedMajorId, - profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null, + profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) + ? majorName + : null, profile?.Stats ?? JsonDefaults.Object(), profile?.Progress ?? JsonDefaults.Object(), profile?.ModuleSelections ?? JsonDefaults.Object(), @@ -249,7 +240,8 @@ public sealed partial class TenantAdminDirectService item.UpdatedAt); } - private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge) + private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, + Badge? badge) { return new TenantAdminBadgeGrantItem( grant.Id, @@ -347,27 +339,18 @@ public sealed partial class TenantAdminDirectService private static JsonElement PermissionObject(JsonElement value) { var result = new Dictionary(StringComparer.Ordinal); - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return JsonDefaults.Object(); if (value.ValueKind != JsonValueKind.Object) - { throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value"); - } foreach (var property in value.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) - { throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value"); - } if (property.Name != "*" && !IsPermissionKey(property.Name)) - { throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key"); - } result[property.Name] = property.Value.GetBoolean(); } @@ -378,27 +361,18 @@ public sealed partial class TenantAdminDirectService private static JsonElement AccessMap(JsonElement value, string codePrefix) { var result = new Dictionary(StringComparer.Ordinal); - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return JsonDefaults.Object(); if (value.ValueKind != JsonValueKind.Object) - { throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}"); - } foreach (var property in value.EnumerateObject()) { if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) - { throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}"); - } if (!IsAccessKey(property.Name)) - { throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key"); - } result[property.Name] = property.Value.GetBoolean(); } @@ -408,15 +382,10 @@ public sealed partial class TenantAdminDirectService private static JsonElement DataScope(JsonElement value) { - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return JsonDefaults.Object(); - } + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return JsonDefaults.Object(); if (value.ValueKind != JsonValueKind.Object) - { throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope"); - } var allowed = new HashSet(StringComparer.Ordinal) { @@ -429,12 +398,8 @@ public sealed partial class TenantAdminDirectService "metadata" }; foreach (var property in value.EnumerateObject()) - { if (!allowed.Contains(property.Name)) - { throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key"); - } - } return value.Clone(); } @@ -444,26 +409,23 @@ public sealed partial class TenantAdminDirectService TenantRole role, CancellationToken cancellationToken) { - if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) - { - return; - } + if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin)) return; var isOwnerRoleHolder = await ( - from binding in dbContext.TenantBackendUserRoles.AsNoTracking() - join backendRole in dbContext.TenantBackendRoles.AsNoTracking() - on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id } - where binding.TenantId == actor.TenantId && - binding.UserId == actor.UserId && - backendRole.Code == "tenant_owner" && - backendRole.IsSystem && - backendRole.Status == BackendRoleStatus.Active - select binding.Id) + from binding in dbContext.TenantBackendUserRoles.AsNoTracking() + join backendRole in dbContext.TenantBackendRoles.AsNoTracking() + on new { binding.TenantId, binding.RoleId } equals new + { backendRole.TenantId, RoleId = backendRole.Id } + where binding.TenantId == actor.TenantId && + binding.UserId == actor.UserId && + backendRole.Code == "tenant_owner" && + backendRole.IsSystem && + backendRole.Status == BackendRoleStatus.Active + select binding.Id) .AnyAsync(cancellationToken); if (!isOwnerRoleHolder) - { - throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); - } + throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", + "tenant_owner_required"); } private static string RoleToPrimaryRole(TenantRole role) @@ -484,18 +446,13 @@ public sealed partial class TenantAdminDirectService private static string NormalizeRoleCode(string value) { var code = new string(value.Trim().ToLowerInvariant() - .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') - .ToArray()) + .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') + .ToArray()) .Trim('-'); - while (code.Contains("--", StringComparison.Ordinal)) - { - code = code.Replace("--", "-", StringComparison.Ordinal); - } + while (code.Contains("--", StringComparison.Ordinal)) code = code.Replace("--", "-", StringComparison.Ordinal); if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0])) - { throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code"); - } return code; } @@ -504,10 +461,10 @@ public sealed partial class TenantAdminDirectService { var host = Normalize(value)?.ToLowerInvariant() .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required"); - if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal)) - { + if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || + host.Contains(':', StringComparison.Ordinal) || + !host.Contains('.', StringComparison.Ordinal)) throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host"); - } return host; } @@ -516,35 +473,22 @@ public sealed partial class TenantAdminDirectService { var result = new Dictionary(StringComparer.Ordinal); if (first.ValueKind == JsonValueKind.Object) - { foreach (var property in first.EnumerateObject()) - { result[property.Name] = property.Value.Clone(); - } - } if (second.ValueKind == JsonValueKind.Object) - { foreach (var property in second.EnumerateObject()) - { result[property.Name] = property.Value.Clone(); - } - } return JsonSerializer.SerializeToElement(result); } private static void AssertNoSecrets(JsonElement value, string code) { - if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return; - } + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return; if (value.ValueKind != JsonValueKind.Object) - { throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}"); - } foreach (var property in value.EnumerateObject()) { @@ -554,9 +498,8 @@ public sealed partial class TenantAdminDirectService key.Contains("token", StringComparison.Ordinal) || key.Contains("private", StringComparison.Ordinal) || key is "appsecret" or "app_secret" or "accesskeysecret") - { - throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret"); - } + throw new TenantAdminDirectException("Public config cannot contain secrets.", + "public_config_contains_secret"); } } @@ -587,13 +530,9 @@ public sealed partial class TenantAdminDirectService var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); foreach (var candidate in Enum.GetValues()) - { if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) - { return candidate; - } - } throw new TenantAdminDirectException("Enum value was invalid.", code); } -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs index adc2534..fd08ef3 100644 --- a/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs +++ b/Tiku.Infrastructure/TenantAdmin/MembersAndAccess/TenantAdminDirectService.MembersAndAccess.cs @@ -1,26 +1,10 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Identity; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -34,14 +18,11 @@ public sealed partial class TenantAdminDirectService await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Role)) - { query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); - } if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); - } + query = query.Where(item => + item.Status == ParseEnum(filter.Status, "invalid_member_status")); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -89,9 +70,7 @@ public sealed partial class TenantAdminDirectService var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); if (user.Id == actor.UserId && status == MembershipStatus.Disabled) - { throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); - } TenantMembership? membership = null; if (command.MembershipId.HasValue) @@ -100,9 +79,7 @@ public sealed partial class TenantAdminDirectService item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, cancellationToken); if (membership is null) - { throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } } else { @@ -123,26 +100,22 @@ public sealed partial class TenantAdminDirectService var otherStudentCounted = await IsUserCountedForMetricAsync( actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId); var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student); - var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student); + var willStudentBeCounted = + otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student); if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner) - { - throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required"); - } + throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", + "tenant_owner_required"); if (!wasStaffCounted && willStaffBeCounted) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( actor.TenantId, SaasQuotaMetricCatalog.StaffCount, cancellationToken: cancellationToken); - } if (!wasStudentCounted && willStudentBeCounted) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( actor.TenantId, SaasQuotaMetricCatalog.StudentCount, cancellationToken: cancellationToken); - } membership ??= new TenantMembership { @@ -152,42 +125,28 @@ public sealed partial class TenantAdminDirectService membership.UserId = user.Id; membership.Role = role; membership.Status = status; - if (isNew) - { - dbContext.TenantMemberships.Add(membership); - } + if (isNew) dbContext.TenantMemberships.Add(membership); if (status != MembershipStatus.Active) - { await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); - } else if (role == TenantRole.TenantOwner) - { await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); - } await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); if (wasStaffCounted && !willStaffBeCounted) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, SaasQuotaMetricCatalog.StaffCount, 1, cancellationToken); - } if (wasStudentCounted && !willStudentBeCounted) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, SaasQuotaMetricCatalog.StudentCount, 1, cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken); @@ -207,14 +166,10 @@ public sealed partial class TenantAdminDirectService item => item.TenantId == actor.TenantId && item.Id == membershipId, cancellationToken); if (membership is null) - { throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); - } if (membership.UserId == actor.UserId) - { throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); - } await AssertGrantableAsync(actor, membership.Role, cancellationToken); var previousStatus = membership.Status; @@ -228,22 +183,16 @@ public sealed partial class TenantAdminDirectService await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, metricCode, 1, cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); - var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); + var user = await dbContext.Users.AsNoTracking() + .SingleAsync(item => item.Id == membership.UserId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs index 6f2a6a3..c2fe845 100644 --- a/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs +++ b/Tiku.Infrastructure/TenantAdmin/SiteSettings/TenantAdminDirectService.SiteSettings.cs @@ -1,26 +1,11 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; using Tiku.Domain.Common; -using Tiku.Domain.Identity; -using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -40,14 +25,9 @@ public sealed partial class TenantAdminDirectService } if (!string.IsNullOrWhiteSpace(filter.TargetType)) - { query = query.Where(item => item.TargetType == filter.TargetType.Trim()); - } - if (filter.ActorUserId.HasValue) - { - query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); - } + if (filter.ActorUserId.HasValue) query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); var logs = await query .OrderByDescending(item => item.CreatedAt) @@ -85,7 +65,8 @@ public sealed partial class TenantAdminDirectService { await RequireAllDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); - var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); + var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, + cancellationToken); if (item is null) { item = new TenantBranding { TenantId = actor.TenantId }; @@ -112,7 +93,8 @@ public sealed partial class TenantAdminDirectService { await RequireAllDataScopeAsync(actor, cancellationToken); AssertNoSecrets(command.PublicConfig, "public_config"); - var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); + var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, + cancellationToken); if (item is null) { item = new TenantSettings { TenantId = actor.TenantId }; @@ -156,10 +138,7 @@ public sealed partial class TenantAdminDirectService await RequireAllDataScopeAsync(actor, cancellationToken); var item = await dbContext.TenantThemeConfigs.AsNoTracking() .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); - if (item is not null) - { - return new ContentManagementResult(ToThemeItem(item)); - } + if (item is not null) return new ContentManagementResult(ToThemeItem(item)); var branding = await dbContext.TenantBrandings.AsNoTracking() .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); @@ -189,11 +168,10 @@ public sealed partial class TenantAdminDirectService item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, cancellationToken); if (template is null) - { throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); - } - var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, + cancellationToken); if (item is null) { item = new TenantThemeConfig { TenantId = actor.TenantId }; @@ -216,16 +194,15 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); - var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, + cancellationToken); JsonElement activeTheme; JsonElement activeAssets; string? templateCode; if (command.UseDraft) { if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode)) - { throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found"); - } activeTheme = item.DraftTheme; activeAssets = item.DraftPublicAssets; @@ -238,15 +215,10 @@ public sealed partial class TenantAdminDirectService theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active, cancellationToken); if (template is null) - { throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); - } item ??= new TenantThemeConfig { TenantId = actor.TenantId }; - if (dbContext.Entry(item).State == EntityState.Detached) - { - dbContext.TenantThemeConfigs.Add(item); - } + if (dbContext.Entry(item).State == EntityState.Detached) dbContext.TenantThemeConfigs.Add(item); activeTheme = MergeJsonObjects(template.Theme, command.Theme); activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); @@ -254,10 +226,7 @@ public sealed partial class TenantAdminDirectService } item ??= new TenantThemeConfig { TenantId = actor.TenantId }; - if (dbContext.Entry(item).State == EntityState.Detached) - { - dbContext.TenantThemeConfigs.Add(item); - } + if (dbContext.Entry(item).State == EntityState.Detached) dbContext.TenantThemeConfigs.Add(item); item.ActiveTemplateCode = templateCode; item.ActiveTheme = activeTheme.Clone(); @@ -273,6 +242,4 @@ public sealed partial class TenantAdminDirectService await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToThemeItem(item)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs index 911f012..9400469 100644 --- a/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs +++ b/Tiku.Infrastructure/TenantAdmin/StudentEngagement/TenantAdminDirectService.StudentEngagement.cs @@ -1,26 +1,9 @@ -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Identity; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -50,9 +33,7 @@ public sealed partial class TenantAdminDirectService member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) - { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); - } var items = await query .OrderByDescending(item => item.IsPinned) @@ -93,23 +74,23 @@ public sealed partial class TenantAdminDirectService classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) - { throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); - } } var isNew = item is null; - item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; + item ??= new TenantStudentNote + { + Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, + CreatedBy = actor.UserId + }; item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); item.Content = command.Content.Trim(); - item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility"); + item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, + "invalid_student_note_visibility"); item.IsPinned = command.IsPinned ?? item.IsPinned; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantStudentNotes.Add(item); - } + if (isNew) dbContext.TenantStudentNotes.Add(item); await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); @@ -128,7 +109,8 @@ public sealed partial class TenantAdminDirectService .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, - item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, + item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || + item.CreatedBy == actor.UserId, item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && @@ -141,17 +123,15 @@ public sealed partial class TenantAdminDirectService member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); if (filter.StudentUserId.HasValue) - { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); - } if (!string.IsNullOrWhiteSpace(filter.Status)) - { - query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); - } + query = query.Where(item => + item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); var items = await query - .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) + .OrderBy(item => + item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) @@ -196,9 +176,7 @@ public sealed partial class TenantAdminDirectService classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) - { throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); - } } var isNew = item is null; @@ -213,23 +191,21 @@ public sealed partial class TenantAdminDirectService item.ClassId = command.ClassId; item.Title = command.Title.Trim(); item.Description = Normalize(command.Description); - item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type"); - item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority"); + item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, + "invalid_student_followup_type"); + item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, + "invalid_student_followup_priority"); item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status"); item.DueAt = command.DueAt; item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null; item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; - if (isNew) - { - dbContext.TenantStudentFollowups.Add(item); - } + if (isNew) dbContext.TenantStudentFollowups.Add(item); - await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); + await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToFollowupItem(item)); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs b/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs index d387d65..2244df4 100644 --- a/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs +++ b/Tiku.Infrastructure/TenantAdmin/Students/TenantAdminDirectService.Students.cs @@ -1,26 +1,13 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -63,12 +50,10 @@ public sealed partial class TenantAdminDirectService } if (filter.RegionId.HasValue) - { query = query.Where(item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId == filter.RegionId.Value)); - } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -102,15 +87,19 @@ public sealed partial class TenantAdminDirectService .Where(major => major.TenantId == actor.TenantId) .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken); var classes = await ( - from member in dbContext.TenantClassMembers.AsNoTracking() - join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id - where member.TenantId == actor.TenantId && - tenantClass.TenantId == actor.TenantId && - userIds.Contains(member.UserId) && - member.Status == TenantClassMemberStatus.Active && - member.MemberType == TenantClassMemberType.Student - orderby tenantClass.SortOrder, tenantClass.CreatedAt descending - select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code }) + from member in dbContext.TenantClassMembers.AsNoTracking() + join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id + where member.TenantId == actor.TenantId && + tenantClass.TenantId == actor.TenantId && + userIds.Contains(member.UserId) && + member.Status == TenantClassMemberStatus.Active && + member.MemberType == TenantClassMemberType.Student + orderby tenantClass.SortOrder, tenantClass.CreatedAt descending + select new + { + member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, + tenantClass.Code + }) .ToArrayAsync(cancellationToken); var classLookup = classes .GroupBy(item => item.UserId) @@ -138,7 +127,7 @@ public sealed partial class TenantAdminDirectService majors, studentClasses ?? []); }).ToArray(), - Scoped: scope.Mode != DataScopeMode.All); + scope.Mode != DataScopeMode.All); } public async Task> UpsertStudentAsync( @@ -148,20 +137,17 @@ public sealed partial class TenantAdminDirectService { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); - await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", + cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SelectedMajorId, "major_not_found", + cancellationToken); await using var transaction = dbContext.Database.CurrentTransaction is null ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var user = await ResolveUserAsync(command.User, "student", cancellationToken); if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId)) - { throw new TenantAdminDirectException("Student was not found.", "student_not_found"); - } - if (command.RawProfile.ValueKind == JsonValueKind.Object) - { - user.RawProfile = command.RawProfile.Clone(); - } + if (command.RawProfile.ValueKind == JsonValueKind.Object) user.RawProfile = command.RawProfile.Clone(); var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); var profile = await EnsureStudentProfileAsync( @@ -178,10 +164,7 @@ public sealed partial class TenantAdminDirectService await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new ContentManagementResult( ToStudentItem( @@ -223,9 +206,7 @@ public sealed partial class TenantAdminDirectService classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (membership is null) - { throw new TenantAdminDirectException("Student membership was not found.", "student_not_found"); - } await using var transaction = dbContext.Database.CurrentTransaction is null ? await dbContext.Database.BeginTransactionAsync(cancellationToken) @@ -236,37 +217,30 @@ public sealed partial class TenantAdminDirectService actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id); var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active; if (!wasCounted && willBeCounted) - { await featureAccessService.ConsumeQuotaIfConfiguredAsync( actor.TenantId, SaasQuotaMetricCatalog.StudentCount, cancellationToken: cancellationToken); - } membership.Status = status; if (status != MembershipStatus.Active) - { await sessionStore.RevokeRealmAsync( command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); - } - await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); + await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); if (wasCounted && !willBeCounted) - { await featureAccessService.ReleaseQuotaAsync( actor.TenantId, SaasQuotaMetricCatalog.StudentCount, 1, cancellationToken); - } - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); return new ContentManagementResult( - new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); + new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, + membership.UpdatedAt)); } public async Task PreviewStudentImportAsync( @@ -300,16 +274,10 @@ public sealed partial class TenantAdminDirectService foreach (var row in command.Rows) { rowNo++; - if (invalidItems.Any(item => item.RowNo == rowNo)) - { - continue; - } + if (invalidItems.Any(item => item.RowNo == rowNo)) continue; var user = await ResolveUserAsync(row.User, "student", cancellationToken); - if (row.RawProfile.ValueKind == JsonValueKind.Object) - { - user.RawProfile = row.RawProfile.Clone(); - } + if (row.RawProfile.ValueKind == JsonValueKind.Object) user.RawProfile = row.RawProfile.Clone(); await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); await EnsureStudentProfileAsync( @@ -341,10 +309,7 @@ public sealed partial class TenantAdminDirectService await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - if (transaction is not null) - { - await transaction.CommitAsync(cancellationToken); - } + if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems); } @@ -358,7 +323,6 @@ public sealed partial class TenantAdminDirectService var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) - { try { await AssertStudentAsync(actor, scope, userId, cancellationToken); @@ -376,9 +340,9 @@ public sealed partial class TenantAdminDirectService { failed.Add(userId); } - } - await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); + await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } @@ -391,7 +355,6 @@ public sealed partial class TenantAdminDirectService var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) - { try { await UpdateStudentStatusAsync( @@ -404,12 +367,10 @@ public sealed partial class TenantAdminDirectService { failed.Add(userId); } - } - await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); + await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs index 2b75216..ad4ce42 100644 --- a/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs +++ b/Tiku.Infrastructure/TenantAdmin/Supervision/TenantAdminDirectService.Supervision.cs @@ -1,26 +1,12 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; -using Tiku.Application.Auth; using Tiku.Application.Content; -using Tiku.Application.Notifications; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Identity; using Tiku.Domain.Learning; -using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -31,7 +17,8 @@ public sealed partial class TenantAdminDirectService CancellationToken cancellationToken = default) { await RequireDataScopeAsync(actor, cancellationToken); - return new CatalogList(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); + return new CatalogList( + await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); } public async Task> UpsertSupervisionRuleAsync( @@ -54,8 +41,10 @@ public sealed partial class TenantAdminDirectService JsonObjectOrDefault(command.Metadata)); rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); rules.Add(item); - await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); - await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); + await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), + cancellationToken); + await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } @@ -86,14 +75,12 @@ public sealed partial class TenantAdminDirectService foreach (var student in riskStudents) { if (await dbContext.TenantStudentFollowups.AnyAsync(item => - item.TenantId == actor.TenantId && - item.StudentUserId == student.UserId && - item.Status != StudentFollowupStatus.Done && - item.FollowupType == StudentFollowupType.Risk, + item.TenantId == actor.TenantId && + item.StudentUserId == student.UserId && + item.Status != StudentFollowupStatus.Done && + item.FollowupType == StudentFollowupType.Risk, cancellationToken)) - { continue; - } dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup { @@ -113,7 +100,8 @@ public sealed partial class TenantAdminDirectService created++; } - await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); + await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantSupervisionGenerateResult(created); } @@ -125,15 +113,21 @@ public sealed partial class TenantAdminDirectService await RequireDataScopeAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; return new TenantFollowupReport( - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), - await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, + cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, + cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, + cancellationToken), await dbContext.TenantStudentFollowups.CountAsync(item => - item.TenantId == actor.TenantId && - item.DueAt.HasValue && - item.DueAt < now && - item.Status != StudentFollowupStatus.Done && - item.Status != StudentFollowupStatus.Cancelled, + item.TenantId == actor.TenantId && + item.DueAt.HasValue && + item.DueAt < now && + item.Status != StudentFollowupStatus.Done && + item.Status != StudentFollowupStatus.Cancelled, cancellationToken)); } @@ -143,11 +137,16 @@ public sealed partial class TenantAdminDirectService { await RequireDataScopeAsync(actor, cancellationToken); return new TenantFeedbackReport( - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), - await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); + await dbContext.Reports.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), + await dbContext.Reports.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), + await dbContext.Reports.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), + await dbContext.Reports.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), + await dbContext.Reports.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); } public async Task GetPointRiskReportAsync( @@ -164,7 +163,8 @@ public sealed partial class TenantAdminDirectService .CountAsync(cancellationToken); var since = DateTimeOffset.UtcNow.AddDays(-7); var highClaimUsers = await dbContext.PointActivityClaims - .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) + .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && + item.ClaimedAt >= since) .GroupBy(item => item.UserId) .Where(group => group.Sum(item => item.Points) >= 1000) .CountAsync(cancellationToken); @@ -173,6 +173,4 @@ public sealed partial class TenantAdminDirectService cancellationToken); return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); } - - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index 57f5238..c15ce60 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -1,26 +1,9 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Tiku.Application.Catalog; using Tiku.Application.Auth; -using Tiku.Application.Content; using Tiku.Application.Notifications; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Application.TenantAdmin; -using Tiku.Domain.Catalog; -using Tiku.Domain.Common; -using Tiku.Domain.Identity; -using Tiku.Domain.Learning; -using Tiku.Domain.Operations; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; -using Tiku.Infrastructure.Tenancy; -using Tiku.Infrastructure.Security; -using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; -using OrderStatus = Tiku.Domain.Commerce.OrderStatus; -using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; -using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; -using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -33,5 +16,4 @@ public sealed partial class TenantAdminDirectService( IFeatureAccessService featureAccessService, IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService { - -} +} \ No newline at end of file diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index d3d8b23..26bff87 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -1,38 +1,38 @@  - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - net10.0 - enable - enable - + + net10.0 + enable + enable + diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 4e87c43..abd70c4 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -5,26 +7,24 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; -using System.Text.Json; -using Tiku.Application.Commerce; -using Tiku.Application.Assets; -using Tiku.Application.PlatformBilling; -using Tiku.Application.Auth; -using Tiku.Application.Growth; -using Tiku.Application.Storage; -using Tiku.Application.Security; -using Tiku.Application.Tenancy; using Tiku.Api; +using Tiku.Application.Assets; +using Tiku.Application.Auth; +using Tiku.Application.Commerce; +using Tiku.Application.Growth; +using Tiku.Application.PlatformBilling; +using Tiku.Application.Security; +using Tiku.Application.Storage; +using Tiku.Application.Tenancy; +using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; -using Tiku.Domain.Content; -using Tiku.Domain.Commerce; using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.IntegrationTests.Infrastructure; using Tiku.Infrastructure.Bootstrap; using Tiku.Infrastructure.Persistence; +using Tiku.IntegrationTests.Infrastructure; namespace Tiku.IntegrationTests.Api; @@ -45,15 +45,12 @@ public sealed class ApiTestFactory( public string DatabaseConnectionString => database.ConnectionString; - protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + protected override void ConfigureWebHost(IWebHostBuilder builder) { if (configurationOverrides is not null) - { foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null)) - { builder.UseSetting(pair.Key, pair.Value); - } - } + builder.ConfigureAppConfiguration((_, configuration) => { var values = new Dictionary @@ -63,12 +60,9 @@ public sealed class ApiTestFactory( ["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api" }; if (configurationOverrides is not null) - { foreach (var pair in configurationOverrides) - { values[pair.Key] = pair.Value; - } - } + configuration.AddInMemoryCollection(values); }); @@ -81,9 +75,7 @@ public sealed class ApiTestFactory( descriptor.ServiceType == typeof(DbContextOptions) || descriptor.ServiceType == typeof(TikuDbContext)) .ToArray()) - { services.Remove(descriptor); - } services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString)); services.AddScoped(); @@ -93,33 +85,18 @@ public sealed class ApiTestFactory( options.UseNpgsql(dataSource, npgsql => npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); options.AddInterceptors(serviceProvider.GetRequiredService()); - if (dbCommandInterceptor is not null) - { - options.AddInterceptors(dbCommandInterceptor); - } + if (dbCommandInterceptor is not null) options.AddInterceptors(dbCommandInterceptor); }); services.RemoveAll(); services.AddSingleton(); - if (wechatOAuthClient is not null) - { - services.AddSingleton(wechatOAuthClient); - } + if (wechatOAuthClient is not null) services.AddSingleton(wechatOAuthClient); - if (objectStorageService is not null) - { - services.AddSingleton(objectStorageService); - } + if (objectStorageService is not null) services.AddSingleton(objectStorageService); - if (referralQrcodeGenerator is not null) - { - services.AddSingleton(referralQrcodeGenerator); - } + if (referralQrcodeGenerator is not null) services.AddSingleton(referralQrcodeGenerator); - if (paymentProviderGateway is not null) - { - services.AddSingleton(paymentProviderGateway); - } + if (paymentProviderGateway is not null) services.AddSingleton(paymentProviderGateway); if (platformBillingPaymentGateway is not null) { @@ -177,7 +154,8 @@ public sealed class ApiTestFactory( Status = SaasFeatureStatus.Active, SortOrder = index * 10 })); - var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == integrationOfferingCode); + var offering = + await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == integrationOfferingCode); if (offering is null) { offering = new SaasOffering @@ -189,6 +167,7 @@ public sealed class ApiTestFactory( }; dbContext.SaasOfferings.Add(offering); } + var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.OfferingId == offering.Id && value.Version == 1); if (version is null) @@ -204,6 +183,7 @@ public sealed class ApiTestFactory( }; dbContext.SaasOfferingVersions.Add(version); } + await dbContext.SaveChangesAsync(); var entitledFeatures = await dbContext.SaasOfferingVersionFeatures .Where(entitlement => entitlement.OfferingVersionId == version.Id) @@ -254,6 +234,7 @@ public sealed class ApiTestFactory( }); entities = entities.Concat(subscriptionGraphs).ToArray(); } + var explicitPermissions = entities.OfType().ToArray(); if (explicitPermissions.Length > 0) { @@ -299,21 +280,16 @@ public sealed class ApiTestFactory( dbContext.PermissionModules.AddRange(modules); await dbContext.SaveChangesAsync(); } + var offeringVersionsToFinalize = entities.OfType() .Where(value => value.Status != SaasOfferingVersionStatus.Draft) .Select(value => new { Version = value, TargetStatus = value.Status }) .ToArray(); - foreach (var item in offeringVersionsToFinalize) - { - item.Version.Status = SaasOfferingVersionStatus.Draft; - } + foreach (var item in offeringVersionsToFinalize) item.Version.Status = SaasOfferingVersionStatus.Draft; dbContext.AddRange(entities); await dbContext.SaveChangesAsync(); - foreach (var item in offeringVersionsToFinalize) - { - item.Version.Status = item.TargetStatus; - } + foreach (var item in offeringVersionsToFinalize) item.Version.Status = item.TargetStatus; await dbContext.SaveChangesAsync(); var backendMembers = entities @@ -324,10 +300,7 @@ public sealed class ApiTestFactory( .Select(membership => (membership.TenantId, membership.UserId)) .Distinct() .ToArray(); - if (backendMembers.Length > 0) - { - await EnsureTenantBackendAccessAsync(dbContext, backendMembers); - } + if (backendMembers.Length > 0) await EnsureTenantBackendAccessAsync(dbContext, backendMembers); } public async Task SeedBuiltinBackofficeCatalogAsync() @@ -354,30 +327,33 @@ public sealed class ApiTestFactory( .Where(value => featureCodes.Contains(value.Code)) .Select(value => value.Code) .ToArrayAsync(); - dbContext.SaasFeatures.AddRange(featureCodes.Except(existingFeatureCodes, StringComparer.Ordinal).Select(code => new SaasFeature - { - Code = code, - Name = code, - Category = code.Split('.')[0], - IsCore = code == SaasFeatureCatalog.CoreBackoffice, - Status = SaasFeatureStatus.Active - })); - var moduleCodes = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode).Distinct(StringComparer.Ordinal).ToArray(); - var existingModuleCodes = await dbContext.PermissionModules.Where(value => moduleCodes.Contains(value.Code)).Select(value => value.Code).ToArrayAsync(); - dbContext.PermissionModules.AddRange(moduleCodes.Except(existingModuleCodes, StringComparer.Ordinal).Select(code => new PermissionModule - { - Code = code, - Name = code, - Area = BackendPermissionArea.Tenant, - RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] - })); + dbContext.SaasFeatures.AddRange(featureCodes.Except(existingFeatureCodes, StringComparer.Ordinal).Select(code => + new SaasFeature + { + Code = code, + Name = code, + Category = code.Split('.')[0], + IsCore = code == SaasFeatureCatalog.CoreBackoffice, + Status = SaasFeatureStatus.Active + })); + var moduleCodes = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode) + .Distinct(StringComparer.Ordinal).ToArray(); + var existingModuleCodes = await dbContext.PermissionModules.Where(value => moduleCodes.Contains(value.Code)) + .Select(value => value.Code).ToArrayAsync(); + dbContext.PermissionModules.AddRange(moduleCodes.Except(existingModuleCodes, StringComparer.Ordinal) + .Select(code => new PermissionModule + { + Code = code, + Name = code, + Area = BackendPermissionArea.Tenant, + RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] + })); await dbContext.SaveChangesAsync(); var existingPermissionCodes = await dbContext.BackendPermissions .Where(permission => permissionCodes.Contains(permission.Code)) .Select(permission => permission.Code) .ToListAsync(); foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal)) - { dbContext.BackendPermissions.Add(new BackendPermission { Code = permissionCode, @@ -386,7 +362,6 @@ public sealed class ApiTestFactory( PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode), IsSystem = true }); - } foreach (var tenantGroup in members.GroupBy(member => member.TenantId)) { @@ -412,28 +387,24 @@ public sealed class ApiTestFactory( .Select(entity => entity.PermissionCode) .ToListAsync(); foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal)) - { dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission { TenantId = tenantId, RoleId = role.Id, PermissionCode = permissionCode }); - } var assignedUserIds = await dbContext.TenantBackendUserRoles .Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id) .Select(entity => entity.UserId) .ToListAsync(); foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId))) - { dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = member.UserId, RoleId = role.Id }); - } } await dbContext.SaveChangesAsync(); @@ -525,7 +496,6 @@ public sealed class ApiTestFactory( session }; if (includeMembership) - { entities.Add(new TenantMembership { TenantId = resolvedTenantId, @@ -533,7 +503,6 @@ public sealed class ApiTestFactory( Role = TenantRole.Student, Status = MembershipStatus.Active }); - } await SeedAsync([.. entities]); return session.Id; @@ -542,9 +511,6 @@ public sealed class ApiTestFactory( protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (disposing) - { - database.Dispose(); - } + if (disposing) database.Dispose(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs index 3383678..831a496 100644 --- a/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs @@ -1,15 +1,12 @@ using System.Net; -using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; -using Tiku.Api.Contracts; using Tiku.Application.Storage; -using Tiku.Domain.Common; using Tiku.Domain.Commerce; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -31,7 +28,8 @@ public sealed class AssetAccessEndpointTests var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString()); + Assert.Equal("https://storage.example.test/download.pdf", + body.RootElement.GetProperty("url").GetProperty("url").GetString()); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -271,14 +269,43 @@ public sealed class AssetAccessEndpointTests private sealed class FakeObjectStorageService : IObjectStorageService { - public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev; - public string ConfiguredDefaultBucket() => "tenant-assets"; - public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev; - public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey; - public string ValidateMimeType(string mimeType) => mimeType; - public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes; - public void AssertUploadProvider(string provider) { } - public void AssertWritableLocation(StorageAssetLocation location) { } + public string ConfiguredDefaultProvider() + { + return ObjectStorageProviders.LocalDev; + } + + public string ConfiguredDefaultBucket() + { + return "tenant-assets"; + } + + public string NormalizeProvider(string? value, string? fallback = null) + { + return value ?? fallback ?? ObjectStorageProviders.LocalDev; + } + + public string ValidateObjectKey(Guid tenantId, string objectKey) + { + return objectKey; + } + + public string ValidateMimeType(string mimeType) + { + return mimeType; + } + + public long? ValidateFileSize(long? fileSizeBytes) + { + return fileSizeBytes; + } + + public void AssertUploadProvider(string provider) + { + } + + public void AssertWritableLocation(StorageAssetLocation location) + { + } public Task SignUploadAsync( ObjectStorageUploadSignRequest request, @@ -319,7 +346,7 @@ public sealed class AssetAccessEndpointTests request.Provider, request.Bucket, request.ObjectKey, - Exists: true, + true, request.DeclaredFileSizeBytes, request.DeclaredMimeType, request.DeclaredChecksumSha256, @@ -347,4 +374,4 @@ public sealed class AssetAccessEndpointTests "fake-signed-url"); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AssetEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs index fe01dfd..be086c1 100644 --- a/Tiku.IntegrationTests/Api/AssetEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetEndpointTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -using Tiku.Domain.Common; using Tiku.Domain.Catalog; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; @@ -56,7 +56,9 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf"); + using var response = + await client.GetAsync( + $"/api/public/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -82,7 +84,8 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync("/api/public/catalog/content-assets?tenantCode=master&includeLocked=true"); + using var response = + await client.GetAsync("/api/public/catalog/content-assets?tenantCode=master&includeLocked=true"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -114,10 +117,13 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var publicResponse = await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner"); - using var lockedResponse = await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner&includeLocked=true"); + using var publicResponse = + await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner"); + using var lockedResponse = + await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner&includeLocked=true"); - Assert.Equal(["公开图"], (await ReadItemsAsync(publicResponse)).Select(item => item.GetProperty("title").GetString()!).ToArray()); + Assert.Equal(["公开图"], + (await ReadItemsAsync(publicResponse)).Select(item => item.GetProperty("title").GetString()!).ToArray()); var lockedTitles = (await ReadItemsAsync(lockedResponse)) .Select(item => item.GetProperty("title").GetString()!) .ToArray(); @@ -190,7 +196,8 @@ public sealed class AssetEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/question-videos?tenantCode=master&questionId={questionId}"); + using var response = + await client.GetAsync($"/api/public/catalog/question-videos?tenantCode=master&questionId={questionId}"); var item = Assert.Single(await ReadItemsAsync(response)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -219,4 +226,4 @@ public sealed class AssetEndpointTests .Select(item => item.Clone()) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs index 0cfd49a..e2f0237 100644 --- a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -11,7 +11,6 @@ using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -62,7 +61,8 @@ public sealed class AssetManagementEndpointTests var assetId = item.GetProperty("id").GetGuid(); Assert.Equal("Pending", item.GetProperty("uploadStatus").GetString()); Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString()); - Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal); + Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), + StringComparison.Ordinal); using var listResponse = await client.GetAsync("/api/tenant/content/assets?uploadStatus=pending"); var list = await ReadJsonAsync(listResponse); @@ -295,7 +295,8 @@ public sealed class AssetManagementEndpointTests "/api/tenant/content/assets/sign-preview", new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 }); var accessEventsResponse = await client.GetAsync($"/api/tenant/content/assets/access-events?assetId={assetId}"); - var scanEventsResponse = await client.GetAsync($"/api/tenant/content/assets/security-scan-events?assetId={assetId}"); + var scanEventsResponse = + await client.GetAsync($"/api/tenant/content/assets/security-scan-events?assetId={assetId}"); var accessEvents = await ReadJsonAsync(accessEventsResponse); var scanEvents = await ReadJsonAsync(scanEventsResponse); @@ -447,20 +448,23 @@ public sealed class AssetManagementEndpointTests return (tenantId, userId, phone); } - private static ContentAsset PendingAsset(Guid id, Guid tenantId, string fileName) => new() + private static ContentAsset PendingAsset(Guid id, Guid tenantId, string fileName) { - Id = id, - TenantId = tenantId, - Title = fileName, - FileName = fileName, - StorageProvider = AssetStorageProvider.AliyunOss, - Bucket = "tenant-assets", - ObjectKey = $"{tenantId:N}/assets/{fileName}", - MimeType = "application/pdf", - AssetType = ContentAssetType.Pdf, - UploadStatus = AssetUploadStatus.Pending, - SecurityScanStatus = AssetSecurityScanStatus.Pending - }; + return new ContentAsset + { + Id = id, + TenantId = tenantId, + Title = fileName, + FileName = fileName, + StorageProvider = AssetStorageProvider.AliyunOss, + Bucket = "tenant-assets", + ObjectKey = $"{tenantId:N}/assets/{fileName}", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Pending, + SecurityScanStatus = AssetSecurityScanStatus.Pending + }; + } private static async Task StorageUsageAsync(ApiTestFactory factory, Guid tenantId) { @@ -490,24 +494,43 @@ public sealed class AssetManagementEndpointTests public string? MetadataChecksumSha256 { get; init; } - public string ConfiguredDefaultProvider() => ObjectStorageProviders.AliyunOss; + public string ConfiguredDefaultProvider() + { + return ObjectStorageProviders.AliyunOss; + } - public string ConfiguredDefaultBucket() => "tenant-assets"; + public string ConfiguredDefaultBucket() + { + return "tenant-assets"; + } public string NormalizeProvider(string? value, string? fallback = null) { return value ?? fallback ?? ObjectStorageProviders.AliyunOss; } - public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey; + public string ValidateObjectKey(Guid tenantId, string objectKey) + { + return objectKey; + } - public string ValidateMimeType(string mimeType) => mimeType; + public string ValidateMimeType(string mimeType) + { + return mimeType; + } - public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes; + public long? ValidateFileSize(long? fileSizeBytes) + { + return fileSizeBytes; + } - public void AssertUploadProvider(string provider) { } + public void AssertUploadProvider(string provider) + { + } - public void AssertWritableLocation(StorageAssetLocation location) { } + public void AssertWritableLocation(StorageAssetLocation location) + { + } public Task SignUploadAsync( ObjectStorageUploadSignRequest request, @@ -566,7 +589,7 @@ public sealed class AssetManagementEndpointTests request.Provider, request.Bucket, request.ObjectKey, - Exists: true, + true, MetadataSizeBytes ?? request.DeclaredFileSizeBytes, request.DeclaredMimeType, MetadataChecksumSha256 ?? request.DeclaredChecksumSha256, @@ -576,4 +599,4 @@ public sealed class AssetManagementEndpointTests "fake-head")); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index 3a5ed3c..d4cfbf2 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -1,11 +1,12 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Tiku.Application.Auth; using Tiku.Api.Contracts; +using Tiku.Application.Auth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; @@ -97,7 +98,7 @@ public sealed class AuthEndpointTests using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); jwtRequest.Headers.Host = "a.example.test"; - jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken); + jwtRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); var jwtResponse = await client.SendAsync(jwtRequest); using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/login/password"); @@ -146,14 +147,12 @@ public sealed class AuthEndpointTests }; foreach (var request in requests) - { using (request) { request.Headers.Host = "unconfigured.example.test"; using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - } } [Fact] @@ -170,8 +169,10 @@ public sealed class AuthEndpointTests Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); - Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); - Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); + Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -285,7 +286,7 @@ public sealed class AuthEndpointTests .GetProperty("accessToken") .GetString(); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var meResponse = await client.GetAsync("/api/tenant/me"); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -539,4 +540,4 @@ public sealed class AuthEndpointTests return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id")); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs b/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs index f40a4ea..3ef491f 100644 --- a/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/AuthPasswordLifecycleTests.cs @@ -31,7 +31,7 @@ public sealed class AuthPasswordLifecycleTests public async Task Forced_password_change_finishes_with_an_authenticated_session() { await using var factory = new ApiTestFactory(); - var seed = await SeedUserAsync(factory, forcePasswordChange: true); + var seed = await SeedUserAsync(factory, true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); @@ -122,4 +122,4 @@ public sealed class AuthPasswordLifecycleTests }); return (tenantId, phone); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs b/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs index a7dc185..b4bfe82 100644 --- a/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs +++ b/Tiku.IntegrationTests/Api/AuthRateLimitPolicyTests.cs @@ -1,7 +1,7 @@ +using System.ComponentModel.DataAnnotations; using System.Net; using System.Reflection; using System.Text; -using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; @@ -51,7 +51,7 @@ public sealed class AuthRateLimitPolicyTests options, new ValidationContext(options), validationResults, - validateAllProperties: true); + true); Assert.False(valid); Assert.Equal(2, validationResults.Count); @@ -161,4 +161,4 @@ public sealed class AuthRateLimitPolicyTests await middleware.InvokeAsync(context); return Assert.IsType(partition); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs index eba3e27..9dcde36 100644 --- a/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; @@ -107,14 +108,16 @@ public sealed class AuthRecoveryAndDeviceEndpointTests }); Assert.Equal(HttpStatusCode.OK, changed.StatusCode); using var body = JsonDocument.Parse(await changed.Content.ReadAsStringAsync()); - var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken").GetString(); - var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken").GetString(); + var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken") + .GetString(); + var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken") + .GetString(); Assert.False(string.IsNullOrWhiteSpace(accessToken)); Assert.False(string.IsNullOrWhiteSpace(refreshToken)); client.UseAccessToken(oldTokens); Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/tenant/me")).StatusCode); - client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/tenant/me")).StatusCode); Assert.Equal( HttpStatusCode.Unauthorized, @@ -139,7 +142,9 @@ public sealed class AuthRecoveryAndDeviceEndpointTests secondClient.UseAccessToken(secondTokens); otherClient.UseAccessToken(otherTokens); - using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); + using var sessions = + JsonDocument.Parse( + await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); var items = sessions.RootElement.EnumerateArray().ToArray(); Assert.Equal(2, items.Length); var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean()) @@ -156,7 +161,9 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Assert.Equal( HttpStatusCode.NoContent, (await secondClient.DeleteAsync($"/api/tenant/me/sessions/{otherOwnedFamily}")).StatusCode); - using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); + using var remaining = + JsonDocument.Parse( + await (await secondClient.GetAsync("/api/tenant/me/sessions")).Content.ReadAsStringAsync()); Assert.Single(remaining.RootElement.EnumerateArray()); } @@ -193,11 +200,13 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode); var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026"); Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode); - Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), + StringComparison.Ordinal); using var scope = factory.CreateSystemScope("Verify administrative password reset"); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync()); + Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId) + .Select(item => item.ForcePasswordChange).SingleAsync()); Assert.True(await dbContext.AuditLogs.AnyAsync(item => item.TenantId == admin.TenantId && item.ActorUserId == admin.UserId && @@ -208,8 +217,9 @@ public sealed class AuthRecoveryAndDeviceEndpointTests private static Task PostPasswordLoginAsync( HttpClient client, UserSeed seed, - string password) => - client.PostAsJsonAsync( + string password) + { + return client.PostAsJsonAsync( "/api/tenant/auth/login/password", new PasswordLoginDto { @@ -218,6 +228,7 @@ public sealed class AuthRecoveryAndDeviceEndpointTests Identifier = seed.Phone, Password = password }); + } private static async Task SeedUserAsync(ApiTestFactory factory) { @@ -286,4 +297,4 @@ public sealed class AuthRecoveryAndDeviceEndpointTests return Task.FromResult(new SmsProviderSendResult("test", "sent", "reset-message-id")); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs b/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs index cb6afed..f8ac97c 100644 --- a/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs @@ -1,6 +1,6 @@ +using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using System.Security.Claims; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Identity; @@ -17,7 +17,8 @@ public sealed class AuthSessionLifecycleTests { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { - ["ConnectionStrings:Redis"] = "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false", + ["ConnectionStrings:Redis"] = + "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false", ["Security:AuthorizationCache:Mode"] = "Active" }); var seed = await SeedActiveMemberAsync(factory); @@ -32,10 +33,7 @@ public sealed class AuthSessionLifecycleTests public async Task Active_redis_cache_rejects_disabled_membership_on_the_next_validation() { var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); - if (string.IsNullOrWhiteSpace(redis)) - { - return; - } + if (string.IsNullOrWhiteSpace(redis)) return; var commandRecorder = new RecordingDbCommandInterceptor(); await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary @@ -52,15 +50,18 @@ public sealed class AuthSessionLifecycleTests Assert.NotNull(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); var cached = await scope.ServiceProvider.GetRequiredService() - .GetAsync(new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); + .GetAsync( + new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); Assert.True(cached?.Complete); } + commandRecorder.Reset(); using (var scope = factory.CreateSystemScope("Validate hot Redis access security state")) { Assert.NotNull(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } + Assert.Empty(commandRecorder.Snapshot()); using (var scope = factory.CreateSystemScope("Disable membership and invalidate Redis")) @@ -85,10 +86,7 @@ public sealed class AuthSessionLifecycleTests public async Task Authorization_version_bypasses_stale_local_permission_snapshot_after_revocation() { var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); - if (string.IsNullOrWhiteSpace(redis)) - { - return; - } + if (string.IsNullOrWhiteSpace(redis)) return; await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["ConnectionStrings:Redis"] = redis, @@ -102,7 +100,8 @@ public sealed class AuthSessionLifecycleTests Code = permissionCode, Name = "Authorization cache test permission", Area = BackendPermissionArea.Tenant, - PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage) + PermissionModuleCode = + PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage) }); using (var scope = factory.CreateSystemScope("Seed versioned tenant permission")) { @@ -134,6 +133,7 @@ public sealed class AuthSessionLifecycleTests }); await dbContext.SaveChangesAsync(); } + var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); AuthSessionValidationResult validated; @@ -143,6 +143,7 @@ public sealed class AuthSessionLifecycleTests .GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } + Assert.True((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode)); using (var scope = factory.CreateSystemScope("Revoke permission and publish durable version")) @@ -155,12 +156,14 @@ public sealed class AuthSessionLifecycleTests await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync(); } + using (var scope = factory.CreateSystemScope("Reload session authorization version")) { validated = Assert.IsType(await scope.ServiceProvider .GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } + Assert.False((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode)); } @@ -439,4 +442,4 @@ public sealed class AuthSessionLifecycleTests } private sealed record SessionSeed(Guid TenantId, Guid UserId, string Phone, string SecurityStamp); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs index e69c486..4592f4c 100644 --- a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs +++ b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs @@ -1,7 +1,7 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Tiku.Api.Contracts; -using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; @@ -81,7 +81,7 @@ internal static class AuthenticationTestClientExtensions public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens) { - client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); } private static void SetTenantHeader(HttpClient client, Guid tenantId) @@ -94,10 +94,8 @@ internal static class AuthenticationTestClientExtensions { var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) - { throw new HttpRequestException( $"Authentication request failed with {(int)response.StatusCode} ({response.StatusCode}): {body}"); - } return JsonDocument.Parse(body); } @@ -105,10 +103,11 @@ internal static class AuthenticationTestClientExtensions private static TestAuthenticationTokens ReadTokens(JsonElement tokens) { var accessToken = tokens.GetProperty("accessToken").GetString() - ?? throw new InvalidOperationException("Authentication response did not contain an access token."); + ?? throw new InvalidOperationException( + "Authentication response did not contain an access token."); var refreshToken = tokens.GetProperty("refreshToken").GetString() - ?? throw new InvalidOperationException("Authentication response did not contain a refresh token."); + ?? throw new InvalidOperationException( + "Authentication response did not contain a refresh token."); return new TestAuthenticationTokens(accessToken, refreshToken); } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs index 92dd77d..bd7c754 100644 --- a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs +++ b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs @@ -3,10 +3,11 @@ using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Tiku.Api; using Tiku.Api.Security; using Tiku.Application.Security; @@ -22,7 +23,7 @@ public sealed class AuthorizationManifestTests [Fact] public void Controller_authorization_surface_matches_reviewed_manifest() { - var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes() + var descriptors = typeof(ApiProgramMarker).Assembly.GetTypes() .Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type)) .SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) .Where(method => method.GetCustomAttributes().Any()) @@ -65,9 +66,7 @@ public sealed class AuthorizationManifestTests metadata.Module is { } module && PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var requiredFeature) && requiredFeature is not null) - { Assert.Contains(requiredFeature, metadata.RequiredFeatures); - } } } @@ -108,14 +107,17 @@ public sealed class AuthorizationManifestTests .Select(attribute => attribute.Template ?? string.Empty) .Order(StringComparer.Ordinal)); var http = action.GetCustomAttributes().ToArray(); - var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal)); - var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal)); + var methods = string.Join(',', + http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal)); + var templates = string.Join(',', + http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal)); var policies = controller.GetCustomAttributes() .Concat(action.GetCustomAttributes()) .Select(attribute => attribute.Policy ?? "authenticated") .Order(StringComparer.Ordinal); var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) || action.IsDefined(typeof(AllowAnonymousAttribute)); - return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}"; + return + $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}"; } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs index 4367c0b..f3e1030 100644 --- a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs +++ b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs @@ -1,12 +1,10 @@ using System.Net; using System.Text.Json; -using Tiku.Application.Backoffice; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; namespace Tiku.IntegrationTests.Api; @@ -85,7 +83,8 @@ public sealed class BackofficeUiBootstrapTests bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); Assert.Equal( ["tenant.dashboard"], - bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString())); + bootstrap.RootElement.GetProperty("menus").EnumerateArray() + .Select(item => item.GetProperty("code").GetString())); Assert.True( firstRequestCommands.Count is >= 1 and <= 10, $"Expected at most 10 SQL commands, but captured {firstRequestCommands.Count}:{Environment.NewLine}{string.Join($"{Environment.NewLine}---{Environment.NewLine}", firstRequestCommands)}"); @@ -153,7 +152,8 @@ public sealed class BackofficeUiBootstrapTests bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); Assert.Equal( ["platform.dashboard"], - bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString())); + bootstrap.RootElement.GetProperty("menus").EnumerateArray() + .Select(item => item.GetProperty("code").GetString())); Assert.InRange(commands.Count, 1, 10); AssertNoPerCodeCatalogExistenceQueries(commands); } @@ -165,4 +165,4 @@ public sealed class BackofficeUiBootstrapTests command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) && catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase))); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs index b9e5343..29809d4 100644 --- a/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs +++ b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs @@ -1,12 +1,11 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; using Tiku.Api.Contracts; using Tiku.Api.Options; -using Tiku.Application.Auth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; namespace Tiku.IntegrationTests.Api; @@ -29,7 +28,7 @@ public sealed class BrowserAuthenticationTests Role = TenantRole.Student, Status = MembershipStatus.Active }); - using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); @@ -76,7 +75,7 @@ public sealed class BrowserAuthenticationTests Role = TenantRole.Student, Status = MembershipStatus.Active }); - using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); @@ -126,7 +125,7 @@ public sealed class BrowserAuthenticationTests Role = TenantRole.Student, Status = MembershipStatus.Active }); - using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); @@ -143,7 +142,8 @@ public sealed class BrowserAuthenticationTests }; login.Headers.Add("Origin", "http://localhost"); var loginResponse = await client.SendAsync(login); - var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie); + var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), + BrowserAuthOptions.AccessCookie); using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); @@ -167,10 +167,7 @@ public sealed class BrowserAuthenticationTests using var request = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/refresh"); request.Headers.Add("Cookie", cookieHeader); request.Headers.Add("Origin", origin); - if (csrf is not null) - { - request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf); - } + if (csrf is not null) request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf); return await client.SendAsync(request); } @@ -180,4 +177,4 @@ public sealed class BrowserAuthenticationTests var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal)); return header[prefix.Length..header.IndexOf(';')]; } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs b/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs index d57da37..16c1c6a 100644 --- a/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs +++ b/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs @@ -1,6 +1,6 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Domain.Operations; @@ -17,7 +17,7 @@ public sealed class CapabilityAuthorizationTests { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); - var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, includeEntitlement: true); + var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, true); await factory.SeedAsync(fixture.Entities); using var scope = factory.CreateSystemScope("Verify job feature access at consumption"); @@ -49,7 +49,7 @@ public sealed class CapabilityAuthorizationTests { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); - var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, includeEntitlement: true); + var fixture = CreateSubscriptionFixture(tenantId, SaasFeatureCatalog.PrivateQuestionBank, true); await factory.SeedAsync(fixture.Entities); await factory.SeedAsync(new SaasFeature @@ -98,7 +98,8 @@ public sealed class CapabilityAuthorizationTests SaasFeatureCatalog.PrivateQuestionBank, FeatureAccessOperation.Write)).Allowed); - var subscription = await dbContext.TenantSaasSubscriptions.SingleAsync(item => item.Id == fixture.SubscriptionId); + var subscription = + await dbContext.TenantSaasSubscriptions.SingleAsync(item => item.Id == fixture.SubscriptionId); subscription.Status = TenantSaasSubscriptionStatus.PastDue; await dbContext.SaveChangesAsync(); Assert.True((await access.EvaluateAsync( @@ -176,13 +177,11 @@ public sealed class CapabilityAuthorizationTests } }; if (includeEntitlement) - { entities.Add(new SaasOfferingVersionFeature { OfferingVersionId = versionId, FeatureCode = featureCode }); - } return new SubscriptionFixture(versionId, subscriptionId, entities.ToArray()); } @@ -191,4 +190,4 @@ public sealed class CapabilityAuthorizationTests Guid VersionId, Guid SubscriptionId, object[] Entities); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs index 4d2d67c..c58539b 100644 --- a/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CatalogEndpointTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Text.Json; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; -using Tiku.Domain.Common; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; @@ -221,7 +220,8 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/banners?tenantCode=master®ionId={regionId}"); + using var response = + await client.GetAsync($"/api/public/catalog/banners?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -473,7 +473,8 @@ public sealed class CatalogEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/svip-plans?tenantCode=master®ionId={regionId}"); + using var response = + await client.GetAsync($"/api/public/catalog/svip-plans?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -501,4 +502,4 @@ public sealed class CatalogEndpointTests .Select(item => item.Clone()) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index cdf9518..0514c2e 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -1,17 +1,15 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Api.Options; -using Tiku.Application.Auth; using Tiku.Application.Commerce; using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -48,7 +46,7 @@ public sealed class CommerceEndpointTests IsActive = true }); using var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), @@ -365,7 +363,6 @@ public sealed class CommerceEndpointTests } }; if (includeMembership) - { entities.Add(new TenantMembership { TenantId = tenantId, @@ -373,7 +370,6 @@ public sealed class CommerceEndpointTests Role = TenantRole.Student, Status = MembershipStatus.Active }); - } await factory.SeedAsync(entities.ToArray()); return new LoginSeed(tenantId, userId, planId, phone); @@ -458,4 +454,4 @@ public sealed class CommerceEndpointTests property.ValueKind == JsonValueKind.True; } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs index 6d34331..cc889df 100644 --- a/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommissionEndpointTests.cs @@ -1,14 +1,11 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using Tiku.Api.Contracts; -using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Domain.Commerce; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; namespace Tiku.IntegrationTests.Api; @@ -35,9 +32,14 @@ public sealed class CommissionEndpointTests var settings = await client.PutAsJsonAsync( "/api/tenant/commission/settings", - new UpdateCommissionSettingsDto { DefaultRate = 0.2m, MinSettlementCents = 1, SettlementCycle = "monthly" }); - var sources = await client.GetAsync($"/api/tenant/commission/orders?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); - var summary = await client.GetAsync($"/api/tenant/commission/summary?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); + new UpdateCommissionSettingsDto + { DefaultRate = 0.2m, MinSettlementCents = 1, SettlementCycle = "monthly" }); + var sources = + await client.GetAsync( + $"/api/tenant/commission/orders?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); + var summary = + await client.GetAsync( + $"/api/tenant/commission/summary?referrerUserId={seed.Referrer.UserId}&startDate=2026-07-01&endDate=2026-07-31"); var generate = await client.PostAsJsonAsync( "/api/tenant/commission/settlements/generate", new GenerateCommissionSettlementDto @@ -77,7 +79,8 @@ public sealed class CommissionEndpointTests var proofStatus = await client.PostAsJsonAsync( "/api/tenant/commission/settlements/proofs/status", new UpdateCommissionProofStatusDto { ProofId = proofItem!.Id, Status = "approved" }); - var export = await client.GetAsync($"/api/tenant/commission/settlements/export?settlementId={settlement.Id}&format=csv"); + var export = + await client.GetAsync($"/api/tenant/commission/settlements/export?settlementId={settlement.Id}&format=csv"); var exportItem = await export.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, settings.StatusCode); @@ -153,9 +156,16 @@ public sealed class CommissionEndpointTests return new CommissionSeed(tenantId, admin, referrer, student); } - private static User User(LoginSeed seed, string role) => - new User { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }.WithTestPassword(); - private static TenantMembership Membership(LoginSeed seed, TenantRole role) => new() { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active }; + private static User User(LoginSeed seed, string role) + { + return new User { Id = seed.UserId, Phone = seed.Phone, Name = role, PrimaryRole = role }.WithTestPassword(); + } + + private static TenantMembership Membership(LoginSeed seed, TenantRole role) + { + return new TenantMembership + { TenantId = seed.TenantId, UserId = seed.UserId, Role = role, Status = MembershipStatus.Active }; + } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { @@ -163,5 +173,6 @@ public sealed class CommissionEndpointTests } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); + private sealed record CommissionSeed(Guid TenantId, LoginSeed Admin, LoginSeed Referrer, LoginSeed Student); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs index 844f8bf..71ee459 100644 --- a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs @@ -3,12 +3,12 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; +using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -135,7 +135,8 @@ public sealed class ContentManagementEndpointTests var blueprintJson = await ReadJsonAsync(blueprintResponse); Assert.Equal(HttpStatusCode.OK, collectionResponse.StatusCode); - Assert.Equal("Manual", collectionJson.RootElement.GetProperty("item").GetProperty("collectionType").GetString()); + Assert.Equal("Manual", + collectionJson.RootElement.GetProperty("item").GetProperty("collectionType").GetString()); Assert.Equal(HttpStatusCode.OK, replaceResponse.StatusCode); Assert.Equal(1, replaceJson.RootElement.GetProperty("questionCount").GetInt32()); Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode); @@ -155,14 +156,17 @@ public sealed class ContentManagementEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - using var mappingResponse = await client.GetAsync("/api/tenant/content/imports/field-mapping?importType=questions"); - using var templateResponse = await client.GetAsync("/api/tenant/content/imports/templates?importType=questions&format=csv"); + using var mappingResponse = + await client.GetAsync("/api/tenant/content/imports/field-mapping?importType=questions"); + using var templateResponse = + await client.GetAsync("/api/tenant/content/imports/templates?importType=questions&format=csv"); var mapping = await ReadJsonAsync(mappingResponse); var template = await ReadJsonAsync(templateResponse); Assert.Equal(HttpStatusCode.OK, mappingResponse.StatusCode); Assert.Equal("questions", mapping.RootElement.GetProperty("importType").GetString()); - Assert.Contains(mapping.RootElement.GetProperty("requiredFields").EnumerateArray(), item => item.GetString() == "type"); + Assert.Contains(mapping.RootElement.GetProperty("requiredFields").EnumerateArray(), + item => item.GetString() == "type"); Assert.Equal(HttpStatusCode.OK, templateResponse.StatusCode); Assert.Equal("csv", template.RootElement.GetProperty("format").GetString()); Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty); @@ -202,8 +206,8 @@ public sealed class ContentManagementEndpointTests CreatedBy = outsideCreatorId }; await factory.SeedAsync( - new Tiku.Domain.Catalog.Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, - new Tiku.Domain.Catalog.Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" }, + new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, + new Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" }, new User { Id = regionalCreatorId, Name = "Regional Creator" }, new User { Id = outsideCreatorId, Name = "Outside Creator" }, ownEntry, @@ -308,5 +312,4 @@ public sealed class ContentManagementEndpointTests var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs index 5e8cc81..89c8bd3 100644 --- a/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -using Tiku.Domain.Common; using Tiku.Domain.Catalog; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; @@ -51,7 +51,8 @@ public sealed class ContentNavigationEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/content-entries?tenantCode=master®ionId={regionId}"); + using var response = + await client.GetAsync($"/api/public/catalog/content-entries?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -98,7 +99,9 @@ public sealed class ContentNavigationEndpointTests using var client = factory.CreateClient(); using var missingEntryResponse = await client.GetAsync("/api/public/catalog/content-nodes?tenantCode=master"); - using var response = await client.GetAsync($"/api/public/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root"); + using var response = + await client.GetAsync( + $"/api/public/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.BadRequest, missingEntryResponse.StatusCode); @@ -250,7 +253,9 @@ public sealed class ContentNavigationEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}"); + using var response = + await client.GetAsync( + $"/api/public/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -280,4 +285,4 @@ public sealed class ContentNavigationEndpointTests .Select(item => item.Clone()) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs index 87a97dd..08f8866 100644 --- a/Tiku.IntegrationTests/Api/CrmEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CrmEndpointTests.cs @@ -3,12 +3,10 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -48,7 +46,9 @@ public sealed class CrmEndpointTests DelaySeconds = 3 }); var body = await response.Content.ReadAsStringAsync(); - var config = JsonSerializer.Deserialize(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var config = + JsonSerializer.Deserialize(body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.DoesNotContain("super-secret", body, StringComparison.OrdinalIgnoreCase); @@ -138,4 +138,4 @@ public sealed class CrmEndpointTests } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs index c27fb66..8836220 100644 --- a/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs +++ b/Tiku.IntegrationTests/Api/CurrentQuotaEnforcementTests.cs @@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Security; -using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Platform; @@ -29,9 +28,19 @@ public sealed class CurrentQuotaEnforcementTests new User { Id = teacherId, Phone = "13940000001", Name = "Reconcile Teacher" }, new User { Id = studentA, Phone = "13940000002", Name = "Reconcile Student A" }, new User { Id = studentB, Phone = "13940000003", Name = "Reconcile Student B" }, - new TenantMembership { TenantId = seed.TenantId, UserId = teacherId, Role = TenantRole.Teacher, Status = MembershipStatus.Active }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentA, Role = TenantRole.Student, Status = MembershipStatus.Active }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentB, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = teacherId, Role = TenantRole.Teacher, + Status = MembershipStatus.Active + }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentA, Role = TenantRole.Student, Status = MembershipStatus.Active + }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentB, Role = TenantRole.Student, Status = MembershipStatus.Active + }, new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new Question { TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Draft }, new ContentAsset @@ -52,23 +61,26 @@ public sealed class CurrentQuotaEnforcementTests $"quota-reconcile-{Guid.NewGuid():N}"); var first = await service.ReconcileTenantAsync(request); var firstVersions = await ReadUsageVersionsAsync(factory, seed.TenantId); - var second = await service.ReconcileTenantAsync(request with { CorrelationId = $"{request.CorrelationId}-again" }); + var second = + await service.ReconcileTenantAsync(request with { CorrelationId = $"{request.CorrelationId}-again" }); var secondVersions = await ReadUsageVersionsAsync(factory, seed.TenantId); Assert.Equal(4, first.Count); Assert.All(first, item => Assert.True(item.Exceeded)); Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StaffCount).ActualValue); Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StudentCount).ActualValue); - Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount).ActualValue); + Assert.Equal(2, + first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount).ActualValue); Assert.Equal(2, first.Single(item => item.MetricCode == SaasQuotaMetricCatalog.StorageBytes).ActualValue); Assert.Equal(first, second); Assert.Equal(firstVersions.Count, secondVersions.Count); Assert.All(firstVersions, item => Assert.Equal(item.Value, secondVersions[item.Key])); using var verificationScope = factory.CreateSystemScope("Verify reconciliation audit"); - Assert.True(await verificationScope.ServiceProvider.GetRequiredService().AuditLogs.AnyAsync(item => - item.TenantId == seed.TenantId && - item.Action == "system_scope.completed" && - item.TargetId == request.CorrelationId)); + Assert.True(await verificationScope.ServiceProvider.GetRequiredService().AuditLogs + .AnyAsync(item => + item.TenantId == seed.TenantId && + item.Action == "system_scope.completed" && + item.TargetId == request.CorrelationId)); } [Fact] @@ -79,15 +91,18 @@ public sealed class CurrentQuotaEnforcementTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); - Task CreateAsync(string content) => client.PostAsJsonAsync( - "/api/tenant/content/questions", - new DirectQuestionWriteDto - { - Type = "choice", - Content = content, - Status = "Published", - CorrectOptionIndex = 0 - }); + Task CreateAsync(string content) + { + return client.PostAsJsonAsync( + "/api/tenant/content/questions", + new DirectQuestionWriteDto + { + Type = "choice", + Content = content, + Status = "Published", + CorrectOptionIndex = 0 + }); + } var responses = await Task.WhenAll(CreateAsync("quota question A"), CreateAsync("quota question B")); Assert.Equal(1, responses.Count(response => response.StatusCode == HttpStatusCode.OK)); @@ -112,7 +127,8 @@ public sealed class CurrentQuotaEnforcementTests using var scope = factory.CreateSystemScope("Verify current question quota"); var usage = await scope.ServiceProvider.GetRequiredService() .TenantFeatureUsages.AsNoTracking() - .SingleAsync(item => item.TenantId == seed.TenantId && item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount); + .SingleAsync(item => + item.TenantId == seed.TenantId && item.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount); Assert.Equal(1, usage.UsedValue); } @@ -208,17 +224,23 @@ public sealed class CurrentQuotaEnforcementTests Assert.Equal(HttpStatusCode.OK, question.StatusCode); } - private static UpsertTenantAdminMemberDto Member(string role, string phone, string name) => new() + private static UpsertTenantAdminMemberDto Member(string role, string phone, string name) { - Role = role, - Status = "Active", - User = new TenantAdminUserLookupDto { Phone = phone, Name = name } - }; + return new UpsertTenantAdminMemberDto + { + Role = role, + Status = "Active", + User = new TenantAdminUserLookupDto { Phone = phone, Name = name } + }; + } - private static UpsertTenantAdminStudentDto Student(string phone, string name) => new() + private static UpsertTenantAdminStudentDto Student(string phone, string name) { - User = new TenantAdminUserLookupDto { Phone = phone, Name = name } - }; + return new UpsertTenantAdminStudentDto + { + User = new TenantAdminUserLookupDto { Phone = phone, Name = name } + }; + } private static async Task<(Guid TenantId, string Phone)> SeedLimitedTenantAsync(ApiTestFactory factory) { @@ -246,7 +268,7 @@ public sealed class CurrentQuotaEnforcementTests Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }, - Feature(SaasFeatureCatalog.CoreBackoffice, isCore: true), + Feature(SaasFeatureCatalog.CoreBackoffice, true), Feature(SaasFeatureCatalog.PrivateQuestionBank), Feature(SaasFeatureCatalog.StudentManagement), new SaasOffering @@ -299,38 +321,50 @@ public sealed class CurrentQuotaEnforcementTests return (tenantId, phone); } - private static SaasFeature Feature(string code, bool isCore = false) => new() + private static SaasFeature Feature(string code, bool isCore = false) { - Code = code, - Name = code, - Category = "integration", - Status = SaasFeatureStatus.Active, - IsCore = isCore - }; + return new SaasFeature + { + Code = code, + Name = code, + Category = "integration", + Status = SaasFeatureStatus.Active, + IsCore = isCore + }; + } - private static SaasOfferingVersionFeature Entitlement(Guid versionId, string featureCode) => new() + private static SaasOfferingVersionFeature Entitlement(Guid versionId, string featureCode) { - OfferingVersionId = versionId, - FeatureCode = featureCode - }; + return new SaasOfferingVersionFeature + { + OfferingVersionId = versionId, + FeatureCode = featureCode + }; + } - private static SaasFeatureLimitDefinition LimitDefinition(string metricCode, string featureCode) => new() + private static SaasFeatureLimitDefinition LimitDefinition(string metricCode, string featureCode) { - MetricCode = metricCode, - FeatureCode = featureCode, - Name = metricCode, - Unit = "count", - Kind = SaasFeatureLimitKind.Current, - WarningPercent = 80, - IsHardLimit = true - }; + return new SaasFeatureLimitDefinition + { + MetricCode = metricCode, + FeatureCode = featureCode, + Name = metricCode, + Unit = "count", + Kind = SaasFeatureLimitKind.Current, + WarningPercent = 80, + IsHardLimit = true + }; + } - private static SaasOfferingVersionLimit Limit(Guid versionId, string metricCode) => new() + private static SaasOfferingVersionLimit Limit(Guid versionId, string metricCode) { - OfferingVersionId = versionId, - MetricCode = metricCode, - LimitValue = 1 - }; + return new SaasOfferingVersionLimit + { + OfferingVersionId = versionId, + MetricCode = metricCode, + LimitValue = 1 + }; + } private static async Task> ReadUsageVersionsAsync(ApiTestFactory factory, Guid tenantId) { @@ -339,4 +373,4 @@ public sealed class CurrentQuotaEnforcementTests .Where(item => item.TenantId == tenantId) .ToDictionaryAsync(item => item.MetricCode, item => item.Version); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs index d067412..c023ed0 100644 --- a/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs +++ b/Tiku.IntegrationTests/Api/DatabasePermissionServiceAuthorizationTests.cs @@ -5,7 +5,6 @@ using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; namespace Tiku.IntegrationTests.Api; @@ -82,4 +81,4 @@ public sealed class DatabasePermissionServiceAuthorizationTests Assert.Equal(HttpStatusCode.OK, commission.StatusCode); Assert.Equal(HttpStatusCode.OK, referral.StatusCode); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs index 07d5ceb..15603eb 100644 --- a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -10,9 +10,7 @@ using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Operations; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -60,7 +58,8 @@ public sealed class DirectContentEndpointTests using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.Contains(dbContext.QuestionCollectionItems, item => item.CollectionId == collectionId && item.QuestionId == questionId); + Assert.Contains(dbContext.QuestionCollectionItems, + item => item.CollectionId == collectionId && item.QuestionId == questionId); Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount); } @@ -168,7 +167,8 @@ public sealed class DirectContentEndpointTests { Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题", correctOptionIndex = 0 }) + JsonSerializer.SerializeToElement( + new { type = "choice", content = "导入预览题", correctOptionIndex = 0 }) ] }); var previewJson = await ReadJsonAsync(previewResponse); @@ -180,7 +180,8 @@ public sealed class DirectContentEndpointTests { Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题", correctOptionIndex = 0 }) + JsonSerializer.SerializeToElement( + new { type = "choice", content = "导入执行题", correctOptionIndex = 0 }) ] }); var executeJson = await ReadJsonAsync(executeResponse); @@ -218,7 +219,8 @@ public sealed class DirectContentEndpointTests Async = true, Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题", correctOptionIndex = 0 }) + JsonSerializer.SerializeToElement( + new { type = "choice", content = "异步导入题", correctOptionIndex = 0 }) ] }); var queuedJob = await queueResponse.Content.ReadFromJsonAsync(JsonOptions); @@ -252,7 +254,10 @@ public sealed class DirectContentEndpointTests await factory.SeedAsync( new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" }, new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" }, - new Major { Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" }); + new Major + { + Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" + }); using var client = factory.CreateClient(); await LoginAsync(client, seed); @@ -281,7 +286,8 @@ public sealed class DirectContentEndpointTests }); var fieldsResponse = await client.GetAsync($"/api/tenant/content/scoreline/fields?regionId={regionId}"); - var recordsResponse = await client.GetAsync($"/api/tenant/content/scoreline/records?regionId={regionId}&year=2026"); + var recordsResponse = + await client.GetAsync($"/api/tenant/content/scoreline/records?regionId={regionId}&year=2026"); var fieldsJson = await ReadJsonAsync(fieldsResponse); var recordsJson = await ReadJsonAsync(recordsResponse); @@ -399,5 +405,4 @@ public sealed class DirectContentEndpointTests using var payload = await ReadJsonAsync(response); return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs index 9787b00..3e52c47 100644 --- a/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs +++ b/Tiku.IntegrationTests/Api/ExceptionHandlingMiddlewareTests.cs @@ -47,4 +47,4 @@ public sealed class ExceptionHandlingMiddlewareTests public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs index 39a6264..d860142 100644 --- a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -4,13 +4,11 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -173,7 +171,9 @@ public sealed class LearningEndpointTests }); var created = await ReadJsonAsync(createResponse); var practiceSessionId = created.RootElement.GetProperty("id").GetGuid(); - var detailResponse = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); + var detailResponse = + await client.GetAsync( + $"/api/student/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); var detail = await ReadJsonAsync(detailResponse); var detailQuestions = detail.RootElement.GetProperty("questions").EnumerateArray().ToArray(); var firstSessionQuestionId = detailQuestions.Single(item => @@ -210,7 +210,9 @@ public sealed class LearningEndpointTests IdempotencyKey = "practice-submit" }); var report = await ReadJsonAsync(submitResponse); - var reportResponse = await client.GetAsync($"/api/student/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); + var reportResponse = + await client.GetAsync( + $"/api/student/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); var reportsResponse = await client.GetAsync("/api/student/learning/practice-reports"); var historyResponse = await client.GetAsync("/api/student/learning/practice-sessions/history?status=finished"); var reports = await ReadItemsAsync(reportsResponse); @@ -433,7 +435,9 @@ public sealed class LearningEndpointTests Assert.Equal(1, stats.RootElement.GetProperty("answerCount").GetInt32()); Assert.Equal(1, stats.RootElement.GetProperty("wrongCount").GetInt32()); Assert.NotEmpty(trend); - Assert.Equal(seed.UserId, Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId").GetGuid()); + Assert.Equal(seed.UserId, + Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId") + .GetGuid()); Assert.Single(wrongPlan.RootElement.GetProperty("items").EnumerateArray()); Assert.Single(wordPlan.RootElement.GetProperty("items").EnumerateArray()); Assert.Equal(1, review.RootElement.GetProperty("correctCount").GetInt32()); @@ -519,7 +523,9 @@ public sealed class LearningEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var before = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); + var before = + await client.GetAsync( + $"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); using (var scope = factory.CreateSystemScope("Mutate source question version after session creation")) { var db = scope.ServiceProvider.GetRequiredService(); @@ -529,7 +535,9 @@ public sealed class LearningEndpointTests version.CorrectOptionIndex = 1; await db.SaveChangesAsync(); } - var after = await client.GetAsync($"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); + + var after = await client.GetAsync( + $"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}"); var beforeQuestion = (await ReadJsonAsync(before)).RootElement.GetProperty("questions")[0]; var afterQuestion = (await ReadJsonAsync(after)).RootElement.GetProperty("questions")[0]; @@ -787,5 +795,4 @@ public sealed class LearningEndpointTests using var body = await ReadJsonAsync(response); return body.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs b/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs index 596ea9c..f484605 100644 --- a/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs +++ b/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using System.Net; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -103,4 +103,4 @@ public sealed class MonolithBackgroundProcessingTests .Select(job => job.Status) .SingleAsync(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs index 1cc8aca..986248d 100644 --- a/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs +++ b/Tiku.IntegrationTests/Api/OpenApiDocumentationTests.cs @@ -61,4 +61,4 @@ public sealed class OpenApiDocumentationTests path.StartsWith("/api/tenant-content", StringComparison.Ordinal) || path.StartsWith("/api/tenant-commerce", StringComparison.Ordinal)); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs b/Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs index 77f2e7d..1f9110d 100644 --- a/Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs +++ b/Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs @@ -53,13 +53,9 @@ public sealed class P0AssetSecurityScanTests Assert.Equal(expectedStatus, scanEvent.ScanStatus); Assert.Equal(expectedRisk, scanEvent.RiskLevel); if (verdict == AssetSecurityScanVerdict.Infected) - { Assert.Contains("Eicar-Signature", scanEvent.IssueCodes); - } else - { Assert.Empty(scanEvent.IssueCodes); - } } [Fact] @@ -139,33 +135,90 @@ public sealed class P0AssetSecurityScanTests public Task ScanAsync( Stream content, long? declaredLength, - CancellationToken cancellationToken = default) => - outcome switch + CancellationToken cancellationToken = default) + { + return outcome switch { AssetSecurityScanResult result => Task.FromResult(result), Exception exception => Task.FromException(exception), _ => throw new InvalidOperationException("Unsupported scanner outcome.") }; + } - public Task CheckHealthAsync(CancellationToken cancellationToken = default) => - Task.FromResult(outcome is AssetSecurityScanResult); + public Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(outcome is AssetSecurityScanResult); + } } private sealed class ReadableStorage : IObjectStorageService { - public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev; - public string ConfiguredDefaultBucket() => "tenant-assets"; - public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev; - public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey; - public string ValidateMimeType(string mimeType) => mimeType; - public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes; - public void AssertUploadProvider(string provider) { } - public void AssertWritableLocation(StorageAssetLocation location) { } - public Task SignUploadAsync(ObjectStorageUploadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task SignDownloadAsync(ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task WriteObjectAsync(ObjectStorageWriteRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task HeadObjectAsync(ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task OpenReadAsync(ObjectStorageReadRequest request, CancellationToken cancellationToken = default) => - Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("hello world!"))); + public string ConfiguredDefaultProvider() + { + return ObjectStorageProviders.LocalDev; + } + + public string ConfiguredDefaultBucket() + { + return "tenant-assets"; + } + + public string NormalizeProvider(string? value, string? fallback = null) + { + return value ?? fallback ?? ObjectStorageProviders.LocalDev; + } + + public string ValidateObjectKey(Guid tenantId, string objectKey) + { + return objectKey; + } + + public string ValidateMimeType(string mimeType) + { + return mimeType; + } + + public long? ValidateFileSize(long? fileSizeBytes) + { + return fileSizeBytes; + } + + public void AssertUploadProvider(string provider) + { + } + + public void AssertWritableLocation(StorageAssetLocation location) + { + } + + public Task SignUploadAsync(ObjectStorageUploadSignRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task SignDownloadAsync(ObjectStorageDownloadSignRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task WriteObjectAsync(ObjectStorageWriteRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task HeadObjectAsync(ObjectStorageHeadRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task OpenReadAsync(ObjectStorageReadRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("hello world!"))); + } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs b/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs index 2fc88a5..7d1b0b6 100644 --- a/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs @@ -63,7 +63,7 @@ public sealed class P0OperationsLifecycleTests ["Storage:DefaultProvider"] = "local_dev", ["Storage:DefaultBucket"] = "tenant-assets" }); - var seed = await SeedTenantAsync(factory, includeSecondMember: true); + var seed = await SeedTenantAsync(factory, true); Guid exportOperationId; using (var scope = factory.CreateSystemScope("Create tenant export operation")) { @@ -85,7 +85,8 @@ public sealed class P0OperationsLifecycleTests { var lifecycle = scope.ServiceProvider.GetRequiredService(); var export = await lifecycle.GetOperationAsync(seed.TenantId, exportOperationId); - var exportJob = await scope.ServiceProvider.GetRequiredService().BackgroundJobs.AsNoTracking() + var exportJob = await scope.ServiceProvider.GetRequiredService().BackgroundJobs + .AsNoTracking() .SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "tenant_export"); Assert.True( export!.Status == TenantLifecycleOperationStatus.Succeeded, @@ -95,19 +96,36 @@ public sealed class P0OperationsLifecycleTests await lifecycle.ArchiveAsync(seed.TenantId, seed.UserId, "Contract ended"); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.Equal(TenantStatus.Archived, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync()); - Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(), + Assert.Equal(TenantStatus.Archived, + await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status) + .SingleAsync()); + Assert.All( + await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status) + .ToArrayAsync(), status => Assert.Equal(TenantDomainStatus.Disabled, status)); await lifecycle.RestoreAsync(seed.TenantId, seed.UserId, "Customer returned"); - Assert.Equal(TenantStatus.Suspended, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync()); - Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(), + Assert.Equal(TenantStatus.Suspended, + await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status) + .SingleAsync()); + Assert.All( + await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status) + .ToArrayAsync(), status => Assert.Equal(TenantDomainStatus.Pending, status)); - await lifecycle.TransferOwnerAsync(seed.TenantId, seed.UserId, seed.SecondUserId!.Value, "Ownership handover"); - Assert.Equal(seed.SecondUserId, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.OwnerUserId).SingleAsync()); - Assert.Equal(TenantRole.TenantAdmin, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId).Select(item => item.Role).SingleAsync()); - Assert.Equal(TenantRole.TenantOwner, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.SecondUserId).Select(item => item.Role).SingleAsync()); + await lifecycle.TransferOwnerAsync(seed.TenantId, seed.UserId, seed.SecondUserId!.Value, + "Ownership handover"); + Assert.Equal(seed.SecondUserId, + await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.OwnerUserId) + .SingleAsync()); + Assert.Equal(TenantRole.TenantAdmin, + await dbContext.TenantMemberships + .Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId) + .Select(item => item.Role).SingleAsync()); + Assert.Equal(TenantRole.TenantOwner, + await dbContext.TenantMemberships + .Where(item => item.TenantId == seed.TenantId && item.UserId == seed.SecondUserId) + .Select(item => item.Role).SingleAsync()); } } @@ -118,19 +136,39 @@ public sealed class P0OperationsLifecycleTests var secondUserId = includeSecondMember ? Guid.NewGuid() : (Guid?)null; var entities = new List { - new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Lifecycle tenant", OwnerUserId = userId }, - new User { Id = userId, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Owner" }.WithTestPassword(), - new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active }, - new TenantDomain { TenantId = tenantId, Host = $"{tenantId:N}.example.test", Status = TenantDomainStatus.Active, IsPrimary = true } + new Tenant + { + Id = tenantId, Slug = tenantId.ToString("N"), Name = "Lifecycle tenant", OwnerUserId = userId + }, + new User { Id = userId, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Owner" } + .WithTestPassword(), + new TenantMembership + { + TenantId = tenantId, UserId = userId, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active + }, + new TenantDomain + { + TenantId = tenantId, Host = $"{tenantId:N}.example.test", Status = TenantDomainStatus.Active, + IsPrimary = true + } }; if (secondUserId.HasValue) { - entities.Add(new User { Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Next owner" }.WithTestPassword()); - entities.Add(new TenantMembership { TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); + entities.Add(new User + { + Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", + Name = "Next owner" + }.WithTestPassword()); + entities.Add(new TenantMembership + { + TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }); } + await factory.SeedAsync(entities.ToArray()); return new TenantSeed(tenantId, userId, secondUserId); } private sealed record TenantSeed(Guid TenantId, Guid UserId, Guid? SecondUserId); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs b/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs index 7809882..7781ba0 100644 --- a/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs +++ b/Tiku.IntegrationTests/Api/PasswordTestUserExtensions.cs @@ -19,4 +19,4 @@ internal static class PasswordTestUserExtensions user.PasswordHash = hasher.HashPassword(user, TestPassword); return user; } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs index 2c8b9a6..24ed233 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -1,18 +1,17 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Security; -using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -58,7 +57,8 @@ public sealed class PlatformAdminEndpointTests using var scope = factory.CreateSystemScope("Verify platform administrative password reset"); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync()); + Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId) + .Select(item => item.ForcePasswordChange).SingleAsync()); Assert.True(await dbContext.AuditLogs.AnyAsync(item => item.TenantId == null && item.ActorUserId == administrator.UserId && @@ -317,63 +317,87 @@ public sealed class PlatformAdminEndpointTests var failedQueueId = Guid.NewGuid(); var otherQueueId = Guid.NewGuid(); await factory.SeedAsync( - new Tenant { Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, - new Tenant { Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, - new CrmWebhookQueueItem { Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", LastError = "timeout" }, - new CrmWebhookQueueItem { Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", LastError = "still failed" }); + new Tenant + { + Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active + }, + new Tenant + { + Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active + }, + new CrmWebhookQueueItem + { + Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", + Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", + LastError = "timeout" + }, + new CrmWebhookQueueItem + { + Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", + Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", + LastError = "still failed" + }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); - var crm = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto - { - TenantId = tenantA, - Enabled = true, - Url = "https://crm.example.test/webhook", - SecretRef = "tenant_secret:crm:redacted", - AssignmentMode = "round_robin", - AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }), - AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 }) - }); - var retry = await client.PostAsJsonAsync("/api/platform/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); - var smsChannel = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto - { - TenantId = tenantA, - Provider = "aliyun", - Name = "阿里云短信", - Signature = "题库测试", - Scene = "login", - Status = TenantExternalProviderStatus.Active, - SecretRef = "tenant_secret:sms:aliyun:redacted", - MonthlyQuota = 1000 - }); + var crm = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/crm/configs", + new UpsertPlatformCrmConfigDto + { + TenantId = tenantA, + Enabled = true, + Url = "https://crm.example.test/webhook", + SecretRef = "tenant_secret:crm:redacted", + AssignmentMode = "round_robin", + AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }), + AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 }) + }); + var retry = await client.PostAsJsonAsync("/api/platform/tenant-capabilities/crm/leads/retry", + new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); + var smsChannel = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", + new UpsertPlatformSmsChannelDto + { + TenantId = tenantA, + Provider = "aliyun", + Name = "阿里云短信", + Signature = "题库测试", + Scene = "login", + Status = TenantExternalProviderStatus.Active, + SecretRef = "tenant_secret:sms:aliyun:redacted", + MonthlyQuota = 1000 + }); var smsJson = JsonDocument.Parse(await smsChannel.Content.ReadAsStringAsync()); var channelId = smsJson.RootElement.GetProperty("id").GetGuid(); - var template = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto - { - TenantId = tenantA, - ChannelId = channelId, - Code = "login_code", - Name = "登录验证码", - Type = SmsTemplateType.VerificationCode, - AuditStatus = SmsTemplateAuditStatus.Draft, - Status = SmsTemplateStatus.Active, - Content = "验证码 ${code}" - }); - var paymentApp = await client.PutAsJsonAsync("/api/platform/payment-settings/apps", new UpsertPlatformPaymentAppDto - { - AppCode = "platform_collect_test", - AppName = "平台收款测试", - Status = PlatformPaymentAppStatus.Active, - SettlementMode = "PlatformCollect" - }); - var tenantPayment = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto - { - TenantId = tenantA, - Provider = "manual", - Status = TenantExternalProviderStatus.Active, - DisplayName = "线下收款", - SecretRef = "tenant_payment:manual:redacted" - }); + var template = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/templates", + new UpsertPlatformSmsTemplateDto + { + TenantId = tenantA, + ChannelId = channelId, + Code = "login_code", + Name = "登录验证码", + Type = SmsTemplateType.VerificationCode, + AuditStatus = SmsTemplateAuditStatus.Draft, + Status = SmsTemplateStatus.Active, + Content = "验证码 ${code}" + }); + var paymentApp = await client.PutAsJsonAsync("/api/platform/payment-settings/apps", + new UpsertPlatformPaymentAppDto + { + AppCode = "platform_collect_test", + AppName = "平台收款测试", + Status = PlatformPaymentAppStatus.Active, + SettlementMode = "PlatformCollect" + }); + var tenantPayment = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/payments/apps", + new UpsertPlatformTenantPaymentAppDto + { + TenantId = tenantA, + Provider = "manual", + Status = TenantExternalProviderStatus.Active, + DisplayName = "线下收款", + SecretRef = "tenant_payment:manual:redacted" + }); var reads = await Task.WhenAll( client.GetAsync("/api/platform/tenant-capabilities/crm/configs"), client.GetAsync("/api/platform/tenant-capabilities/crm/leads"), @@ -392,11 +416,16 @@ public sealed class PlatformAdminEndpointTests using var scope = factory.CreateSystemScope("Verify platform capability endpoints"); var db = scope.ServiceProvider.GetRequiredService(); - Assert.Equal(CrmWebhookQueueStatus.Retrying, await db.CrmWebhookQueue.Where(item => item.Id == failedQueueId).Select(item => item.Status).SingleAsync()); - Assert.Equal(CrmWebhookQueueStatus.Failed, await db.CrmWebhookQueue.Where(item => item.Id == otherQueueId).Select(item => item.Status).SingleAsync()); - Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.crm.lead.retry")); - Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.sms.channel.upserted")); - Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.payment.app.upserted")); + Assert.Equal(CrmWebhookQueueStatus.Retrying, + await db.CrmWebhookQueue.Where(item => item.Id == failedQueueId).Select(item => item.Status).SingleAsync()); + Assert.Equal(CrmWebhookQueueStatus.Failed, + await db.CrmWebhookQueue.Where(item => item.Id == otherQueueId).Select(item => item.Status).SingleAsync()); + Assert.True(await db.AuditLogs.AnyAsync(item => + item.ActorUserId == platform.UserId && item.Action == "platform.crm.lead.retry")); + Assert.True(await db.AuditLogs.AnyAsync(item => + item.ActorUserId == platform.UserId && item.Action == "platform.sms.channel.upserted")); + Assert.True(await db.AuditLogs.AnyAsync(item => + item.ActorUserId == platform.UserId && item.Action == "platform.payment.app.upserted")); } [Fact] @@ -413,20 +442,22 @@ public sealed class PlatformAdminEndpointTests BackendPermissions.PlatformPaymentRead ]); var tenantId = Guid.NewGuid(); - await factory.SeedAsync(new Tenant { Id = tenantId, Slug = "readonly-capability", Name = "Readonly Capability" }); + await factory.SeedAsync( + new Tenant { Id = tenantId, Slug = "readonly-capability", Name = "Readonly Capability" }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var read = await client.GetAsync("/api/platform/tenant-capabilities/sms/channels"); - var write = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto - { - TenantId = tenantId, - Provider = "aliyun", - Name = "只读不应写入", - Signature = "题库", - Scene = "login", - Status = TenantExternalProviderStatus.Active - }); + var write = await client.PutAsJsonAsync("/api/platform/tenant-capabilities/sms/channels", + new UpsertPlatformSmsChannelDto + { + TenantId = tenantId, + Provider = "aliyun", + Name = "只读不应写入", + Signature = "题库", + Scene = "login", + Status = TenantExternalProviderStatus.Active + }); Assert.Equal(HttpStatusCode.OK, read.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, write.StatusCode); @@ -550,7 +581,8 @@ public sealed class PlatformAdminEndpointTests new ResolvePlatformBillingDunningEventDto { EventId = eventId, Reason = "delivery confirmed manually" }); var ignoreResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/events/ignore", - new ResolvePlatformBillingDunningEventDto { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); + new ResolvePlatformBillingDunningEventDto + { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); var disableResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/channels/disable", new DisablePlatformBillingDunningChannelDto @@ -572,11 +604,14 @@ public sealed class PlatformAdminEndpointTests using var scope = factory.CreateSystemScope("Verify platform dunning side effects"); var dbContext = scope.ServiceProvider.GetRequiredService(); - var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId); - var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId); + var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() + .SingleAsync(item => item.Id == eventId); + var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking() + .SingleAsync(item => item.Id == channelId); Assert.Equal(PlatformBillingDunningNotificationStatus.Acknowledged, storedEvent.Status); Assert.Null(storedEvent.LastError); - Assert.Equal(PlatformBillingDunningNotificationStatus.Ignored, await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking() + Assert.Equal(PlatformBillingDunningNotificationStatus.Ignored, await dbContext + .PlatformBillingDunningNotificationEvents.AsNoTracking() .Where(item => item.Id == ignoredEventId) .Select(item => item.Status) .SingleAsync()); @@ -659,14 +694,17 @@ public sealed class PlatformAdminEndpointTests Assert.True(failedProvisioning.Length == 0, string.Join(Environment.NewLine, failedProvisioning)); var payloads = await Task.WhenAll(responses.Select(async response => await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()))); - var tenantIds = payloads.Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct().ToArray(); + var tenantIds = payloads + .Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct() + .ToArray(); Assert.Single(tenantIds); Assert.Single(payloads, payload => payload.RootElement.GetProperty("isReplay").GetBoolean()); Assert.All(payloads, payload => { Assert.Equal(primaryHost, payload.RootElement.GetProperty("primaryDomain").GetProperty("host").GetString()); Assert.Equal("Pending", payload.RootElement.GetProperty("primaryDomain").GetProperty("status").GetString()); - Assert.Equal("domain_pending", payload.RootElement.GetProperty("ownerActivation").GetProperty("status").GetString()); + Assert.Equal("domain_pending", + payload.RootElement.GetProperty("ownerActivation").GetProperty("status").GetString()); Assert.False(payload.RootElement.TryGetProperty("activationToken", out _)); }); @@ -687,13 +725,13 @@ public sealed class PlatformAdminEndpointTests var tenantId = tenantIds[0]; using (var pendingIssueRequest = new HttpRequestMessage( - HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") - { - Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto - { - Reason = "Domain is not ready yet" - }) - }) + HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") + { + Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto + { + Reason = "Domain is not ready yet" + }) + }) { pendingIssueRequest.Headers.Add("Idempotency-Key", $"pending-{Guid.NewGuid():N}"); var pendingIssue = await client.SendAsync(pendingIssueRequest); @@ -705,7 +743,8 @@ public sealed class PlatformAdminEndpointTests using (var activationScope = factory.CreateSystemScope("Activate provisioned primary domain")) { var activationDb = activationScope.ServiceProvider.GetRequiredService(); - var domain = await activationDb.TenantDomains.SingleAsync(value => value.TenantId == tenantId && value.IsPrimary); + var domain = + await activationDb.TenantDomains.SingleAsync(value => value.TenantId == tenantId && value.IsPrimary); domainId = domain.Id; domain.Status = TenantDomainStatus.Active; domain.DnsVerifiedAt = DateTimeOffset.UtcNow; @@ -714,9 +753,10 @@ public sealed class PlatformAdminEndpointTests await activationDb.SaveChangesAsync(); } - using var browserClient = factory.CreateClient(new() { HandleCookies = false }); + using var browserClient = + factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); using (var setupRuntimeRequest = new HttpRequestMessage( - HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) + HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var setupRuntime = await browserClient.SendAsync(setupRuntimeRequest); using var setupPayload = await JsonDocument.ParseAsync(await setupRuntime.Content.ReadAsStreamAsync()); @@ -749,7 +789,8 @@ public sealed class PlatformAdminEndpointTests using var issuedPayload = await JsonDocument.ParseAsync(await issued.Content.ReadAsStreamAsync()); var firstActivationId = issuedPayload.RootElement.GetProperty("activationId").GetGuid(); var activationUrl = issuedPayload.RootElement.GetProperty("activationUrl").GetString()!; - Assert.StartsWith($"https://{primaryHost}/activate/{firstActivationId}#token=", activationUrl, StringComparison.Ordinal); + Assert.StartsWith($"https://{primaryHost}/activate/{firstActivationId}#token=", activationUrl, + StringComparison.Ordinal); var firstActivationToken = new Uri(activationUrl).Fragment["#token=".Length..]; using var replayIssueRequest = new HttpRequestMessage( @@ -782,15 +823,15 @@ public sealed class PlatformAdminEndpointTests var activationToken = new Uri(replacementUrl).Fragment["#token=".Length..]; using (var revokedRequest = new HttpRequestMessage( - HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") - { - Content = JsonContent.Create(new CompleteOwnerActivationDto - { - ActivationId = firstActivationId, - Token = firstActivationToken, - NewPassword = "ActivatedOwner2026" - }) - }) + HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") + { + Content = JsonContent.Create(new CompleteOwnerActivationDto + { + ActivationId = firstActivationId, + Token = firstActivationToken, + NewPassword = "ActivatedOwner2026" + }) + }) { revokedRequest.Headers.Add("Origin", $"https://{primaryHost}"); var revoked = await browserClient.SendAsync(revokedRequest); @@ -830,7 +871,7 @@ public sealed class PlatformAdminEndpointTests accessCookie = accessCookie[..accessCookie.IndexOf(';')]; using (var readyRuntimeRequest = new HttpRequestMessage( - HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) + HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var readyRuntime = await browserClient.SendAsync(readyRuntimeRequest); using var readyPayload = await JsonDocument.ParseAsync(await readyRuntime.Content.ReadAsStreamAsync()); @@ -869,22 +910,23 @@ public sealed class PlatformAdminEndpointTests using var scope = factory.CreateSystemScope("Verify tenant provisioning and owner activation"); var dbContext = scope.ServiceProvider.GetRequiredService(); - var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == activationId); - var revokedGrant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == firstActivationId); - var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().SingleAsync(value => value.TenantId == tenantId); + var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() + .SingleAsync(value => value.Id == activationId); + var revokedGrant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() + .SingleAsync(value => value.Id == firstActivationId); + var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() + .SingleAsync(value => value.TenantId == tenantId); Assert.NotNull(grant.ConsumedAt); Assert.NotNull(revokedGrant.RevokedAt); Assert.Equal(platform.UserId, revokedGrant.RevokedBy); Assert.Equal(domainId, grant.DomainId); Assert.DoesNotContain(activationToken, grant.TokenHash, StringComparison.Ordinal); - Assert.InRange(subscription.CurrentPeriodEnd - subscription.StartsAt, TimeSpan.FromDays(16.9), TimeSpan.FromDays(17.1)); + Assert.InRange(subscription.CurrentPeriodEnd - subscription.StartsAt, TimeSpan.FromDays(16.9), + TimeSpan.FromDays(17.1)); Assert.True(await dbContext.TenantFrontendConfigs.AnyAsync(value => value.TenantId == tenantId)); Assert.True(await dbContext.AuditLogs.AnyAsync(value => value.TenantId == tenantIds[0] && value.Action == "tenant.owner.activated")); - foreach (var payload in payloads) - { - payload.Dispose(); - } + foreach (var payload in payloads) payload.Dispose(); } private static async Task ReadProblemCodeAsync(HttpResponseMessage response) @@ -964,39 +1006,39 @@ public sealed class PlatformAdminEndpointTests IsSystem = true }).Cast().ToList(); await factory.SeedAsync( - [ - ..modules, - ..permissions, - new User - { - Id = userId, - Email = email, - NormalizedEmail = email.ToUpperInvariant(), - UserName = email, - NormalizedUserName = email.ToUpperInvariant(), - Name = "Platform Admin", - PrimaryRole = "platform_admin", - RawProfile = JsonDefaults.Object() - }.WithTestPassword(), - new PlatformBackendRole - { - Id = roleId, - Code = "platform_super_admin", - Name = "Platform Super Admin", - Status = BackendRoleStatus.Active, - IsSystem = true - }, - ..permissionCodes.Select(code => new PlatformBackendRolePermission - { - RoleId = roleId, - PermissionCode = code - }), - new PlatformBackendUserRole - { - UserId = userId, - RoleId = roleId - } - ]); + [ + .. modules, + .. permissions, + new User + { + Id = userId, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Name = "Platform Admin", + PrimaryRole = "platform_admin", + RawProfile = JsonDefaults.Object() + }.WithTestPassword(), + new PlatformBackendRole + { + Id = roleId, + Code = "platform_super_admin", + Name = "Platform Super Admin", + Status = BackendRoleStatus.Active, + IsSystem = true + }, + .. permissionCodes.Select(code => new PlatformBackendRolePermission + { + RoleId = roleId, + PermissionCode = code + }), + new PlatformBackendUserRole + { + UserId = userId, + RoleId = roleId + } + ]); return (userId, email); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PlatformAdminFrontendSeparationTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminFrontendSeparationTests.cs index 0a9d1d2..7523349 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminFrontendSeparationTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminFrontendSeparationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; namespace Tiku.IntegrationTests.Api; @@ -11,7 +12,7 @@ public sealed class PlatformAdminFrontendSeparationTests public async Task Unauthenticated_platform_admin_paths_use_the_api_fallback_policy(string path) { await using var factory = new ApiTestFactory(); - using var client = factory.CreateClient(new() + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); @@ -20,4 +21,4 @@ public sealed class PlatformAdminFrontendSeparationTests Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs index 6eb7bb4..4a51c95 100644 --- a/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformApprovalEndpointTests.cs @@ -35,7 +35,11 @@ public sealed class PlatformApprovalEndpointTests BackendPermissions.PlatformApprovalDecide }; var (requester, approver) = await SeedActorsAsync(factory, permissions); - var tenant = new Tenant { Slug = $"approval-{Guid.NewGuid():N}"[..30], Name = "Approval Tenant", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }; + var tenant = new Tenant + { + Slug = $"approval-{Guid.NewGuid():N}"[..30], Name = "Approval Tenant", Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active + }; await factory.SeedAsync( tenant, new PlatformApprovalPolicy @@ -50,7 +54,8 @@ public sealed class PlatformApprovalEndpointTests requesterClient.UseAccessToken(await requesterClient.LoginAsPlatformAsync(requester.Email)); using var archive = new HttpRequestMessage(HttpMethod.Patch, "/api/platform/tenants/status") { - Content = JsonContent.Create(new UpdatePlatformTenantStatusDto { TenantId = tenant.Id, Status = TenantStatus.Archived, Reason = "Close expired customer" }) + Content = JsonContent.Create(new UpdatePlatformTenantStatusDto + { TenantId = tenant.Id, Status = TenantStatus.Archived, Reason = "Close expired customer" }) }; archive.Headers.Add("Idempotency-Key", "archive-approval-1"); var submission = await requesterClient.SendAsync(archive); @@ -58,12 +63,16 @@ public sealed class PlatformApprovalEndpointTests var body = await submission.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(body?.ApprovalRequest); - var selfApproval = await requesterClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Self approval")); + var selfApproval = await requesterClient.PostAsJsonAsync( + $"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", + new PlatformApprovalDecisionDto("Self approval")); Assert.Equal(HttpStatusCode.Conflict, selfApproval.StatusCode); using var approverClient = factory.CreateClient(); approverClient.UseAccessToken(await approverClient.LoginAsPlatformAsync(approver.Email)); - var approval = await approverClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Independent verification complete")); + var approval = await approverClient.PostAsJsonAsync( + $"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", + new PlatformApprovalDecisionDto("Independent verification complete")); Assert.Equal(HttpStatusCode.OK, approval.StatusCode); var approved = await approval.Content.ReadFromJsonAsync(JsonOptions); Assert.Equal(PlatformApprovalRequestStatus.Approved, approved?.Status); @@ -74,51 +83,79 @@ public sealed class PlatformApprovalEndpointTests Assert.Equal(1, await processor.ProcessApprovedAsync()); } - var replay = await approverClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", new PlatformApprovalDecisionDto("Replay")); + var replay = await approverClient.PostAsJsonAsync($"/api/platform/approvals/{body.ApprovalRequest.Id}/approve", + new PlatformApprovalDecisionDto("Replay")); Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode); using var scope = factory.CreateSystemScope("Verify platform approval execution"); var db = scope.ServiceProvider.GetRequiredService(); - var executed = await db.PlatformApprovalRequests.AsNoTracking().SingleAsync(item => item.Id == body.ApprovalRequest.Id); - Assert.True(executed.Status == PlatformApprovalRequestStatus.Succeeded, $"Approval execution failed: {executed.Error}"); - Assert.Equal(TenantStatus.Archived, await db.Tenants.Where(item => item.Id == tenant.Id).Select(item => item.Status).SingleAsync()); - Assert.True(await db.AuditLogs.AnyAsync(item => item.Action == "platform.approval.executed" && item.TargetId == body.ApprovalRequest.Id.ToString("N"))); + var executed = await db.PlatformApprovalRequests.AsNoTracking() + .SingleAsync(item => item.Id == body.ApprovalRequest.Id); + Assert.True(executed.Status == PlatformApprovalRequestStatus.Succeeded, + $"Approval execution failed: {executed.Error}"); + Assert.Equal(TenantStatus.Archived, + await db.Tenants.Where(item => item.Id == tenant.Id).Select(item => item.Status).SingleAsync()); + Assert.True(await db.AuditLogs.AnyAsync(item => + item.Action == "platform.approval.executed" && item.TargetId == body.ApprovalRequest.Id.ToString("N"))); } - private static async Task<((Guid UserId, string Email) Requester, (Guid UserId, string Email) Approver)> SeedActorsAsync( - ApiTestFactory factory, - IReadOnlyCollection permissionCodes) + private static async Task<((Guid UserId, string Email) Requester, (Guid UserId, string Email) Approver)> + SeedActorsAsync( + ApiTestFactory factory, + IReadOnlyCollection permissionCodes) { var requester = (UserId: Guid.NewGuid(), Email: $"approval-requester-{Guid.NewGuid():N}@example.test"); var approver = (UserId: Guid.NewGuid(), Email: $"approval-approver-{Guid.NewGuid():N}@example.test"); var requesterRole = Guid.NewGuid(); var approverRole = Guid.NewGuid(); - var modules = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode).Distinct(StringComparer.Ordinal) - .Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Platform, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] }); - var permissions = permissionCodes.Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true }); + var modules = permissionCodes.Select(PermissionModuleCatalog.ResolvePermissionModuleCode) + .Distinct(StringComparer.Ordinal) + .Select(code => new PermissionModule + { + Code = code, Name = code, Area = BackendPermissionArea.Platform, + RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] + }); + var permissions = permissionCodes.Select(code => new BackendPermission + { + Code = code, Name = code, Area = BackendPermissionArea.Platform, + PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true + }); await factory.SeedAsync([ - ..modules, ..permissions, + .. modules, .. permissions, User(requester.UserId, requester.Email), User(approver.UserId, approver.Email), - new PlatformBackendRole { Id = requesterRole, Code = $"approval_requester_{requesterRole:N}", Name = "Approval Requester", Status = BackendRoleStatus.Active }, - new PlatformBackendRole { Id = approverRole, Code = $"approval_approver_{approverRole:N}", Name = "Approval Approver", Status = BackendRoleStatus.Active }, - ..permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = requesterRole, PermissionCode = code }), - ..permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = approverRole, PermissionCode = code }), + new PlatformBackendRole + { + Id = requesterRole, Code = $"approval_requester_{requesterRole:N}", Name = "Approval Requester", + Status = BackendRoleStatus.Active + }, + new PlatformBackendRole + { + Id = approverRole, Code = $"approval_approver_{approverRole:N}", Name = "Approval Approver", + Status = BackendRoleStatus.Active + }, + .. permissionCodes.Select(code => new PlatformBackendRolePermission + { RoleId = requesterRole, PermissionCode = code }), + .. permissionCodes.Select(code => new PlatformBackendRolePermission + { RoleId = approverRole, PermissionCode = code }), new PlatformBackendUserRole { UserId = requester.UserId, RoleId = requesterRole }, new PlatformBackendUserRole { UserId = approver.UserId, RoleId = approverRole } ]); return (requester, approver); } - private static User User(Guid id, string email) => new User + private static User User(Guid id, string email) { - Id = id, - Email = email, - NormalizedEmail = email.ToUpperInvariant(), - UserName = email, - NormalizedUserName = email.ToUpperInvariant(), - Name = email, - PrimaryRole = "platform_admin", - RawProfile = JsonDefaults.Object() - }.WithTestPassword(); + return new User + { + Id = id, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Name = email, + PrimaryRole = "platform_admin", + RawProfile = JsonDefaults.Object() + }.WithTestPassword(); + } private static JsonSerializerOptions CreateJsonOptions() { @@ -126,4 +163,4 @@ public sealed class PlatformApprovalEndpointTests options.Converters.Add(new JsonStringEnumConverter()); return options; } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs b/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs index 8e4b33d..ec6efd7 100644 --- a/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs @@ -29,8 +29,10 @@ public sealed class PlatformBillingCallbackTests await PublishVersionAsync(factory, fixture.VersionId); using var client = factory.CreateClient(); - var first = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); - var repeated = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); + var first = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", + new { eventId = fixture.Notification.EventId }); + var repeated = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}", + new { eventId = fixture.Notification.EventId }); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, repeated.StatusCode); @@ -41,7 +43,8 @@ public sealed class PlatformBillingCallbackTests using var scope = factory.CreateSystemScope("Verify platform payment callback idempotency"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(PlatformBillingPaymentStatus.Succeeded, - (await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == fixture.PaymentId)).Status); + (await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == fixture.PaymentId)) + .Status); Assert.Single(await db.PlatformBillingPaymentEvents.AsNoTracking() .Where(value => value.PaymentId == fixture.PaymentId) .ToArrayAsync()); @@ -251,14 +254,16 @@ public sealed class PlatformBillingCallbackTests public Task CreatePaymentAsync( string provider, CreatePaymentProviderRequest request, - CancellationToken cancellationToken = default) => - Task.FromResult(new CreatePaymentProviderResult( + CancellationToken cancellationToken = default) + { + return Task.FromResult(new CreatePaymentProviderResult( provider, request.Method, "pending", null, JsonDefaults.Object(), JsonDefaults.Object())); + } public Task ParseNotificationAsync( string provider, @@ -269,4 +274,4 @@ public sealed class PlatformBillingCallbackTests return Task.FromResult(notification); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs index 1d648c5..6781af5 100644 --- a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs @@ -13,7 +13,6 @@ using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -37,21 +36,32 @@ public sealed class PlatformQuestionBankEndpointTests var ordinaryBankId = Guid.NewGuid(); var platformUser = await SeedPlatformQuestionBankUserAsync(factory); await factory.SeedAsync( - new Tenant { Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, - new Tenant { Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, + new Tenant + { + Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, + Status = TenantStatus.Active, BillingStatus = BillingStatus.Active + }, + new Tenant + { + Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, + BillingStatus = BillingStatus.Active + }, new QuestionBank { Id = ordinaryBankId, TenantId = ordinaryTenantId, Name = "普通租户私有题库" }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformUser.Email)); - var bankResponse = await client.PutAsJsonAsync("/api/platform/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); + var bankResponse = await client.PutAsJsonAsync("/api/platform/question-banks", + new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); Assert.True(bankResponse.StatusCode == HttpStatusCode.OK, await bankResponse.Content.ReadAsStringAsync()); var bank = await bankResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(bank); Assert.NotNull(bank.ContentEntryId); - var nodeResponse = await client.PutAsJsonAsync("/api/platform/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand( - null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, JsonDefaults.Object())); + var nodeResponse = await client.PutAsJsonAsync("/api/platform/question-banks/nodes", + new UpsertPlatformQuestionBankNodeCommand( + null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, + JsonDefaults.Object())); Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); var node = await nodeResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(node); @@ -93,10 +103,14 @@ public sealed class PlatformQuestionBankEndpointTests var firstImportResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", import); var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", import); - Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, await firstImportResponse.Content.ReadAsStringAsync()); - Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, await repeatedImportResponse.Content.ReadAsStringAsync()); - var firstImport = await firstImportResponse.Content.ReadFromJsonAsync(JsonOptions); - var repeatedImport = await repeatedImportResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, + await firstImportResponse.Content.ReadAsStringAsync()); + Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, + await repeatedImportResponse.Content.ReadAsStringAsync()); + var firstImport = + await firstImportResponse.Content.ReadFromJsonAsync(JsonOptions); + var repeatedImport = + await repeatedImportResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(firstImport); Assert.NotNull(repeatedImport); Assert.Equal(1, firstImport.InsertedQuestionCount); @@ -124,7 +138,11 @@ public sealed class PlatformQuestionBankEndpointTests name = "极限", questions = new[] { - new { legacyId = "structured-stable-1", type = "choice", content = "下列极限存在的是?", options = new[] { "A", "B" }, answerText = "A" } + new + { + legacyId = "structured-stable-1", type = "choice", content = "下列极限存在的是?", + options = new[] { "A", "B" }, answerText = "A" + } } } } @@ -132,11 +150,15 @@ public sealed class PlatformQuestionBankEndpointTests } })); var structuredResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", structuredImport); - var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform/question-banks/imports", structuredImport); - Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, await structuredResponse.Content.ReadAsStringAsync()); - Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, await repeatedStructuredResponse.Content.ReadAsStringAsync()); + var repeatedStructuredResponse = + await client.PostAsJsonAsync("/api/platform/question-banks/imports", structuredImport); + Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, + await structuredResponse.Content.ReadAsStringAsync()); + Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, + await repeatedStructuredResponse.Content.ReadAsStringAsync()); var structured = await structuredResponse.Content.ReadFromJsonAsync(JsonOptions); - var repeatedStructured = await repeatedStructuredResponse.Content.ReadFromJsonAsync(JsonOptions); + var repeatedStructured = + await repeatedStructuredResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(structured); Assert.NotNull(repeatedStructured); Assert.Equal(2, structured.CreatedNodeCount); @@ -144,22 +166,32 @@ public sealed class PlatformQuestionBankEndpointTests Assert.Equal(0, repeatedStructured.CreatedNodeCount); Assert.Equal(1, repeatedStructured.SkippedQuestionCount); - var listedResponse = await client.GetAsync($"/api/platform/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); + var listedResponse = + await client.GetAsync( + $"/api/platform/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); Assert.Equal(HttpStatusCode.OK, listedResponse.StatusCode); var listed = await listedResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(listed); Assert.Equal(3, listed.Total); - var bankList = await client.GetFromJsonAsync("/api/platform/question-banks?status=all", JsonOptions); + var bankList = + await client.GetFromJsonAsync("/api/platform/question-banks?status=all", + JsonOptions); Assert.NotNull(bankList); Assert.Contains(bankList, item => item.Id == bank.Id); Assert.DoesNotContain(bankList, item => item.Id == ordinaryBankId); using var scope = factory.CreateSystemScope("验证平台公共题库租户隔离与版本"); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.Equal(platformTenantId, await dbContext.QuestionBanks.Where(item => item.Id == bank.Id).Select(item => item.TenantId).SingleAsync()); - Assert.Equal(2, await dbContext.QuestionVersions.CountAsync(item => item.TenantId == platformTenantId && item.QuestionId == question.Id)); - Assert.Equal(1, await dbContext.Questions.CountAsync(item => item.TenantId == platformTenantId && item.LegacyId == "import-stable-1")); + Assert.Equal(platformTenantId, + await dbContext.QuestionBanks.Where(item => item.Id == bank.Id).Select(item => item.TenantId) + .SingleAsync()); + Assert.Equal(2, + await dbContext.QuestionVersions.CountAsync(item => + item.TenantId == platformTenantId && item.QuestionId == question.Id)); + Assert.Equal(1, + await dbContext.Questions.CountAsync(item => + item.TenantId == platformTenantId && item.LegacyId == "import-stable-1")); } [Fact] @@ -170,7 +202,10 @@ public sealed class PlatformQuestionBankEndpointTests ["Tenancy:Resolution:PlatformHosts:0"] = "platform.example.test" }); await factory.SeedAsync( - new Tenant { Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned }); + new Tenant + { + Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned + }); var platformWithoutPermission = await SeedPlatformUserAsync(factory, BackendPermissions.PlatformDashboardView); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformWithoutPermission.Email)); @@ -187,21 +222,30 @@ public sealed class PlatformQuestionBankEndpointTests Assert.Equal(HttpStatusCode.Forbidden, missingPermission.StatusCode); } - private static UpsertPlatformQuestionCommand QuestionCommand(Guid? id, Guid bankId, Guid nodeId, string legacyId, string content) => new( - id, bankId, nodeId, legacyId, "short_answer", "简答题", 3, - JsonSerializer.SerializeToElement(new[] { "极限" }), content, JsonDefaults.Array(), null, JsonDefaults.Array(), - "按定义作答", "考查函数极限定义", JsonDefaults.Array(), null, null, null, "published", JsonDefaults.Object(), null); + private static UpsertPlatformQuestionCommand QuestionCommand(Guid? id, Guid bankId, Guid nodeId, string legacyId, + string content) + { + return new UpsertPlatformQuestionCommand( + id, bankId, nodeId, legacyId, "short_answer", "简答题", 3, + JsonSerializer.SerializeToElement(new[] { "极限" }), content, JsonDefaults.Array(), null, + JsonDefaults.Array(), + "按定义作答", "考查函数极限定义", JsonDefaults.Array(), null, null, null, "published", JsonDefaults.Object(), null); + } private static async Task CountImportJobsAsync(ApiTestFactory factory, Guid tenantId) { using var scope = factory.CreateSystemScope("统计平台公共题库导入任务"); - return await scope.ServiceProvider.GetRequiredService().ContentImportJobs.CountAsync(item => item.TenantId == tenantId); + return await scope.ServiceProvider.GetRequiredService().ContentImportJobs + .CountAsync(item => item.TenantId == tenantId); } - private static Task<(Guid UserId, string Email)> SeedPlatformQuestionBankUserAsync(ApiTestFactory factory) => - SeedPlatformUserAsync(factory, BackendPermissions.PlatformQuestionBankManage); + private static Task<(Guid UserId, string Email)> SeedPlatformQuestionBankUserAsync(ApiTestFactory factory) + { + return SeedPlatformUserAsync(factory, BackendPermissions.PlatformQuestionBankManage); + } - private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(ApiTestFactory factory, string permissionCode) + private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(ApiTestFactory factory, + string permissionCode) { var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); @@ -209,11 +253,24 @@ public sealed class PlatformQuestionBankEndpointTests var moduleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode); await factory.SeedAsync( new PermissionModule { Code = moduleCode, Name = "平台公共题库", Area = BackendPermissionArea.Platform }, - new BackendPermission { Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, PermissionModuleCode = moduleCode, IsSystem = true }, - new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(), - new PlatformBackendRole { Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", Status = BackendRoleStatus.Active }, + new BackendPermission + { + Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, + PermissionModuleCode = moduleCode, IsSystem = true + }, + new User + { + Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, + NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", + RawProfile = JsonDefaults.Object() + }.WithTestPassword(), + new PlatformBackendRole + { + Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", + Status = BackendRoleStatus.Active + }, new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = permissionCode }, new PlatformBackendUserRole { UserId = userId, RoleId = roleId }); return (userId, email); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs index 10619c1..59e4971 100644 --- a/Tiku.IntegrationTests/Api/PointsEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PointsEndpointTests.cs @@ -1,17 +1,14 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Application.Auth; using Tiku.Application.Points; using Tiku.Application.Security; -using Tiku.Api.Options; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -33,7 +30,7 @@ public sealed class PointsEndpointTests public async Task Non_member_cannot_access_points() { await using var factory = new ApiTestFactory(); - var seed = await SeedLoginUserAsync(factory, includeMembership: false); + var seed = await SeedLoginUserAsync(factory, false); var sessionId = Guid.NewGuid(); await factory.SeedAsync(new AuthSession { @@ -45,7 +42,7 @@ public sealed class PointsEndpointTests ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }); using var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()), @@ -212,7 +209,6 @@ public sealed class PointsEndpointTests } }; if (includeMembership) - { entities.Add(new TenantMembership { TenantId = tenantId, @@ -220,7 +216,6 @@ public sealed class PointsEndpointTests Role = TenantRole.Student, Status = MembershipStatus.Active }); - } await factory.SeedAsync(entities.ToArray()); return new PointSeed(tenantId, userId, exchangeItemId, phone); @@ -232,4 +227,4 @@ public sealed class PointsEndpointTests } private sealed record PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs index 50c8028..7b7db81 100644 --- a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs +++ b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs @@ -13,7 +13,7 @@ public sealed class ProductionConfigurationTests var configuration = new ConfigurationBuilder().Build(); var exception = Assert.Throws(() => - OptionsValidation.ResolveDatabaseConnectionString(configuration, isDevelopment: false)); + OptionsValidation.ResolveDatabaseConnectionString(configuration, false)); Assert.Contains("Database connection is required", exception.Message, StringComparison.Ordinal); } @@ -25,7 +25,7 @@ public sealed class ProductionConfigurationTests var connectionString = OptionsValidation.ResolveDatabaseConnectionString( configuration, - isDevelopment: true); + true); Assert.Contains($"Username={Environment.UserName}", connectionString, StringComparison.Ordinal); Assert.DoesNotContain("Password=", connectionString, StringComparison.OrdinalIgnoreCase); @@ -40,11 +40,11 @@ public sealed class ProductionConfigurationTests PrivateKeyPem = string.Empty }; - Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); - Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + Assert.False(OptionsValidation.BeValidJwtOptions(options, true)); + Assert.True(OptionsValidation.BeValidJwtOptions(options, false)); options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem; - Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); + Assert.True(OptionsValidation.BeValidJwtOptions(options, true)); } [Fact] @@ -55,19 +55,19 @@ public sealed class ProductionConfigurationTests KeyId = "development-ephemeral", PrivateKeyPem = TestJwtKeys.PrivateKeyPem }; - Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); + Assert.False(OptionsValidation.BeValidJwtOptions(options, true)); options.KeyId = "current-key"; options.PrivateKeyPem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----"; - Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + Assert.False(OptionsValidation.BeValidJwtOptions(options, false)); options.PrivateKeyPem = TestJwtKeys.PrivateKeyPem; options.PublicKeys[options.KeyId] = TestJwtKeys.PublicKeyPem; - Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: false)); + Assert.False(OptionsValidation.BeValidJwtOptions(options, false)); options.PublicKeys.Clear(); options.PublicKeys["old-key"] = TestJwtKeys.PublicKeyPem; - Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: true)); + Assert.True(OptionsValidation.BeValidJwtOptions(options, true)); } [Fact] @@ -101,8 +101,9 @@ public sealed class ProductionConfigurationTests TrustedProxyAddresses = ["10.0.0.10"] }; var explicitHosts = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["AllowedHosts"] = "admin.example.com;api.example.com" }) + .AddInMemoryCollection(new Dictionary + { ["AllowedHosts"] = "admin.example.com;api.example.com" }) .Build(); Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true)); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs index 9ba7494..0c90a65 100644 --- a/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ProfileEndpointTests.cs @@ -6,13 +6,12 @@ using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Profile; using Tiku.Domain.Catalog; -using Tiku.Domain.Common; using Tiku.Domain.Commerce; +using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -35,7 +34,10 @@ public sealed class ProfileEndpointTests await factory.SeedAsync( new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" }, new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" }, - new Major { Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" }); + new Major + { + Id = majorId, TenantId = seed.TenantId, RegionId = regionId, SchoolId = schoolId, Name = "视觉传达" + }); using var client = factory.CreateClient(); await LoginAsync(client, seed); @@ -182,10 +184,13 @@ public sealed class ProfileEndpointTests var feedbacksJson = await ReadJsonAsync(feedbacksResponse); Assert.Equal(HttpStatusCode.OK, notificationsResponse.StatusCode); - Assert.Equal("unread", notificationsJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("status").GetString()); + Assert.Equal("unread", + notificationsJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("status") + .GetString()); Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, badgesResponse.StatusCode); - Assert.True(badgesJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("isUnlocked").GetBoolean()); + Assert.True(badgesJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("isUnlocked") + .GetBoolean()); Assert.Equal(HttpStatusCode.OK, feedbackResponse.StatusCode); Assert.Equal("suggestion", feedbackJson.RootElement.GetProperty("type").GetString()); Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode); @@ -193,7 +198,8 @@ public sealed class ProfileEndpointTests using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.Equal(NotificationStatus.Read, dbContext.UserNotifications.Single(item => item.Id == notificationId).Status); + Assert.Equal(NotificationStatus.Read, + dbContext.UserNotifications.Single(item => item.Id == notificationId).Status); Assert.Single(dbContext.ReportStatusEvents); } @@ -293,5 +299,4 @@ public sealed class ProfileEndpointTests var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs index b0c66d3..a2205f0 100644 --- a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -using Tiku.Domain.Common; using Tiku.Domain.Catalog; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; @@ -68,7 +68,8 @@ public sealed class QuestionBankEndpointTests new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" }, new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" }, new QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" }, - new QuestionCollection { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active }, + new QuestionCollection + { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active }, new Question { Id = excludedQuestionId, @@ -184,7 +185,8 @@ public sealed class QuestionBankEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/questions/{questionId}/versions?tenantCode=master"); + using var response = + await client.GetAsync($"/api/public/catalog/questions/{questionId}/versions?tenantCode=master"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -203,15 +205,18 @@ public sealed class QuestionBankEndpointTests }; } - private static Tenant PlatformTenant() => new() + private static Tenant PlatformTenant() { - Id = Guid.NewGuid(), - Slug = $"platform-{Guid.NewGuid():N}", - Name = "Platform Question Bank", - Status = TenantStatus.Active, - Mode = TenantMode.PlatformOwned, - Metadata = JsonDefaults.Object() - }; + return new Tenant + { + Id = Guid.NewGuid(), + Slug = $"platform-{Guid.NewGuid():N}", + Name = "Platform Question Bank", + Status = TenantStatus.Active, + Mode = TenantMode.PlatformOwned, + Metadata = JsonDefaults.Object() + }; + } private static async Task ReadItemsAsync(HttpResponseMessage response) { @@ -222,4 +227,4 @@ public sealed class QuestionBankEndpointTests .Select(item => item.Clone()) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs b/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs index b75c6e0..e253284 100644 --- a/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs +++ b/Tiku.IntegrationTests/Api/RbacAuthorizationTests.cs @@ -16,7 +16,7 @@ public sealed class RbacAuthorizationTests var snapshot = Snapshot( userId, tenantId, - tenantPermissions: [BackendPermissions.TenantRoleManage]); + [BackendPermissions.TenantRoleManage]); await using var provider = Services(snapshot); var authorization = provider.GetRequiredService(); @@ -86,7 +86,7 @@ public sealed class RbacAuthorizationTests var selfSnapshot = Snapshot( userId, tenantId, - tenantPermissions: [BackendPermissions.TenantContentManage]); + [BackendPermissions.TenantContentManage]); await using var selfProvider = Services(selfSnapshot); var denied = await selfProvider.GetRequiredService().AuthorizeAsync( principal, @@ -97,7 +97,7 @@ public sealed class RbacAuthorizationTests var allSnapshot = Snapshot( userId, tenantId, - tenantPermissions: [BackendPermissions.TenantContentManage], + [BackendPermissions.TenantContentManage], dataScope: allScope); await using var allProvider = Services(allSnapshot); var allowed = await allProvider.GetRequiredService().AuthorizeAsync( @@ -128,7 +128,7 @@ public sealed class RbacAuthorizationTests var own = await authorization.AuthorizeAsync( principal, - new TenantResourceAuthorizationResource(tenantId, OwnerUserId: userId), + new TenantResourceAuthorizationResource(tenantId, userId), requirement); var region = await authorization.AuthorizeAsync( principal, @@ -144,7 +144,7 @@ public sealed class RbacAuthorizationTests requirement); var otherTenant = await authorization.AuthorizeAsync( principal, - new TenantResourceAuthorizationResource(Guid.NewGuid(), OwnerUserId: userId), + new TenantResourceAuthorizationResource(Guid.NewGuid(), userId), requirement); Assert.True(own.Succeeded); @@ -206,10 +206,7 @@ public sealed class RbacAuthorizationTests new(TikuClaimTypes.UserId, userId.ToString()), new(TikuClaimTypes.Realm, realm) }; - if (tenantId.HasValue) - { - claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); - } + if (tenantId.HasValue) claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString())); return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } @@ -233,7 +230,9 @@ public sealed class RbacAuthorizationTests private sealed class StubCurrentAccessContext(CurrentAccessSnapshot snapshot) : ICurrentAccessContext { - public Task GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult(snapshot); + public Task GetAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(snapshot); + } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs b/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs index 2f8e5a3..32009c7 100644 --- a/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs +++ b/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs @@ -8,7 +8,10 @@ internal sealed class RecordingDbCommandInterceptor : DbCommandInterceptor { private readonly ConcurrentQueue commandTexts = new(); - public IReadOnlyList Snapshot() => commandTexts.ToArray(); + public IReadOnlyList Snapshot() + { + return commandTexts.ToArray(); + } public void Reset() { @@ -68,5 +71,8 @@ internal sealed class RecordingDbCommandInterceptor : DbCommandInterceptor return ValueTask.FromResult(result); } - private void Record(DbCommand command) => commandTexts.Enqueue(command.CommandText); -} + private void Record(DbCommand command) + { + commandTexts.Enqueue(command.CommandText); + } +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs index a2196ef..0fa2714 100644 --- a/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ReferralEndpointTests.cs @@ -3,13 +3,11 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Domain.Commerce; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -35,7 +33,8 @@ public sealed class ReferralEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed.Referrer); - var first = await client.PostAsJsonAsync("/api/student/referral/invite-code", new ReferralInviteDto { Channel = "h5" }); + var first = await client.PostAsJsonAsync("/api/student/referral/invite-code", + new ReferralInviteDto { Channel = "h5" }); var second = await client.PostAsJsonAsync("/api/student/referral/invite-code", new ReferralInviteDto()); var firstItem = await first.Content.ReadFromJsonAsync(); var secondItem = await second.Content.ReadFromJsonAsync(); @@ -49,7 +48,7 @@ public sealed class ReferralEndpointTests public async Task Anonymous_can_resolve_and_track_referral_code() { await using var factory = new ApiTestFactory(); - var seed = await SeedReferralAsync(factory, includeCode: true); + var seed = await SeedReferralAsync(factory, true); using var client = factory.CreateClient(); var resolve = await client.PostAsJsonAsync( @@ -83,7 +82,7 @@ public sealed class ReferralEndpointTests { var qrcodeGenerator = new FakeReferralQrcodeGenerator(); await using var factory = new ApiTestFactory(referralQrcodeGenerator: qrcodeGenerator); - var seed = await SeedReferralAsync(factory, includeCode: true); + var seed = await SeedReferralAsync(factory, true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); @@ -142,7 +141,7 @@ public sealed class ReferralEndpointTests public async Task Non_admin_cannot_access_referral_management() { await using var factory = new ApiTestFactory(); - var seed = await SeedReferralAsync(factory, includeCode: true); + var seed = await SeedReferralAsync(factory, true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Student); @@ -155,7 +154,7 @@ public sealed class ReferralEndpointTests public async Task Admin_can_manage_referral_team_and_query_stats() { await using var factory = new ApiTestFactory(); - var seed = await SeedReferralAsync(factory, includeCode: true); + var seed = await SeedReferralAsync(factory, true); using var client = factory.CreateClient(); await LoginAsync(client, seed.Admin); @@ -192,7 +191,8 @@ public sealed class ReferralEndpointTests var stats = await client.GetAsync($"/api/tenant/referral/stats?referrerUserId={seed.Referrer.UserId}"); var salesStats = await client.GetAsync("/api/tenant/referral/sales-stats"); var conversion = await client.GetAsync("/api/tenant/referral/conversion-report?days=30"); - var clients = await client.GetAsync($"/api/tenant/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); + var clients = + await client.GetAsync($"/api/tenant/referral/sales-clients?referrerUserId={seed.Referrer.UserId}"); var teamList = await client.GetAsync($"/api/tenant/referral/team?leaderUserId={seed.Admin.UserId}"); var statsItem = await stats.Content.ReadFromJsonAsync(); @@ -206,7 +206,8 @@ public sealed class ReferralEndpointTests Assert.Equal(HttpStatusCode.OK, conversion.StatusCode); Assert.Equal(HttpStatusCode.OK, clients.StatusCode); Assert.Equal(HttpStatusCode.OK, teamList.StatusCode); - Assert.Contains(seed.Referrer.UserId.ToString(), await teamList.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(seed.Referrer.UserId.ToString(), await teamList.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); } private static async Task SeedReferralAsync( @@ -248,7 +249,6 @@ public sealed class ReferralEndpointTests } }; if (includeCode) - { entities.Add(new ReferralCode { TenantId = tenantId, @@ -256,7 +256,6 @@ public sealed class ReferralEndpointTests Code = referralCode, Status = ReferralCodeStatus.Active }); - } await factory.SeedAsync(entities.ToArray()); return new ReferralSeed(tenantId, tenantCode, referrer, student, admin, referralCode); @@ -305,4 +304,4 @@ public sealed class ReferralEndpointTests LoginSeed Student, LoginSeed Admin, string ReferralCode); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs b/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs index d0b3aea..397c6a3 100644 --- a/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs +++ b/Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs @@ -23,7 +23,7 @@ public sealed class SaasBillingLifecycleTests { await using var factory = new ApiTestFactory(); var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "renewal-worker"); - var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null); + var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, null); var subscriptionId = Guid.NewGuid(); var periodEnd = new DateTimeOffset(DateTime.UtcNow.Date.AddDays(7).AddHours(12), TimeSpan.Zero); using (var scope = factory.CreateSystemScope("Set renewal fixture owner")) @@ -33,6 +33,7 @@ public sealed class SaasBillingLifecycleTests storedTenant.OwnerUserId = tenant.UserId; await dbContext.SaveChangesAsync(); } + await factory.SeedAsync( new TenantBillingPolicy { @@ -90,7 +91,7 @@ public sealed class SaasBillingLifecycleTests await using var factory = PlatformFactory(); var platform = await SeedPlatformAdminAsync(factory); var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "trial-state"); - var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null); + var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, null); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var trialRequest = new GrantTenantTrialDto( @@ -142,6 +143,7 @@ public sealed class SaasBillingLifecycleTests .Select(value => value.CurrentPeriodEnd) .SingleAsync(); } + var extended = await client.PostAsJsonAsync( $"/api/platform/saas/subscriptions/{subscriptionId}/extend", new ChangePlatformSubscriptionDto("integration extension", 10)); @@ -275,7 +277,7 @@ public sealed class SaasBillingLifecycleTests RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 }); - var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null); + var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, null); using var tenantClient = factory.CreateClient(); tenantClient.UseAccessToken(await tenantClient.LoginAsTenantAsync(tenantA.TenantId, tenantA.Phone)); @@ -376,6 +378,7 @@ public sealed class SaasBillingLifecycleTests .ProcessDueAsync(); Assert.True(processed >= 1); } + using (var partialScope = factory.CreateSystemScope("Verify partial SaaS refund")) { var partialDb = partialScope.ServiceProvider.GetRequiredService(); @@ -384,6 +387,7 @@ public sealed class SaasBillingLifecycleTests .Select(value => value.Status) .SingleAsync()); } + var excessiveRefund = await platformClient.PostAsJsonAsync( "/api/platform/saas/refunds", refundRequest with @@ -414,6 +418,7 @@ public sealed class SaasBillingLifecycleTests .ProcessDueAsync(); Assert.True(processed >= 1); } + var tenantRefunds = await tenantClient.GetAsync("/api/tenant/billing/refunds"); Assert.Equal(HttpStatusCode.OK, tenantRefunds.StatusCode); @@ -551,7 +556,7 @@ public sealed class SaasBillingLifecycleTests { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); - await SeedQuotaSubscriptionAsync(factory, tenantId, limit: 10); + await SeedQuotaSubscriptionAsync(factory, tenantId, 10); using (var scope = factory.CreateSystemScope("Seed SaaS quota usage")) { @@ -842,7 +847,7 @@ public sealed class SaasBillingLifecycleTests var version = CreatePublishedVersion(offering.Id, 1); var fixtures = new[] { - CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-1), cancelAtPeriodEnd: true), + CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-1), true), CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Trial, asOf.AddDays(-1)), CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-8)), CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.PastDue, asOf.AddDays(-8)) @@ -857,7 +862,7 @@ public sealed class SaasBillingLifecycleTests OfferingVersionId = version.Id, FeatureCode = feature.Code }, - ..fixtures.SelectMany(value => new object[] { value.Tenant, value.Subscription, value.Item }) + .. fixtures.SelectMany(value => new object[] { value.Tenant, value.Subscription, value.Item }) ]); using var lifecycleScope = factory.CreateSystemScope("Run SaaS subscription terminal lifecycle transitions"); @@ -866,7 +871,8 @@ public sealed class SaasBillingLifecycleTests Assert.Equal(1, await lifecycle.ProcessDueAsync(asOf)); Assert.Equal(0, await lifecycle.ProcessDueAsync(asOf)); - using var verificationScope = factory.CreateSystemScope("Verify SaaS subscription terminal lifecycle transitions"); + using var verificationScope = + factory.CreateSystemScope("Verify SaaS subscription terminal lifecycle transitions"); var dbContext = verificationScope.ServiceProvider.GetRequiredService(); var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => fixtures.Select(fixture => fixture.Subscription.Id).Contains(value.Id)) @@ -881,10 +887,13 @@ public sealed class SaasBillingLifecycleTests Assert.Equal(1, subscriptions[fixtures[3].Subscription.Id].LifecycleVersion); } - private static ApiTestFactory PlatformFactory() => new(configurationOverrides: new Dictionary + private static ApiTestFactory PlatformFactory() { - ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" - }); + return new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }); + } private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory) { @@ -912,8 +921,8 @@ public sealed class SaasBillingLifecycleTests }).Cast(); await factory.SeedAsync( [ - ..modules, - ..permissions, + .. modules, + .. permissions, new User { Id = userId, @@ -933,7 +942,7 @@ public sealed class SaasBillingLifecycleTests Status = BackendRoleStatus.Active, IsSystem = true }, - ..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission + .. BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code @@ -1012,6 +1021,7 @@ public sealed class SaasBillingLifecycleTests LimitValue = quota.Value }); } + await factory.SeedAsync([.. entities]); await PublishFixtureVersionAsync(factory, graph.Version.Id); return (graph.Offering.Id, graph.Version.Id); @@ -1114,18 +1124,21 @@ public sealed class SaasBillingLifecycleTests Guid.NewGuid()); } - private static SaasOfferingVersion CreatePublishedVersion(Guid offeringId, int version) => new() + private static SaasOfferingVersion CreatePublishedVersion(Guid offeringId, int version) { - OfferingId = offeringId, - Version = version, - Status = SaasOfferingVersionStatus.Published, - BillingCycle = PlatformBillingCycle.Monthly, - OriginalAmountCents = 1_000, - AmountCents = 1_000, - Currency = "CNY", - EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1), - PublishedAt = DateTimeOffset.UtcNow.AddDays(-1) - }; + return new SaasOfferingVersion + { + OfferingId = offeringId, + Version = version, + Status = SaasOfferingVersionStatus.Published, + BillingCycle = PlatformBillingCycle.Monthly, + OriginalAmountCents = 1_000, + AmountCents = 1_000, + Currency = "CNY", + EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1), + PublishedAt = DateTimeOffset.UtcNow.AddDays(-1) + }; + } private static LifecycleFixture CreateLifecycleFixture( Guid offeringVersionId, @@ -1136,7 +1149,7 @@ public sealed class SaasBillingLifecycleTests var tenantId = Guid.NewGuid(); var subscriptionId = Guid.NewGuid(); var periodStart = periodEnd.AddMonths(-1); - return new( + return new LifecycleFixture( new Tenant { Id = tenantId, @@ -1216,10 +1229,13 @@ public sealed class SaasBillingLifecycleTests { using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return payload.RootElement.GetProperty(propertyName).GetString() - ?? throw new InvalidOperationException($"Response property '{propertyName}' was null."); + ?? throw new InvalidOperationException($"Response property '{propertyName}' was null."); } - private static Task ReadCodeAsync(HttpResponseMessage response) => ReadStringAsync(response, "code"); + private static Task ReadCodeAsync(HttpResponseMessage response) + { + return ReadStringAsync(response, "code"); + } private sealed record PublishedPlanGraph( SaasFeature Feature, @@ -1232,4 +1248,4 @@ public sealed class SaasBillingLifecycleTests Tenant Tenant, TenantSaasSubscription Subscription, TenantSaasSubscriptionItem Item); -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs b/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs index 77a8fee..990f914 100644 --- a/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/ScorelineEndpointTests.cs @@ -1,10 +1,8 @@ using System.Net; using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -119,4 +117,4 @@ public sealed class ScorelineEndpointTests var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs index 7f0f945..478739e 100644 --- a/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs +++ b/Tiku.IntegrationTests/Api/SecurityFoundationTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; using Tiku.Api.Options; @@ -31,7 +32,7 @@ public sealed class SecurityFoundationTests includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), @@ -56,7 +57,7 @@ public sealed class SecurityFoundationTests includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), @@ -81,7 +82,7 @@ public sealed class SecurityFoundationTests var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), @@ -103,7 +104,7 @@ public sealed class SecurityFoundationTests var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); - client.DefaultRequestHeaders.Authorization = new( + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), @@ -130,7 +131,9 @@ public sealed class SecurityFoundationTests rejectedResponse = await client.GetAsync("/api/system/health"); } - using var secondResponse = rejectedResponse ?? throw new InvalidOperationException("Rate limit test did not send a second request."); + using var secondResponse = rejectedResponse ?? + throw new InvalidOperationException( + "Rate limit test did not send a second request."); var body = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); @@ -168,5 +171,4 @@ public sealed class SecurityFoundationTests { return new ApiTestFactory(); } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs b/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs index bd47214..dd65a7c 100644 --- a/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs +++ b/Tiku.IntegrationTests/Api/SmsVerificationConcurrencyTests.cs @@ -91,4 +91,4 @@ public sealed class SmsVerificationConcurrencyTests verification); return (tenantId, verification.Id); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs index e55371c..cdbc6f8 100644 --- a/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -using Tiku.Domain.Common; using Tiku.Domain.Catalog; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Tenancy; @@ -46,7 +46,8 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/vocabulary-units?tenantCode=master®ionId={regionId}"); + using var response = + await client.GetAsync($"/api/public/catalog/vocabulary-units?tenantCode=master®ionId={regionId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -92,7 +93,8 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃"); + using var response = + await client.GetAsync($"/api/public/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -134,7 +136,8 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var response = await client.GetAsync($"/api/public/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}"); + using var response = + await client.GetAsync($"/api/public/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}"); var items = await ReadItemsAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -169,8 +172,11 @@ public sealed class StudyContentEndpointTests }); using var client = factory.CreateClient(); - using var listResponse = await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}"); - using var detailResponse = await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true"); + using var listResponse = + await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}"); + using var detailResponse = + await client.GetAsync( + $"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true"); var listItem = Assert.Single(await ReadItemsAsync(listResponse)); var detailItem = Assert.Single(await ReadItemsAsync(detailResponse)); @@ -201,4 +207,4 @@ public sealed class StudyContentEndpointTests .Select(item => item.Clone()) .ToArray(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index 8c3cebe..92b92a4 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -11,7 +11,6 @@ using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -27,9 +26,14 @@ public sealed class TenantAdminDirectEndpointTests var classId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900009901", Name = "概览学生" }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + Status = MembershipStatus.Active + }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, - new TenantClass { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active }, + new TenantClass + { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active }, new TenantStudentFollowup { TenantId = seed.TenantId, @@ -82,9 +86,17 @@ public sealed class TenantAdminDirectEndpointTests var existingStudentId = Guid.NewGuid(); await factory.SeedAsync( new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" }, - new TenantClass { Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", Status = TenantRecordStatus.Active }, + new TenantClass + { + Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", + Status = TenantRecordStatus.Active + }, new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 }, - new TenantMembership { TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, + Status = MembershipStatus.Active + }, new StudentProfile { TenantId = seed.TenantId, @@ -93,8 +105,13 @@ public sealed class TenantAdminDirectEndpointTests LastCheckInDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)), QuestionsAnsweredToday = 0 }, - new Report { TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, Type = ReportType.Suggestion }, - new PointActivityTask { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 }, + new Report + { + TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, + Type = ReportType.Suggestion + }, + new PointActivityTask + { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 }, new PointActivityClaim { TenantId = seed.TenantId, @@ -104,7 +121,8 @@ public sealed class TenantAdminDirectEndpointTests Points = 1000, Status = PointActivityClaimStatus.Claimed }, - new PointExchangeItem { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 }, + new PointExchangeItem + { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 }, new PointExchangeOrder { TenantId = seed.TenantId, @@ -166,12 +184,17 @@ public sealed class TenantAdminDirectEndpointTests Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, ruleResponse.StatusCode); - Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); - Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); + Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, generateResponse.StatusCode); - Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); - Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); - Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); + Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); + Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -240,9 +263,12 @@ public sealed class TenantAdminDirectEndpointTests using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId)); - Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student)); - Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.TenantId == seed.TenantId && log.Action == "tenant.class_member.upserted")); + Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => + profile.TenantId == seed.TenantId && profile.UserId == studentUserId)); + Assert.True(await dbContext.TenantMemberships.AnyAsync(member => + member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student)); + Assert.True(await dbContext.AuditLogs.AnyAsync(log => + log.TenantId == seed.TenantId && log.Action == "tenant.class_member.upserted")); } [Fact] @@ -253,7 +279,11 @@ public sealed class TenantAdminDirectEndpointTests var studentId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000002", Name = "李同学" }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + Status = MembershipStatus.Active + }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }); using var client = factory.CreateClient(); await LoginAsync(client, seed); @@ -298,7 +328,11 @@ public sealed class TenantAdminDirectEndpointTests var studentId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000003", Name = "王同学" }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + Status = MembershipStatus.Active + }, new AuthSession { TenantId = seed.TenantId, @@ -322,7 +356,8 @@ public sealed class TenantAdminDirectEndpointTests Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null)); + Assert.True(await dbContext.AuthSessions.AnyAsync(session => + session.UserId == studentId && session.RevokedAt != null)); } [Fact] @@ -413,9 +448,12 @@ public sealed class TenantAdminDirectEndpointTests using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库")); - Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic")); - Assert.True(await dbContext.TenantDomains.AnyAsync(item => item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary)); + Assert.True(await dbContext.TenantBrandings.AnyAsync(item => + item.TenantId == seed.TenantId && item.BrandName == "机构题库")); + Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => + item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic")); + Assert.True(await dbContext.TenantDomains.AnyAsync(item => + item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary)); Assert.True(await dbContext.TenantExternalProviders.AnyAsync(item => item.TenantId == seed.TenantId && item.Capability == TenantExternalProviderCapability.Identity && @@ -461,7 +499,11 @@ public sealed class TenantAdminDirectEndpointTests var feedbackId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000005", Name = "反馈学生" }, - new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new TenantMembership + { + TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, + Status = MembershipStatus.Active + }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, new Report { @@ -531,9 +573,12 @@ public sealed class TenantAdminDirectEndpointTests using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - Assert.True(await dbContext.UserBadges.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId)); - Assert.True(await dbContext.UserNotifications.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted")); - Assert.True(await dbContext.ReportStatusEvents.AnyAsync(item => item.TenantId == seed.TenantId && item.ReportId == feedbackId && item.ToStatus == ReportStatus.Resolved)); + Assert.True(await dbContext.UserBadges.AnyAsync(item => + item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId)); + Assert.True(await dbContext.UserNotifications.AnyAsync(item => + item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted")); + Assert.True(await dbContext.ReportStatusEvents.AnyAsync(item => + item.TenantId == seed.TenantId && item.ReportId == feedbackId && item.ToStatus == ReportStatus.Resolved)); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync( @@ -591,5 +636,4 @@ public sealed class TenantAdminDirectEndpointTests var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } - -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index 36323c1..227aac2 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -5,7 +5,6 @@ using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; -using Tiku.Application.Auth; using Tiku.Application.Commerce; using Tiku.Application.Jobs; using Tiku.Domain.Catalog; @@ -14,7 +13,6 @@ using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -100,10 +98,12 @@ public sealed class TenantCommerceEndpointTests var accountsResponse = await client.GetAsync("/api/tenant/commerce/payment-accounts?provider=wechat_pay"); Assert.Equal(HttpStatusCode.OK, secretResponse.StatusCode); - Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode); - Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -234,7 +234,8 @@ public sealed class TenantCommerceEndpointTests var processed = await jobService.ProcessPendingAsync("integration-worker", 10); var dbContext = scope.ServiceProvider.GetRequiredService(); - var storedExportJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id); + var storedExportJob = + await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id); var storedStatsJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == statsJob.Id); Assert.Equal(2, processed); Assert.Equal(BackgroundJobStatus.Succeeded, storedExportJob.Status); @@ -288,11 +289,14 @@ public sealed class TenantCommerceEndpointTests var reportResponse = await client.GetAsync("/api/tenant/commerce/coupons/report"); Assert.Equal(HttpStatusCode.OK, taskResponse.StatusCode); - Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, exchangeItemResponse.StatusCode); - Assert.Contains("admin_svip_7d", await exchangeItemsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("admin_svip_7d", await exchangeItemsResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, couponResponse.StatusCode); - Assert.Contains("ADMIN10", await couponsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("ADMIN10", await couponsResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode); } @@ -394,8 +398,12 @@ public sealed class TenantCommerceEndpointTests var items = await itemsResponse.Content.ReadFromJsonAsync(JsonOptions); var issuesResponse = await client.GetAsync("/api/tenant/commerce/reconciliation/issues"); var issues = await issuesResponse.Content.ReadFromJsonAsync(JsonOptions); - var issueEventsResponse = await client.GetAsync($"/api/tenant/commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); - var anomalies = await client.GetFromJsonAsync("/api/tenant/commerce/reconciliation/anomalies", JsonOptions); + var issueEventsResponse = + await client.GetAsync( + $"/api/tenant/commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); + var anomalies = + await client.GetFromJsonAsync("/api/tenant/commerce/reconciliation/anomalies", + JsonOptions); var accountResponse = await client.PutAsJsonAsync( "/api/tenant/commerce/payment-accounts", new UpsertPaymentAccountDto @@ -501,8 +509,11 @@ public sealed class TenantCommerceEndpointTests Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" }) }; - var first = await client.PostAsJsonAsync($"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); - var second = await client.PostAsJsonAsync($"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + var first = await client.PostAsJsonAsync( + $"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + var second = + await client.PostAsJsonAsync( + $"/api/student/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); @@ -510,7 +521,8 @@ public sealed class TenantCommerceEndpointTests var dbContext = scope.ServiceProvider.GetRequiredService(); var storedOrder = await dbContext.Orders.AsNoTracking().SingleAsync(item => item.Id == order.Id); var storedPayment = await dbContext.Payments.AsNoTracking().SingleAsync(item => item.Id == payment.Id); - var storedRefund = await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id); + var storedRefund = + await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id); var matchingEvents = await dbContext.PaymentEvents.AsNoTracking() .CountAsync(item => item.TenantId == seed.TenantId && @@ -579,8 +591,10 @@ public sealed class TenantCommerceEndpointTests Status = CommerceAdjustmentVoucherStatus.Approved, Note = "approved" }); - var detailResponse = await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/detail?voucherId={created.Id}"); - var eventsResponse = await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/events?voucherId={created.Id}"); + var detailResponse = + await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/detail?voucherId={created.Id}"); + var eventsResponse = + await client.GetAsync($"/api/tenant/commerce/adjustment-vouchers/events?voucherId={created.Id}"); var listResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers?status=approved"); var reportResponse = await client.GetAsync("/api/tenant/commerce/adjustment-vouchers/report"); @@ -590,7 +604,8 @@ public sealed class TenantCommerceEndpointTests Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); - Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(), + StringComparison.OrdinalIgnoreCase); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -598,7 +613,8 @@ public sealed class TenantCommerceEndpointTests .Where(item => item.Id == created.Id) .Select(item => item.Status) .SingleAsync()); - Assert.Equal(3, await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id)); + Assert.Equal(3, + await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id)); Assert.True(await dbContext.AuditLogs.AnyAsync(item => item.TenantId == seed.TenantId && item.Action == "commerce.adjustment_voucher.status_changed")); @@ -638,16 +654,19 @@ public sealed class TenantCommerceEndpointTests client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } - private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo) => new() + private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo) { - TenantId = tenantId, - UserId = userId, - RegionId = regionId, - OrderNo = orderNo, - Status = OrderStatus.Paid, - AmountCents = 1_000, - PaidAt = DateTimeOffset.UtcNow - }; + return new Order + { + TenantId = tenantId, + UserId = userId, + RegionId = regionId, + OrderNo = orderNo, + Status = OrderStatus.Paid, + AmountCents = 1_000, + PaidAt = DateTimeOffset.UtcNow + }; + } private static async Task SetAdminDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) { @@ -679,4 +698,4 @@ public sealed class TenantCommerceEndpointTests throw new NotSupportedException(); } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs index 520ee09..7e3613a 100644 --- a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs @@ -79,7 +79,8 @@ public sealed class TenantPublicEndpointTests Assert.Equal(tenantId.ToString(), document.RootElement.GetProperty("tenant").GetProperty("id").GetString()); Assert.Equal("升本刷题通", document.RootElement.GetProperty("branding").GetProperty("brandName").GetString()); Assert.True(document.RootElement.GetProperty("features").GetProperty("enableVocabulary").GetBoolean()); - Assert.Equal("http://127.0.0.1:5173", document.RootElement.GetProperty("publicConfig").GetProperty("appUrl").GetString()); + Assert.Equal("http://127.0.0.1:5173", + document.RootElement.GetProperty("publicConfig").GetProperty("appUrl").GetString()); } [Fact] @@ -94,7 +95,8 @@ public sealed class TenantPublicEndpointTests var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("student.example.test", document.RootElement.GetProperty("tenant").GetProperty("host").GetString()); + Assert.Equal("student.example.test", + document.RootElement.GetProperty("tenant").GetProperty("host").GetString()); } [Fact] @@ -122,8 +124,10 @@ public sealed class TenantPublicEndpointTests var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("red", document.RootElement.GetProperty("branding").GetProperty("theme").GetProperty("color").GetString()); - Assert.Equal("/hero.png", document.RootElement.GetProperty("branding").GetProperty("publicAssets").GetProperty("hero").GetString()); + Assert.Equal("red", + document.RootElement.GetProperty("branding").GetProperty("theme").GetProperty("color").GetString()); + Assert.Equal("/hero.png", + document.RootElement.GetProperty("branding").GetProperty("publicAssets").GetProperty("hero").GetString()); } [Fact] @@ -217,4 +221,4 @@ public sealed class TenantPublicEndpointTests }) }); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/TestJwtKeys.cs b/Tiku.IntegrationTests/Api/TestJwtKeys.cs index 14987cc..728e5e0 100644 --- a/Tiku.IntegrationTests/Api/TestJwtKeys.cs +++ b/Tiku.IntegrationTests/Api/TestJwtKeys.cs @@ -35,11 +35,9 @@ internal static class TestJwtKeys var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); var tokenClaims = claims.ToList(); if (tokenClaims.All(claim => claim.Type != TikuClaimTypes.Realm)) - { tokenClaims.Add(new Claim( TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant())); - } if (includeStandardClaims) { @@ -95,4 +93,4 @@ internal sealed class TestJwtKeyRing : IJwtKeyRing } }; } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs index c06c05a..9735091 100644 --- a/Tiku.IntegrationTests/Api/VideoEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/VideoEndpointTests.cs @@ -11,7 +11,6 @@ using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; -using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -31,7 +30,8 @@ public sealed class VideoEndpointTests var questionId = Guid.NewGuid(); var videoId = Guid.NewGuid(); await factory.SeedAsync( - new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, + new Question + { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }, new VideoExplanation { Id = videoId, @@ -51,8 +51,12 @@ public sealed class VideoEndpointTests using var client = factory.CreateClient(); await LoginAsync(client, seed); - var search = await client.GetFromJsonAsync>("/api/student/videos/search?keyword=透视", JsonOptions); - var questionVideos = await client.GetFromJsonAsync>($"/api/student/questions/videos?questionId={questionId}", JsonOptions); + var search = + await client.GetFromJsonAsync>( + "/api/student/videos/search?keyword=透视", JsonOptions); + var questionVideos = + await client.GetFromJsonAsync>( + $"/api/student/questions/videos?questionId={questionId}", JsonOptions); var batch = await (await client.PostAsJsonAsync( "/api/student/questions/videos/batch", new QuestionVideoQueryDto { QuestionIds = [questionId] })) @@ -158,4 +162,4 @@ public sealed class VideoEndpointTests { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index a437d47..7e99d31 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -1,3 +1,9 @@ +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Tiku.Api.Controllers; + namespace Tiku.IntegrationTests; public sealed class ArchitectureBoundaryTests @@ -170,28 +176,28 @@ public sealed class ArchitectureBoundaryTests [Fact] public void Every_controller_action_declares_authorization_or_anonymous_access() { - var controllerAssembly = typeof(Tiku.Api.Controllers.AuthController).Assembly; + var controllerAssembly = typeof(AuthController).Assembly; var violations = controllerAssembly .GetTypes() - .Where(type => !type.IsAbstract && typeof(Microsoft.AspNetCore.Mvc.ControllerBase).IsAssignableFrom(type)) + .Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type)) .SelectMany(type => type - .GetMethods(System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.DeclaredOnly) + .GetMethods(BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.DeclaredOnly) .Where(method => method - .GetCustomAttributes(inherit: true) - .OfType() + .GetCustomAttributes(true) + .OfType() .Any()) .Select(method => new { Controller = type, Action = method, - Metadata = type.GetCustomAttributes(inherit: true) - .Concat(method.GetCustomAttributes(inherit: true)) + Metadata = type.GetCustomAttributes(true) + .Concat(method.GetCustomAttributes(true)) })) .Where(candidate => !candidate.Metadata.Any(attribute => - attribute is Microsoft.AspNetCore.Authorization.IAuthorizeData or - Microsoft.AspNetCore.Authorization.IAllowAnonymous)) + attribute is IAuthorizeData or + IAllowAnonymous)) .Select(candidate => $"{candidate.Controller.FullName}.{candidate.Action.Name}") .Order(StringComparer.Ordinal) .ToArray(); @@ -344,9 +350,7 @@ public sealed class ArchitectureBoundaryTests { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "TIKU-BACKEND.slnx"))) - { directory = directory.Parent; - } return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found."); } @@ -369,4 +373,4 @@ public sealed class ArchitectureBoundaryTests violations.Length == 0, $"{failureMessage}:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}"); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs b/Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs index 094256f..9ba3c70 100644 --- a/Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs +++ b/Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs @@ -44,4 +44,4 @@ public sealed class AuthorizationCachePersistenceTests Assert.True(await dbContext.AuthorizationCacheInvalidations.AsNoTracking().AnyAsync(item => item.TargetType == "scope" && item.TenantId == tenantId && item.Version == version.Version)); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs b/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs index 9db1f7d..f235663 100644 --- a/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs +++ b/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; using Tiku.Domain.Operations; using Tiku.Domain.Platform; -using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Bootstrap; using Tiku.Infrastructure.Persistence; using Tiku.IntegrationTests.Infrastructure; @@ -80,14 +79,15 @@ public sealed class BuiltinBackofficeCatalogSeederTests await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync(); - Assert.Equal("Custom feature name", (await dbContext.SaasFeatures.SingleAsync( - item => item.Code == SaasFeatureCatalog.CoreBackoffice)).Name); - Assert.Equal("Custom module name", (await dbContext.PermissionModules.SingleAsync( - item => item.Code == "tenant_dashboard")).Name); - Assert.Equal("Custom permission name", (await dbContext.BackendPermissions.SingleAsync( - item => item.Code == BackendPermissions.TenantDashboardView)).Name); - Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync( - item => item.Code == "tenant.dashboard")).Title); + Assert.Equal("Custom feature name", + (await dbContext.SaasFeatures.SingleAsync(item => item.Code == SaasFeatureCatalog.CoreBackoffice)).Name); + Assert.Equal("Custom module name", + (await dbContext.PermissionModules.SingleAsync(item => item.Code == "tenant_dashboard")).Name); + Assert.Equal("Custom permission name", + (await dbContext.BackendPermissions.SingleAsync(item => + item.Code == BackendPermissions.TenantDashboardView)).Name); + Assert.Equal("Custom menu title", + (await dbContext.BackendMenus.SingleAsync(item => item.Code == "tenant.dashboard")).Title); Assert.Equal(15, await dbContext.SaasFeatures.CountAsync()); Assert.Equal(28, await dbContext.PermissionModules.CountAsync()); Assert.Equal(42, await dbContext.BackendPermissions.CountAsync()); @@ -146,4 +146,4 @@ public sealed class BuiltinBackofficeCatalogSeederTests .Options; return new TikuDbContext(options); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs b/Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs index e0705c1..3415e4f 100644 --- a/Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs +++ b/Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs @@ -7,9 +7,11 @@ namespace Tiku.IntegrationTests.Infrastructure; internal sealed class PostgresTestDatabase : IDisposable { private const string DatabasePrefix = "tiku_it_"; + private static readonly Lazy Template = new( PostgresTestDatabaseTemplate.Create, LazyThreadSafetyMode.ExecutionAndPublication); + private readonly string adminConnectionString; private bool disposed; @@ -24,6 +26,28 @@ internal sealed class PostgresTestDatabase : IDisposable public string ConnectionString { get; } + public void Dispose() + { + if (disposed) return; + + disposed = true; + var builder = new NpgsqlConnectionStringBuilder(adminConnectionString); + using var adminConnection = new NpgsqlConnection(builder.ConnectionString); + adminConnection.Open(); + + using (var terminateConnections = adminConnection.CreateCommand()) + { + terminateConnections.CommandText = + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()"; + terminateConnections.Parameters.AddWithValue(DatabaseName); + terminateConnections.ExecuteNonQuery(); + } + + using var dropDatabase = adminConnection.CreateCommand(); + dropDatabase.CommandText = $"DROP DATABASE IF EXISTS {QuoteIdentifier(DatabaseName)}"; + dropDatabase.ExecuteNonQuery(); + } + public static PostgresTestDatabase Create() { var template = Template.Value; @@ -54,38 +78,11 @@ internal sealed class PostgresTestDatabase : IDisposable return database; } - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - var builder = new NpgsqlConnectionStringBuilder(adminConnectionString); - using var adminConnection = new NpgsqlConnection(builder.ConnectionString); - adminConnection.Open(); - - using (var terminateConnections = adminConnection.CreateCommand()) - { - terminateConnections.CommandText = - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()"; - terminateConnections.Parameters.AddWithValue(DatabaseName); - terminateConnections.ExecuteNonQuery(); - } - - using var dropDatabase = adminConnection.CreateCommand(); - dropDatabase.CommandText = $"DROP DATABASE IF EXISTS {QuoteIdentifier(DatabaseName)}"; - dropDatabase.ExecuteNonQuery(); - } - internal static string QuoteIdentifier(string identifier) { if (!identifier.StartsWith(DatabasePrefix, StringComparison.Ordinal) || identifier.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '_')) - { throw new InvalidOperationException("Refusing to use an unsafe integration-test database name."); - } return $"\"{identifier}\""; } @@ -106,16 +103,27 @@ internal sealed class PostgresTestDatabaseTemplate : IDisposable public string DatabaseName { get; } + public void Dispose() + { + if (disposed) return; + + disposed = true; + using var adminConnection = new NpgsqlConnection(AdminConnectionString); + adminConnection.Open(); + using var dropDatabase = adminConnection.CreateCommand(); + dropDatabase.CommandText = + $"DROP DATABASE IF EXISTS {PostgresTestDatabase.QuoteIdentifier(DatabaseName)}"; + dropDatabase.ExecuteNonQuery(); + } + public static PostgresTestDatabaseTemplate Create() { var adminConnectionString = Environment.GetEnvironmentVariable("TIKU_TEST_POSTGRES_ADMIN") ?? $"Host=localhost;Database=postgres;Username={Environment.UserName};Pooling=false;Timeout=5;Command Timeout=60"; var adminBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString); if (!string.Equals(adminBuilder.Database, "postgres", StringComparison.OrdinalIgnoreCase)) - { throw new InvalidOperationException( "TIKU_TEST_POSTGRES_ADMIN must target the postgres maintenance database."); - } var databaseName = $"{TemplatePrefix}{Environment.ProcessId}_{Guid.NewGuid():N}"; var template = new PostgresTestDatabaseTemplate(adminBuilder.ConnectionString, databaseName); @@ -163,20 +171,4 @@ internal sealed class PostgresTestDatabaseTemplate : IDisposable throw; } } - - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - using var adminConnection = new NpgsqlConnection(AdminConnectionString); - adminConnection.Open(); - using var dropDatabase = adminConnection.CreateCommand(); - dropDatabase.CommandText = - $"DROP DATABASE IF EXISTS {PostgresTestDatabase.QuoteIdentifier(DatabaseName)}"; - dropDatabase.ExecuteNonQuery(); - } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/MigrationExecutionTests.cs b/Tiku.IntegrationTests/MigrationExecutionTests.cs index b3a117c..693e5b9 100644 --- a/Tiku.IntegrationTests/MigrationExecutionTests.cs +++ b/Tiku.IntegrationTests/MigrationExecutionTests.cs @@ -2,8 +2,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; -using Tiku.IntegrationTests.Api; using Tiku.Infrastructure.Persistence; +using Tiku.IntegrationTests.Api; namespace Tiku.IntegrationTests; @@ -51,4 +51,4 @@ public sealed class MigrationExecutionTests Assert.Contains("create trigger trg_tenant_saas_subscription_items_offering_type", script); Assert.Contains("create or replace function tiku_guard_saas_subscription_offering_type()", script); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/PersistenceModelTests.cs b/Tiku.IntegrationTests/PersistenceModelTests.cs index 54205c4..833b51c 100644 --- a/Tiku.IntegrationTests/PersistenceModelTests.cs +++ b/Tiku.IntegrationTests/PersistenceModelTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -194,7 +195,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -238,7 +239,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -307,7 +308,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -348,9 +349,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -382,7 +383,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -411,9 +412,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -469,7 +470,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -482,13 +483,13 @@ public sealed class PersistenceModelTests .FindEntityType(typeof(PracticeDailyUsage))! .GetIndexes() .Single(index => index.IsUnique - && index.Properties.Select(property => property.Name).SequenceEqual([ - nameof(PracticeDailyUsage.TenantId), - nameof(PracticeDailyUsage.UserId), - nameof(PracticeDailyUsage.UsageDate), - nameof(PracticeDailyUsage.ScopeType), - nameof(PracticeDailyUsage.ScopeId) - ])); + && index.Properties.Select(property => property.Name).SequenceEqual([ + nameof(PracticeDailyUsage.TenantId), + nameof(PracticeDailyUsage.UserId), + nameof(PracticeDailyUsage.UsageDate), + nameof(PracticeDailyUsage.ScopeType), + nameof(PracticeDailyUsage.ScopeId) + ])); Assert.False(index.GetAreNullsDistinct()); } @@ -530,7 +531,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -563,9 +564,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -583,7 +584,8 @@ public sealed class PersistenceModelTests [InlineData(typeof(CommerceReconciliationBatch), nameof(CommerceReconciliationBatch.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationItem), nameof(CommerceReconciliationItem.Details), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationIssue), nameof(CommerceReconciliationIssue.Metadata), "'{}'::jsonb")] - [InlineData(typeof(CommerceReconciliationIssueEvent), nameof(CommerceReconciliationIssueEvent.Details), "'{}'::jsonb")] + [InlineData(typeof(CommerceReconciliationIssueEvent), nameof(CommerceReconciliationIssueEvent.Details), + "'{}'::jsonb")] public void Commerce_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, @@ -596,7 +598,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -606,20 +608,20 @@ public sealed class PersistenceModelTests var productType = context.Model.FindEntityType(typeof(Product))!; var isActive = productType.FindProperty(nameof(Product.IsActive))!; - var productsTable = StoreObjectIdentifier.Table("products", null); + var productsTable = StoreObjectIdentifier.Table("products"); Assert.Equal("is_active", isActive.GetColumnName(productsTable)); Assert.Contains( productType.GetIndexes(), index => !index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual([ - nameof(Product.TenantId), - nameof(Product.RegionId), - nameof(Product.Type), - nameof(Product.IsActive), - nameof(Product.SortOrder) - ])); + && index.Properties + .Select(property => property.Name) + .SequenceEqual([ + nameof(Product.TenantId), + nameof(Product.RegionId), + nameof(Product.Type), + nameof(Product.IsActive), + nameof(Product.SortOrder) + ])); AssertHasUniqueIndex(nameof(Order.TenantId), nameof(Order.OrderNo)); AssertHasUniqueIndex(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code)); @@ -652,9 +654,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -679,7 +681,8 @@ public sealed class PersistenceModelTests [InlineData(typeof(CommissionSettlement), nameof(CommissionSettlement.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommissionSettlementItem), nameof(CommissionSettlementItem.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommissionSettlementProof), nameof(CommissionSettlementProof.Metadata), "'{}'::jsonb")] - [InlineData(typeof(CommissionSettlementExportEvent), nameof(CommissionSettlementExportEvent.Metadata), "'{}'::jsonb")] + [InlineData(typeof(CommissionSettlementExportEvent), nameof(CommissionSettlementExportEvent.Metadata), + "'{}'::jsonb")] public void Growth_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, @@ -692,7 +695,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -702,11 +705,13 @@ public sealed class PersistenceModelTests AssertHasUniqueIndex(nameof(PointActivityTask.TenantId), nameof(PointActivityTask.TaskKey)); AssertHasUniqueIndex(nameof(PointExchangeItem.TenantId), nameof(PointExchangeItem.ItemKey)); - AssertHasUniqueIndex(nameof(PointExchangeOrder.TenantId), nameof(PointExchangeOrder.OrderNo)); + AssertHasUniqueIndex(nameof(PointExchangeOrder.TenantId), + nameof(PointExchangeOrder.OrderNo)); AssertHasUniqueIndex(nameof(ReferralCode.TenantId), nameof(ReferralCode.Code)); AssertHasUniqueIndex(nameof(ReferralLead.TenantId), nameof(ReferralLead.StudentUserId)); AssertHasUniqueIndex(nameof(CrmConfig.TenantId)); - AssertHasUniqueIndex(nameof(CrmWebhookQueueItem.TenantId), nameof(CrmWebhookQueueItem.IdempotencyKey)); + AssertHasUniqueIndex(nameof(CrmWebhookQueueItem.TenantId), + nameof(CrmWebhookQueueItem.IdempotencyKey)); AssertHasUniqueIndex( nameof(CommissionSettlement.TenantId), nameof(CommissionSettlement.SettlementNo)); @@ -726,9 +731,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } void AssertPrecision(string propertyName, int precision, int scale) @@ -764,7 +769,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -799,9 +804,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -815,9 +820,12 @@ public sealed class PersistenceModelTests [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Conditions), "'{}'::jsonb")] [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Metadata), "'{}'::jsonb")] [InlineData(typeof(PlatformAuditAlert), nameof(PlatformAuditAlert.Details), "'{}'::jsonb")] - [InlineData(typeof(PlatformBillingDunningNotificationChannel), nameof(PlatformBillingDunningNotificationChannel.Metadata), "'{}'::jsonb")] - [InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.RequestPayload), "'{}'::jsonb")] - [InlineData(typeof(PlatformBillingDunningNotificationEvent), nameof(PlatformBillingDunningNotificationEvent.Metadata), "'{}'::jsonb")] + [InlineData(typeof(PlatformBillingDunningNotificationChannel), + nameof(PlatformBillingDunningNotificationChannel.Metadata), "'{}'::jsonb")] + [InlineData(typeof(PlatformBillingDunningNotificationEvent), + nameof(PlatformBillingDunningNotificationEvent.RequestPayload), "'{}'::jsonb")] + [InlineData(typeof(PlatformBillingDunningNotificationEvent), + nameof(PlatformBillingDunningNotificationEvent.Metadata), "'{}'::jsonb")] public void Platform_operations_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, @@ -830,7 +838,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -849,8 +857,10 @@ public sealed class PersistenceModelTests nameof(PlatformBillingInvoiceReminder.Channel), nameof(PlatformBillingInvoiceReminder.ReminderDate)); AssertHasUniqueIndex(nameof(PlatformAuditAlertRule.Code)); - AssertHasUniqueIndex(nameof(PlatformAuditAlert.RuleId), nameof(PlatformAuditAlert.AuditLogId)); - AssertHasUniqueIndex(nameof(PlatformBillingDunningNotificationChannel.ChannelCode)); + AssertHasUniqueIndex(nameof(PlatformAuditAlert.RuleId), + nameof(PlatformAuditAlert.AuditLogId)); + AssertHasUniqueIndex(nameof(PlatformBillingDunningNotificationChannel + .ChannelCode)); AssertHasUniqueIndex( nameof(PlatformBillingDunningNotificationEvent.TenantId), nameof(PlatformBillingDunningNotificationEvent.ChannelId), @@ -878,9 +888,9 @@ public sealed class PersistenceModelTests Assert.Contains( indexes, index => index.IsUnique - && index.Properties - .Select(property => property.Name) - .SequenceEqual(propertyNames)); + && index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames)); } } @@ -900,7 +910,7 @@ public sealed class PersistenceModelTests Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); - Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); + Assert.Equal(typeof(JsonElement), property.ClrType); } [Fact] @@ -922,4 +932,4 @@ public sealed class PersistenceModelTests ], keyProperties); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs b/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs index 4079e35..9c532d8 100644 --- a/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs +++ b/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs @@ -1,16 +1,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Tiku.Application.QuestionBanks; using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; using Tiku.Application.Security; -using Tiku.Domain.Common; -using Tiku.Domain.Commerce; using Tiku.Domain.Catalog; +using Tiku.Domain.Common; using Tiku.Domain.Content; -using Tiku.Domain.QuestionBanks; using Tiku.Domain.Identity; -using Tiku.Domain.Learning; using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.IntegrationTests.Api; @@ -173,7 +171,11 @@ public sealed class PhaseThreeTenantIsolationTests Category = "learning", Status = SaasFeatureStatus.Active }, - new SaasOffering { Id = offeringId, Code = "standard", Name = "Standard", Type = SaasOfferingType.BasePlan, Status = SaasOfferingStatus.Active }, + new SaasOffering + { + Id = offeringId, Code = "standard", Name = "Standard", Type = SaasOfferingType.BasePlan, + Status = SaasOfferingStatus.Active + }, new SaasOfferingVersion { Id = versionId, @@ -330,7 +332,8 @@ public sealed class PhaseThreeTenantIsolationTests .CreatePracticeSessionAsync( new LearningActor(tenantId, userId), new PracticeSessionCommand( - "collection", null, null, null, collectionId, null, null, 10, null, null, JsonDefaults.Object())); + "collection", null, null, null, collectionId, null, null, 10, null, null, + JsonDefaults.Object())); firstSessionId = created.Id; } @@ -359,7 +362,8 @@ public sealed class PhaseThreeTenantIsolationTests .CreatePracticeSessionAsync( new LearningActor(tenantId, userId), new PracticeSessionCommand( - "collection", null, null, null, collectionId, null, null, 10, null, null, JsonDefaults.Object())); + "collection", null, null, null, collectionId, null, null, 10, null, null, + JsonDefaults.Object())); secondSessionId = created.Id; } @@ -373,17 +377,22 @@ public sealed class PhaseThreeTenantIsolationTests .ToArrayAsync(); Assert.Equal(2, firstQuestions.Length); Assert.Equal(2, secondQuestions.Length); - Assert.Equal(platformV1, firstQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId); - Assert.Equal(platformV2, secondQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId); + Assert.Equal(platformV1, + firstQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId); + Assert.Equal(platformV2, + secondQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId); Assert.Equal(privateV1, firstQuestions.Single(item => item.QuestionId == privateQuestionId).QuestionVersionId); } - private static Tenant Tenant(Guid id, string slug, TenantMode mode = TenantMode.Saas) => new() + private static Tenant Tenant(Guid id, string slug, TenantMode mode = TenantMode.Saas) { - Id = id, - Slug = slug, - Name = slug, - Status = TenantStatus.Active, - Mode = mode - }; -} + return new Tenant + { + Id = id, + Slug = slug, + Name = slug, + Status = TenantStatus.Active, + Mode = mode + }; + } +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/RedisSecurityStoreTests.cs b/Tiku.IntegrationTests/RedisSecurityStoreTests.cs index b1fff3e..2cf332d 100644 --- a/Tiku.IntegrationTests/RedisSecurityStoreTests.cs +++ b/Tiku.IntegrationTests/RedisSecurityStoreTests.cs @@ -1,12 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; -using StackExchange.Redis; using System.Security.Cryptography; using System.Text; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; using Tiku.Application.Security; -using Tiku.Infrastructure; -using Tiku.Infrastructure.Security; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Security; namespace Tiku.IntegrationTests; @@ -31,10 +31,7 @@ public sealed class RedisSecurityStoreTests public async Task Two_instances_share_atomic_limit_and_keys_contain_no_plaintext_identifier() { var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); - if (string.IsNullOrWhiteSpace(connectionString)) - { - return; - } + if (string.IsNullOrWhiteSpace(connectionString)) return; var environment = $"integration-{Guid.NewGuid():N}"; await using var first = BuildProvider(connectionString, environment); @@ -66,10 +63,7 @@ public sealed class RedisSecurityStoreTests var multiplexer = first.GetRequiredService(); var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single()); var keys = server.Keys(pattern: $"tiku:{environment}:*").ToArray(); - if (keys.Length > 0) - { - await multiplexer.GetDatabase().KeyDeleteAsync(keys); - } + if (keys.Length > 0) await multiplexer.GetDatabase().KeyDeleteAsync(keys); } } @@ -77,10 +71,7 @@ public sealed class RedisSecurityStoreTests public async Task Authorization_state_is_shared_and_older_version_cannot_overwrite_newer_version() { var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); - if (string.IsNullOrWhiteSpace(connectionString)) - { - return; - } + if (string.IsNullOrWhiteSpace(connectionString)) return; var environment = $"integration-authz-{Guid.NewGuid():N}"; await using var first = BuildProvider(connectionString, environment); @@ -92,7 +83,8 @@ public sealed class RedisSecurityStoreTests var sessionId = Guid.NewGuid(); var lookup = new AccessSecurityCacheLookup(sessionId, userId, AuthRealm.Tenant, tenantId); await writer.SetAsync(new AccessSecurityCacheState( - new CachedSessionSecurityState(sessionId, userId, AuthRealm.Tenant, tenantId, "stamp", DateTimeOffset.UtcNow.AddMinutes(15), false), + new CachedSessionSecurityState(sessionId, userId, AuthRealm.Tenant, tenantId, "stamp", + DateTimeOffset.UtcNow.AddMinutes(15), false), new CachedUserSecurityState(userId, UserStatus.Active, "stamp"), new CachedTenantSecurityState(tenantId, TenantStatus.Active), new CachedMembershipSecurityState(tenantId, userId, MembershipStatus.Active), @@ -113,4 +105,4 @@ public sealed class RedisSecurityStoreTests services.AddRedisSecurity(connectionString, environment); return services.BuildServiceProvider(); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/SystemScopeAuditTests.cs b/Tiku.IntegrationTests/SystemScopeAuditTests.cs index 3207e60..091e78d 100644 --- a/Tiku.IntegrationTests/SystemScopeAuditTests.cs +++ b/Tiku.IntegrationTests/SystemScopeAuditTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Security; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; +using Tiku.IntegrationTests.Api; namespace Tiku.IntegrationTests; @@ -10,7 +11,7 @@ public sealed class SystemScopeAuditTests [Fact] public async Task Descriptor_is_required_and_success_is_audited() { - await using var factory = new Api.ApiTestFactory(); + await using var factory = new ApiTestFactory(); using var outer = factory.CreateSystemScope("Resolve execution scope"); var executionScope = outer.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => executionScope.ExecuteAsync( @@ -29,19 +30,22 @@ public sealed class SystemScopeAuditTests Name = "System Scope Tenant" }); await executionScope.ExecuteAsync( - new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId), + new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, "background-worker", "test audit", + correlationId), (_, _) => Task.CompletedTask); using var verification = factory.CreateSystemScope("Verify execution scope audit"); var dbContext = verification.ServiceProvider.GetRequiredService(); - Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.entered" && item.TargetId == correlationId); - Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.completed" && item.TargetId == correlationId); + Assert.Contains(dbContext.AuditLogs, + item => item.Action == "system_scope.entered" && item.TargetId == correlationId); + Assert.Contains(dbContext.AuditLogs, + item => item.Action == "system_scope.completed" && item.TargetId == correlationId); } [Fact] public async Task Failed_system_scope_rolls_back_business_write_and_persists_failure_audit() { - await using var factory = new Api.ApiTestFactory(); + await using var factory = new ApiTestFactory(); using var outer = factory.CreateSystemScope("Resolve execution scope"); var executionScope = outer.ServiceProvider.GetRequiredService(); var correlationId = Guid.NewGuid().ToString("N"); @@ -82,4 +86,4 @@ public sealed class SystemScopeAuditTests Assert.DoesNotContain(verificationDb.AuditLogs, item => item.Action == "system_scope.completed" && item.TargetId == correlationId); } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/TenantDomainLifecycleTests.cs b/Tiku.IntegrationTests/TenantDomainLifecycleTests.cs index bc5f938..81e956e 100644 --- a/Tiku.IntegrationTests/TenantDomainLifecycleTests.cs +++ b/Tiku.IntegrationTests/TenantDomainLifecycleTests.cs @@ -61,7 +61,7 @@ public sealed class TenantDomainLifecycleTests { await using var factory = new ApiTestFactory( domainOwnershipVerifier: new FakeOwnershipVerifier(true), - domainGatewayProvisioner: new FakeGatewayProvisioner(false, configured: false)); + domainGatewayProvisioner: new FakeGatewayProvisioner(false, false)); var tenantId = Guid.NewGuid(); var domainId = Guid.NewGuid(); await factory.SeedAsync( @@ -100,18 +100,22 @@ public sealed class TenantDomainLifecycleTests public Task VerifyAsync( string host, string verificationToken, - CancellationToken cancellationToken = default) => - Task.FromResult(new DomainOwnershipResult(verified, true, verified ? null : "DNS failed")); + CancellationToken cancellationToken = default) + { + return Task.FromResult(new DomainOwnershipResult(verified, true, verified ? null : "DNS failed")); + } } private sealed class FakeGatewayProvisioner(bool ready, bool configured = true) : IDomainGatewayProvisioner { public Task EnsureTlsAsync( string host, - CancellationToken cancellationToken = default) => - Task.FromResult(new DomainGatewayResult( + CancellationToken cancellationToken = default) + { + return Task.FromResult(new DomainGatewayResult( ready, configured, ready ? null : configured ? "TLS pending" : "Gateway is not configured")); + } } -} +} \ No newline at end of file diff --git a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj index 101d730..b47c15e 100644 --- a/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj +++ b/Tiku.IntegrationTests/Tiku.IntegrationTests.csproj @@ -1,33 +1,33 @@  - - net10.0 - enable - enable - false - + + net10.0 + enable + enable + false + - - - + + + - - - - + + + + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs b/Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs index 39f642e..77c60f2 100644 --- a/Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs +++ b/Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs @@ -55,7 +55,7 @@ public sealed class ClamAvAssetSecurityScannerTests [Fact] public async Task Oversized_stream_is_rejected_before_connecting() { - var scanner = CreateScanner(1, streamMaxLength: 4); + var scanner = CreateScanner(1, 4); var exception = await Assert.ThrowsAsync(() => scanner.ScanAsync(new MemoryStream(new byte[5]), 5)); Assert.Equal("clamav_stream_too_large", exception.Code); @@ -73,8 +73,9 @@ public sealed class ClamAvAssetSecurityScannerTests Assert.Equal("clamav_unavailable", exception.Code); } - private static ClamAvAssetSecurityScanner CreateScanner(int port, long streamMaxLength = 1024 * 1024) => - new(Options.Create(new ClamAvOptions + private static ClamAvAssetSecurityScanner CreateScanner(int port, long streamMaxLength = 1024 * 1024) + { + return new ClamAvAssetSecurityScanner(Options.Create(new ClamAvOptions { Host = IPAddress.Loopback.ToString(), Port = port, @@ -82,22 +83,27 @@ public sealed class ClamAvAssetSecurityScannerTests ChunkBytes = 1024, StreamMaxLength = streamMaxLength })); + } - private static Task ServeScanAsync(TcpListener listener, string response) => Task.Run(async () => + private static Task ServeScanAsync(TcpListener listener, string response) { - using var client = await listener.AcceptTcpClientAsync(); - await using var stream = client.GetStream(); - Assert.Equal("zINSTREAM\0", Encoding.ASCII.GetString(await ReadExactAsync(stream, 10))); - while (true) + return Task.Run(async () => { - var lengthBytes = await ReadExactAsync(stream, 4); - var length = BinaryPrimitives.ReadUInt32BigEndian(lengthBytes); - if (length == 0) break; - _ = await ReadExactAsync(stream, checked((int)length)); - } - await stream.WriteAsync(Encoding.UTF8.GetBytes(response + "\0")); - listener.Stop(); - }); + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + Assert.Equal("zINSTREAM\0", Encoding.ASCII.GetString(await ReadExactAsync(stream, 10))); + while (true) + { + var lengthBytes = await ReadExactAsync(stream, 4); + var length = BinaryPrimitives.ReadUInt32BigEndian(lengthBytes); + if (length == 0) break; + _ = await ReadExactAsync(stream, checked((int)length)); + } + + await stream.WriteAsync(Encoding.UTF8.GetBytes(response + "\0")); + listener.Stop(); + }); + } private static async Task ReadExactAsync(Stream stream, int length) { @@ -105,4 +111,4 @@ public sealed class ClamAvAssetSecurityScannerTests await stream.ReadExactlyAsync(buffer); return buffer; } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index 4675023..fba045e 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -69,11 +69,9 @@ public sealed class AuthServiceTests await using var fixture = await AuthFixture.CreateAsync(); for (var attempt = 0; attempt < 5; attempt++) - { await Assert.ThrowsAsync(() => fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null))); - } var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString()); Assert.True(await fixture.UserManager.IsLockedOutAsync(user!)); @@ -89,7 +87,7 @@ public sealed class AuthServiceTests [Fact] public async Task Backend_permission_user_authenticates_without_an_additional_challenge() { - await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true); + await using var fixture = await AuthFixture.CreateAsync(true); var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest( AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null)); @@ -122,6 +120,12 @@ public sealed class AuthServiceTests public Guid TenantId { get; private set; } public Guid UserId { get; private set; } + public async ValueTask DisposeAsync() + { + await scope.DisposeAsync(); + await provider.DisposeAsync(); + } + public static async Task CreateAsync(bool includeBackendPermission = false) { var services = new ServiceCollection(); @@ -168,46 +172,6 @@ public sealed class AuthServiceTests return fixture; } - private sealed class UnlimitedFeatureAccessService : IFeatureAccessService - { - public Task EvaluateAsync( - Guid tenantId, - string featureCode, - FeatureAccessOperation operation, - CancellationToken cancellationToken = default) => - Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation)); - - public Task> GetEnabledFeaturesAsync( - Guid tenantId, - FeatureAccessOperation operation = FeatureAccessOperation.Read, - CancellationToken cancellationToken = default) => - Task.FromResult>(new HashSet(SaasFeatureCatalog.All, StringComparer.Ordinal)); - - public Task> FilterPermissionCodesAsync( - Guid tenantId, - IEnumerable permissionCodes, - FeatureAccessOperation operation = FeatureAccessOperation.Read, - CancellationToken cancellationToken = default) => - Task.FromResult>(permissionCodes.ToHashSet(StringComparer.Ordinal)); - - public Task> GetQuotaSummaryAsync( - Guid tenantId, - CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task TryConsumeQuotaAsync( - Guid tenantId, - string metricCode, - long amount, - CancellationToken cancellationToken = default) => Task.FromResult(true); - - public Task ReleaseQuotaAsync( - Guid tenantId, - string metricCode, - long amount, - CancellationToken cancellationToken = default) => Task.CompletedTask; - } - private async Task SeedAsync(bool includeBackendPermission) { var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test" }; @@ -258,16 +222,66 @@ public sealed class AuthServiceTests await DbContext.SaveChangesAsync(); } - public async ValueTask DisposeAsync() + private sealed class UnlimitedFeatureAccessService : IFeatureAccessService { - await scope.DisposeAsync(); - await provider.DisposeAsync(); + public Task EvaluateAsync( + Guid tenantId, + string featureCode, + FeatureAccessOperation operation, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation)); + } + + public Task> GetEnabledFeaturesAsync( + Guid tenantId, + FeatureAccessOperation operation = FeatureAccessOperation.Read, + CancellationToken cancellationToken = default) + { + return Task.FromResult>(new HashSet(SaasFeatureCatalog.All, + StringComparer.Ordinal)); + } + + public Task> FilterPermissionCodesAsync( + Guid tenantId, + IEnumerable permissionCodes, + FeatureAccessOperation operation = FeatureAccessOperation.Read, + CancellationToken cancellationToken = default) + { + return Task.FromResult>(permissionCodes.ToHashSet(StringComparer.Ordinal)); + } + + public Task> GetQuotaSummaryAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } + + public Task TryConsumeQuotaAsync( + Guid tenantId, + string metricCode, + long amount, + CancellationToken cancellationToken = default) + { + return Task.FromResult(true); + } + + public Task ReleaseQuotaAsync( + Guid tenantId, + string metricCode, + long amount, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } } private sealed class TestJwtKeyRing : IJwtKeyRing, IDisposable { private readonly RSA rsa = RSA.Create(2048); + public TestJwtKeyRing() { var key = new RsaSecurityKey(rsa) { KeyId = "unit-test-rsa" }; @@ -275,34 +289,65 @@ public sealed class AuthServiceTests ValidationKeys = [key]; } + public void Dispose() + { + rsa.Dispose(); + } + public SigningCredentials SigningCredentials { get; } public IReadOnlyCollection ValidationKeys { get; } - public void Dispose() => rsa.Dispose(); } private sealed class RejectingSmsVerificationService : ISmsVerificationService { - public Task CreateCodeAsync(SendSmsCodeRequest request, CancellationToken cancellationToken = default) => + public Task CreateCodeAsync(SendSmsCodeRequest request, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); - public Task VerifyCodeAsync(Guid tenantId, string phone, SmsPurpose purpose, string code, CancellationToken cancellationToken = default) => + } + + public Task VerifyCodeAsync(Guid tenantId, string phone, SmsPurpose purpose, string code, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } } private sealed class RejectingWechatClient : IWechatOAuthClient { - public Task ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => + public Task ExchangeWebCodeAsync(WechatProviderOptions options, string code, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); - public Task ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) => + } + + public Task ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } } private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService { - public Task GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) => + public Task GetActiveProviderAsync(Guid tenantId, + TenantExternalProviderCapability capability, string? provider = null, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); - public Task> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) => + } + + public Task> GetProvidersAsync(Guid tenantId, + TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); - public Task UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) => + } + + public Task UpsertProviderAsync(Guid tenantId, + UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs b/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs index d60f7d4..07e9d21 100644 --- a/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs +++ b/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs @@ -64,10 +64,8 @@ public sealed class SmsVerificationServiceTests var service = CreateService(context); for (var attempt = 0; attempt < 5; attempt++) - { await Assert.ThrowsAsync(() => service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999")); - } context.ChangeTracker.Clear(); var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id); @@ -196,12 +194,13 @@ public sealed class SmsVerificationServiceTests var quota = new RecordingFeatureAccessService { AllowConsumption = false }; var service = CreateService(context, provider, featureAccessService: quota); - var exception = await Assert.ThrowsAsync(() => service.CreateCodeAsync(new SendSmsCodeRequest( - tenantId, - "13800000000", - SmsPurpose.Login, - "127.0.0.1", - "quota-exhausted"))); + var exception = await Assert.ThrowsAsync(() => + service.CreateCodeAsync(new SendSmsCodeRequest( + tenantId, + "13800000000", + SmsPurpose.Login, + "127.0.0.1", + "quota-exhausted"))); Assert.Equal("feature_quota_exhausted", exception.Code); Assert.Empty(provider.Requests); @@ -328,16 +327,30 @@ public sealed class SmsVerificationServiceTests return Task.CompletedTask; } - public Task EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) => + public Task EvaluateAsync(Guid tenantId, string featureCode, + FeatureAccessOperation operation, CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } - public Task> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => + public Task> GetEnabledFeaturesAsync(Guid tenantId, + FeatureAccessOperation operation = FeatureAccessOperation.Read, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } - public Task> FilterPermissionCodesAsync(Guid tenantId, IEnumerable permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) => + public Task> FilterPermissionCodesAsync(Guid tenantId, IEnumerable permissionCodes, + FeatureAccessOperation operation = FeatureAccessOperation.Read, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } - public Task> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) => + public Task> GetQuotaSummaryAsync(Guid tenantId, + CancellationToken cancellationToken = default) + { throw new NotSupportedException(); + } } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs index a9cf9df..7f0e3b0 100644 --- a/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs +++ b/Tiku.UnitTests/Bootstrap/PlatformAdminBootstrapperTests.cs @@ -1,11 +1,11 @@ -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Security; using Tiku.Domain.Identity; -using Tiku.Infrastructure.Bootstrap; using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Bootstrap; using Tiku.Infrastructure.Persistence; namespace Tiku.UnitTests.Bootstrap; @@ -35,7 +35,8 @@ public sealed class PlatformAdminBootstrapperTests Assert.Equal(PlatformAdminBootstrapper.SuperAdminRoleCode, role.Code); Assert.True(role.IsSystem); Assert.Equal(BackendPermissions.Platform.Count, await context.PlatformBackendRolePermissions.CountAsync()); - Assert.True(await context.PlatformBackendUserRoles.AnyAsync(item => item.UserId == user.Id && item.RoleId == role.Id)); + Assert.True( + await context.PlatformBackendUserRoles.AnyAsync(item => item.UserId == user.Id && item.RoleId == role.Id)); Assert.True(await context.AuditLogs.AnyAsync(item => item.ActorUserId == user.Id && item.Action == "platform.bootstrap_admin.created")); @@ -142,4 +143,4 @@ public sealed class PlatformAdminBootstrapperTests services.AddDataProtection().UseEphemeralDataProtectionProvider(); return services.BuildServiceProvider(); } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Learning/QuestionGraderTests.cs b/Tiku.UnitTests/Learning/QuestionGraderTests.cs index 015aa7d..3d80ae1 100644 --- a/Tiku.UnitTests/Learning/QuestionGraderTests.cs +++ b/Tiku.UnitTests/Learning/QuestionGraderTests.cs @@ -10,7 +10,7 @@ public sealed class QuestionGraderTests [Fact] public void Choice_requires_exactly_one_matching_index() { - var result = Grade("choice", correctIndex: 1, selected: [1]); + var result = Grade("choice", 1, selected: [1]); Assert.Equal(AnswerGradingStatus.Correct, result.Status); Assert.True(result.IsCorrect); @@ -68,8 +68,9 @@ public sealed class QuestionGraderTests string? correctText = null, IReadOnlyCollection? selected = null, string? answerText = null, - JsonElement? rules = null) => - QuestionGrader.Grade(new QuestionGradingInput( + JsonElement? rules = null) + { + return QuestionGrader.Grade(new QuestionGradingInput( type, correctIndex, correctIndices ?? JsonDefaults.Array(), @@ -78,4 +79,5 @@ public sealed class QuestionGraderTests selected, answerText, 5)); -} + } +} \ No newline at end of file diff --git a/Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs b/Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs index 9f431de..9100dc2 100644 --- a/Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs +++ b/Tiku.UnitTests/PlatformAdmin/PlatformApprovalRulesTests.cs @@ -9,16 +9,22 @@ public sealed class PlatformApprovalRulesTests [InlineData(4_999_999, false)] [InlineData(5_000_000, true)] [InlineData(5_000_001, true)] - public void Financial_threshold_is_fifty_thousand_yuan(int amountCents, bool expected) => + public void Financial_threshold_is_fifty_thousand_yuan(int amountCents, bool expected) + { Assert.Equal(expected, PlatformApprovalRules.RequiresApproval(true, false, 5_000_000, amountCents)); + } [Fact] - public void Always_policy_requires_approval_without_amount() => + public void Always_policy_requires_approval_without_amount() + { Assert.True(PlatformApprovalRules.RequiresApproval(true, true, null, null)); + } [Fact] - public void Disabled_policy_executes_immediately() => + public void Disabled_policy_executes_immediately() + { Assert.False(PlatformApprovalRules.RequiresApproval(false, true, 1, 10)); + } [Fact] public void Requester_cannot_approve_own_request() @@ -38,4 +44,4 @@ public sealed class PlatformApprovalRulesTests "platform:tenant:manage", new HashSet(), DateTimeOffset.UtcNow); Assert.Equal("approval_business_permission_required", denial); } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Security/CurrentDataScopeTests.cs b/Tiku.UnitTests/Security/CurrentDataScopeTests.cs index 409c588..b5fe681 100644 --- a/Tiku.UnitTests/Security/CurrentDataScopeTests.cs +++ b/Tiku.UnitTests/Security/CurrentDataScopeTests.cs @@ -86,7 +86,7 @@ public sealed class CurrentDataScopeTests Assert.True(scope.AllowsResource(userId, regionId: regionId)); Assert.True(scope.AllowsResource(userId, classId: classId)); - Assert.False(scope.AllowsResource(userId, ownerUserId: userId)); + Assert.False(scope.AllowsResource(userId, userId)); Assert.False(scope.AllowsResource(userId, regionId: Guid.NewGuid(), classId: Guid.NewGuid())); } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs b/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs index 1e68ec7..7f05297 100644 --- a/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs +++ b/Tiku.UnitTests/Security/DataProtectionKeyRingOptionsTests.cs @@ -11,8 +11,8 @@ public sealed class DataProtectionKeyRingOptionsTests { var options = new DataProtectionKeyRingOptions(); - Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false)); - Assert.Null(options.LoadCertificate(requireCertificate: false)); + Assert.True(DataProtectionKeyRingOptions.BeValid(options, false)); + Assert.Null(options.LoadCertificate(false)); } [Fact] @@ -20,9 +20,9 @@ public sealed class DataProtectionKeyRingOptionsTests { var options = new DataProtectionKeyRingOptions(); - Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + Assert.False(DataProtectionKeyRingOptions.BeValid(options, true)); var exception = Assert.Throws(() => - options.LoadCertificate(requireCertificate: true)); + options.LoadCertificate(true)); Assert.Contains("required outside Development", exception.Message, StringComparison.Ordinal); } @@ -35,8 +35,8 @@ public sealed class DataProtectionKeyRingOptionsTests CertificatePath = "/configured/key-ring.pfx" }; - Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: false)); - Assert.False(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + Assert.False(DataProtectionKeyRingOptions.BeValid(options, false)); + Assert.False(DataProtectionKeyRingOptions.BeValid(options, true)); } [Fact] @@ -49,9 +49,9 @@ public sealed class DataProtectionKeyRingOptionsTests $"missing-data-protection-{Guid.NewGuid():N}.pfx") }; - Assert.True(DataProtectionKeyRingOptions.BeValid(options, requireCertificate: true)); + Assert.True(DataProtectionKeyRingOptions.BeValid(options, true)); var exception = Assert.Throws(() => - options.LoadCertificate(requireCertificate: true)); + options.LoadCertificate(true)); Assert.Contains("could not be loaded", exception.Message, StringComparison.Ordinal); } @@ -82,7 +82,7 @@ public sealed class DataProtectionKeyRingOptionsTests CertificatePassword = password }; - using var loaded = options.LoadCertificate(requireCertificate: true); + using var loaded = options.LoadCertificate(true); Assert.NotNull(loaded); Assert.True(loaded.HasPrivateKey); } @@ -91,4 +91,4 @@ public sealed class DataProtectionKeyRingOptionsTests File.Delete(certificatePath); } } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs index 944573a..727c2e4 100644 --- a/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs +++ b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs @@ -31,7 +31,8 @@ public sealed class AliyunOssObjectStorageServiceTests Assert.Equal("image/png", service.ValidateMimeType(" Image/PNG ")); Assert.Equal(99, service.ValidateFileSize(99)); - Assert.Equal("MIME_TYPE_NOT_ALLOWED", Assert.Throws(() => service.ValidateMimeType("text/html")).Code); + Assert.Equal("MIME_TYPE_NOT_ALLOWED", + Assert.Throws(() => service.ValidateMimeType("text/html")).Code); Assert.Equal("FILE_TOO_LARGE", Assert.Throws(() => service.ValidateFileSize(101)).Code); } @@ -119,8 +120,9 @@ public sealed class AliyunOssObjectStorageServiceTests })); } - private static AliyunOssObjectStorageService CreateConfiguredService(ObjectStorageOptions? storageOptions = null) => - new( + private static AliyunOssObjectStorageService CreateConfiguredService(ObjectStorageOptions? storageOptions = null) + { + return new AliyunOssObjectStorageService( Options.Create(storageOptions ?? new ObjectStorageOptions()), Options.Create(new AliyunOssOptions { @@ -128,7 +130,11 @@ public sealed class AliyunOssObjectStorageServiceTests AccessKeyId = "test-access-key-id", AccessKeySecret = "test-access-key-secret" })); + } - private static AliyunOssObjectStorageService CreateUnconfiguredService() => - new(Options.Create(new ObjectStorageOptions()), Options.Create(new AliyunOssOptions())); -} + private static AliyunOssObjectStorageService CreateUnconfiguredService() + { + return new AliyunOssObjectStorageService(Options.Create(new ObjectStorageOptions()), + Options.Create(new AliyunOssOptions())); + } +} \ No newline at end of file diff --git a/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs b/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs index 9011a30..73d96fd 100644 --- a/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs +++ b/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs @@ -45,4 +45,4 @@ public sealed class TenantOwnerActivationUrlPolicyTests Assert.Throws(() => TenantOwnerActivationUrlPolicy.Build(options, "school.example.test", Guid.NewGuid(), "secret")); } -} +} \ No newline at end of file diff --git a/Tiku.UnitTests/Tiku.UnitTests.csproj b/Tiku.UnitTests/Tiku.UnitTests.csproj index 0875050..e5d0b14 100644 --- a/Tiku.UnitTests/Tiku.UnitTests.csproj +++ b/Tiku.UnitTests/Tiku.UnitTests.csproj @@ -1,38 +1,38 @@  - - net10.0 - enable - enable - false - + + net10.0 + enable + enable + false + - - - + + + - - - - - + + + + + - - - - + + + + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/Tiku.UnitTests/UnitTest1.cs b/Tiku.UnitTests/UnitTest1.cs index 39b2f19..bc32dd4 100644 --- a/Tiku.UnitTests/UnitTest1.cs +++ b/Tiku.UnitTests/UnitTest1.cs @@ -5,6 +5,5 @@ public class UnitTest1 [Fact] public void Test1() { - } -} +} \ No newline at end of file diff --git a/Tiku.Worker/Program.cs b/Tiku.Worker/Program.cs index 7cbe744..2c61a86 100644 --- a/Tiku.Worker/Program.cs +++ b/Tiku.Worker/Program.cs @@ -23,4 +23,4 @@ catch (Exception exception) finally { await Log.CloseAndFlushAsync(); -} +} \ No newline at end of file diff --git a/Tiku.Worker/Tiku.Worker.csproj b/Tiku.Worker/Tiku.Worker.csproj index 0975b33..eea2f78 100644 --- a/Tiku.Worker/Tiku.Worker.csproj +++ b/Tiku.Worker/Tiku.Worker.csproj @@ -1,24 +1,24 @@ - - Exe - net10.0 - enable - enable - + + Exe + net10.0 + enable + enable + - - - - + + + + - - - - - - - - + + + + + + + + diff --git a/Tiku.Worker/WorkerDependencyInjection.cs b/Tiku.Worker/WorkerDependencyInjection.cs index 63514c7..0fe69c7 100644 --- a/Tiku.Worker/WorkerDependencyInjection.cs +++ b/Tiku.Worker/WorkerDependencyInjection.cs @@ -1,14 +1,16 @@ +using Microsoft.Extensions.Options; +using OpenTelemetry.Metrics; using Serilog; using Tiku.Application; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; +using Tiku.Application.Storage; using Tiku.Application.Tenancy; using Tiku.Infrastructure; using Tiku.Infrastructure.Assets; -using Tiku.Infrastructure.Storage; using Tiku.Infrastructure.Observability; using Tiku.Infrastructure.Security; -using OpenTelemetry.Metrics; +using Tiku.Infrastructure.Storage; namespace Tiku.Worker; @@ -17,10 +19,10 @@ internal static class WorkerDependencyInjection internal static HostApplicationBuilder AddWorkerServices(this HostApplicationBuilder builder) { builder.Services.AddSerilog((services, configuration) => configuration - .ReadFrom.Configuration(builder.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext(), - preserveStaticLogger: true); + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(), + true); var connectionString = builder.Configuration.GetConnectionString("Database") ?? builder.Configuration["DATABASE_URL"] ?? @@ -30,26 +32,22 @@ internal static class WorkerDependencyInjection "Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL.")); builder.Services.AddApplication(); builder.Services.AddInfrastructure(connectionString); - var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; + var redisConnectionString = + builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(AuthorizationCacheOptions.SectionName)); if (!string.IsNullOrWhiteSpace(redisConnectionString)) - { builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName); - } else if (builder.Environment.IsProduction()) - { throw new InvalidOperationException( "Redis is required in Production for authorization cache invalidation retries."); - } var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"]; var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName)); if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint)) - { telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint)); - } - builder.Services.Configure(builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(AliyunOssOptions.SectionName)); builder.Services.PostConfigure(options => { @@ -89,21 +87,21 @@ internal static class WorkerDependencyInjection builder.Services.AddOptions() .Validate( options => !builder.Environment.IsProduction() || - options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss, + options.DefaultProvider == ObjectStorageProviders.AliyunOss, "Production managed storage must use the configured Aliyun OSS provider.") .ValidateOnStart(); builder.Services.AddOptions() - .Validate>( + .Validate>( (aliyun, storage) => !builder.Environment.IsProduction() || - storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss || + storage.Value.DefaultProvider != ObjectStorageProviders.AliyunOss || aliyun.IsConfigured, "Aliyun OSS credentials and region or endpoint are required when it is the default provider.") .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName)) .Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.") - .Validate>( + .Validate>( (clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes, "ClamAV StreamMaxLength must cover the storage max upload size.") .ValidateOnStart(); @@ -133,8 +131,10 @@ internal static class WorkerDependencyInjection return builder; } - private static string[] SplitLegacyList(string? value, string[] fallback) => - string.IsNullOrWhiteSpace(value) + private static string[] SplitLegacyList(string? value, string[] fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); -} + } +} \ No newline at end of file diff --git a/Tiku.Worker/WorkerServices.cs b/Tiku.Worker/WorkerServices.cs index 17358e1..1a2728a 100644 --- a/Tiku.Worker/WorkerServices.cs +++ b/Tiku.Worker/WorkerServices.cs @@ -1,13 +1,13 @@ using System.Data; +using System.Diagnostics; using Microsoft.Extensions.Options; using Npgsql; using Tiku.Application.Jobs; -using Tiku.Application.PlatformBilling; using Tiku.Application.PlatformAdmin; +using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Infrastructure.Observability; -using System.Diagnostics; namespace Tiku.Worker; @@ -20,10 +20,12 @@ public sealed class WorkerOptions public int JobParallelism { get; set; } = 4; public int JobBatchSize { get; set; } = 5; - public static bool BeValid(WorkerOptions options) => - options.JobPollSeconds is >= 1 and <= 3600 && - options.JobParallelism is >= 1 and <= 32 && - options.JobBatchSize is >= 1 and <= 100; + public static bool BeValid(WorkerOptions options) + { + return options.JobPollSeconds is >= 1 and <= 3600 && + options.JobParallelism is >= 1 and <= 32 && + options.JobBatchSize is >= 1 and <= 100; + } } internal interface IPeriodicProcessorLock @@ -33,14 +35,18 @@ internal interface IPeriodicProcessorLock internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource) { - private readonly string workerId = $"{Environment.MachineName}:{Environment.ProcessId}"; private readonly DateTimeOffset startedAt = DateTimeOffset.UtcNow; + private readonly string workerId = $"{Environment.MachineName}:{Environment.ProcessId}"; - public Task StartedAsync(string processor, CancellationToken cancellationToken) => - UpsertAsync(processor, true, null, cancellationToken); + public Task StartedAsync(string processor, CancellationToken cancellationToken) + { + return UpsertAsync(processor, true, null, cancellationToken); + } - public Task CompletedAsync(string processor, Exception? error, CancellationToken cancellationToken) => - UpsertAsync(processor, false, error?.Message, cancellationToken); + public Task CompletedAsync(string processor, Exception? error, CancellationToken cancellationToken) + { + return UpsertAsync(processor, false, error?.Message, cancellationToken); + } private async Task UpsertAsync( string processor, @@ -52,23 +58,23 @@ internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource) await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ - INSERT INTO worker_heartbeats - (id, worker_id, processor, started_at, last_heartbeat_at, last_iteration_started_at, - last_iteration_completed_at, last_succeeded_at, last_error, is_running) - VALUES - (gen_random_uuid(), @worker_id, @processor, @started_at, @now, - CASE WHEN @running THEN @now ELSE NULL END, - CASE WHEN @running THEN NULL ELSE @now END, - CASE WHEN NOT @running AND @error IS NULL THEN @now ELSE NULL END, - @error, @running) - ON CONFLICT (worker_id, processor) DO UPDATE SET - last_heartbeat_at = EXCLUDED.last_heartbeat_at, - last_iteration_started_at = CASE WHEN EXCLUDED.is_running THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_iteration_started_at END, - last_iteration_completed_at = CASE WHEN EXCLUDED.is_running THEN worker_heartbeats.last_iteration_completed_at ELSE EXCLUDED.last_heartbeat_at END, - last_succeeded_at = CASE WHEN NOT EXCLUDED.is_running AND EXCLUDED.last_error IS NULL THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_succeeded_at END, - last_error = EXCLUDED.last_error, - is_running = EXCLUDED.is_running - """; + INSERT INTO worker_heartbeats + (id, worker_id, processor, started_at, last_heartbeat_at, last_iteration_started_at, + last_iteration_completed_at, last_succeeded_at, last_error, is_running) + VALUES + (gen_random_uuid(), @worker_id, @processor, @started_at, @now, + CASE WHEN @running THEN @now ELSE NULL END, + CASE WHEN @running THEN NULL ELSE @now END, + CASE WHEN NOT @running AND @error IS NULL THEN @now ELSE NULL END, + @error, @running) + ON CONFLICT (worker_id, processor) DO UPDATE SET + last_heartbeat_at = EXCLUDED.last_heartbeat_at, + last_iteration_started_at = CASE WHEN EXCLUDED.is_running THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_iteration_started_at END, + last_iteration_completed_at = CASE WHEN EXCLUDED.is_running THEN worker_heartbeats.last_iteration_completed_at ELSE EXCLUDED.last_heartbeat_at END, + last_succeeded_at = CASE WHEN NOT EXCLUDED.is_running AND EXCLUDED.last_error IS NULL THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_succeeded_at END, + last_error = EXCLUDED.last_error, + is_running = EXCLUDED.is_running + """; command.Parameters.AddWithValue("worker_id", workerId); command.Parameters.AddWithValue("processor", processor); command.Parameters.AddWithValue("started_at", startedAt); @@ -108,6 +114,7 @@ internal sealed class PostgresPeriodicProcessorLock(NpgsqlDataSource dataSource) command.Parameters.AddWithValue("processor", processor); await command.ExecuteScalarAsync(); } + await connection.DisposeAsync(); } } @@ -125,13 +132,9 @@ internal abstract class PeriodicWorker( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!enabled) - { - return; - } + if (!enabled) return; while (!stoppingToken.IsCancellationRequested) - { try { await using var lease = await processorLock.TryAcquireAsync(processorName, stoppingToken); @@ -143,19 +146,20 @@ internal abstract class PeriodicWorker( { var processed = await ProcessAsync(stoppingToken); await stateReporter.CompletedAsync(processorName, null, stoppingToken); - WorkerTelemetry.RecordIteration(processorName, true, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds); + WorkerTelemetry.RecordIteration(processorName, true, + Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds); if (processed > 0) - { logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed); - } } catch (Exception exception) { await stateReporter.CompletedAsync(processorName, exception, CancellationToken.None); - WorkerTelemetry.RecordIteration(processorName, false, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds); + WorkerTelemetry.RecordIteration(processorName, false, + Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds); throw; } } + await Task.Delay(interval, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -174,11 +178,12 @@ internal abstract class PeriodicWorker( break; } } - } } - protected static void InitializeSystem(IServiceProvider services, string reason) => + protected static void InitializeSystem(IServiceProvider services, string reason) + { services.GetRequiredService().InitializeSystem(null, reason); + } } internal sealed class TenantDomainWorker( @@ -206,7 +211,8 @@ internal sealed class SaasSubscriptionWorker( WorkerStateReporter stateReporter, IOptions options, ILogger logger) - : PeriodicWorker(logger, processorLock, stateReporter, "saas-subscription-lifecycle", TimeSpan.FromSeconds(60), options.Value.Enabled) + : PeriodicWorker(logger, processorLock, stateReporter, "saas-subscription-lifecycle", TimeSpan.FromSeconds(60), + options.Value.Enabled) { protected override async Task ProcessAsync(CancellationToken cancellationToken) { @@ -242,11 +248,12 @@ internal sealed class BackgroundJobsWorker( WorkerStateReporter stateReporter, IOptions options, ILogger logger) - : PeriodicWorker(logger, processorLock, stateReporter, "background-jobs", TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled) + : PeriodicWorker(logger, processorLock, stateReporter, "background-jobs", + TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled) { - private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}"; - private readonly int parallelism = options.Value.JobParallelism; private readonly int batchSize = options.Value.JobBatchSize; + private readonly int parallelism = options.Value.JobParallelism; + private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}"; protected override async Task ProcessAsync(CancellationToken cancellationToken) { @@ -260,7 +267,7 @@ internal sealed class BackgroundJobsWorker( await using var scope = scopeFactory.CreateAsyncScope(); InitializeSystem(scope.ServiceProvider, "Background job lease worker"); return await scope.ServiceProvider.GetRequiredService() - .ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken); + .ProcessPendingAsync($"{workerId}:{index}", batchSize, true, cancellationToken); } } @@ -316,4 +323,4 @@ internal sealed class PlatformApprovalWorker( return await scope.ServiceProvider.GetRequiredService() .ProcessApprovedAsync(cancellationToken: cancellationToken); } -} +} \ No newline at end of file