diff --git a/.env.example b/.env.example index aea4801..159a89f 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,8 @@ ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false # 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。 ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false +# 考试创建、修改、发布状态及不可逆归档;必须同时启用管理端基础读取。 +ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index dfa5268..31a95cf 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 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -70,9 +70,10 @@ $env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' $env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' $env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' +$env:ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled`、`administration.nativeRegistrationPaymentWritesEnabled`、`administration.nativeWorkflowOperationsEnabled` 和 `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.nativeCandidateManagementEnabled`、`administration.nativeRegistrationPaymentWritesEnabled`、`administration.nativeWorkflowOperationsEnabled`、`administration.nativeExamManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 46c2892..395b26f 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -446,6 +446,7 @@ try { ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' + ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -1353,6 +1354,99 @@ try { $restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null + $examCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -Method Post -ContentType 'application/json' -Body '{}' -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($examCreateForbidden.StatusCode -ne 403 -or $examCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native exam creation did not preserve the super-admin boundary' + } + $invalidExam = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -Method Post -ContentType 'application/json' -Body (@{ name = '缺少日期的考试' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidExam.StatusCode -ne 400 -or $invalidExam.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native exam creation accepted a request without the required dates' + } + $invalidExamScoringBody = @{ + name = '合格线无效的考试' + registrationStart = '2035-01-01T00:00:00.000Z' + registrationEnd = '2035-01-10T00:00:00.000Z' + examStart = '2035-02-01T00:00:00.000Z' + examEnd = '2035-02-02T00:00:00.000Z' + subjects = @(@{ name = '语文'; fullScore = 100; passRule = 'fixed_score'; passValue = 101 }) + passPolicy = 'fixed_score' + passValue = 60 + } | ConvertTo-Json -Depth 10 -Compress + $invalidExamScoring = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -Method Post -ContentType 'application/json' -Body $invalidExamScoringBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidExamScoring.StatusCode -ne 400) { + throw 'Native exam creation accepted an invalid subject passing score' + } + $examCode = "NATIVE-$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $examCreateBody = @{ + code = $examCode + name = '原生迁移考试' + description = '考试维护迁移冒烟验证' + registrationStart = '2035-01-01T00:00:00.000Z' + registrationEnd = '2035-01-10T00:00:00.000Z' + examStart = '2035-02-01T00:00:00.000Z' + examEnd = '2035-02-02T00:00:00.000Z' + location = '迁移测试考点' + passPolicy = 'fixed_score' + passValue = 130 + status = 'draft' + subjects = @( + @{ name = '语文'; fullScore = 120; passRule = 'fixed_score'; passValue = 72; fee = 30 }, + @{ name = '英语'; fullScore = 100; passRule = 'rank_percent'; passValue = 30; fee = 25 } + ) + } | ConvertTo-Json -Depth 10 -Compress + $examCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -Method Post -ContentType 'application/json' -Body $examCreateBody -WebSession $nativeSession + $createdExam = ($examCreateResponse.Content | ConvertFrom-Json).exam + if ($examCreateResponse.StatusCode -ne 201 -or $examCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or + $createdExam.code -ne $examCode -or $createdExam.status -ne 'draft' -or @($createdExam.subjects).Count -ne 2) { + throw 'Native exam creation did not persist the requested draft and subjects' + } + $legacyExamsAfterCreate = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams" -WebSession $session + $nativeExamsAfterCreate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -WebSession $nativeSession + Assert-JsonEquivalent -Expected $legacyExamsAfterCreate.Content -Actual $nativeExamsAfterCreate.Content -Label 'Exam creation follow-up' + + $examUpdateBody = @{ + name = '原生迁移考试(已修改)' + location = '迁移测试新考点' + passPolicy = 'subject_scores' + passValue = 0 + subjects = @( + @{ name = '综合能力'; fullScore = 100; passRule = 'fixed_score'; passValue = 60; fee = 40; start = '13:30'; end = '15:30' } + ) + } | ConvertTo-Json -Depth 10 -Compress + $examUpdateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)" -Method Patch -ContentType 'application/json' -Body $examUpdateBody -WebSession $nativeSession + $updatedExam = ($examUpdateResponse.Content | ConvertFrom-Json).exam + if ($examUpdateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $updatedExam.name -ne '原生迁移考试(已修改)' -or + $updatedExam.passPolicy -ne 'subject_scores' -or @($updatedExam.subjects).Count -ne 1 -or $updatedExam.subjects[0].start -ne '13:30') { + throw 'Native exam update did not replace the draft configuration' + } + $examPublishBody = @{ status = 'published' } | ConvertTo-Json -Compress + $examPublishResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)" -Method Patch -ContentType 'application/json' -Body $examPublishBody -WebSession $nativeSession + if (($examPublishResponse.Content | ConvertFrom-Json).exam.status -ne 'published') { + throw 'Native exam update did not publish the draft' + } + $publishedExamEdit = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)" -Method Patch -ContentType 'application/json' -Body (@{ name = '不应保存的名称' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($publishedExamEdit.StatusCode -ne 409) { + throw 'Native exam update allowed detail changes while published' + } + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)" -Method Patch -ContentType 'application/json' -Body (@{ status = 'draft' } | ConvertTo-Json -Compress) -WebSession $nativeSession | Out-Null + $examArchiveResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)/archive" -Method Post -WebSession $nativeSession + $archivedExam = ($examArchiveResponse.Content | ConvertFrom-Json).exam + if ($examArchiveResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $archivedExam.status -ne 'closed' -or + $archivedExam.registrationState -ne 'archived' -or -not $archivedExam.archivedAt -or $archivedExam.totalScore -ne 100) { + throw 'Native exam archive did not permanently close the exam' + } + $duplicateExamArchive = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)/archive" -Method Post -WebSession $nativeSession -SkipHttpErrorCheck + if ($duplicateExamArchive.StatusCode -ne 409) { + throw 'Native exam archive did not reject a duplicate archive operation' + } + $archivedExamUpdate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)" -Method Patch -ContentType 'application/json' -Body (@{ status = 'draft' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($archivedExamUpdate.StatusCode -ne 409) { + throw 'Native exam update did not keep archived exams locked' + } + $legacyExamsAfterArchive = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams" -WebSession $session + $nativeExamsAfterArchive = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -WebSession $nativeSession + Assert-JsonEquivalent -Expected $legacyExamsAfterArchive.Content -Actual $nativeExamsAfterArchive.Content -Label 'Exam archive follow-up' + $noticeCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '越权公告'; content = '

无权发布

' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck if ($noticeCreateForbidden.StatusCode -ne 403 -or $noticeCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { throw 'Native notice creation did not preserve the super-admin boundary' @@ -1487,6 +1581,7 @@ try { NativeAdminCandidateManagement = 'passed' NativeAdminRegistrationPaymentWrites = 'passed' NativeAdminWorkflowOperations = 'passed' + NativeAdminExamManagement = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminExamManagementService.cs b/src/Eis.Application/Administration/IAdminExamManagementService.cs new file mode 100644 index 0000000..ec4d065 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminExamManagementService.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminExamManagementService +{ + Task CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task UpdateAsync(string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken); + Task ArchiveAsync(string sessionToken, string examId, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs b/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs new file mode 100644 index 0000000..18d577e --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs @@ -0,0 +1,342 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminExamManagementService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + AdminOperationalSnapshotLoader operationalSnapshotLoader, + AdminAccountBatchSnapshotLoader workflowSnapshotLoader, + AdminWorkflowResultSnapshotLoader resultSnapshotLoader, + AdminExamRepository repository) : IAdminExamManagementService +{ + private static readonly HashSet PassPolicies = ["fixed_score", "rank_percent", "subject_scores", "none"]; + private static readonly HashSet SubjectPassRules = ["fixed_score", "rank_percent", "none"]; + + public async Task CreateAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var name = Clean(Text(body["name"]), 100); + if (name.Length == 0 || !JsBoolean(body["registrationStart"]) || !JsBoolean(body["registrationEnd"]) || + !JsBoolean(body["examStart"]) || !JsBoolean(body["examEnd"])) + return Error(400, "请完整填写考试名称和关键日期"); + var examStart = Text(body["examStart"]); + var subjects = NormalizeSubjects(body["subjects"], examStart); + var requestedPolicy = Text(body["passPolicy"]); + if (requestedPolicy == "score_ratio") requestedPolicy = "rank_percent"; + var passPolicy = PassPolicies.Contains(requestedPolicy) ? requestedPolicy : "rank_percent"; + var passValue = passPolicy is "subject_scores" or "none" ? 0 : NumberOr(body["passValue"], 60); + var scoringError = ValidateScoring(subjects, passPolicy, passValue); + if (scoringError.Length > 0) return Error(400, scoringError); + + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var code = Clean(Text(body["code"]), 30); + if (code.Length == 0) code = $"EX-{DateTime.Now.Year}-{operational.Exams.Count + 1:00}"; + var exam = new ManagedExam( + Uid("exam"), + code, + name, + Clean(Text(body["description"]), 500), + Text(body["registrationStart"]), + Text(body["registrationEnd"]), + examStart, + Text(body["examEnd"]), + JsBoolean(body["admitDownloadStart"]) ? Text(body["admitDownloadStart"]) : Text(body["registrationEnd"]), + JsBoolean(body["admitDownloadEnd"]) ? Text(body["admitDownloadEnd"]) : examStart, + Clean(Text(body["location"]), 100), + passPolicy, + passValue, + Text(body["status"]) == "published" ? "published" : "draft", + null, + null, + NowIso(), + subjects); + await repository.CreateAsync( + exam, + Audit(user, "创建考试", $"{exam.Name} · {subjects.Count} 个科目"), + cancellationToken); + var responseExam = ExamJson(exam); + responseExam.Remove("archivedAt"); + responseExam.Remove("archivedBy"); + return Result(201, new JsonObject { ["ok"] = true, ["exam"] = responseExam }); + } + + public async Task UpdateAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var source = operational.Exams.FirstOrDefault(item => item.Id == examId); + if (source is null) return Error(404, "考试不存在"); + var exam = FromOperational(source); + if (exam.ArchivedAt is not null) return Error(409, "该考试已归档,所有配置和成绩均已锁定"); + + string[] detailFields = + [ + "code", "name", "description", "location", "registrationStart", "registrationEnd", + "examStart", "examEnd", "admitDownloadStart", "admitDownloadEnd" + ]; + var editingDetails = detailFields.Any(body.ContainsKey) || + body.ContainsKey("subjects") || body.ContainsKey("passPolicy") || body.ContainsKey("passValue"); + if (editingDetails && exam.Status != "draft") return Error(409, "请先将考试撤回为草稿后再编辑"); + var requestedStatus = Text(body["status"]); + if (requestedStatus is "draft" or "published" or "closed") exam = exam with { Status = requestedStatus }; + foreach (var field in detailFields.Where(body.ContainsKey)) + { + var value = Clean(Text(body[field]), field == "description" ? 500 : 100); + exam = field switch + { + "code" => exam with { Code = value }, + "name" => exam with { Name = value }, + "description" => exam with { Description = value }, + "location" => exam with { Location = value }, + "registrationStart" => exam with { RegistrationStart = value }, + "registrationEnd" => exam with { RegistrationEnd = value }, + "examStart" => exam with { ExamStart = value }, + "examEnd" => exam with { ExamEnd = value }, + "admitDownloadStart" => exam with { AdmitDownloadStart = value }, + "admitDownloadEnd" => exam with { AdmitDownloadEnd = value }, + _ => exam + }; + } + if (body.ContainsKey("passPolicy")) + { + var policy = Text(body["passPolicy"]); + if (policy == "score_ratio") policy = "rank_percent"; + if (PassPolicies.Contains(policy)) exam = exam with { PassPolicy = policy }; + } + if (body.ContainsKey("passValue")) + exam = exam with { PassValue = body["passValue"] is null ? 0 : Number(body["passValue"]) }; + var replaceSubjects = false; + if (body.ContainsKey("subjects")) + { + if (operational.Registrations.Any(item => item.ExamId == exam.Id)) + return Error(409, "已有报名记录,不能修改考试科目"); + exam = exam with { Subjects = NormalizeSubjects(body["subjects"], exam.ExamStart) }; + replaceSubjects = true; + } + var scoringError = ValidateScoring(exam.Subjects, exam.PassPolicy, exam.PassValue); + if (scoringError.Length > 0) return Error(400, scoringError); + if (exam.Name.Length == 0 || exam.RegistrationStart.Length == 0 || exam.RegistrationEnd.Length == 0 || + exam.ExamStart.Length == 0 || exam.ExamEnd.Length == 0) + return Error(400, "请完整填写考试名称和关键日期"); + if (exam.Status == "published" && exam.Subjects.Count == 0) return Error(400, "请先配置考试科目再发布"); + + await repository.UpdateAsync( + exam, + replaceSubjects, + Audit(user, "更新考试", $"{exam.Name} · 状态 {exam.Status}"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["exam"] = ExamJson(exam) }); + } + + public async Task ArchiveAsync( + string sessionToken, + string examId, + CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var source = operational.Exams.FirstOrDefault(item => item.Id == examId); + if (source is null) return Error(404, "考试不存在"); + var exam = FromOperational(source); + if (exam.ArchivedAt is not null) return Error(409, "该考试已经归档,归档操作不可撤销"); + var resultIds = (await resultSnapshotLoader.LoadAsync(cancellationToken)) + .Where(result => operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId)?.ExamId == exam.Id) + .Select(result => result.Id) + .ToHashSet(StringComparer.Ordinal); + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var pendingAppeals = workflow.Instances.Count(item => + item.BusinessType == "score_appeal" && item.Status == "pending" && resultIds.Contains(item.BusinessId)); + if (pendingAppeals > 0) return Error(409, $"本场还有 {pendingAppeals} 项成绩复议待处理,请先办结后再归档"); + var archived = exam with { ArchivedAt = NowIso(), ArchivedBy = user.Id, Status = "closed" }; + await repository.ArchiveAsync( + archived, + Audit(user, "归档考试并永久锁定成绩", $"{archived.Name} · {archived.Code}"), + cancellationToken); + var output = ExamJson(archived); + output["totalScore"] = archived.Subjects.Sum(item => item.FullScore); + output["registrationState"] = "archived"; + return Success(new JsonObject + { + ["ok"] = true, + ["exam"] = output, + ["message"] = "考试已归档,全部成绩已永久锁定" + }); + } + + private static IReadOnlyList NormalizeSubjects(JsonNode? input, string examStart) + { + IEnumerable source = input switch + { + JsonArray array => array, + _ => Text(input).Split([',', ','], StringSplitOptions.None).Select(value => (JsonNode?)JsonValue.Create(value)) + }; + var output = new List(); + var index = 0; + foreach (var item in source) + { + var structured = item is JsonObject; + var objectItem = item as JsonObject; + var name = Clean(Text(structured ? objectItem!["name"] : item), 50); + if (name.Length == 0) continue; + var fullScore = structured + ? objectItem!.ContainsKey("fullScore") + ? objectItem["fullScore"] is null ? 0 : Number(objectItem["fullScore"]) + : double.NaN + : 150; + var requestedRule = Text(objectItem?["passRule"]); + if (requestedRule == "score_ratio") requestedRule = "rank_percent"; + var passRule = SubjectPassRules.Contains(requestedRule) ? requestedRule : "fixed_score"; + var rawPassValue = objectItem?["passValue"] ?? objectItem?["passScore"]; + var passValue = passRule == "none" ? 0 : rawPassValue is null ? fullScore * 0.6 : Number(rawPassValue); + double? passScore = passRule == "fixed_score" + ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) + : null; + output.Add(new ManagedSubject( + Uid("sub"), + name, + Clean(Text(objectItem?["date"]), 10) is { Length: > 0 } date ? date : examStart[..Math.Min(10, examStart.Length)], + Clean(Text(objectItem?["start"]), 5) is { Length: > 0 } start ? start : "09:00", + Clean(Text(objectItem?["end"]), 5) is { Length: > 0 } end ? end : "11:00", + objectItem?["fee"] is null ? 0 : Number(objectItem["fee"]), + fullScore, + passRule, + passValue, + passScore, + ++index)); + } + return output; + } + + private static string ValidateScoring(IReadOnlyList subjects, string passPolicy, double passValue) + { + if (subjects.Count == 0) return "请至少添加一个考试科目"; + if (subjects.Any(item => !double.IsFinite(item.FullScore) || item.FullScore <= 0 || item.FullScore > 1000)) + return "科目满分必须大于 0 且不超过 1000"; + if (subjects.Any(item => !SubjectPassRules.Contains(item.PassRule))) return "请选择有效的单科合格线计算方式"; + if (subjects.Any(item => item.PassRule == "fixed_score" && (!double.IsFinite(item.PassValue) || item.PassValue < 0 || item.PassValue > item.FullScore))) + return "固定单科合格分必须在 0 与该科满分之间"; + if (subjects.Any(item => item.PassRule == "rank_percent" && (!double.IsFinite(item.PassValue) || item.PassValue <= 0 || item.PassValue > 100))) + return "单科排名比例必须大于 0 且不超过 100%"; + if (subjects.Any(item => !double.IsFinite(item.Fee) || item.Fee < 0 || item.Fee > 100000)) + return "科目费用必须在有效范围内"; + if (!PassPolicies.Contains(passPolicy)) return "请选择有效的合格线策略"; + var totalScore = subjects.Sum(item => item.FullScore); + if (passPolicy == "fixed_score" && (!double.IsFinite(passValue) || passValue < 0 || passValue > totalScore)) + return $"固定合格线必须在 0 与总分 {Format(totalScore)} 之间"; + if (passPolicy == "rank_percent" && (!double.IsFinite(passValue) || passValue <= 0 || passValue > 100)) + return "排名比例必须大于 0 且不超过 100"; + return ""; + } + + private static ManagedExam FromOperational(OperationalExam source) + { + var data = source.Data; + return new ManagedExam( + source.Id, + Text(data["code"]), + Text(data["name"]), + Text(data["description"]), + Text(data["registrationStart"]), + Text(data["registrationEnd"]), + Text(data["examStart"]), + Text(data["examEnd"]), + Text(data["admitDownloadStart"]), + Text(data["admitDownloadEnd"]), + Text(data["location"]), + Text(data["passPolicy"]), + Number(data["passValue"]), + Text(data["status"]), + OptionalText(data["archivedAt"]), + OptionalText(data["archivedBy"]), + Text(data["createdAt"]), + source.Subjects.Select((item, index) => new ManagedSubject( + Text(item["id"]), Text(item["name"]), Text(item["date"]), Text(item["start"]), Text(item["end"]), + Number(item["fee"]), Number(item["fullScore"]), Text(item["passRule"]), Number(item["passValue"]), + OptionalNumber(item["passScore"]), (int)NumberOr(item["order"], index + 1))).ToArray()); + } + + private static JsonObject ExamJson(ManagedExam 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), + ["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray()), + ["createdAt"] = item.CreatedAt + }; + + private static JsonObject SubjectJson(ManagedSubject 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 async Task ResolveSuperAsync(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, "请先登录")); + if (user.Role != "admin") return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作")); + return (user.AdminLevel ?? "super") == "super" + ? new(user, null) + : ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作")); + } + + private static bool JsBoolean(JsonNode? node) { if (node is null) return false; if (node is JsonValue value && value.TryGetValue(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue(out var text)) return text.Length > 0; if (node is JsonValue numberValue && numberValue.TryGetValue(out var number)) return number != 0 && !double.IsNaN(number); return true; } + private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : node?.ToString() ?? ""; + private static string? OptionalText(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + private static double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN; + private static double NumberOr(JsonNode? node, double fallback) => node is null ? fallback : Number(node); + private static double? OptionalNumber(JsonNode? node) => node is null ? null : Number(node); + private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; } + private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + private static string NowIso() => DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + private static string Uid(string prefix) => $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}"; + private static string ToBase36(long value) { const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; Span buffer = stackalloc char[16]; var position = buffer.Length; do { buffer[--position] = alphabet[(int)(value % 36)]; value /= 36; } while (value > 0); return new(buffer[position..]); } + private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso()); + private static AdminEndpointResult Success(JsonObject body) => Result(200, body); + private static AdminEndpointResult Result(int status, JsonObject body) => new(status, body); + private static AdminEndpointResult Error(int status, string message) => Result(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/AdminExamRepository.cs b/src/Eis.Infrastructure/Administration/AdminExamRepository.cs new file mode 100644 index 0000000..2f8bba9 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminExamRepository.cs @@ -0,0 +1,185 @@ +using System.Data.Common; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminExamRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task CreateAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken) => + SaveAsync(exam, replaceSubjects: true, isNew: true, audit, cancellationToken); + + public Task UpdateAsync(ManagedExam exam, bool replaceSubjects, AdminAuditEntry audit, CancellationToken cancellationToken) => + SaveAsync(exam, replaceSubjects, isNew: false, audit, cancellationToken); + + public async Task ArchiveAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + await ExecuteAsync(connection, transaction, new SqlOperation( + "UPDATE exams SET status = @status, archived_at = @archivedAt, archived_by = @archivedBy WHERE id = @id AND archived_at IS NULL", + [new("@status", exam.Status), new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@id", exam.Id)]), cancellationToken); + await InsertAuditAsync(connection, transaction, audit, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private async Task SaveAsync( + ManagedExam exam, + bool replaceSubjects, + bool isNew, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + var examOperation = isNew + ? new SqlOperation( + """ + INSERT INTO exams ( + 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 + ) VALUES ( + @id, @code, @name, @description, @registrationStart, @registrationEnd, @examStart, @examEnd, + @admitDownloadStart, @admitDownloadEnd, @location, @passPolicy, @passValue, @status, + @archivedAt, @archivedBy, @createdAt + ) + """, + ExamParameters(exam)) + : new SqlOperation( + """ + UPDATE exams SET code = @code, name = @name, description = @description, + registration_start = @registrationStart, registration_end = @registrationEnd, + exam_start = @examStart, exam_end = @examEnd, + admit_download_start = @admitDownloadStart, admit_download_end = @admitDownloadEnd, + location = @location, pass_policy = @passPolicy, pass_value = @passValue, status = @status + WHERE id = @id + """, + ExamParameters(exam)); + await ExecuteAsync(connection, transaction, examOperation, cancellationToken); + if (replaceSubjects) + { + if (!isNew) + { + await ExecuteAsync(connection, transaction, new SqlOperation( + "DELETE FROM exam_subjects WHERE exam_id = @examId", + [new("@examId", exam.Id)]), cancellationToken); + } + foreach (var subject in exam.Subjects) + { + await ExecuteAsync(connection, transaction, new SqlOperation( + """ + INSERT INTO exam_subjects ( + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, + pass_score, pass_rule, pass_value, position + ) VALUES ( + @id, @examId, @name, @date, @start, @end, @fee, @fullScore, + @passScore, @passRule, @passValue, @position + ) + """, + [ + new("@id", subject.Id), new("@examId", exam.Id), new("@name", subject.Name), + new("@date", subject.Date), new("@start", subject.Start), new("@end", subject.End), + new("@fee", subject.Fee), new("@fullScore", subject.FullScore), + new("@passScore", subject.PassScore ?? 0), + new("@passRule", subject.PassRule == "rank_percent" ? "score_ratio" : subject.PassRule), + new("@passValue", subject.PassValue), new("@position", subject.Order) + ]), cancellationToken); + } + } + await InsertAuditAsync(connection, transaction, audit, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static IReadOnlyList ExamParameters(ManagedExam exam) => + [ + new("@id", exam.Id), new("@code", exam.Code), new("@name", exam.Name), + new("@description", exam.Description), new("@registrationStart", exam.RegistrationStart), + new("@registrationEnd", exam.RegistrationEnd), new("@examStart", exam.ExamStart), + new("@examEnd", exam.ExamEnd), new("@admitDownloadStart", exam.AdmitDownloadStart), + new("@admitDownloadEnd", exam.AdmitDownloadEnd), new("@location", exam.Location), + new("@passPolicy", exam.PassPolicy), new("@passValue", exam.PassValue), new("@status", exam.Status), + new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@createdAt", exam.CreatedAt) + ]; + + private static Task InsertAuditAsync(DbConnection connection, DbTransaction transaction, AdminAuditEntry audit, CancellationToken cancellationToken) => + ExecuteAsync(connection, transaction, new SqlOperation( + """ + INSERT INTO audit_logs (id, actor_id, action, detail, created_at) + VALUES (@id, @actorId, @action, @detail, @createdAt) + """, + [ + new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action), + new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt) + ]), cancellationToken); + + private static async Task ExecuteAsync( + DbConnection connection, + DbTransaction transaction, + SqlOperation operation, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = operation.Sql; + foreach (var item in operation.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = item.Name; + parameter.Value = item.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private sealed record SqlOperation(string Sql, IReadOnlyList Parameters); + private sealed record SqlParameterValue(string Name, object? Value); +} + +internal sealed record ManagedExam( + 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 ManagedSubject( + string Id, + string Name, + string Date, + string Start, + string End, + double Fee, + double FullScore, + string PassRule, + double PassValue, + double? PassScore, + int Order); diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 05bdd55..cd85cdc 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -10,7 +10,8 @@ public sealed record AdminMigrationOptions( bool NativeOperationalReadsEnabled = false, bool NativeCandidateManagementEnabled = false, bool NativeRegistrationPaymentWritesEnabled = false, - bool NativeWorkflowOperationsEnabled = false) + bool NativeWorkflowOperationsEnabled = false, + bool NativeExamManagementEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -24,7 +25,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeOperationalReadsEnabled = false, bool configuredNativeCandidateManagementEnabled = false, bool configuredNativeRegistrationPaymentWritesEnabled = false, - bool configuredNativeWorkflowOperationsEnabled = false) + bool configuredNativeWorkflowOperationsEnabled = false, + bool configuredNativeExamManagementEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -57,7 +59,10 @@ public sealed record AdminMigrationOptions( var workflowOperationsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"), configuredNativeWorkflowOperationsEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled) && !readsEnabled) + var examManagementEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"), + configuredNativeExamManagementEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); @@ -67,7 +72,7 @@ public sealed record AdminMigrationOptions( throw new InvalidOperationException( "启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -83,7 +88,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 65ae813..afccd68 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -63,6 +63,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -73,6 +74,7 @@ public static class DependencyInjection 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 6ebd3eb..662de83 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -129,6 +129,16 @@ public static class NativeAdminReadEndpoints endpoints.MapPatch("/api/admin/workflow-instances/{instanceId}/supervise", (HttpContext context, string instanceId, JsonObject body, IAdminWorkflowService service, CancellationToken cancellationToken) => Execute(context, service.SuperviseAsync(Token(context), instanceId, body, cancellationToken))); } + + if (options.NativeExamManagementEnabled) + { + endpoints.MapPost("/api/admin/exams", (HttpContext context, JsonObject body, IAdminExamManagementService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/exams/{examId}", (HttpContext context, string examId, JsonObject body, IAdminExamManagementService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAsync(Token(context), examId, body, cancellationToken))); + endpoints.MapPost("/api/admin/exams/{examId}/archive", (HttpContext context, string examId, IAdminExamManagementService service, CancellationToken cancellationToken) => + Execute(context, service.ArchiveAsync(Token(context), examId, cancellationToken))); + } return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index fc6c045..7d0022f 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -54,7 +54,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeOperationalReadsEnabled"), builder.Configuration.GetValue("AdminMigration:NativeCandidateManagementEnabled"), builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeExamManagementEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -118,6 +119,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled, nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, + nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -152,6 +154,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeWorkflowOperationsEnabled ? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" } : []) + .Concat(adminMigrationOptions.NativeExamManagementEnabled + ? new[] { "POST/PATCH exams", "POST exam archive" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index a30efd3..8b3374d 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -19,7 +19,8 @@ "NativeOperationalReadsEnabled": false, "NativeCandidateManagementEnabled": false, "NativeRegistrationPaymentWritesEnabled": false, - "NativeWorkflowOperationsEnabled": false + "NativeWorkflowOperationsEnabled": false, + "NativeExamManagementEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 2aab9be..d663168 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -256,6 +256,21 @@ public sealed class AdminMigrationOptionsTests }, workflowOperationsEnabled: "true"); } + [Fact] + public void EnablesExamManagementWithBaseReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeExamManagementEnabled); + }, examManagementEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -269,7 +284,8 @@ public sealed class AdminMigrationOptionsTests string? operationalReadsEnabled = null, string? candidateManagementEnabled = null, string? registrationPaymentWritesEnabled = null, - string? workflowOperationsEnabled = null) + string? workflowOperationsEnabled = null, + string? examManagementEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -282,6 +298,7 @@ public sealed class AdminMigrationOptionsTests var previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"); var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"); var previousWorkflowOperationsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"); + var previousExamManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -296,6 +313,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", examManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -312,6 +330,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", previousExamManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }