已完成考生域第一批只读迁移:
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:
@@ -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.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<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
||||
@@ -39,6 +42,9 @@ public static class DependencyInjection
|
||||
: new MemoryAuthenticationStateStore(authenticationOptions));
|
||||
services.AddScoped<AuthenticationRepository>();
|
||||
services.AddScoped<IAuthenticationService, AuthenticationService>();
|
||||
services.AddSingleton(candidateMigrationOptions);
|
||||
services.AddScoped<CandidateReadSnapshotLoader>();
|
||||
services.AddScoped<ICandidateQueryService, CandidateQueryService>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
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
@@ -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<IPublicSiteConfiguration, PublicSiteConfiguration>
|
||||
var authenticationOptions = AuthenticationOptions.FromEnvironment(
|
||||
builder.Environment.IsProduction(),
|
||||
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
|
||||
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
|
||||
builder.Configuration.GetValue<bool>("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 =
|
||||
[
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"AuthenticationMigration": {
|
||||
"NativeEnabled": false
|
||||
},
|
||||
"CandidateMigration": {
|
||||
"NativeReadEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
Reference in New Issue
Block a user