From 07efa6813bf308a1b2a1dd0c7da2ba206afee0b4 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 19:45:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E8=80=83=E7=94=9F?= =?UTF-8?q?=E5=9F=9F=E7=AC=AC=E4=B8=80=E6=89=B9=E5=8F=AA=E8=AF=BB=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=EF=BC=9A=20GET=20/api/candidate/dashboard=20GET=20/ap?= =?UTF-8?q?i/candidate/notices=20GET=20/api/candidate/profile=20GET=20/api?= =?UTF-8?q?/candidate/exams=20GET=20/api/candidate/registrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + MIGRATION.md | 7 + scripts/smoke-dotnet-migration.ps1 | 105 +++- .../Candidate/ICandidateQueryService.cs | 18 + .../Candidate/CandidateMigrationOptions.cs | 37 ++ .../Candidate/CandidateQueryService.cs | 410 ++++++++++++++ .../Candidate/CandidateReadSnapshot.cs | 507 ++++++++++++++++++ src/Eis.Infrastructure/DependencyInjection.cs | 8 +- .../Candidate/NativeCandidateReadEndpoints.cs | 60 +++ src/Eis.Web/Program.cs | 17 +- src/Eis.Web/appsettings.json | 3 + 11 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 src/Eis.Application/Candidate/ICandidateQueryService.cs create mode 100644 src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs create mode 100644 src/Eis.Infrastructure/Candidate/CandidateQueryService.cs create mode 100644 src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs create mode 100644 src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs diff --git a/.env.example b/.env.example index dcd82f7..c9a3c57 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ PORT=4173 # 原生 ASP.NET Core 认证切换。迁移期间默认关闭;生产环境开启时必须配置共享 Redis, # 以便尚未迁移的 Node 受保护接口识别由 ASP.NET Core 创建的会话。 AUTH_NATIVE_ENABLED=false +# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。 +CANDIDATE_NATIVE_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 92fb7e7..a5c264d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -47,6 +47,13 @@ $env:AUTH_NATIVE_ENABLED = 'true' 开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。 +考生域第一批只读端点(首页、通知、个人资料、可报名考试、我的报名)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。资料更新、报名提交、成绩、准考证、复议和志愿填报仍会继续转发给 Node: + +```powershell +$env:AUTH_NATIVE_ENABLED = 'true' +$env:CANDIDATE_NATIVE_ENABLED = 'true' +``` + 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: ```powershell diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 2abc6d7..9e1c5a5 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -84,6 +84,74 @@ function Wait-ForUrl { throw "Timed out waiting for $Uri" } +function Assert-JsonEquivalent { + param( + [Parameter(Mandatory)] + [string] $Expected, + + [Parameter(Mandatory)] + [string] $Actual, + + [Parameter(Mandatory)] + [string] $Label + ) + + $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) + $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) + if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { + $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' + throw "$Label JSON payload differs from the legacy API at $difference" + } +} + +function Find-JsonDifference { + param( + [AllowNull()] + [System.Text.Json.Nodes.JsonNode] $Expected, + + [AllowNull()] + [System.Text.Json.Nodes.JsonNode] $Actual, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ($null -eq $Expected -or $null -eq $Actual) { + return "$Path (expected=$Expected, actual=$Actual)" + } + + if ($Expected -is [System.Text.Json.Nodes.JsonObject] -and $Actual -is [System.Text.Json.Nodes.JsonObject]) { + foreach ($entry in $Expected) { + if (-not $Actual.ContainsKey($entry.Key)) { + return "$Path.$($entry.Key) (missing from actual)" + } + if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($entry.Value, $Actual[$entry.Key])) { + return Find-JsonDifference -Expected $entry.Value -Actual $Actual[$entry.Key] -Path "$Path.$($entry.Key)" + } + } + foreach ($entry in $Actual) { + if (-not $Expected.ContainsKey($entry.Key)) { + return "$Path.$($entry.Key) (unexpected in actual)" + } + } + return "$Path (object values differ)" + } + + if ($Expected -is [System.Text.Json.Nodes.JsonArray] -and $Actual -is [System.Text.Json.Nodes.JsonArray]) { + if ($Expected.Count -ne $Actual.Count) { + return "$Path.Count (expected=$($Expected.Count), actual=$($Actual.Count))" + } + for ($index = 0; $index -lt $Expected.Count; $index++) { + if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($Expected[$index], $Actual[$index])) { + return Find-JsonDifference -Expected $Expected[$index] -Actual $Actual[$index] -Path "$Path[$index]" + } + } + return "$Path (array values differ)" + } + + return "$Path (expected=$($Expected.ToJsonString()), actual=$($Actual.ToJsonString()))" +} + try { New-Item -ItemType Directory -Path $testDirectory | Out-Null @@ -308,6 +376,8 @@ try { ) -Environment @{ ASPNETCORE_ENVIRONMENT = 'Development' AUTH_NATIVE_ENABLED = 'true' + CANDIDATE_NATIVE_ENABLED = 'true' + CANDIDATE_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl DATABASE_CLIENT = 'sqlite' @@ -319,6 +389,25 @@ try { } Wait-ForUrl -Uri "$nativeAuthBaseUrl/health/live" -Processes @($nodeProcess, $nativeAuthProcess) + $anonymousCandidate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -SkipHttpErrorCheck + if ($anonymousCandidate.StatusCode -ne 401 -or $anonymousCandidate.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native candidate API did not reject an anonymous request' + } + + $nativeCandidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $nativeCandidateLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $nativeCandidateSession + if ($nativeCandidateLogin.user.candidateNumber -ne '2026-HZ01-F-0001') { + throw 'Native authentication could not create the candidate parity-test session' + } + foreach ($candidateRoute in @('dashboard', 'notices', 'profile', 'exams', 'registrations')) { + $legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $candidateSession + $nativeCandidateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateRoute" -WebSession $nativeCandidateSession + if ($nativeCandidateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Candidate route '$candidateRoute' did not use the native ASP.NET Core endpoint" + } + Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'" + } + $registrationSchool = @($homePayload.schools | Select-Object -First 1)[0] $registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | Select-Object -First 1)[0] if ($null -eq $registrationSchool -or $null -eq $registrationClass) { @@ -337,10 +426,19 @@ try { } $registration = $registrationResponse.Content | ConvertFrom-Json $registeredLoginBody = @{ username = $registration.registrationNumber; password = 'Registration456!' } | ConvertTo-Json -Compress - $registeredLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody + $registeredSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $registeredLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $registeredSession if ($registeredLogin.user.candidateNumber -ne $registration.registrationNumber) { throw 'Native self-registration did not create a usable candidate account' } + $incompleteDashboard = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/dashboard" -WebSession $registeredSession -SkipHttpErrorCheck + if ($incompleteDashboard.StatusCode -ne 428) { + throw 'Native candidate API did not require completion of a newly registered profile' + } + $incompleteProfile = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $registeredSession + if ($incompleteProfile.StatusCode -ne 200) { + throw 'Native candidate profile route was not available during onboarding' + } $nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() $nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession @@ -351,6 +449,10 @@ try { if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') { throw 'Native authentication could not verify the existing Node PBKDF2 account' } + $adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck + if ($adminCandidateRoute.StatusCode -ne 403) { + throw 'Native candidate API did not enforce the candidate role boundary' + } $nativeMe = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $nativeSession if ($nativeMe.user.username -ne 'admin' -or $nativeMe.permissions[0] -ne '*') { @@ -425,6 +527,7 @@ try { PublicParity = 'passed' DocumentCodes = 'passed' NativeAuthentication = 'passed' + NativeCandidateReads = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Candidate/ICandidateQueryService.cs b/src/Eis.Application/Candidate/ICandidateQueryService.cs new file mode 100644 index 0000000..aedb822 --- /dev/null +++ b/src/Eis.Application/Candidate/ICandidateQueryService.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Candidate; + +public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body); + +public interface ICandidateQueryService +{ + Task GetDashboardAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetNoticesAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetProfileAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetExamsAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs new file mode 100644 index 0000000..783aaa2 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs @@ -0,0 +1,37 @@ +namespace Eis.Infrastructure.Candidate; + +public sealed record CandidateMigrationOptions(bool NativeReadEnabled) +{ + public static CandidateMigrationOptions FromEnvironment( + bool configuredNativeReadEnabled, + bool authenticationNativeEnabled, + bool sharesLegacySessions) + { + var enabled = ParseBoolean( + Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"), + configuredNativeReadEnabled); + if (enabled && !authenticationNativeEnabled) + { + throw new InvalidOperationException( + "启用原生考生接口前必须同时设置 AUTH_NATIVE_ENABLED=true,以确保 ASP.NET Core 能识别登录会话"); + } + + var allowMemoryForIsolatedTesting = ParseBoolean( + Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ALLOW_MEMORY"), + fallback: false); + if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting) + { + throw new InvalidOperationException( + "考生域尚有接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话"); + } + + return new CandidateMigrationOptions(enabled); + } + + private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch + { + "1" or "true" or "yes" or "on" => true, + "0" or "false" or "no" or "off" => false, + _ => fallback + }; +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateQueryService.cs b/src/Eis.Infrastructure/Candidate/CandidateQueryService.cs new file mode 100644 index 0000000..54c3977 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateQueryService.cs @@ -0,0 +1,410 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Eis.Application.Candidate; +using Eis.Application.Public; +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Candidate; + +internal sealed class CandidateQueryService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + CandidateReadSnapshotLoader snapshotLoader, + IPublicQueryService publicQueries) : ICandidateQueryService +{ + public async Task GetDashboardAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var user = context.User!; + var profile = context.Profile!; + var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); + var registrations = snapshot.Registrations.Select(item => RegistrationView(snapshot, item)).ToArray(); + var registrationIds = snapshot.Registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + var results = snapshot.Results + .Where(item => item.Published && registrationIds.Contains(item.RegistrationId)) + .Select(ResultJson) + .ToArray(); + var home = await publicQueries.GetHomeAsync(cancellationToken); + var notices = home["notices"]?.AsArray() + .OfType() + .OrderByDescending(item => ParseDate(item["publishAt"]?.GetValue())) + .Take(5) + .Select(item => item?.DeepClone()) + .ToArray() ?? []; + var profileInstance = FindInstance(snapshot, "profile_change", ProfileId(profile)); + + return Success(new JsonObject + { + ["ok"] = true, + ["profile"] = profile, + ["profileWorkflow"] = WorkflowView(snapshot, profileInstance), + ["registrations"] = new JsonArray(registrations), + ["results"] = new JsonArray(results), + ["notices"] = new JsonArray(notices) + }); + } + + public async Task GetNoticesAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var home = await publicQueries.GetHomeAsync(cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["notices"] = home["notices"]?.DeepClone() ?? new JsonArray() + }); + } + + public async Task GetProfileAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: true, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); + var profile = context.Profile!; + var instance = FindInstance(snapshot, "profile_change", ProfileId(profile)); + var home = await publicQueries.GetHomeAsync(cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["profile"] = profile, + ["workflow"] = WorkflowView(snapshot, instance), + ["schools"] = home["schools"]?.DeepClone() ?? new JsonArray(), + ["classes"] = home["classes"]?.DeepClone() ?? new JsonArray() + }); + } + + public async Task GetExamsAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); + var registrationsByExam = snapshot.Registrations + .GroupBy(item => item.ExamId) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var home = await publicQueries.GetHomeAsync(cancellationToken); + var exams = home["exams"]?.AsArray().OfType().Select(item => + { + var exam = item.DeepClone().AsObject(); + exam.Remove("registrationCount"); + var examId = exam["id"]?.GetValue() ?? string.Empty; + exam["registration"] = registrationsByExam.TryGetValue(examId, out var registration) + ? RegistrationJson(registration) + : null; + return exam; + }).ToArray() ?? []; + + return Success(new JsonObject + { + ["ok"] = true, + ["profileStatus"] = context.Profile!["status"]?.GetValue() ?? string.Empty, + ["exams"] = new JsonArray(exams) + }); + } + + public async Task GetRegistrationsAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["registrations"] = new JsonArray(snapshot.Registrations.Select(item => RegistrationView(snapshot, item)).ToArray()) + }); + } + + private async Task ResolveAsync( + string sessionToken, + bool profileRoute, + CancellationToken cancellationToken) + { + if (sessionToken.Length == 0) + { + return ResolvedCandidate.Failed(Error(401, "请先登录")); + } + + var userId = await authenticationState.GetSessionUserIdAsync(sessionToken); + if (userId is null) + { + return ResolvedCandidate.Failed(Error(401, "请先登录")); + } + + var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken); + if (user is not { Active: true, ArchivedAt: null }) + { + return ResolvedCandidate.Failed(Error(401, "请先登录")); + } + + if (user.Role != "candidate") + { + return ResolvedCandidate.Failed(Error(403, "当前账号无权执行此操作")); + } + + var profile = await authenticationRepository.GetCandidateProfileAsync(user.Id, cancellationToken); + if (profile is null) + { + return ResolvedCandidate.Failed(Error(404, "考生资料不存在")); + } + + if (user.MustChangePassword) + { + return ResolvedCandidate.Failed(Error(428, "首次登录必须先修改初始密码")); + } + + if (!profileRoute && profile["profileCompleted"]?.GetValue() != true) + { + return ResolvedCandidate.Failed(Error(428, "请先补全个人信息并提交审核")); + } + + return new ResolvedCandidate(user, profile, null); + } + + private static CandidateWorkflowInstance? FindInstance( + CandidateReadSnapshot snapshot, + string businessType, + string businessId) + { + var matches = snapshot.WorkflowInstances + .Where(item => item.BusinessType == businessType && item.BusinessId == businessId) + .ToArray(); + return matches.FirstOrDefault(item => item.Status == "pending") ?? matches.FirstOrDefault(); + } + + private static JsonObject RegistrationView(CandidateReadSnapshot snapshot, CandidateRegistration registration) + { + var json = RegistrationJson(registration); + var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + var subjects = exam?.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray() ?? []; + var instance = FindInstance(snapshot, "registration_review", registration.Id); + json["exam"] = exam is null ? null : ExamJson(exam); + json["subjects"] = new JsonArray(subjects.Select(SubjectJson).ToArray()); + json["amountDue"] = Math.Round(subjects.Sum(item => item.Fee), 2, MidpointRounding.AwayFromZero); + json["paidByName"] = registration.PaidBy is not null && snapshot.Users.TryGetValue(registration.PaidBy, out var payer) + ? payer.DisplayName + : string.Empty; + json["workflow"] = WorkflowView(snapshot, instance); + return json; + } + + private static JsonObject RegistrationJson(CandidateRegistration item) => new() + { + ["id"] = item.Id, + ["userId"] = item.UserId, + ["examId"] = item.ExamId, + ["subjectIds"] = new JsonArray(item.SubjectIds.Select(value => JsonValue.Create(value)).ToArray()), + ["status"] = item.Status, + ["paymentStatus"] = item.PaymentStatus, + ["paidAt"] = JsonValue.Create(item.PaidAt), + ["paidBy"] = JsonValue.Create(item.PaidBy), + ["createdAt"] = item.CreatedAt, + ["reviewedAt"] = JsonValue.Create(item.ReviewedAt), + ["reviewNote"] = item.ReviewNote, + ["registrationNumber"] = item.RegistrationNumber, + ["numberRuleId"] = JsonValue.Create(item.NumberRuleId), + ["featureScore"] = item.FeatureScore, + ["admitCard"] = item.AdmitCard is null ? null : AdmitCardJson(item.AdmitCard) + }; + + private static JsonObject AdmitCardJson(CandidateAdmitCard item) => new() + { + ["planId"] = item.PlanId, + ["number"] = item.Number, + ["centerId"] = JsonValue.Create(item.CenterId), + ["testCenter"] = item.TestCenter, + ["centerCode"] = item.CenterCode, + ["centerAddress"] = item.CenterAddress, + ["room"] = item.Room, + ["seat"] = item.Seat, + ["assignments"] = new JsonArray(item.Assignments.Select(AssignmentJson).ToArray()), + ["generatedAt"] = item.GeneratedAt + }; + + private static JsonObject AssignmentJson(CandidateAdmitAssignment item) => new() + { + ["subjectId"] = item.SubjectId, + ["roomId"] = JsonValue.Create(item.RoomId), + ["roomName"] = item.RoomName, + ["room"] = item.RoomName, + ["roomCode"] = item.RoomCode, + ["examRoomCode"] = item.ExamRoomCode, + ["building"] = item.Building, + ["floor"] = item.Floor, + ["seat"] = item.Seat, + ["subjectSignature"] = item.SubjectSignature + }; + + private static JsonObject ExamJson(CandidateExam item) => new() + { + ["id"] = item.Id, + ["code"] = item.Code, + ["name"] = item.Name, + ["description"] = item.Description, + ["registrationStart"] = item.RegistrationStart, + ["registrationEnd"] = item.RegistrationEnd, + ["examStart"] = item.ExamStart, + ["examEnd"] = item.ExamEnd, + ["admitDownloadStart"] = item.AdmitDownloadStart, + ["admitDownloadEnd"] = item.AdmitDownloadEnd, + ["location"] = item.Location, + ["passPolicy"] = item.PassPolicy, + ["passValue"] = item.PassValue, + ["status"] = item.Status, + ["archivedAt"] = JsonValue.Create(item.ArchivedAt), + ["archivedBy"] = JsonValue.Create(item.ArchivedBy), + ["createdAt"] = item.CreatedAt, + ["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray()) + }; + + private static JsonObject SubjectJson(CandidateSubject item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["date"] = item.Date, + ["start"] = item.Start, + ["end"] = item.End, + ["fee"] = item.Fee, + ["fullScore"] = item.FullScore, + ["passRule"] = item.PassRule, + ["passValue"] = item.PassValue, + ["passScore"] = JsonValue.Create(item.PassScore), + ["order"] = item.Order + }; + + private static JsonObject ResultJson(CandidateResult item) => new() + { + ["id"] = item.Id, + ["registrationId"] = item.RegistrationId, + ["subjectId"] = item.SubjectId, + ["score"] = item.Score, + ["grade"] = item.Grade, + ["published"] = item.Published, + ["updatedAt"] = JsonValue.Create(item.UpdatedAt), + ["publishedAt"] = JsonValue.Create(item.PublishedAt) + }; + + private static JsonObject? WorkflowView( + CandidateReadSnapshot snapshot, + CandidateWorkflowInstance? instance) + { + if (instance is null) + { + return null; + } + + var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId); + var steps = workflow?.Steps ?? []; + snapshot.Users.TryGetValue(instance.AssigneeId ?? string.Empty, out var assignee); + var actions = snapshot.WorkflowActions.Where(item => item.InstanceId == instance.Id).Select(item => new JsonObject + { + ["id"] = item.Id, + ["instanceId"] = item.InstanceId, + ["actorId"] = JsonValue.Create(item.ActorId), + ["action"] = item.Action, + ["note"] = item.Note, + ["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId), + ["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId), + ["createdAt"] = item.CreatedAt, + ["actorName"] = UserName(snapshot, item.ActorId, "系统"), + ["fromAssigneeName"] = UserName(snapshot, item.FromAssigneeId, string.Empty), + ["toAssigneeName"] = UserName(snapshot, item.ToAssigneeId, string.Empty) + }).ToArray(); + return new JsonObject + { + ["id"] = instance.Id, + ["workflowId"] = instance.WorkflowId, + ["businessType"] = instance.BusinessType, + ["businessId"] = instance.BusinessId, + ["status"] = instance.Status, + ["currentStep"] = instance.CurrentStep, + ["assigneeId"] = JsonValue.Create(instance.AssigneeId), + ["createdAt"] = instance.CreatedAt, + ["completedAt"] = JsonValue.Create(instance.CompletedAt), + ["workflowName"] = workflow?.Name ?? "未命名流程", + ["steps"] = new JsonArray(steps.Select(WorkflowStepJson).ToArray()), + ["currentStepDetail"] = steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } current + ? WorkflowStepJson(current) + : null, + ["assignee"] = assignee is null ? null : SafeUser(assignee), + ["actions"] = new JsonArray(actions) + }; + } + + private static JsonObject WorkflowStepJson(CandidateWorkflowStep item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["adminLevel"] = item.AdminLevel, + ["position"] = item.Position + }; + + private static JsonObject SafeUser(CandidateUserSummary user) => new() + { + ["id"] = user.Id, + ["username"] = user.Username, + ["role"] = user.Role, + ["adminLevel"] = user.Role == "admin" ? user.AdminLevel ?? "super" : null, + ["schoolId"] = JsonValue.Create(user.SchoolId), + ["classId"] = JsonValue.Create(user.ClassId), + ["displayName"] = user.DisplayName, + ["candidateNumber"] = JsonValue.Create(user.CandidateNumber), + ["mustChangePassword"] = user.MustChangePassword, + ["totpEnabled"] = user.TotpEnabled, + ["archived"] = user.ArchivedAt is not null + }; + + private static string UserName(CandidateReadSnapshot snapshot, string? id, string fallback) => + id is not null && snapshot.Users.TryGetValue(id, out var user) ? user.DisplayName : fallback; + + private static string ProfileId(JsonObject profile) => profile["id"]?.GetValue() ?? string.Empty; + + private static DateTimeOffset ParseDate(string? value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed + : DateTimeOffset.MinValue; + + private static CandidateEndpointResult Success(JsonObject body) => new(200, body); + + private static CandidateEndpointResult Error(int statusCode, string message) => new( + statusCode, + new JsonObject { ["ok"] = false, ["message"] = message }); + + private sealed record ResolvedCandidate( + AuthenticationUser? User, + JsonObject? Profile, + CandidateEndpointResult? Error) + { + public static ResolvedCandidate Failed(CandidateEndpointResult error) => new(null, null, error); + } +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs new file mode 100644 index 0000000..cbe6958 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs @@ -0,0 +1,507 @@ +using System.Data.Common; +using System.Globalization; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Candidate; + +internal sealed record CandidateReadSnapshot( + IReadOnlyList Exams, + IReadOnlyList Registrations, + IReadOnlyList Results, + IReadOnlyList Workflows, + IReadOnlyList WorkflowInstances, + IReadOnlyList WorkflowActions, + IReadOnlyDictionary Users); + +internal sealed record CandidateExam( + string Id, + string Code, + string Name, + string Description, + string RegistrationStart, + string RegistrationEnd, + string ExamStart, + string ExamEnd, + string AdmitDownloadStart, + string AdmitDownloadEnd, + string Location, + string PassPolicy, + double PassValue, + string Status, + string? ArchivedAt, + string? ArchivedBy, + string CreatedAt, + IReadOnlyList Subjects); + +internal sealed record CandidateSubject( + string Id, + string Name, + string Date, + string Start, + string End, + double Fee, + double FullScore, + string PassRule, + double PassValue, + double? PassScore, + int Order); + +internal sealed record CandidateRegistration( + string Id, + string UserId, + string ExamId, + IReadOnlyList SubjectIds, + string Status, + string PaymentStatus, + string? PaidAt, + string? PaidBy, + string CreatedAt, + string? ReviewedAt, + string ReviewNote, + string RegistrationNumber, + string? NumberRuleId, + double FeatureScore, + CandidateAdmitCard? AdmitCard); + +internal sealed record CandidateAdmitCard( + string PlanId, + string Number, + string? CenterId, + string TestCenter, + string CenterCode, + string CenterAddress, + string Room, + string Seat, + IReadOnlyList Assignments, + string GeneratedAt); + +internal sealed record CandidateAdmitAssignment( + string SubjectId, + string? RoomId, + string RoomName, + string RoomCode, + string ExamRoomCode, + string Building, + string Floor, + string Seat, + string SubjectSignature); + +internal sealed record CandidateResult( + string Id, + string RegistrationId, + string SubjectId, + double Score, + string Grade, + bool Published, + string? UpdatedAt, + string? PublishedAt); + +internal sealed record CandidateWorkflow( + string Id, + string BusinessType, + string Name, + bool Active, + string? UpdatedBy, + string UpdatedAt, + IReadOnlyList Steps); + +internal sealed record CandidateWorkflowStep( + string Id, + string Name, + string AdminLevel, + int Position); + +internal sealed record CandidateWorkflowInstance( + string Id, + string WorkflowId, + string BusinessType, + string BusinessId, + string Status, + int CurrentStep, + string? AssigneeId, + string CreatedAt, + string? CompletedAt); + +internal sealed record CandidateWorkflowAction( + string Id, + string InstanceId, + string? ActorId, + string Action, + string Note, + string? FromAssigneeId, + string? ToAssigneeId, + string CreatedAt); + +internal sealed record CandidateUserSummary( + string Id, + string Username, + string Role, + string? AdminLevel, + string? SchoolId, + string? ClassId, + string DisplayName, + string? CandidateNumber, + bool MustChangePassword, + bool TotpEnabled, + string? ArchivedAt); + +internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory) +{ + public async Task LoadAsync(string userId, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var subjects = await QueryAsync(connection, + "SELECT id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position FROM exam_subjects ORDER BY exam_id, position, id", + ReadSubject, + cancellationToken); + var subjectsByExam = subjects.GroupBy(item => item.ExamId) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.Subject).ToArray(), StringComparer.Ordinal); + var subjectPositions = subjects.ToDictionary(item => item.Subject.Id, item => item.Subject.Order, StringComparer.Ordinal); + + var exams = await QueryAsync(connection, + "SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id", + reader => ReadExam(reader, subjectsByExam), + cancellationToken); + var registrationRows = await QueryAsync(connection, + "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id", + ReadRegistrationRow, + cancellationToken, + new QueryParameter("@userId", userId)); + var registrationIds = registrationRows.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + + var registrationSubjects = await QueryAsync(connection, + "SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id", + reader => new RegistrationSubject(ReadString(reader, "registration_id"), ReadString(reader, "subject_id")), + cancellationToken); + var subjectIdsByRegistration = registrationSubjects + .Where(item => registrationIds.Contains(item.RegistrationId)) + .GroupBy(item => item.RegistrationId) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal); + + var assignments = await QueryAsync(connection, + "SELECT registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature FROM admit_card_subjects ORDER BY registration_id, subject_id", + ReadAssignment, + cancellationToken); + var assignmentsByRegistration = assignments + .Where(item => registrationIds.Contains(item.RegistrationId)) + .GroupBy(item => item.RegistrationId) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group + .OrderBy(item => subjectPositions.GetValueOrDefault(item.Assignment.SubjectId)) + .Select(item => item.Assignment) + .ToArray(), + StringComparer.Ordinal); + var cards = await QueryAsync(connection, + "SELECT registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at FROM admit_cards ORDER BY registration_id", + reader => ReadCard(reader, assignmentsByRegistration), + cancellationToken); + var cardsByRegistration = cards + .Where(item => registrationIds.Contains(item.RegistrationId)) + .ToDictionary(item => item.RegistrationId, item => item.Card, StringComparer.Ordinal); + + var registrations = registrationRows.Select(row => new CandidateRegistration( + row.Id, + row.UserId, + row.ExamId, + subjectIdsByRegistration.GetValueOrDefault(row.Id) ?? [], + row.Status, + row.PaymentStatus, + row.PaidAt, + row.PaidBy, + row.CreatedAt, + row.ReviewedAt, + row.ReviewNote, + row.RegistrationNumber, + row.NumberRuleId, + row.FeatureScore, + cardsByRegistration.GetValueOrDefault(row.Id))).ToArray(); + + var results = await QueryAsync(connection, + """ + SELECT r.id, r.registration_id, r.subject_id, r.score, r.grade, r.published, r.updated_at, r.published_at + FROM results r JOIN registrations registration ON registration.id = r.registration_id + WHERE registration.user_id = @userId ORDER BY r.id + """, + ReadResult, + cancellationToken, + new QueryParameter("@userId", userId)); + var workflowSteps = await QueryAsync(connection, + "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", + reader => new WorkflowStepRow( + ReadString(reader, "workflow_id"), + new CandidateWorkflowStep( + ReadString(reader, "id"), + ReadString(reader, "name"), + ReadString(reader, "admin_level"), + ReadInt32(reader, "position"))), + cancellationToken); + var stepsByWorkflow = workflowSteps.GroupBy(item => item.WorkflowId) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.Step).ToArray(), StringComparer.Ordinal); + var workflows = await QueryAsync(connection, + "SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions ORDER BY business_type, id", + reader => + { + var id = ReadString(reader, "id"); + return new CandidateWorkflow( + id, + ReadString(reader, "business_type"), + ReadString(reader, "name"), + ReadBoolean(reader, "active"), + ReadOptionalString(reader, "updated_by"), + ReadString(reader, "updated_at"), + stepsByWorkflow.GetValueOrDefault(id) ?? []); + }, + cancellationToken); + var instances = await QueryAsync(connection, + "SELECT id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at FROM workflow_instances ORDER BY created_at DESC, id", + ReadWorkflowInstance, + cancellationToken); + var actions = await QueryAsync(connection, + "SELECT id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at FROM workflow_actions ORDER BY created_at, id", + ReadWorkflowAction, + cancellationToken); + var users = await QueryAsync(connection, + "SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, must_change_password, totp_enabled, archived_at FROM users ORDER BY created_at, id", + ReadUserSummary, + cancellationToken); + + return new CandidateReadSnapshot( + exams, + registrations, + results, + workflows, + instances, + actions, + users.ToDictionary(item => item.Id, StringComparer.Ordinal)); + } + + private static async Task> QueryAsync( + DbConnection connection, + string sql, + Func projector, + CancellationToken cancellationToken, + params QueryParameter[] parameters) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var item in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = item.Name; + parameter.Value = item.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + var output = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + output.Add(projector(reader)); + } + + return output; + } + + private static SubjectRow ReadSubject(DbDataReader reader) + { + var fullScore = ReadDouble(reader, "full_score", 150); + var rawRule = ReadOptionalString(reader, "pass_rule") ?? "fixed_score"; + var passRule = rawRule == "score_ratio" ? "rank_percent" : rawRule; + var passValue = ReadNullableDouble(reader, "pass_value") + ?? ReadNullableDouble(reader, "pass_score") + ?? fullScore * 0.6; + return new SubjectRow( + ReadString(reader, "exam_id"), + new CandidateSubject( + ReadString(reader, "id"), + ReadString(reader, "name"), + ReadString(reader, "subject_date"), + ReadString(reader, "start_time"), + ReadString(reader, "end_time"), + ReadDouble(reader, "fee"), + fullScore, + passRule, + passValue, + passRule == "fixed_score" ? Math.Round(passValue, 2) : null, + ReadInt32(reader, "position"))); + } + + private static CandidateExam ReadExam( + DbDataReader reader, + IReadOnlyDictionary> subjectsByExam) + { + var id = ReadString(reader, "id"); + var rawPolicy = ReadOptionalString(reader, "pass_policy") ?? "rank_percent"; + return new CandidateExam( + id, + ReadString(reader, "code"), + ReadString(reader, "name"), + ReadString(reader, "description"), + ReadString(reader, "registration_start"), + ReadString(reader, "registration_end"), + ReadString(reader, "exam_start"), + ReadString(reader, "exam_end"), + ReadString(reader, "admit_download_start"), + ReadString(reader, "admit_download_end"), + ReadString(reader, "location"), + rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy, + ReadDouble(reader, "pass_value", 60), + ReadString(reader, "status"), + ReadOptionalString(reader, "archived_at"), + ReadOptionalString(reader, "archived_by"), + ReadString(reader, "created_at"), + subjectsByExam.GetValueOrDefault(id) ?? []); + } + + private static RegistrationRow ReadRegistrationRow(DbDataReader reader) => new( + ReadString(reader, "id"), + ReadString(reader, "user_id"), + ReadString(reader, "exam_id"), + ReadString(reader, "status"), + ReadString(reader, "payment_status"), + ReadOptionalString(reader, "paid_at"), + ReadOptionalString(reader, "paid_by"), + ReadString(reader, "created_at"), + ReadOptionalString(reader, "reviewed_at"), + ReadOptionalString(reader, "review_note") ?? string.Empty, + ReadOptionalString(reader, "registration_number") ?? string.Empty, + ReadOptionalString(reader, "number_rule_id"), + ReadDouble(reader, "feature_score")); + + private static AssignmentRow ReadAssignment(DbDataReader reader) => new( + ReadString(reader, "registration_id"), + new CandidateAdmitAssignment( + ReadString(reader, "subject_id"), + ReadOptionalString(reader, "room_id"), + ReadString(reader, "room"), + ReadString(reader, "room_code"), + ReadString(reader, "exam_room_code"), + ReadOptionalString(reader, "building") ?? string.Empty, + ReadOptionalString(reader, "floor") ?? string.Empty, + ReadString(reader, "seat"), + ReadOptionalString(reader, "subject_signature") ?? string.Empty)); + + private static CardRow ReadCard( + DbDataReader reader, + IReadOnlyDictionary> assignmentsByRegistration) + { + var registrationId = ReadString(reader, "registration_id"); + var assignments = assignmentsByRegistration.GetValueOrDefault(registrationId) ?? []; + var primary = assignments.FirstOrDefault(); + return new CardRow( + registrationId, + new CandidateAdmitCard( + ReadString(reader, "plan_id"), + ReadString(reader, "card_number"), + ReadOptionalString(reader, "center_id"), + ReadString(reader, "test_center"), + ReadOptionalString(reader, "center_code") ?? string.Empty, + ReadOptionalString(reader, "center_address") ?? string.Empty, + primary?.RoomName ?? string.Empty, + primary?.Seat ?? string.Empty, + assignments, + ReadString(reader, "generated_at"))); + } + + private static CandidateResult ReadResult(DbDataReader reader) => new( + ReadString(reader, "id"), + ReadString(reader, "registration_id"), + ReadString(reader, "subject_id"), + ReadDouble(reader, "score"), + ReadString(reader, "grade"), + ReadBoolean(reader, "published"), + ReadOptionalString(reader, "updated_at"), + ReadOptionalString(reader, "published_at")); + + private static CandidateWorkflowInstance ReadWorkflowInstance(DbDataReader reader) => new( + ReadString(reader, "id"), + ReadString(reader, "workflow_id"), + ReadString(reader, "business_type"), + ReadString(reader, "business_id"), + ReadString(reader, "status"), + ReadInt32(reader, "current_step"), + ReadOptionalString(reader, "assignee_id"), + ReadString(reader, "created_at"), + ReadOptionalString(reader, "completed_at")); + + private static CandidateWorkflowAction ReadWorkflowAction(DbDataReader reader) => new( + ReadString(reader, "id"), + ReadString(reader, "instance_id"), + ReadOptionalString(reader, "actor_id"), + ReadString(reader, "action"), + ReadOptionalString(reader, "note") ?? string.Empty, + ReadOptionalString(reader, "from_assignee_id"), + ReadOptionalString(reader, "to_assignee_id"), + ReadString(reader, "created_at")); + + private static CandidateUserSummary ReadUserSummary(DbDataReader reader) => new( + ReadString(reader, "id"), + ReadString(reader, "username"), + ReadString(reader, "role"), + ReadOptionalString(reader, "admin_level"), + ReadOptionalString(reader, "school_id"), + ReadOptionalString(reader, "class_id"), + ReadString(reader, "display_name"), + ReadOptionalString(reader, "candidate_number"), + ReadBoolean(reader, "must_change_password"), + ReadBoolean(reader, "totp_enabled"), + ReadOptionalString(reader, "archived_at")); + + private static string ReadString(DbDataReader reader, string name) => + Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty; + + private static string? ReadOptionalString(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static bool ReadBoolean(DbDataReader reader, string name) + { + var value = reader.GetValue(reader.GetOrdinal(name)); + return value is bool boolean ? boolean : Convert.ToInt64(value, CultureInfo.InvariantCulture) != 0; + } + + private static int ReadInt32(DbDataReader reader, string name) => + Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); + + private static double ReadDouble(DbDataReader reader, string name, double fallback = 0) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? fallback : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static double? ReadNullableDouble(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private sealed record QueryParameter(string Name, object? Value); + + private sealed record SubjectRow(string ExamId, CandidateSubject Subject); + + private sealed record RegistrationRow( + string Id, + string UserId, + string ExamId, + string Status, + string PaymentStatus, + string? PaidAt, + string? PaidBy, + string CreatedAt, + string? ReviewedAt, + string ReviewNote, + string RegistrationNumber, + string? NumberRuleId, + double FeatureScore); + + private sealed record RegistrationSubject(string RegistrationId, string SubjectId); + + private sealed record AssignmentRow(string RegistrationId, CandidateAdmitAssignment Assignment); + + private sealed record CardRow(string RegistrationId, CandidateAdmitCard Card); + + private sealed record WorkflowStepRow(string WorkflowId, CandidateWorkflowStep Step); +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index d4ca546..c7becab 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -1,6 +1,8 @@ using Eis.Application.Authentication; +using Eis.Application.Candidate; using Eis.Application.Public; using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Candidate; using Eis.Infrastructure.Data; using Eis.Infrastructure.Public; using Eis.Infrastructure.Security; @@ -24,7 +26,8 @@ public static class DependencyInjection this IServiceCollection services, DatabaseOptions databaseOptions, DocumentVerificationOptions documentVerificationOptions, - AuthenticationOptions authenticationOptions) + AuthenticationOptions authenticationOptions, + CandidateMigrationOptions candidateMigrationOptions) { services.AddSingleton(databaseOptions); services.AddSingleton(); @@ -39,6 +42,9 @@ public static class DependencyInjection : new MemoryAuthenticationStateStore(authenticationOptions)); services.AddScoped(); services.AddScoped(); + services.AddSingleton(candidateMigrationOptions); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs b/src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs new file mode 100644 index 0000000..0a6b297 --- /dev/null +++ b/src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs @@ -0,0 +1,60 @@ +using Eis.Application.Candidate; +using Eis.Infrastructure.Candidate; + +namespace Eis.Web.Candidate; + +public static class NativeCandidateReadEndpoints +{ + public static IEndpointRouteBuilder MapNativeCandidateReadEndpoints( + this IEndpointRouteBuilder endpoints, + CandidateMigrationOptions options) + { + if (!options.NativeReadEnabled) + { + return endpoints; + } + + endpoints.MapGet("/api/candidate/dashboard", async ( + HttpContext context, + ICandidateQueryService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetDashboardAsync(SessionToken(context), cancellationToken))); + endpoints.MapGet("/api/candidate/notices", async ( + HttpContext context, + ICandidateQueryService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetNoticesAsync(SessionToken(context), cancellationToken))); + endpoints.MapGet("/api/candidate/profile", async ( + HttpContext context, + ICandidateQueryService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetProfileAsync(SessionToken(context), cancellationToken))); + endpoints.MapGet("/api/candidate/exams", async ( + HttpContext context, + ICandidateQueryService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetExamsAsync(SessionToken(context), cancellationToken))); + endpoints.MapGet("/api/candidate/registrations", async ( + HttpContext context, + ICandidateQueryService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetRegistrationsAsync(SessionToken(context), cancellationToken))); + + return endpoints; + } + + private static string SessionToken(HttpContext context) => + context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; + + private static IResult ToResult(HttpContext context, CandidateEndpointResult result) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; + return Results.Json(result.Body, statusCode: result.StatusCode); + } +} diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 3bcd004..fac9c06 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -1,5 +1,6 @@ using System.Net; using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Candidate; using Eis.Application.Public; using Eis.Infrastructure; using Eis.Infrastructure.Data; @@ -7,6 +8,7 @@ using Eis.Infrastructure.Migration; using Eis.Infrastructure.Security; using Eis.Web.Configuration; using Eis.Web.Authentication; +using Eis.Web.Candidate; using Eis.Web.Frontend; using Eis.Web.Legacy; using Eis.Web.Public; @@ -34,10 +36,15 @@ builder.Services.AddSingleton var authenticationOptions = AuthenticationOptions.FromEnvironment( builder.Environment.IsProduction(), builder.Configuration.GetValue("AuthenticationMigration:NativeEnabled")); +var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment( + builder.Configuration.GetValue("CandidateMigration:NativeReadEnabled"), + authenticationOptions.NativeEnabled, + authenticationOptions.SharesLegacySessions); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), - authenticationOptions); + authenticationOptions, + candidateMigrationOptions); var app = builder.Build(); app.Services.EnsureNativeAuthenticationReady(authenticationOptions); @@ -72,12 +79,20 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c stateBackend = authenticationOptions.UsesRedis ? "redis" : "memory", sharesLegacySessions = authenticationOptions.SharesLegacySessions }, + candidate = new + { + nativeReadEnabled = candidateMigrationOptions.NativeReadEnabled, + nativeRoutes = candidateMigrationOptions.NativeReadEnabled + ? new[] { "dashboard", "notices", "profile", "exams", "registrations" } + : [] + }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled) }, statusCode: statusCode); }); app.MapNativePublicEndpoints(); app.MapNativeAuthenticationEndpoints(authenticationOptions); +app.MapNativeCandidateReadEndpoints(candidateMigrationOptions); string[] methods = [ diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 1545cc6..7949e83 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -6,6 +6,9 @@ "AuthenticationMigration": { "NativeEnabled": false }, + "CandidateMigration": { + "NativeReadEnabled": false + }, "Logging": { "LogLevel": { "Default": "Information",