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);
}
}