已完成第十一批迁移:考试维护功能已原生化到 ASP.NET Core 10。

支持考试创建、草稿修改、发布状态切换及不可逆归档。
支持科目、费用、满分和合格线策略校验。
保留已有报名禁止修改科目、待处理成绩复议禁止归档等规则。
数据修改、科目替换与审计日志使用同一数据库事务。
新增 ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED 独立开关。
路由与实现见 [AdminExamManagementService.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs:9) 和 [NativeAdminReadEndpoints.cs (line 133)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs:133)。
迁移说明已更新至 [MIGRATION.md (line 57)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:57)。
This commit is contained in:
2026-07-23 10:50:24 +08:00 Unverified
parent 2589305f3c
commit 25501651e1
12 changed files with 688 additions and 11 deletions
+2
View File
@@ -45,6 +45,8 @@ ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false
# 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。 # 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false
# 考试创建、修改、发布状态及不可逆归档;必须同时启用管理端基础读取。
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+4 -3
View File
@@ -12,7 +12,7 @@
- [x] 招生公示与 HMAC 文书验真公开接口 - [x] 招生公示与 HMAC 文书验真公开接口
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [x] 考生业务 - [x] 考生业务
- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入工作流调度已原生化) - [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入工作流调度及考试维护已原生化)
- [x] 考生志愿填报与招生录取查询 - [x] 考生志愿填报与招生录取查询
- [ ] Excel、文书和缓存 - [ ] Excel、文书和缓存
- [ ] 容器入口切换及 Node.js 后端移除 - [ ] 容器入口切换及 Node.js 后端移除
@@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
$env:CANDIDATE_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;各写入子功能还必须与只读开关一起启用: 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
@@ -70,9 +70,10 @@ $env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
$env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' $env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
$env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
$env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_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 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
+95
View File
@@ -446,6 +446,7 @@ try {
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true'
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
@@ -1353,6 +1354,99 @@ try {
$restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress $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 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 = '<p>无权发布</p>' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck $noticeCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '越权公告'; content = '<p>无权发布</p>' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($noticeCreateForbidden.StatusCode -ne 403 -or $noticeCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { 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' throw 'Native notice creation did not preserve the super-admin boundary'
@@ -1487,6 +1581,7 @@ try {
NativeAdminCandidateManagement = 'passed' NativeAdminCandidateManagement = 'passed'
NativeAdminRegistrationPaymentWrites = 'passed' NativeAdminRegistrationPaymentWrites = 'passed'
NativeAdminWorkflowOperations = 'passed' NativeAdminWorkflowOperations = 'passed'
NativeAdminExamManagement = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -0,0 +1,10 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminExamManagementService
{
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ArchiveAsync(string sessionToken, string examId, CancellationToken cancellationToken);
}
@@ -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<string> PassPolicies = ["fixed_score", "rank_percent", "subject_scores", "none"];
private static readonly HashSet<string> SubjectPassRules = ["fixed_score", "rank_percent", "none"];
public async Task<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> 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<ManagedSubject> NormalizeSubjects(JsonNode? input, string examStart)
{
IEnumerable<JsonNode?> source = input switch
{
JsonArray array => array,
_ => Text(input).Split([',', ''], StringSplitOptions.None).Select(value => (JsonNode?)JsonValue.Create(value))
};
var output = new List<ManagedSubject>();
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<ManagedSubject> 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<ResolvedAdmin> 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<bool>(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text)) return text.Length > 0; if (node is JsonValue numberValue && numberValue.TryGetValue<double>(out var number)) return number != 0 && !double.IsNaN(number); return true; }
private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string? OptionalText(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(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<char> 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); }
}
@@ -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<SqlParameterValue> 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<SqlParameterValue> 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<ManagedSubject> 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);
@@ -10,7 +10,8 @@ public sealed record AdminMigrationOptions(
bool NativeOperationalReadsEnabled = false, bool NativeOperationalReadsEnabled = false,
bool NativeCandidateManagementEnabled = false, bool NativeCandidateManagementEnabled = false,
bool NativeRegistrationPaymentWritesEnabled = false, bool NativeRegistrationPaymentWritesEnabled = false,
bool NativeWorkflowOperationsEnabled = false) bool NativeWorkflowOperationsEnabled = false,
bool NativeExamManagementEnabled = false)
{ {
public static AdminMigrationOptions FromEnvironment( public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled, bool configuredNativeReadsEnabled,
@@ -24,7 +25,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeOperationalReadsEnabled = false, bool configuredNativeOperationalReadsEnabled = false,
bool configuredNativeCandidateManagementEnabled = false, bool configuredNativeCandidateManagementEnabled = false,
bool configuredNativeRegistrationPaymentWritesEnabled = false, bool configuredNativeRegistrationPaymentWritesEnabled = false,
bool configuredNativeWorkflowOperationsEnabled = false) bool configuredNativeWorkflowOperationsEnabled = false,
bool configuredNativeExamManagementEnabled = false)
{ {
var readsEnabled = ParseBoolean( var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -57,7 +59,10 @@ public sealed record AdminMigrationOptions(
var workflowOperationsEnabled = ParseBoolean( var workflowOperationsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
configuredNativeWorkflowOperationsEnabled); 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( throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
@@ -67,7 +72,7 @@ public sealed record AdminMigrationOptions(
throw new InvalidOperationException( throw new InvalidOperationException(
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true"); "启用原生考生、报名或缴费写接口前必须同时设置 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) if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -83,7 +88,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); "管理端仍有接口需要转发给 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 private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -63,6 +63,7 @@ public static class DependencyInjection
services.AddScoped<AdminOperationalSnapshotLoader>(); services.AddScoped<AdminOperationalSnapshotLoader>();
services.AddScoped<AdminWorkflowResultSnapshotLoader>(); services.AddScoped<AdminWorkflowResultSnapshotLoader>();
services.AddScoped<AdminWorkflowRepository>(); services.AddScoped<AdminWorkflowRepository>();
services.AddScoped<AdminExamRepository>();
services.AddScoped<IAdminReadService, AdminReadService>(); services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>(); services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>(); services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
@@ -73,6 +74,7 @@ public static class DependencyInjection
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>(); services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>(); services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>(); services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
services.AddSingleton<NoticeContentFormatter>(); services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
@@ -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) => 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))); 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; return endpoints;
} }
+6 -1
View File
@@ -54,7 +54,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeRegistrationPaymentWritesEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeRegistrationPaymentWritesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled")); builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeExamManagementEnabled"));
builder.Services.AddEisInfrastructure( builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
@@ -118,6 +119,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled, nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled,
nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled,
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } ? 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 .Concat(adminMigrationOptions.NativeWorkflowOperationsEnabled
? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" } ? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" }
: []) : [])
.Concat(adminMigrationOptions.NativeExamManagementEnabled
? new[] { "POST/PATCH exams", "POST exam archive" }
: [])
.ToArray() .ToArray()
}, },
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
+2 -1
View File
@@ -19,7 +19,8 @@
"NativeOperationalReadsEnabled": false, "NativeOperationalReadsEnabled": false,
"NativeCandidateManagementEnabled": false, "NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false, "NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false "NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -256,6 +256,21 @@ public sealed class AdminMigrationOptionsTests
}, workflowOperationsEnabled: "true"); }, 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( private static void WithEnvironment(
string? nativeReadsEnabled, string? nativeReadsEnabled,
string? nativeOrganizationWritesEnabled, string? nativeOrganizationWritesEnabled,
@@ -269,7 +284,8 @@ public sealed class AdminMigrationOptionsTests
string? operationalReadsEnabled = null, string? operationalReadsEnabled = null,
string? candidateManagementEnabled = null, string? candidateManagementEnabled = null,
string? registrationPaymentWritesEnabled = null, string? registrationPaymentWritesEnabled = null,
string? workflowOperationsEnabled = null) string? workflowOperationsEnabled = null,
string? examManagementEnabled = null)
{ {
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_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 previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED");
var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"); var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED");
var previousWorkflowOperationsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_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"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
try try
{ {
@@ -296,6 +313,7 @@ public sealed class AdminMigrationOptionsTests
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", examManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
test(); test();
} }
@@ -312,6 +330,7 @@ public sealed class AdminMigrationOptionsTests
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", previousExamManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
} }
} }