本批迁移已完成:

原生化后台只读接口:GET /api/admin/candidates
GET /api/admin/registrations
GET /api/admin/payments

保持超级、校级、班级管理员的数据范围一致。
保持证件号脱敏、审批流、考试科目、缴费信息及座位号前导零兼容。
新增灰度开关:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true
归档、审核、缴费更新等写操作暂时继续转发 Node。
This commit is contained in:
2026-07-23 09:39:47 +08:00 Unverified
parent 518c922773
commit 95a87f0276
12 changed files with 620 additions and 11 deletions
@@ -6,7 +6,8 @@ public sealed record AdminMigrationOptions(
bool NativeAccountBatchesEnabled = false,
bool NativeConfigurationEnabled = false,
bool NativeNoticeManagementEnabled = false,
bool NativeCentersEnabled = false)
bool NativeCentersEnabled = false,
bool NativeOperationalReadsEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -16,7 +17,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeAccountBatchesEnabled = false,
bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeManagementEnabled = false,
bool configuredNativeCentersEnabled = false)
bool configuredNativeCentersEnabled = false,
bool configuredNativeOperationalReadsEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -37,12 +39,15 @@ public sealed record AdminMigrationOptions(
var centersEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
configuredNativeCentersEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled) && !readsEnabled)
var operationalReadsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
configuredNativeOperationalReadsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -58,7 +63,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -0,0 +1,221 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminOperationalReadService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
AdminOperationalSnapshotLoader operationalSnapshotLoader) : IAdminOperationalReadService
{
public Task<AdminEndpointResult> GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "candidates", cancellationToken);
public Task<AdminEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "registrations", cancellationToken);
public Task<AdminEndpointResult> GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "payments", cancellationToken);
private async Task<AdminEndpointResult> ExecuteAsync(
string sessionToken,
string resource,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
return resource switch
{
"candidates" => Candidates(user, workflow, operational),
"registrations" => Registrations(user, workflow, operational),
_ => Payments(user, workflow, operational)
};
}
private static AdminEndpointResult Candidates(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var candidates = operational.Profiles.Where(profile => InScope(user, profile)).Select(profile =>
{
var output = profile.Data.DeepClone().AsObject();
var account = operational.Users.FirstOrDefault(item => item.Id == profile.UserId);
var idNumber = output["idNumber"]?.GetValue<string>() ?? "";
output["idNumberMasked"] = idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ? "待考生补充" : MaskId(idNumber);
if (account is not null) output["username"] = account.Username;
output["candidateNumber"] = account?.CandidateNumber ?? "";
output["mustChangePassword"] = account?.MustChangePassword ?? false;
output["accountArchived"] = account?.ArchivedAt is not null;
output["archivedAt"] = JsonValue.Create(account?.ArchivedAt);
output["archivedByName"] = operational.Users.FirstOrDefault(item => item.Id == account?.ArchivedBy)?.DisplayName ?? "";
var registrations = operational.Registrations.Where(item => item.UserId == profile.UserId)
.OrderByDescending(item => ParseDate(item.CreatedAt))
.Select(item => RegistrationJson(workflow, operational, item)).ToArray();
output["registrations"] = new JsonArray(registrations);
var instance = WorkflowInstance(workflow, "profile_change", profile.Id);
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
return output;
}).ToArray();
var schools = workflow.Schools.Where(item => item.Active && (Level(user) == "super" || item.Id == user.SchoolId))
.Select(SchoolJson).ToArray();
var classes = workflow.Classes.Where(item => item.Active && (Level(user) == "super" || item.SchoolId == user.SchoolId))
.Select(ClassJson).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["candidates"] = new JsonArray(candidates),
["schools"] = new JsonArray(schools),
["classes"] = new JsonArray(classes)
});
}
private static AdminEndpointResult Registrations(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var registrations = operational.Registrations.Select(registration =>
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
if (profile is null || !InScope(user, profile)) return null;
var output = RegistrationJson(workflow, operational, registration);
var candidate = profile.Data.DeepClone().AsObject();
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
output["candidate"] = candidate;
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
profile.Data["school"]?.GetValue<string>() ?? "";
output["gradeName"] = schoolClass?.Grade ?? "";
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
return output;
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
return Success(new JsonObject { ["ok"] = true, ["registrations"] = new JsonArray(registrations) });
}
private static AdminEndpointResult Payments(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var registrations = operational.Registrations.Where(item => item.Status == "approved").Select(registration =>
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
if (profile is null || !InScope(user, profile)) return null;
var output = RegistrationJson(workflow, operational, registration);
var candidate = profile.Data.DeepClone().AsObject();
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
output["candidate"] = candidate;
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
profile.Data["school"]?.GetValue<string>() ?? "";
output["gradeName"] = schoolClass?.Grade ?? "";
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
return output;
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
return Success(new JsonObject
{
["ok"] = true,
["scopeLabel"] = ScopeLabel(user, workflow),
["canConfirmPayment"] = true,
["canUpdatePayment"] = true,
["registrations"] = new JsonArray(registrations)
});
}
private static JsonObject RegistrationJson(
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational,
OperationalRegistration registration)
{
var output = registration.Data.DeepClone().AsObject();
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var subjectIds = output["subjectIds"]?.AsArray().Select(item => item?.GetValue<string>() ?? "").ToHashSet(StringComparer.Ordinal) ?? [];
var subjects = (exam?.Subjects ?? []).Where(item => subjectIds.Contains(item["id"]?.GetValue<string>() ?? ""))
.Select(item => item.DeepClone()).ToArray();
output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray(subjects);
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = WorkflowInstance(workflow, "registration_review", registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
return output;
}
private static AccountWorkflowInstance? WorkflowInstance(AdminAccountBatchSnapshot snapshot, string businessType, string businessId) =>
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId && item.Status == "pending") ??
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId);
private static JsonObject WorkflowJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance)
{
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id).Select(item => ActionJson(snapshot, item)).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((workflow?.Steps ?? []).Select(StepJson).ToArray()),
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step ? StepJson(step) : null,
["assignee"] = assignee is null ? null : SafeUser(assignee),
["actions"] = new JsonArray(actions)
};
}
private async Task<ResolvedAdmin> ResolveAsync(string token, CancellationToken cancellationToken)
{
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(token);
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
}
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
_ => user.ClassId is not null && profile.ClassId == user.ClassId
};
private static string ScopeLabel(AuthenticationUser user, AdminAccountBatchSnapshot snapshot)
{
if (Level(user) == "super") return "全部学校与班级";
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
if (Level(user) == "school") return school;
var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
return $"{school} · {schoolClass}";
}
private static double AmountDue(JsonArray? subjects) =>
Math.Round(subjects?.OfType<JsonObject>().Sum(item => item["fee"]?.GetValue<double>() ?? 0) ?? 0, 2, MidpointRounding.AwayFromZero);
private static string MaskId(string value) => value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value;
private static DateTimeOffset ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue;
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static JsonObject SchoolJson(AdminSchool item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["code"] = item.Code, ["address"] = item.Address, ["isSourceSchool"] = item.IsSourceSchool, ["isAdmissionSchool"] = item.IsAdmissionSchool, ["active"] = item.Active };
private static JsonObject ClassJson(AdminClass item) => new() { ["id"] = item.Id, ["schoolId"] = item.SchoolId, ["name"] = item.Name, ["grade"] = item.Grade, ["active"] = item.Active };
private static JsonObject StepJson(AccountWorkflowStep item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["adminLevel"] = item.AdminLevel, ["position"] = item.Position };
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new() { ["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"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统", ["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "", ["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? "" };
private static JsonObject SafeUser(AdminUser item) => new() { ["id"] = item.Id, ["username"] = item.Username, ["role"] = item.Role, ["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null, ["schoolId"] = JsonValue.Create(item.SchoolId), ["classId"] = JsonValue.Create(item.ClassId), ["displayName"] = item.DisplayName, ["candidateNumber"] = JsonValue.Create(item.CandidateNumber), ["mustChangePassword"] = item.MustChangePassword, ["totpEnabled"] = item.TotpEnabled, ["archived"] = item.ArchivedAt is not null };
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message });
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
}
@@ -0,0 +1,317 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminOperationalSnapshot(
IReadOnlyList<OperationalUser> Users,
IReadOnlyList<OperationalProfile> Profiles,
IReadOnlyList<OperationalExam> Exams,
IReadOnlyList<OperationalRegistration> Registrations);
internal sealed record OperationalUser(
string Id,
string Username,
string CandidateNumber,
bool MustChangePassword,
string? ArchivedAt,
string? ArchivedBy,
string DisplayName);
internal sealed record OperationalProfile(
string Id,
string UserId,
string? SchoolId,
string? ClassId,
JsonObject Data);
internal sealed record OperationalExam(string Id, JsonObject Data, IReadOnlyList<JsonObject> Subjects);
internal sealed record OperationalRegistration(
string Id,
string UserId,
string ExamId,
string Status,
string PaymentStatus,
string? PaidBy,
string CreatedAt,
JsonObject Data);
internal sealed class AdminOperationalSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminOperationalSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var users = await QueryAsync(connection,
"SELECT id, username, candidate_number, must_change_password, archived_at, archived_by, display_name FROM users ORDER BY created_at, id",
reader => new OperationalUser(
Text(reader, "id"), Text(reader, "username"), Optional(reader, "candidate_number") ?? "",
Boolean(reader, "must_change_password"), Optional(reader, "archived_at"), Optional(reader, "archived_by"), Text(reader, "display_name")),
cancellationToken);
var profiles = await QueryAsync(connection,
"""
SELECT id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
province_code, province_name, city_code, city_name, district_code, district_name, address,
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code,
guardian_name, guardian_phone, specialty_category, specialty_type, specialty_types,
specialty_certificate, policy_eligibility, profile_completed, status, review_note,
reviewed_at, reviewer_id, updated_at
FROM candidate_profiles ORDER BY updated_at, id
""",
ReadProfile,
cancellationToken);
var subjectRows = 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 = subjectRows.GroupBy(item => item.ExamId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<JsonObject>)group.Select(item => item.Data).ToArray(), 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 registrationSubjects = await QueryAsync(connection,
"SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id",
reader => new RegistrationSubject(Text(reader, "registration_id"), Text(reader, "subject_id")),
cancellationToken);
var subjectIdsByRegistration = registrationSubjects.GroupBy(item => item.RegistrationId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal);
var subjectOrder = subjectRows.ToDictionary(item => item.Id, item => item.Order, 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.GroupBy(item => item.RegistrationId)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<JsonObject>)group.OrderBy(item => subjectOrder.GetValueOrDefault(item.SubjectId)).Select(item => item.Data).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.ToDictionary(item => item.RegistrationId, item => item.Data, StringComparer.Ordinal);
var registrations = 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 ORDER BY created_at, id
""",
reader => ReadRegistration(reader, subjectIdsByRegistration, cardsByRegistration),
cancellationToken);
return new(users, profiles, exams, registrations);
}
private static OperationalProfile ReadProfile(DbDataReader reader)
{
var id = Text(reader, "id");
var userId = Text(reader, "user_id");
var schoolId = Optional(reader, "school_id");
var classId = Optional(reader, "class_id");
return new(id, userId, schoolId, classId, new JsonObject
{
["id"] = id,
["userId"] = userId,
["name"] = Text(reader, "name"),
["gender"] = Optional(reader, "gender") ?? "",
["idNumber"] = Text(reader, "id_number"),
["phone"] = Text(reader, "phone"),
["email"] = Optional(reader, "email") ?? "",
["school"] = Optional(reader, "school") ?? "",
["grade"] = Optional(reader, "grade") ?? "",
["schoolId"] = JsonValue.Create(schoolId),
["classId"] = JsonValue.Create(classId),
["provinceCode"] = Optional(reader, "province_code") ?? "",
["provinceName"] = Optional(reader, "province_name") ?? "",
["cityCode"] = Optional(reader, "city_code") ?? "",
["cityName"] = Optional(reader, "city_name") ?? "",
["districtCode"] = Optional(reader, "district_code") ?? "",
["districtName"] = Optional(reader, "district_name") ?? "",
["address"] = Optional(reader, "address") ?? "",
["emergencyContact"] = Optional(reader, "emergency_contact") ?? "",
["emergencyPhone"] = Optional(reader, "emergency_phone") ?? "",
["nativePlace"] = Optional(reader, "native_place") ?? "",
["birthDate"] = Optional(reader, "birth_date") ?? "",
["ethnicity"] = Optional(reader, "ethnicity") ?? "",
["postalCode"] = Optional(reader, "postal_code") ?? "",
["guardianName"] = Optional(reader, "guardian_name") ?? "",
["guardianPhone"] = Optional(reader, "guardian_phone") ?? "",
["specialtyCategory"] = Optional(reader, "specialty_category") ?? "",
["specialtyType"] = Optional(reader, "specialty_type") ?? "",
["specialtyTypes"] = ParseArray(Optional(reader, "specialty_types")),
["specialtyCertificate"] = Optional(reader, "specialty_certificate") ?? "",
["policyEligibility"] = Optional(reader, "policy_eligibility") ?? "",
["profileCompleted"] = Boolean(reader, "profile_completed"),
["status"] = Text(reader, "status"),
["reviewNote"] = Optional(reader, "review_note") ?? "",
["reviewedAt"] = JsonValue.Create(Optional(reader, "reviewed_at")),
["reviewerId"] = JsonValue.Create(Optional(reader, "reviewer_id")),
["updatedAt"] = Text(reader, "updated_at")
});
}
private static SubjectRow ReadSubject(DbDataReader reader)
{
var fullScore = Number(reader, "full_score", 150);
var rawRule = Optional(reader, "pass_rule") ?? "fixed_score";
var passRule = rawRule == "score_ratio" ? "rank_percent" : rawRule;
var passValue = NullableNumber(reader, "pass_value") ?? NullableNumber(reader, "pass_score") ?? fullScore * 0.6;
var order = Integer(reader, "position");
return new(Text(reader, "id"), Text(reader, "exam_id"), order, new JsonObject
{
["id"] = Text(reader, "id"),
["name"] = Text(reader, "name"),
["date"] = Text(reader, "subject_date"),
["start"] = Text(reader, "start_time"),
["end"] = Text(reader, "end_time"),
["fee"] = Number(reader, "fee"),
["fullScore"] = fullScore,
["passRule"] = passRule,
["passValue"] = passValue,
["passScore"] = passRule == "fixed_score" ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) : null,
["order"] = order
});
}
private static OperationalExam ReadExam(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<JsonObject>> subjectsByExam)
{
var id = Text(reader, "id");
var subjects = subjectsByExam.GetValueOrDefault(id) ?? [];
var rawPolicy = Optional(reader, "pass_policy") ?? "rank_percent";
var data = new JsonObject
{
["id"] = id,
["code"] = Text(reader, "code"),
["name"] = Text(reader, "name"),
["description"] = Text(reader, "description"),
["registrationStart"] = Text(reader, "registration_start"),
["registrationEnd"] = Text(reader, "registration_end"),
["examStart"] = Text(reader, "exam_start"),
["examEnd"] = Text(reader, "exam_end"),
["admitDownloadStart"] = Text(reader, "admit_download_start"),
["admitDownloadEnd"] = Text(reader, "admit_download_end"),
["location"] = Text(reader, "location"),
["passPolicy"] = rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy,
["passValue"] = Number(reader, "pass_value", 60),
["status"] = Text(reader, "status"),
["archivedAt"] = JsonValue.Create(Optional(reader, "archived_at")),
["archivedBy"] = JsonValue.Create(Optional(reader, "archived_by")),
["createdAt"] = Text(reader, "created_at"),
["subjects"] = new JsonArray(subjects.Select(item => item.DeepClone()).ToArray())
};
return new(id, data, subjects);
}
private static AssignmentRow ReadAssignment(DbDataReader reader)
{
var subjectId = Text(reader, "subject_id");
var room = Text(reader, "room");
return new(Text(reader, "registration_id"), subjectId, new JsonObject
{
["subjectId"] = subjectId,
["roomId"] = JsonValue.Create(Optional(reader, "room_id")),
["roomName"] = room,
["room"] = room,
["roomCode"] = Text(reader, "room_code"),
["examRoomCode"] = Text(reader, "exam_room_code"),
["building"] = Optional(reader, "building") ?? "",
["floor"] = Optional(reader, "floor") ?? "",
["seat"] = Text(reader, "seat"),
["subjectSignature"] = Optional(reader, "subject_signature") ?? ""
});
}
private static CardRow ReadCard(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<JsonObject>> assignmentsByRegistration)
{
var registrationId = Text(reader, "registration_id");
var assignments = assignmentsByRegistration.GetValueOrDefault(registrationId) ?? [];
var primary = assignments.FirstOrDefault();
return new(registrationId, new JsonObject
{
["planId"] = JsonValue.Create(Optional(reader, "plan_id")),
["number"] = Text(reader, "card_number"),
["centerId"] = JsonValue.Create(Optional(reader, "center_id")),
["testCenter"] = Text(reader, "test_center"),
["centerCode"] = Optional(reader, "center_code") ?? "",
["centerAddress"] = Optional(reader, "center_address") ?? "",
["room"] = primary?["roomName"]?.GetValue<string>() ?? "",
["seat"] = primary?["seat"]?.GetValue<string>() ?? "",
["assignments"] = new JsonArray(assignments.Select(item => item.DeepClone()).ToArray()),
["generatedAt"] = Text(reader, "generated_at")
});
}
private static OperationalRegistration ReadRegistration(
DbDataReader reader,
IReadOnlyDictionary<string, IReadOnlyList<string>> subjectIdsByRegistration,
IReadOnlyDictionary<string, JsonObject> cardsByRegistration)
{
var id = Text(reader, "id");
var userId = Text(reader, "user_id");
var examId = Text(reader, "exam_id");
var status = Text(reader, "status");
var paymentStatus = Text(reader, "payment_status");
var paidBy = Optional(reader, "paid_by");
var createdAt = Text(reader, "created_at");
return new(id, userId, examId, status, paymentStatus, paidBy, createdAt, new JsonObject
{
["id"] = id,
["userId"] = userId,
["examId"] = examId,
["subjectIds"] = new JsonArray((subjectIdsByRegistration.GetValueOrDefault(id) ?? []).Select(subjectId => JsonValue.Create(subjectId)).ToArray()),
["status"] = status,
["paymentStatus"] = paymentStatus,
["paidAt"] = JsonValue.Create(Optional(reader, "paid_at")),
["paidBy"] = JsonValue.Create(paidBy),
["createdAt"] = createdAt,
["reviewedAt"] = JsonValue.Create(Optional(reader, "reviewed_at")),
["reviewNote"] = Optional(reader, "review_note") ?? "",
["registrationNumber"] = Optional(reader, "registration_number") ?? "",
["numberRuleId"] = JsonValue.Create(Optional(reader, "number_rule_id")),
["featureScore"] = Number(reader, "feature_score"),
["admitCard"] = cardsByRegistration.TryGetValue(id, out var card) ? card.DeepClone() : null
});
}
private static async Task<IReadOnlyList<T>> QueryAsync<T>(DbConnection connection, string sql, Func<DbDataReader, T> map, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<T>();
while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader));
return output;
}
private static JsonArray ParseArray(string? value)
{
try { return JsonNode.Parse(value ?? "[]") as JsonArray ?? []; }
catch { return []; }
}
private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static string? Optional(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
private static bool Boolean(DbDataReader reader, string name) => Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
private static int Integer(DbDataReader reader, string name) => Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static double Number(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? NullableNumber(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
private sealed record SubjectRow(string Id, string ExamId, int Order, JsonObject Data);
private sealed record RegistrationSubject(string RegistrationId, string SubjectId);
private sealed record AssignmentRow(string RegistrationId, string SubjectId, JsonObject Data);
private sealed record CardRow(string RegistrationId, JsonObject Data);
}
@@ -60,12 +60,14 @@ public static class DependencyInjection
services.AddScoped<AdminNoticeRepository>();
services.AddScoped<AdminCenterSnapshotLoader>();
services.AddScoped<AdminCenterRepository>();
services.AddScoped<AdminOperationalSnapshotLoader>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
services.AddScoped<IAdminCenterService, AdminCenterService>();
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;