已完成考生域第一批只读迁移:

GET /api/candidate/dashboard
GET /api/candidate/notices
GET /api/candidate/profile
GET /api/candidate/exams
GET /api/candidate/registrations
This commit is contained in:
2026-07-22 19:45:22 +08:00 Unverified
parent ae472aabb0
commit 07efa6813b
11 changed files with 1171 additions and 3 deletions
+2
View File
@@ -23,6 +23,8 @@ PORT=4173
# 原生 ASP.NET Core 认证切换。迁移期间默认关闭;生产环境开启时必须配置共享 Redis, # 原生 ASP.NET Core 认证切换。迁移期间默认关闭;生产环境开启时必须配置共享 Redis,
# 以便尚未迁移的 Node 受保护接口识别由 ASP.NET Core 创建的会话。 # 以便尚未迁移的 Node 受保护接口识别由 ASP.NET Core 创建的会话。
AUTH_NATIVE_ENABLED=false AUTH_NATIVE_ENABLED=false
# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。
CANDIDATE_NATIVE_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+7
View File
@@ -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 受保护接口的联调环境必须配置 `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 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
```powershell ```powershell
+104 -1
View File
@@ -84,6 +84,74 @@ function Wait-ForUrl {
throw "Timed out waiting for $Uri" 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 { try {
New-Item -ItemType Directory -Path $testDirectory | Out-Null New-Item -ItemType Directory -Path $testDirectory | Out-Null
@@ -308,6 +376,8 @@ try {
) -Environment @{ ) -Environment @{
ASPNETCORE_ENVIRONMENT = 'Development' ASPNETCORE_ENVIRONMENT = 'Development'
AUTH_NATIVE_ENABLED = 'true' AUTH_NATIVE_ENABLED = 'true'
CANDIDATE_NATIVE_ENABLED = 'true'
CANDIDATE_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
DATABASE_CLIENT = 'sqlite' DATABASE_CLIENT = 'sqlite'
@@ -319,6 +389,25 @@ try {
} }
Wait-ForUrl -Uri "$nativeAuthBaseUrl/health/live" -Processes @($nodeProcess, $nativeAuthProcess) 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] $registrationSchool = @($homePayload.schools | Select-Object -First 1)[0]
$registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | 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) { if ($null -eq $registrationSchool -or $null -eq $registrationClass) {
@@ -337,10 +426,19 @@ try {
} }
$registration = $registrationResponse.Content | ConvertFrom-Json $registration = $registrationResponse.Content | ConvertFrom-Json
$registeredLoginBody = @{ username = $registration.registrationNumber; password = 'Registration456!' } | ConvertTo-Json -Compress $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) { if ($registeredLogin.user.candidateNumber -ne $registration.registrationNumber) {
throw 'Native self-registration did not create a usable candidate account' 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() $nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession $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') { if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
throw 'Native authentication could not verify the existing Node PBKDF2 account' 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 $nativeMe = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $nativeSession
if ($nativeMe.user.username -ne 'admin' -or $nativeMe.permissions[0] -ne '*') { if ($nativeMe.user.username -ne 'admin' -or $nativeMe.permissions[0] -ne '*') {
@@ -425,6 +527,7 @@ try {
PublicParity = 'passed' PublicParity = 'passed'
DocumentCodes = 'passed' DocumentCodes = 'passed'
NativeAuthentication = 'passed' NativeAuthentication = 'passed'
NativeCandidateReads = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -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<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> GetNoticesAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> GetProfileAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -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
};
}
@@ -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<CandidateEndpointResult> 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<JsonObject>()
.OrderByDescending(item => ParseDate(item["publishAt"]?.GetValue<string>()))
.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<CandidateEndpointResult> 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<CandidateEndpointResult> 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<CandidateEndpointResult> 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<JsonObject>().Select(item =>
{
var exam = item.DeepClone().AsObject();
exam.Remove("registrationCount");
var examId = exam["id"]?.GetValue<string>() ?? 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>() ?? string.Empty,
["exams"] = new JsonArray(exams)
});
}
public async Task<CandidateEndpointResult> 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<ResolvedCandidate> 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<bool>() != 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>() ?? 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);
}
}
@@ -0,0 +1,507 @@
using System.Data.Common;
using System.Globalization;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Candidate;
internal sealed record CandidateReadSnapshot(
IReadOnlyList<CandidateExam> Exams,
IReadOnlyList<CandidateRegistration> Registrations,
IReadOnlyList<CandidateResult> Results,
IReadOnlyList<CandidateWorkflow> Workflows,
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
IReadOnlyList<CandidateWorkflowAction> WorkflowActions,
IReadOnlyDictionary<string, CandidateUserSummary> 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<CandidateSubject> 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<string> 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<CandidateAdmitAssignment> 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<CandidateWorkflowStep> 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<CandidateReadSnapshot> 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<CandidateSubject>)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<string>)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<CandidateAdmitAssignment>)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<CandidateWorkflowStep>)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<IReadOnlyList<T>> QueryAsync<T>(
DbConnection connection,
string sql,
Func<DbDataReader, T> 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<T>();
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<string, IReadOnlyList<CandidateSubject>> 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<string, IReadOnlyList<CandidateAdmitAssignment>> 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);
}
@@ -1,6 +1,8 @@
using Eis.Application.Authentication; using Eis.Application.Authentication;
using Eis.Application.Candidate;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Data; using Eis.Infrastructure.Data;
using Eis.Infrastructure.Public; using Eis.Infrastructure.Public;
using Eis.Infrastructure.Security; using Eis.Infrastructure.Security;
@@ -24,7 +26,8 @@ public static class DependencyInjection
this IServiceCollection services, this IServiceCollection services,
DatabaseOptions databaseOptions, DatabaseOptions databaseOptions,
DocumentVerificationOptions documentVerificationOptions, DocumentVerificationOptions documentVerificationOptions,
AuthenticationOptions authenticationOptions) AuthenticationOptions authenticationOptions,
CandidateMigrationOptions candidateMigrationOptions)
{ {
services.AddSingleton(databaseOptions); services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>(); services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
@@ -39,6 +42,9 @@ public static class DependencyInjection
: new MemoryAuthenticationStateStore(authenticationOptions)); : new MemoryAuthenticationStateStore(authenticationOptions));
services.AddScoped<AuthenticationRepository>(); services.AddScoped<AuthenticationRepository>();
services.AddScoped<IAuthenticationService, AuthenticationService>(); services.AddScoped<IAuthenticationService, AuthenticationService>();
services.AddSingleton(candidateMigrationOptions);
services.AddScoped<CandidateReadSnapshotLoader>();
services.AddScoped<ICandidateQueryService, CandidateQueryService>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
} }
@@ -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);
}
}
+16 -1
View File
@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure; using Eis.Infrastructure;
using Eis.Infrastructure.Data; using Eis.Infrastructure.Data;
@@ -7,6 +8,7 @@ using Eis.Infrastructure.Migration;
using Eis.Infrastructure.Security; using Eis.Infrastructure.Security;
using Eis.Web.Configuration; using Eis.Web.Configuration;
using Eis.Web.Authentication; using Eis.Web.Authentication;
using Eis.Web.Candidate;
using Eis.Web.Frontend; using Eis.Web.Frontend;
using Eis.Web.Legacy; using Eis.Web.Legacy;
using Eis.Web.Public; using Eis.Web.Public;
@@ -34,10 +36,15 @@ builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>
var authenticationOptions = AuthenticationOptions.FromEnvironment( var authenticationOptions = AuthenticationOptions.FromEnvironment(
builder.Environment.IsProduction(), builder.Environment.IsProduction(),
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled")); builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("CandidateMigration:NativeReadEnabled"),
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions);
builder.Services.AddEisInfrastructure( builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
authenticationOptions); authenticationOptions,
candidateMigrationOptions);
var app = builder.Build(); var app = builder.Build();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions); app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
@@ -72,12 +79,20 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
stateBackend = authenticationOptions.UsesRedis ? "redis" : "memory", stateBackend = authenticationOptions.UsesRedis ? "redis" : "memory",
sharesLegacySessions = authenticationOptions.SharesLegacySessions sharesLegacySessions = authenticationOptions.SharesLegacySessions
}, },
candidate = new
{
nativeReadEnabled = candidateMigrationOptions.NativeReadEnabled,
nativeRoutes = candidateMigrationOptions.NativeReadEnabled
? new[] { "dashboard", "notices", "profile", "exams", "registrations" }
: []
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
}, statusCode: statusCode); }, statusCode: statusCode);
}); });
app.MapNativePublicEndpoints(); app.MapNativePublicEndpoints();
app.MapNativeAuthenticationEndpoints(authenticationOptions); app.MapNativeAuthenticationEndpoints(authenticationOptions);
app.MapNativeCandidateReadEndpoints(candidateMigrationOptions);
string[] methods = string[] methods =
[ [
+3
View File
@@ -6,6 +6,9 @@
"AuthenticationMigration": { "AuthenticationMigration": {
"NativeEnabled": false "NativeEnabled": false
}, },
"CandidateMigration": {
"NativeReadEnabled": false
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",