diff --git a/.env.example b/.env.example index c9a3c57..a16c5e2 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ PORT=4173 AUTH_NATIVE_ENABLED=false # 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。 CANDIDATE_NATIVE_ENABLED=false +# 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。 +ADMIN_NATIVE_READS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 195dad7..bd6d22f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -11,9 +11,9 @@ - [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取) - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) -- [ ] 考生业务 -- [ ] 管理后台、审批流和考务编排 -- [ ] 招生录取 +- [x] 考生业务 +- [ ] 管理后台、审批流和考务编排(第一批管理端只读接口已原生化) +- [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,6 +54,15 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。其余管理端写入、审批流和考务编排接口仍转发给 Node,因此该开关同样要求原生认证和共享 Redis: + +```powershell +$env:AUTH_NATIVE_ENABLED = 'true' +$env:ADMIN_NATIVE_READS_ENABLED = 'true' +``` + +`GET /health/migration` 的 `administration.nativeReadsEnabled` 和 `administration.routes` 会报告这一批端点是否已切换。 + 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: ```powershell diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 4698fc7..d757c18 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -422,6 +422,8 @@ try { AUTH_NATIVE_ENABLED = 'true' CANDIDATE_NATIVE_ENABLED = 'true' CANDIDATE_NATIVE_ALLOW_MEMORY = 'true' + ADMIN_NATIVE_READS_ENABLED = 'true' + ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl DATABASE_CLIENT = 'sqlite' @@ -692,6 +694,48 @@ try { if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') { throw 'Native authentication could not verify the existing Node PBKDF2 account' } + foreach ($adminReadRoute in @('context', 'dashboard', 'schools', 'admins', 'exams')) { + $legacyAdminRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$adminReadRoute" -WebSession $session + $nativeAdminRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$adminReadRoute" -WebSession $nativeSession + if ($nativeAdminRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Admin read route '$adminReadRoute' did not use the native ASP.NET Core endpoint" + } + Assert-JsonEquivalent -Expected $legacyAdminRead.Content -Actual $nativeAdminRead.Content -Label "Admin read route '$adminReadRoute'" + } + $superSchoolOrganization = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/school-organization" -WebSession $nativeSession -SkipHttpErrorCheck + if ($superSchoolOrganization.StatusCode -ne 403 -or $superSchoolOrganization.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native school-organization route did not preserve the school-level authorization boundary' + } + $schoolLoginBody = @{ username = 'school_admin'; password = '12345678' } | ConvertTo-Json -Compress + $legacySchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $nativeSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $legacySchoolSession | Out-Null + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $nativeSchoolSession | Out-Null + foreach ($schoolAdminRoute in @('context', 'dashboard', 'school-organization', 'admins')) { + $legacySchoolRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$schoolAdminRoute" -WebSession $legacySchoolSession + $nativeSchoolRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$schoolAdminRoute" -WebSession $nativeSchoolSession + Assert-JsonEquivalent -Expected $legacySchoolRead.Content -Actual $nativeSchoolRead.Content -Label "School admin read route '$schoolAdminRoute'" + } + foreach ($superOnlyRoute in @('schools', 'exams')) { + $schoolForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$superOnlyRoute" -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolForbidden.StatusCode -ne 403 -or $schoolForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Native admin route '$superOnlyRoute' did not preserve the super-admin boundary" + } + } + $classLoginBody = @{ username = 'class_admin'; password = '12345678' } | ConvertTo-Json -Compress + $legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $legacyClassSession | Out-Null + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $nativeClassSession | Out-Null + foreach ($classAdminRoute in @('context', 'dashboard')) { + $legacyClassRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$classAdminRoute" -WebSession $legacyClassSession + $nativeClassRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$classAdminRoute" -WebSession $nativeClassSession + Assert-JsonEquivalent -Expected $legacyClassRead.Content -Actual $nativeClassRead.Content -Label "Class admin read route '$classAdminRoute'" + } + $classAdminsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins" -WebSession $nativeClassSession -SkipHttpErrorCheck + if ($classAdminsForbidden.StatusCode -ne 403) { + throw 'Native admins route did not preserve the class-admin boundary' + } $adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck if ($adminCandidateRoute.StatusCode -ne 403) { throw 'Native candidate API did not enforce the candidate role boundary' @@ -774,6 +818,7 @@ try { NativeCandidateWrites = 'passed' NativeCandidateDocuments = 'passed' NativeCandidateAdmissions = 'passed' + NativeAdminReads = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminReadService.cs b/src/Eis.Application/Administration/IAdminReadService.cs new file mode 100644 index 0000000..8ae406c --- /dev/null +++ b/src/Eis.Application/Administration/IAdminReadService.cs @@ -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 GetContextAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetDashboardAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetAdminsAsync(string sessionToken, CancellationToken cancellationToken); + + Task GetExamsAsync(string sessionToken, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs new file mode 100644 index 0000000..efff3ec --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -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 + }; +} diff --git a/src/Eis.Infrastructure/Administration/AdminReadService.cs b/src/Eis.Infrastructure/Administration/AdminReadService.cs new file mode 100644 index 0000000..2e53b33 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminReadService.cs @@ -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 LevelNames = new Dictionary(StringComparer.Ordinal) + { + ["super"] = "超级管理员", + ["school"] = "校级管理员", + ["class"] = "班级管理员" + }; + + private static readonly IReadOnlyDictionary Permissions = new Dictionary(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 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 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 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 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 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 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 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 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); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminReadSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminReadSnapshot.cs new file mode 100644 index 0000000..95e7783 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminReadSnapshot.cs @@ -0,0 +1,255 @@ +using System.Data.Common; +using System.Globalization; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminReadSnapshot( + IReadOnlyList Schools, + IReadOnlyList Classes, + IReadOnlyList Users, + IReadOnlyList Profiles, + IReadOnlyList Registrations, + IReadOnlyList Exams, + IReadOnlyList WorkflowInstances, + IReadOnlyList CenterChanges, + IReadOnlyList AccountBatches, + IReadOnlyList Results, + IReadOnlyList AuditLogs, + IReadOnlyList 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 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 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)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> 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> QueryAsync( + DbConnection connection, + string sql, + Func map, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var output = new List(); + while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader)); + return output; + } + + private static async Task 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); +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 625ff3b..840cfa4 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -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(); @@ -48,6 +51,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(adminMigrationOptions); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs new file mode 100644 index 0000000..b9db60b --- /dev/null +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -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 Execute(HttpContext context, Task 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; +} diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 1e13996..29aaa9c 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -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("CandidateMigration:NativeEnabled"), authenticationOptions.NativeEnabled, authenticationOptions.SharesLegacySessions); +var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( + builder.Configuration.GetValue("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 = [ diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index aeba1f4..f7794ba 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -9,6 +9,9 @@ "CandidateMigration": { "NativeEnabled": false }, + "AdminMigration": { + "NativeReadsEnabled": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs new file mode 100644 index 0000000..0ba1347 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -0,0 +1,84 @@ +using Eis.Infrastructure.Administration; + +namespace Eis.Infrastructure.Tests.Administration; + +public sealed class AdminMigrationOptionsTests +{ + [Fact] + public void DisabledByDefault() + { + WithEnvironment(null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: false, + sharesLegacySessions: false); + + Assert.False(options.NativeReadsEnabled); + }); + } + + [Fact] + public void RequiresNativeAuthentication() + { + WithEnvironment("true", "true", () => + { + var exception = Assert.Throws(() => + AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: false, + sharesLegacySessions: false)); + + Assert.Contains("AUTH_NATIVE_ENABLED=true", exception.Message); + }); + } + + [Fact] + public void RequiresSharedSessionsOutsideIsolatedTests() + { + WithEnvironment("true", null, () => + { + var exception = Assert.Throws(() => + AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: false)); + + Assert.Contains("Redis", exception.Message); + }); + } + + [Fact] + public void AllowsSharedRedisSessions() + { + WithEnvironment("true", null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + }); + } + + private static void WithEnvironment( + string? nativeReadsEnabled, + string? allowMemory, + Action test) + { + var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); + var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); + try + { + Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); + test(); + } + finally + { + Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); + } + } +}