已完成管理后台第一批只读功能的 ASP.NET Core 10 迁移:

原生接口:context、dashboard、schools、school-organization、admins、exams
保持超级、校级、班级管理员的数据范围及权限隔离
其余管理写入、审批流和考务编排仍由 Node 转发
新增 ADMIN_NATIVE_READS_ENABLED=true 开关,并强制要求原生认证和共享 Redis
This commit is contained in:
2026-07-22 20:34:13 +08:00 Unverified
parent 4b0dae6d71
commit 712bd1a3a2
12 changed files with 894 additions and 5 deletions
@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public sealed record AdminEndpointResult(int StatusCode, JsonObject Body);
public interface IAdminReadService
{
Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,37 @@
namespace Eis.Infrastructure.Administration;
public sealed record AdminMigrationOptions(bool NativeReadsEnabled)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生管理端读取接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"管理端仍有写入接口需要转发给 Node;启用原生管理端读取接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(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,374 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminReadService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminReadSnapshotLoader snapshotLoader) : IAdminReadService
{
private static readonly IReadOnlyDictionary<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
private static readonly IReadOnlyDictionary<string, string[]> Permissions = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["super"] = ["*"],
["school"] =
[
"dashboard.read", "candidates.read", "candidates.write", "candidates.review", "registrations.read",
"registrations.review", "payments.read", "payments.write", "results.read", "centers.read", "centers.write",
"workflows.inbox"
],
["class"] =
[
"dashboard.read", "candidates.read", "candidates.review", "registrations.read", "registrations.review",
"payments.read", "payments.write", "results.read", "workflows.inbox"
]
};
public async Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var level = Level(context.User!);
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(context.User!),
["adminLevelName"] = LevelNames[level],
["permissions"] = StringArray(Permissions[level]),
["scopeLabel"] = ScopeLabel(snapshot, context.User!),
["schools"] = new JsonArray(snapshot.Schools.Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray())
});
}
public async Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var profiles = snapshot.Profiles.Where(item => InScope(user, item.SchoolId, item.ClassId)).ToArray();
var userIds = profiles.Select(item => item.UserId).ToHashSet(StringComparer.Ordinal);
var registrations = snapshot.Registrations.Where(item => userIds.Contains(item.UserId)).ToArray();
var visibleFlows = snapshot.WorkflowInstances.Where(instance =>
{
if (Level(user) == "super") return true;
var scope = WorkflowScope(snapshot, instance);
return scope is not null && InScope(user, scope.Value.SchoolId, scope.Value.ClassId) &&
(instance.AssigneeId == user.Id || instance.Status != "pending");
}).ToArray();
var logs = snapshot.AuditLogs
.Where(item => Level(user) == "super" || item.ActorId == user.Id)
.Take(8)
.Select(LogJson)
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(user),
["scopeLabel"] = ScopeLabel(snapshot, user),
["permissions"] = StringArray(Permissions[Level(user)]),
["metrics"] = new JsonObject
{
["candidates"] = profiles.Length,
["pendingCandidates"] = profiles.Count(item => item.Status == "pending"),
["registrations"] = registrations.Length,
["pendingRegistrations"] = registrations.Count(item => item.Status == "pending"),
["pendingPayments"] = registrations.Count(item => item.Status == "approved" && item.PaymentStatus == "unpaid"),
["pendingFlows"] = visibleFlows.Count(item => item.Status == "pending"),
["publishedExams"] = snapshot.Exams.Count(item => item.Status == "published"),
["notices"] = snapshot.PublishedNoticeCount
},
["logs"] = new JsonArray(logs)
});
}
public async Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "只有超级管理员可以管理学校");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var schools = snapshot.Schools.Select(school =>
{
var item = SchoolJson(school);
item["classCount"] = snapshot.Classes.Count(entry => entry.SchoolId == school.Id);
item["adminCount"] = snapshot.Users.Count(entry => entry.Role == "admin" && entry.SchoolId == school.Id);
item["candidateCount"] = snapshot.Profiles.Count(entry => entry.SchoolId == school.Id);
item["centerCount"] = snapshot.CenterSchoolIds.Count(id => id == school.Id);
return item;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["schools"] = new JsonArray(schools) });
}
public async Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "school") return Error(403, "只有校级管理员可以维护本校组织");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId);
var classes = snapshot.Classes.Where(item => item.SchoolId == user.SchoolId).Select(item =>
{
var output = ClassJson(item);
output["candidateCount"] = snapshot.Profiles.Count(profile => profile.ClassId == item.Id);
output["admins"] = new JsonArray(snapshot.Users
.Where(admin => admin.Role == "admin" && admin.AdminLevel == "class" && admin.ClassId == item.Id)
.Select(admin =>
{
var json = SafeUser(admin);
json["active"] = admin.Active;
return json;
}).ToArray());
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = school is null ? null : SchoolJson(school),
["classes"] = new JsonArray(classes)
});
}
public async Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能管理管理员");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var admins = snapshot.Users.Where(item => item.Role == "admin" &&
(Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId)).Select(item =>
{
var output = SafeUser(item);
output["active"] = item.Active;
output["levelName"] = LevelNames[item.AdminLevel ?? "super"];
output["schoolName"] = snapshot.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Name ?? string.Empty;
output["className"] = snapshot.Classes.FirstOrDefault(schoolClass => schoolClass.Id == item.ClassId)?.Name ?? string.Empty;
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admins"] = new JsonArray(admins),
["schools"] = new JsonArray(snapshot.Schools.Where(item => item.IsSourceSchool).Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray()),
["selfRegistrationEnabled"] = snapshot.SelfRegistrationEnabled
});
}
public async Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "当前管理员层级无权执行此操作");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var exams = snapshot.Exams.Select(exam =>
{
var output = PublicExamJson(exam);
output["registrationCount"] = snapshot.Registrations.Count(item => item.ExamId == exam.Id);
return output;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["exams"] = new JsonArray(exams) });
}
private async Task<ResolvedAdmin> ResolveAsync(string sessionToken, CancellationToken cancellationToken)
{
if (sessionToken.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(sessionToken);
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 ResolvedAdmin(user, null)
: ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
}
private static (string? SchoolId, string? ClassId)? WorkflowScope(AdminReadSnapshot snapshot, AdminWorkflowInstance instance)
{
if (instance.BusinessType == "profile_change")
{
var profile = snapshot.Profiles.FirstOrDefault(item => item.Id == instance.BusinessId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "registration_review")
{
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == instance.BusinessId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "center_change")
{
var change = snapshot.CenterChanges.FirstOrDefault(item => item.Id == instance.BusinessId);
return change is null ? null : (change.SchoolId, null);
}
if (instance.BusinessType == "candidate_account_batch")
{
var batch = snapshot.AccountBatches.FirstOrDefault(item => item.Id == instance.BusinessId);
return batch is null ? null : (batch.SchoolId, null);
}
if (instance.BusinessType == "score_appeal")
{
var result = snapshot.Results.FirstOrDefault(item => item.Id == instance.BusinessId);
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
return null;
}
private static bool InScope(AuthenticationUser user, string? schoolId, string? classId) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && schoolId == user.SchoolId,
_ => user.ClassId is not null && classId == user.ClassId
};
private static string ScopeLabel(AdminReadSnapshot snapshot, AuthenticationUser user)
{
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 JsonObject PublicExamJson(AdminExam item)
{
var json = ExamJson(item);
json["totalScore"] = item.Subjects.Sum(subject => subject.FullScore);
json["registrationState"] = item.ArchivedAt is not null
? "archived"
: DateTimeOffset.UtcNow < ParseDate(item.RegistrationStart) ? "upcoming"
: DateTimeOffset.UtcNow > ParseDate(item.RegistrationEnd) ? "closed"
: "open";
return json;
}
private static JsonObject ExamJson(AdminExam 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(AdminSubject 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 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 SafeUser(AuthenticationUser 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 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 JsonObject LogJson(AdminAuditLog item) => new()
{
["id"] = item.Id,
["actorId"] = JsonValue.Create(item.ActorId),
["action"] = item.Action,
["detail"] = item.Detail,
["createdAt"] = item.CreatedAt
};
private static JsonArray StringArray(IEnumerable<string> values) =>
new(values.Select(value => JsonValue.Create(value)).ToArray());
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static DateTimeOffset ParseDate(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: DateTimeOffset.MinValue;
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,255 @@
using System.Data.Common;
using System.Globalization;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminReadSnapshot(
IReadOnlyList<AdminSchool> Schools,
IReadOnlyList<AdminClass> Classes,
IReadOnlyList<AdminUser> Users,
IReadOnlyList<AdminProfile> Profiles,
IReadOnlyList<AdminRegistration> Registrations,
IReadOnlyList<AdminExam> Exams,
IReadOnlyList<AdminWorkflowInstance> WorkflowInstances,
IReadOnlyList<AdminScopeBusiness> CenterChanges,
IReadOnlyList<AdminScopeBusiness> AccountBatches,
IReadOnlyList<AdminResultScope> Results,
IReadOnlyList<AdminAuditLog> AuditLogs,
IReadOnlyList<string> CenterSchoolIds,
int PublishedNoticeCount,
bool SelfRegistrationEnabled);
internal sealed record AdminSchool(
string Id,
string Name,
string Code,
string Address,
bool IsSourceSchool,
bool IsAdmissionSchool,
bool Active);
internal sealed record AdminClass(string Id, string SchoolId, string Name, string Grade, bool Active);
internal sealed record AdminUser(
string Id,
string Username,
string Role,
string? AdminLevel,
string? SchoolId,
string? ClassId,
string DisplayName,
string? CandidateNumber,
bool Active,
bool MustChangePassword,
bool TotpEnabled,
string? ArchivedAt,
string CreatedAt);
internal sealed record AdminProfile(string Id, string UserId, string? SchoolId, string? ClassId, string Status);
internal sealed record AdminRegistration(
string Id,
string UserId,
string ExamId,
string Status,
string PaymentStatus);
internal sealed record AdminExam(
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<AdminSubject> Subjects);
internal sealed record AdminSubject(
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 AdminWorkflowInstance(
string Id,
string BusinessType,
string BusinessId,
string Status,
string? AssigneeId);
internal sealed record AdminScopeBusiness(string Id, string SchoolId);
internal sealed record AdminResultScope(string Id, string RegistrationId);
internal sealed record AdminAuditLog(string Id, string? ActorId, string Action, string Detail, string CreatedAt);
internal sealed class AdminReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminReadSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var schools = await QueryAsync(connection,
"SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id",
reader => new AdminSchool(
Text(reader, "id"), Text(reader, "name"), Text(reader, "code"), Optional(reader, "address") ?? string.Empty,
Boolean(reader, "is_source_school"), Boolean(reader, "is_admission_school"), Boolean(reader, "active")),
cancellationToken);
var classes = await QueryAsync(connection,
"SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, grade, name, id",
reader => new AdminClass(Text(reader, "id"), Text(reader, "school_id"), Text(reader, "name"), Text(reader, "grade"), Boolean(reader, "active")),
cancellationToken);
var users = await QueryAsync(connection,
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
reader => new AdminUser(
Text(reader, "id"), Text(reader, "username"), Text(reader, "role"), Optional(reader, "admin_level"),
Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "display_name"), Optional(reader, "candidate_number"),
Boolean(reader, "active"), Boolean(reader, "must_change_password"), Boolean(reader, "totp_enabled"),
Optional(reader, "archived_at"), Text(reader, "created_at")),
cancellationToken);
var profiles = await QueryAsync(connection,
"SELECT id, user_id, school_id, class_id, status FROM candidate_profiles ORDER BY updated_at, id",
reader => new AdminProfile(Text(reader, "id"), Text(reader, "user_id"), Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "status")),
cancellationToken);
var registrations = await QueryAsync(connection,
"SELECT id, user_id, exam_id, status, payment_status FROM registrations ORDER BY created_at, id",
reader => new AdminRegistration(Text(reader, "id"), Text(reader, "user_id"), Text(reader, "exam_id"), Text(reader, "status"), Text(reader, "payment_status")),
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<AdminSubject>)group.Select(item => item.Subject).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 instances = await QueryAsync(connection,
"SELECT id, business_type, business_id, status, assignee_id FROM workflow_instances ORDER BY created_at DESC, id",
reader => new AdminWorkflowInstance(
Text(reader, "id"), Text(reader, "business_type"), Text(reader, "business_id"), Text(reader, "status"), Optional(reader, "assignee_id")),
cancellationToken);
var centerChanges = await QueryAsync(connection,
"SELECT id, school_id FROM center_change_requests ORDER BY created_at DESC, id",
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
cancellationToken);
var batches = await QueryAsync(connection,
"SELECT id, school_id FROM candidate_account_batches ORDER BY created_at DESC, id",
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
cancellationToken);
var results = await QueryAsync(connection,
"SELECT id, registration_id FROM results ORDER BY id",
reader => new AdminResultScope(Text(reader, "id"), Text(reader, "registration_id")),
cancellationToken);
var logs = await QueryAsync(connection,
"SELECT id, actor_id, action, detail, created_at FROM audit_logs ORDER BY created_at DESC, id DESC",
reader => new AdminAuditLog(Text(reader, "id"), Optional(reader, "actor_id"), Text(reader, "action"), Text(reader, "detail"), Text(reader, "created_at")),
cancellationToken);
var centerSchoolIds = await QueryAsync(connection,
"SELECT school_id FROM test_centers ORDER BY school_id, name, id",
reader => Text(reader, "school_id"),
cancellationToken);
var noticeCount = await ScalarIntAsync(connection, "SELECT COUNT(*) FROM notices WHERE status = 'published'", cancellationToken);
var selfRegistration = await ScalarIntAsync(connection, "SELECT self_registration_enabled FROM schema_metadata WHERE id = 1", cancellationToken) != 0;
return new AdminReadSnapshot(
schools, classes, users, profiles, registrations, exams, instances, centerChanges, batches, results, logs,
centerSchoolIds, noticeCount, selfRegistration);
}
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;
return new SubjectRow(
Text(reader, "exam_id"),
new AdminSubject(
Text(reader, "id"), Text(reader, "name"), Text(reader, "subject_date"), Text(reader, "start_time"), Text(reader, "end_time"),
Number(reader, "fee"), fullScore, passRule, passValue,
passRule == "fixed_score" ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) : null,
Integer(reader, "position")));
}
private static AdminExam ReadExam(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<AdminSubject>> subjects)
{
var id = Text(reader, "id");
var rawPolicy = Optional(reader, "pass_policy") ?? "rank_percent";
return new AdminExam(
id, Text(reader, "code"), Text(reader, "name"), Text(reader, "description"),
Text(reader, "registration_start"), Text(reader, "registration_end"), Text(reader, "exam_start"), Text(reader, "exam_end"),
Text(reader, "admit_download_start"), Text(reader, "admit_download_end"), Text(reader, "location"),
rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy, Number(reader, "pass_value", 60), Text(reader, "status"),
Optional(reader, "archived_at"), Optional(reader, "archived_by"), Text(reader, "created_at"),
subjects.GetValueOrDefault(id) ?? []);
}
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 async Task<int> ScalarIntAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
private static string Text(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
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 ExamId, AdminSubject Subject);
}
@@ -1,7 +1,9 @@
using Eis.Application.Authentication;
using Eis.Application.Administration;
using Eis.Application.Candidate;
using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Data;
using Eis.Infrastructure.Public;
@@ -27,7 +29,8 @@ public static class DependencyInjection
DatabaseOptions databaseOptions,
DocumentVerificationOptions documentVerificationOptions,
AuthenticationOptions authenticationOptions,
CandidateMigrationOptions candidateMigrationOptions)
CandidateMigrationOptions candidateMigrationOptions,
AdminMigrationOptions adminMigrationOptions)
{
services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
@@ -48,6 +51,9 @@ public static class DependencyInjection
services.AddScoped<CandidateWriteRepository>();
services.AddScoped<CandidateAdmissionRepository>();
services.AddScoped<ICandidateService, CandidateService>();
services.AddSingleton(adminMigrationOptions);
services.AddScoped<AdminReadSnapshotLoader>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
}
@@ -0,0 +1,39 @@
using Eis.Application.Administration;
using Eis.Infrastructure.Administration;
namespace Eis.Web.Administration;
public static class NativeAdminReadEndpoints
{
public static IEndpointRouteBuilder MapNativeAdminReadEndpoints(
this IEndpointRouteBuilder endpoints,
AdminMigrationOptions options)
{
if (!options.NativeReadsEnabled) return endpoints;
endpoints.MapGet("/api/admin/context", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetContextAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/dashboard", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetDashboardAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/school-organization", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetSchoolOrganizationAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/admins", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdminsAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetExamsAsync(Token(context), cancellationToken)));
return endpoints;
}
private static async Task<IResult> Execute(HttpContext context, Task<AdminEndpointResult> operation)
{
var result = await operation;
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
return Results.Json(result.Body, statusCode: result.StatusCode);
}
private static string Token(HttpContext context) =>
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
}
+16 -1
View File
@@ -1,5 +1,6 @@
using System.Net;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
using Eis.Application.Public;
using Eis.Infrastructure;
@@ -8,6 +9,7 @@ using Eis.Infrastructure.Migration;
using Eis.Infrastructure.Security;
using Eis.Web.Configuration;
using Eis.Web.Authentication;
using Eis.Web.Administration;
using Eis.Web.Candidate;
using Eis.Web.Frontend;
using Eis.Web.Legacy;
@@ -40,11 +42,16 @@ var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions);
var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeReadsEnabled"),
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions);
builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
authenticationOptions,
candidateMigrationOptions);
candidateMigrationOptions,
adminMigrationOptions);
var app = builder.Build();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
@@ -90,6 +97,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
}
: []
},
administration = new
{
nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled,
nativeRoutes = adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: []
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode);
});
@@ -97,6 +111,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
app.MapNativePublicEndpoints();
app.MapNativeAuthenticationEndpoints(authenticationOptions);
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
app.MapNativeAdminReadEndpoints(adminMigrationOptions);
string[] methods =
[
+3
View File
@@ -9,6 +9,9 @@
"CandidateMigration": {
"NativeEnabled": false
},
"AdminMigration": {
"NativeReadsEnabled": false
},
"Logging": {
"LogLevel": {
"Default": "Information",