From 95a87f02760eb792e02f429675582807a52a244d Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 09:39:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E6=89=B9=E8=BF=81=E7=A7=BB=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=9A=20=E5=8E=9F=E7=94=9F=E5=8C=96?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8F=AA=E8=AF=BB=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?GET=20/api/admin/candidates=20GET=20/api/admin/registrations=20?= =?UTF-8?q?GET=20/api/admin/payments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保持超级、校级、班级管理员的数据范围一致。 保持证件号脱敏、审批流、考试科目、缴费信息及座位号前导零兼容。 新增灰度开关:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true 归档、审核、缴费更新等写操作暂时继续转发 Node。 --- .env.example | 2 + MIGRATION.md | 7 +- scripts/smoke-dotnet-migration.ps1 | 18 + .../IAdminOperationalReadService.cs | 8 + .../Administration/AdminMigrationOptions.cs | 15 +- .../AdminOperationalReadService.cs | 221 ++++++++++++ .../AdminOperationalSnapshot.cs | 317 ++++++++++++++++++ src/Eis.Infrastructure/DependencyInjection.cs | 2 + .../NativeAdminReadEndpoints.cs | 10 + src/Eis.Web/Program.cs | 7 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 21 +- 12 files changed, 620 insertions(+), 11 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminOperationalReadService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminOperationalReadService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminOperationalSnapshot.cs diff --git a/.env.example b/.env.example index d1e5087..6b0b8bd 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,8 @@ ADMIN_NATIVE_CONFIGURATION_ENABLED=false ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false # 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。 ADMIN_NATIVE_CENTERS_ENABLED=false +# 考生档案、报名记录与缴费记录读取;必须同时启用管理端只读接口。 +ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index ffc4ca2..8f6e8db 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置、通知公告及考点变更审批已原生化) +- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批及考生/报名/缴费读取已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -66,9 +66,10 @@ $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' $env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' $env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' $env:ADMIN_NATIVE_CENTERS_ENABLED = 'true' +$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index a5e4ebb..26bd4a9 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -442,6 +442,7 @@ try { ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_CENTERS_ENABLED = 'true' + ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -802,6 +803,22 @@ try { throw 'Native admins route did not preserve the class-admin boundary' } + $operationalSessions = @( + @{ Level = 'super'; Legacy = $session; Native = $nativeSession }, + @{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession }, + @{ Level = 'class'; Legacy = $legacyClassSession; Native = $nativeClassSession } + ) + foreach ($operationalSession in $operationalSessions) { + foreach ($operationalRoute in @('candidates', 'registrations', 'payments')) { + $legacyOperationalRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$operationalRoute" -WebSession $operationalSession.Legacy + $nativeOperationalRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$operationalRoute" -WebSession $operationalSession.Native + if ($nativeOperationalRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Admin operational route '$operationalRoute' for $($operationalSession.Level) admin did not use ASP.NET Core" + } + Assert-JsonEquivalent -Expected $legacyOperationalRead.Content -Actual $nativeOperationalRead.Content -Label "Admin operational route '$operationalRoute' for $($operationalSession.Level) admin" + } + } + $legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session $nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { @@ -1270,6 +1287,7 @@ try { NativeAdminConfiguration = 'passed' NativeAdminNoticeManagement = 'passed' NativeAdminCenters = 'passed' + NativeAdminOperationalReads = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminOperationalReadService.cs b/src/Eis.Application/Administration/IAdminOperationalReadService.cs new file mode 100644 index 0000000..5af6cec --- /dev/null +++ b/src/Eis.Application/Administration/IAdminOperationalReadService.cs @@ -0,0 +1,8 @@ +namespace Eis.Application.Administration; + +public interface IAdminOperationalReadService +{ + Task GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken); + Task GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken); + Task GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 3c92b84..b885469 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -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 diff --git a/src/Eis.Infrastructure/Administration/AdminOperationalReadService.cs b/src/Eis.Infrastructure/Administration/AdminOperationalReadService.cs new file mode 100644 index 0000000..6908527 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminOperationalReadService.cs @@ -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 GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken) => + ExecuteAsync(sessionToken, "candidates", cancellationToken); + + public Task GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken) => + ExecuteAsync(sessionToken, "registrations", cancellationToken); + + public Task GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken) => + ExecuteAsync(sessionToken, "payments", cancellationToken); + + private async Task 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() ?? ""; + 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() ?? ""); + 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() ?? ""; + output["gradeName"] = schoolClass?.Grade ?? ""; + output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue() ?? ""; + return output; + }).Where(item => item is not null).Cast().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() ?? ""); + 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() ?? ""; + output["gradeName"] = schoolClass?.Grade ?? ""; + output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue() ?? ""; + 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().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() ?? "").ToHashSet(StringComparer.Ordinal) ?? []; + var subjects = (exam?.Subjects ?? []).Where(item => subjectIds.Contains(item["id"]?.GetValue() ?? "")) + .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 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().Sum(item => item["fee"]?.GetValue() ?? 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); } +} diff --git a/src/Eis.Infrastructure/Administration/AdminOperationalSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminOperationalSnapshot.cs new file mode 100644 index 0000000..275680f --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminOperationalSnapshot.cs @@ -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 Users, + IReadOnlyList Profiles, + IReadOnlyList Exams, + IReadOnlyList 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 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 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)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)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)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> 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> 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() ?? "", + ["seat"] = primary?["seat"]?.GetValue() ?? "", + ["assignments"] = new JsonArray(assignments.Select(item => item.DeepClone()).ToArray()), + ["generatedAt"] = Text(reader, "generated_at") + }); + } + + private static OperationalRegistration ReadRegistration( + DbDataReader reader, + IReadOnlyDictionary> subjectIdsByRegistration, + IReadOnlyDictionary 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> 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 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); +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 0f47deb..c311f92 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -60,12 +60,14 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 3ce1570..4f88d2e 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -91,6 +91,16 @@ public static class NativeAdminReadEndpoints endpoints.MapPatch("/api/admin/center-change-requests/{changeRequestId}", (HttpContext context, string changeRequestId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) => Execute(context, service.ReviewAsync(Token(context), changeRequestId, body, cancellationToken))); } + + if (options.NativeOperationalReadsEnabled) + { + endpoints.MapGet("/api/admin/candidates", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) => + Execute(context, service.GetCandidatesAsync(Token(context), cancellationToken))); + endpoints.MapGet("/api/admin/registrations", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) => + Execute(context, service.GetRegistrationsAsync(Token(context), cancellationToken))); + endpoints.MapGet("/api/admin/payments", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) => + Execute(context, service.GetPaymentsAsync(Token(context), cancellationToken))); + } return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 5893a8b..3153fd0 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -50,7 +50,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled"), builder.Configuration.GetValue("AdminMigration:NativeNoticeManagementEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeCentersEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeCentersEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeOperationalReadsEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -110,6 +111,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled, nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled, nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled, + nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -132,6 +134,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeCentersEnabled ? new[] { "GET/POST/PATCH centers", "PATCH center-change-requests" } : []) + .Concat(adminMigrationOptions.NativeOperationalReadsEnabled + ? new[] { "GET candidates", "GET registrations", "GET payments" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index e079fcf..a35dc90 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -15,7 +15,8 @@ "NativeAccountBatchesEnabled": false, "NativeConfigurationEnabled": false, "NativeNoticeManagementEnabled": false, - "NativeCentersEnabled": false + "NativeCentersEnabled": false, + "NativeOperationalReadsEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index bca982a..02b8c8c 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -166,6 +166,21 @@ public sealed class AdminMigrationOptionsTests }, centersEnabled: "true"); } + [Fact] + public void EnablesOperationalReadsWithBaseReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeOperationalReadsEnabled); + }, operationalReadsEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -175,7 +190,8 @@ public sealed class AdminMigrationOptionsTests string? configurationEnabled = null, string? noticeManagementEnabled = null, string? legacyNoticeWritesEnabled = null, - string? centersEnabled = null) + string? centersEnabled = null, + string? operationalReadsEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -184,6 +200,7 @@ public sealed class AdminMigrationOptionsTests var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED"); var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"); var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"); + var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -194,6 +211,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -206,6 +224,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }