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

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
@@ -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;
}