支持五级混编、稳定种子、容量预检、固定考点、逐科考场/座位及四种准考证号规则。

正式编排会事务性替换整场计划、准考证、座位和审计记录。
支持三级管理员范围读取、批量 HTML 准考证、准考证信息 Excel,以及校级考点桌贴、门贴和签名单。
核心实现见 [AdminArrangementPlanner.cs (line 7)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementPlanner.cs:7)、[AdminArrangementService.cs (line 11)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementService.cs:11) 和 [AdminArrangementExporter.cs (line 5)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminArrangementExporter.cs:5)。
新增开关 ADMIN_NATIVE_ARRANGEMENTS_ENABLED=true,说明已更新到 [MIGRATION.md (line 57)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:57)。
Excel 由 .NET 直接生成,使用 ClosedXML 0.105.0;该库支持服务端生成 Excel 2007+ 工作簿。ClosedXML NuGet 页面
This commit is contained in:
2026-07-23 11:25:09 +08:00 Unverified
parent 25501651e1
commit 8f83adbc1e
18 changed files with 2138 additions and 11 deletions
+2
View File
@@ -47,6 +47,8 @@ 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 ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false
# 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。
ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.5.2" /> <PackageVersion Include="AngleSharp" Version="1.5.2" />
<PackageVersion Include="ClosedXML" Version="0.105.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageVersion Include="MySqlConnector" Version="2.6.1" /> <PackageVersion Include="MySqlConnector" Version="2.6.1" />
<PackageVersion Include="QRCoder" Version="1.8.0" /> <PackageVersion Include="QRCoder" Version="1.8.0" />
+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 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
@@ -71,9 +71,10 @@ $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' $env:ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true'
$env:ADMIN_NATIVE_ARRANGEMENTS_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.nativeExamManagementEnabled``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.nativeArrangementsEnabled``administration.nativeRoutes` 会报告这些端点是否已切换。
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
+130
View File
@@ -148,6 +148,28 @@ function Assert-CandidateAdmissionsEquivalent {
} }
} }
function Assert-ArrangementPreviewEquivalent {
param(
[Parameter(Mandatory)]
[string] $Expected,
[Parameter(Mandatory)]
[string] $Actual
)
$expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected)
$actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual)
foreach ($node in @($expectedNode, $actualNode)) {
foreach ($sample in $node['samples'].AsArray()) {
$sample.AsObject().Remove('generatedAt') | Out-Null
}
}
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) {
$difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$'
throw "Arrangement preview JSON payload differs from the legacy API at $difference"
}
}
function Find-JsonDifference { function Find-JsonDifference {
param( param(
[AllowNull()] [AllowNull()]
@@ -249,6 +271,20 @@ try {
} }
$registrationSwitchProcess.Dispose() $registrationSwitchProcess.Dispose()
$arrangementCapacityProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
'tests/helpers/prepare-admin-arrangement-smoke.mjs', $smokeDatabasePath
) -Environment $nodeEnvironment
if (-not $arrangementCapacityProcess.WaitForExit(10000)) {
$arrangementCapacityProcess.Kill($true)
throw 'Timed out while preparing arrangement capacity in the smoke-test database'
}
$arrangementCapacityOutput = $arrangementCapacityProcess.StandardOutput.ReadToEnd()
$arrangementCapacityError = $arrangementCapacityProcess.StandardError.ReadToEnd()
if ($arrangementCapacityProcess.ExitCode -ne 0) {
throw "Could not prepare arrangement capacity in the smoke-test database`n$arrangementCapacityOutput`n$arrangementCapacityError"
}
$arrangementCapacityProcess.Dispose()
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj' $projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
$buildProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( $buildProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
'build', $projectPath, 'build', $projectPath,
@@ -447,6 +483,7 @@ try {
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_EXAM_MANAGEMENT_ENABLED = 'true'
ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
@@ -807,6 +844,94 @@ try {
throw 'Native admins route did not preserve the class-admin boundary' throw 'Native admins route did not preserve the class-admin boundary'
} }
$arrangementSessions = @(
@{ Level = 'super'; Legacy = $session; Native = $nativeSession },
@{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession },
@{ Level = 'class'; Legacy = $legacyClassSession; Native = $nativeClassSession }
)
foreach ($arrangementSession in $arrangementSessions) {
$legacyArrangementRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Legacy
$nativeArrangementRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Native
if ($nativeArrangementRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Arrangement read for $($arrangementSession.Level) admin did not use ASP.NET Core"
}
Assert-JsonEquivalent -Expected $legacyArrangementRead.Content -Actual $nativeArrangementRead.Content -Label "Arrangement read for $($arrangementSession.Level) admin"
}
$arrangementState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $nativeSession
$arrangementExam = @($arrangementState.exams | Where-Object { $_.approvedCount -gt 0 -and -not $_.archivedAt })[0]
$arrangementRule = @($arrangementState.rules | Where-Object { $_.code -eq 'district_room_seat' })[0]
if ($null -eq $arrangementExam -or $null -eq $arrangementRule) {
throw 'Seed data did not provide an exam and number rule for arrangement smoke testing'
}
$arrangementBody = @{
mixingScope = 'province'
numberRuleId = $arrangementRule.id
seed = 'native-arrangement-parity'
} | ConvertTo-Json -Compress
$schoolArrangementForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($schoolArrangementForbidden.StatusCode -ne 403 -or $schoolArrangementForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement preview did not preserve the super-admin boundary'
}
$invalidArrangementBody = @{
mixingScope = 'invalid'
numberRuleId = $arrangementRule.id
seed = 'native-arrangement-parity'
} | ConvertTo-Json -Compress
$invalidArrangement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $invalidArrangementBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($invalidArrangement.StatusCode -ne 400) {
throw 'Native arrangement preview accepted an invalid mixing scope'
}
$legacyArrangementPreview = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $session
$nativeArrangementPreview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession
if ($nativeArrangementPreview.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement preview did not use ASP.NET Core'
}
Assert-ArrangementPreviewEquivalent -Expected $legacyArrangementPreview.Content -Actual $nativeArrangementPreview.Content
$arrangementApplyResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($arrangementExam.id)/admission-arrangement" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession
$appliedArrangement = $arrangementApplyResponse.Content | ConvertFrom-Json
if ($arrangementApplyResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$appliedArrangement.plan.examId -ne $arrangementExam.id -or
$appliedArrangement.summary.candidateCount -le 0 -or
@($appliedArrangement.cards).Count -ne $appliedArrangement.summary.candidateCount) {
throw 'Native arrangement application did not persist the complete exam plan'
}
foreach ($arrangementSession in $arrangementSessions) {
$legacyArrangementAfterApply = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Legacy
$nativeArrangementAfterApply = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-arrangements" -WebSession $arrangementSession.Native
Assert-JsonEquivalent -Expected $legacyArrangementAfterApply.Content -Actual $nativeArrangementAfterApply.Content -Label "Arrangement application follow-up for $($arrangementSession.Level) admin"
}
$legacySingleAdmit = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($appliedArrangement.cards[0].registrationId)/admit-card" -Method Post -WebSession $nativeSession -SkipHttpErrorCheck
if ($legacySingleAdmit.StatusCode -ne 410 -or $legacySingleAdmit.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native arrangement routes did not retire single-candidate admit-card generation'
}
$anonymousSingleAdmit = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($appliedArrangement.cards[0].registrationId)/admit-card" -Method Post -SkipHttpErrorCheck
if ($anonymousSingleAdmit.StatusCode -ne 401) {
throw 'Native retired admit-card route did not preserve administrator authentication'
}
$batchAdmitExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/admit-cards?examId=$($arrangementExam.id)" -WebSession $nativeSession
if ($batchAdmitExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$batchAdmitExport.Headers['Content-Type'] -notmatch '^text/html' -or
$batchAdmitExport.Content -notmatch '衡准 · 准考证') {
throw 'Native batch admit-card HTML export is invalid'
}
$admitInfoExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/info?examId=$($arrangementExam.id)" -WebSession $nativeSession
if ($admitInfoExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$admitInfoExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or
$admitInfoExport.RawContentLength -lt 1000) {
throw 'Native admit-card information workbook export is invalid'
}
$superCenterMaterialsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/center-materials?examId=$($arrangementExam.id)" -WebSession $nativeSession -SkipHttpErrorCheck
if ($superCenterMaterialsForbidden.StatusCode -ne 403) {
throw 'Native center-material export did not preserve the school-admin boundary'
}
$centerMaterialsExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-exports/center-materials?examId=$($arrangementExam.id)" -WebSession $nativeSchoolSession
if ($centerMaterialsExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
$centerMaterialsExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or
$centerMaterialsExport.RawContentLength -lt 1000) {
throw 'Native center-material workbook export is invalid'
}
$operationalSessions = @( $operationalSessions = @(
@{ Level = 'super'; Legacy = $session; Native = $nativeSession }, @{ Level = 'super'; Legacy = $session; Native = $nativeSession },
@{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession }, @{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession },
@@ -1443,6 +1568,10 @@ try {
if ($archivedExamUpdate.StatusCode -ne 409) { if ($archivedExamUpdate.StatusCode -ne 409) {
throw 'Native exam update did not keep archived exams locked' throw 'Native exam update did not keep archived exams locked'
} }
$archivedExamArrangement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams/$($createdExam.id)/admission-arrangement/preview" -Method Post -ContentType 'application/json' -Body $arrangementBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($archivedExamArrangement.StatusCode -ne 409) {
throw 'Native arrangement preview did not keep archived exams locked'
}
$legacyExamsAfterArchive = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams" -WebSession $session $legacyExamsAfterArchive = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/exams" -WebSession $session
$nativeExamsAfterArchive = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -WebSession $nativeSession $nativeExamsAfterArchive = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/exams" -WebSession $nativeSession
Assert-JsonEquivalent -Expected $legacyExamsAfterArchive.Content -Actual $nativeExamsAfterArchive.Content -Label 'Exam archive follow-up' Assert-JsonEquivalent -Expected $legacyExamsAfterArchive.Content -Actual $nativeExamsAfterArchive.Content -Label 'Exam archive follow-up'
@@ -1582,6 +1711,7 @@ try {
NativeAdminRegistrationPaymentWrites = 'passed' NativeAdminRegistrationPaymentWrites = 'passed'
NativeAdminWorkflowOperations = 'passed' NativeAdminWorkflowOperations = 'passed'
NativeAdminExamManagement = 'passed' NativeAdminExamManagement = 'passed'
NativeAdminArrangements = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -0,0 +1,37 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public sealed record AdminDocumentResult(
int StatusCode,
byte[]? Content,
string? ContentType,
string? FileName,
JsonObject? Error);
public interface IAdminArrangementService
{
Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> PreviewAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> ApplyAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> RetiredSingleAsync(
string sessionToken,
CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAsync(
string sessionToken,
string type,
string examId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,244 @@
using ClosedXML.Excel;
namespace Eis.Infrastructure.Administration;
internal static class AdminArrangementExporter
{
private static readonly string[] InformationHeaders =
[
"考生学校", "班级", "报名号", "姓名", "证件号码", "考试代码", "考试名称", "准考证号",
"考点代码", "考点名称", "考点详细地址", "科目", "日期", "时间", "考试考场序号",
"考场通用名称", "物理场地代码", "楼栋", "楼层", "座位号"
];
public static byte[] AdmitCardInformation(
IReadOnlyList<ArrangementExportRow> rows,
string subtitle)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("准考证信息");
ConfigureTable(
sheet,
$"准考证信息台账|{subtitle}",
InformationHeaders,
rows.Select(InformationValues).ToArray());
return Save(workbook);
}
public static byte[] CenterMaterials(
IReadOnlyList<ArrangementExportRow> rows,
string subtitle)
{
using var workbook = new XLWorkbook();
var sorted = rows
.OrderBy(item => item.CenterCode, StringComparer.Ordinal)
.ThenBy(item => item.SubjectDate, StringComparer.Ordinal)
.ThenBy(item => item.SubjectTime, StringComparer.Ordinal)
.ThenBy(item => item.ExamRoomCode, StringComparer.Ordinal)
.ThenBy(item => item.Seat, StringComparer.Ordinal)
.ToArray();
AddDeskStickers(workbook.Worksheets.Add("桌贴"), sorted);
var doors = sorted
.GroupBy(
item => new
{
item.ExamCode,
item.SubjectName,
item.CenterCode,
item.ExamRoomCode,
item.RoomCode
})
.Select(group => (Row: group.First(), Count: group.Count()))
.ToArray();
ConfigureTable(
workbook.Worksheets.Add("门贴"),
$"考场门贴|{subtitle}",
[
"考试", "科目", "日期", "时间", "考点", "考试考场序号",
"考场通用名称", "场地代码", "楼栋", "楼层", "人数"
],
doors.Select(item => new object[]
{
item.Row.ExamName,
item.Row.SubjectName,
item.Row.SubjectDate,
item.Row.SubjectTime,
item.Row.CenterName,
item.Row.ExamRoomCode,
item.Row.RoomName,
item.Row.RoomCode,
item.Row.Building,
item.Row.Floor,
item.Count
}).ToArray());
ConfigureTable(
workbook.Worksheets.Add("考场签名单"),
$"考场考生签名单|{subtitle}",
[
"考试", "科目", "日期", "时间", "考试考场序号", "考场通用名称",
"楼栋", "楼层", "座位号", "准考证号", "姓名", "考生学校", "班级", "考生签名"
],
sorted.Select(item => new object[]
{
item.ExamName,
item.SubjectName,
item.SubjectDate,
item.SubjectTime,
item.ExamRoomCode,
item.RoomName,
item.Building,
item.Floor,
item.Seat,
item.CardNumber,
item.CandidateName,
item.SchoolName,
item.ClassName,
""
}).ToArray());
return Save(workbook);
}
private static void AddDeskStickers(
IXLWorksheet sheet,
IReadOnlyList<ArrangementExportRow> rows)
{
sheet.ShowGridLines = false;
for (var column = 1; column <= 7; column++)
sheet.Column(column).Width = column == 4 ? 3 : 14.5;
for (var index = 0; index < rows.Count; index++)
{
var item = rows[index];
var slot = index % 2;
var group = index / 2;
var startRow = group * 9 + 1;
var startColumn = slot == 0 ? 1 : 5;
var values = new[]
{
item.ExamName,
$"{item.SubjectName}{item.SubjectDate} {item.SubjectTime}",
"考试考场序号",
item.ExamRoomCode,
$"座位号 {item.Seat}",
$"{item.RoomName}(场地 {item.RoomCode})|{item.Building} {item.Floor}",
$"{item.CandidateName}{item.SchoolName} {item.ClassName}",
$"{item.CenterName}|准考证号 {item.CardNumber}"
};
for (var offset = 0; offset < values.Length; offset++)
{
var range = sheet.Range(
startRow + offset,
startColumn,
startRow + offset,
startColumn + 2);
range.Merge();
range.Value = values[offset];
range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
range.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
range.Style.Alignment.WrapText = true;
range.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
range.Style.Border.InsideBorder = XLBorderStyleValues.Hair;
sheet.Row(startRow + offset).Height = 28;
}
var title = sheet.Range(startRow, startColumn, startRow, startColumn + 2);
title.Style.Fill.BackgroundColor = XLColor.FromHtml("#173F60");
title.Style.Font.FontColor = XLColor.White;
title.Style.Font.Bold = true;
var room = sheet.Range(startRow + 3, startColumn, startRow + 3, startColumn + 2);
room.Style.Font.FontSize = 24;
room.Style.Font.Bold = true;
var seat = sheet.Range(startRow + 4, startColumn, startRow + 4, startColumn + 2);
seat.Style.Fill.BackgroundColor = XLColor.FromHtml("#FFF1C7");
seat.Style.Font.FontColor = XLColor.FromHtml("#8A4B08");
seat.Style.Font.FontSize = 18;
seat.Style.Font.Bold = true;
}
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins.SetLeft(0.25);
sheet.PageSetup.Margins.SetRight(0.25);
sheet.PageSetup.Margins.SetTop(0.25);
sheet.PageSetup.Margins.SetBottom(0.25);
}
private static void ConfigureTable(
IXLWorksheet sheet,
string title,
IReadOnlyList<string> headers,
IReadOnlyList<object[]> rows)
{
sheet.ShowGridLines = false;
var lastColumn = headers.Count;
var titleRange = sheet.Range(1, 1, 1, lastColumn);
titleRange.Merge();
titleRange.Value = title;
titleRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#173F60");
titleRange.Style.Font.FontColor = XLColor.White;
titleRange.Style.Font.FontSize = 16;
titleRange.Style.Font.Bold = true;
titleRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Row(1).Height = 28;
for (var column = 0; column < headers.Count; column++)
{
var cell = sheet.Cell(2, column + 1);
cell.Value = headers[column];
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#2C7080");
cell.Style.Font.FontColor = XLColor.White;
cell.Style.Font.Bold = true;
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
cell.Style.Alignment.WrapText = true;
}
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
for (var column = 0; column < headers.Count; column++)
{
var value = column < rows[rowIndex].Length ? rows[rowIndex][column] : "";
var cell = sheet.Cell(rowIndex + 3, column + 1);
if (value is int integer) cell.Value = integer;
else if (value is double number) cell.Value = number;
else cell.Value = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? "";
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
cell.Style.Alignment.WrapText = true;
cell.Style.Border.BottomBorder = XLBorderStyleValues.Hair;
cell.Style.Border.BottomBorderColor = XLColor.FromHtml("#D8E2E7");
}
}
sheet.SheetView.FreezeRows(2);
if (rows.Count > 0) sheet.Range(2, 1, rows.Count + 2, lastColumn).SetAutoFilter();
sheet.Columns().AdjustToContents(8, 40);
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.SetRowsToRepeatAtTop(1, 2);
}
private static object[] InformationValues(ArrangementExportRow item) =>
[
item.SchoolName,
item.ClassName,
item.CandidateNumber,
item.CandidateName,
item.IdNumber,
item.ExamCode,
item.ExamName,
item.CardNumber,
item.CenterCode,
item.CenterName,
item.CenterAddress,
item.SubjectName,
item.SubjectDate,
item.SubjectTime,
item.ExamRoomCode,
item.RoomName,
item.RoomCode,
item.Building,
item.Floor,
item.Seat
];
private static byte[] Save(XLWorkbook workbook)
{
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
}
@@ -0,0 +1,447 @@
using System.Globalization;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Eis.Infrastructure.Administration;
internal static partial class AdminArrangementPlanner
{
private static readonly IReadOnlyDictionary<string, string> MixingScopeNames =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["class"] = "班内混编",
["school"] = "校内混编",
["district"] = "县区内混编",
["city"] = "市内混编",
["province"] = "省内混编"
};
public static ArrangementBuildResult Build(
AdminOperationalSnapshot operational,
AdminCenterSnapshot centers,
AdminArrangementSnapshot arrangements,
AdminAccountBatchSnapshot directory,
string examId,
string requestedScope,
string numberRuleId,
string requestedSeed,
string generatedAt)
{
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId)
?? throw Error("考试不存在", 404);
var mixingScope = requestedScope.Length == 0 ? "school" : requestedScope;
if (!MixingScopeNames.ContainsKey(mixingScope)) throw Error("请选择有效的混编范围", 400);
var rule = arrangements.Rules.FirstOrDefault(item => item.Id == numberRuleId && item.Active)
?? throw Error("请选择有效的准考证号规则", 400);
var seedSource = requestedSeed.Length > 0 ? requestedSeed : First(Text(exam.Data, "code"), exam.Id);
var seed = seedSource[..Math.Min(seedSource.Length, 80)];
var warnings = new List<string>();
void Warn(string message)
{
if (!warnings.Contains(message, StringComparer.Ordinal)) warnings.Add(message);
}
var subjectOrder = exam.Subjects
.Select((subject, index) => new { Id = Text(subject, "id"), Index = index })
.ToDictionary(item => item.Id, item => item.Index, StringComparer.Ordinal);
var approvedRegistrations = operational.Registrations
.Where(item => item.ExamId == exam.Id && item.Status == "approved")
.ToArray();
if (approvedRegistrations.Length == 0)
throw Error("该考试没有已审核通过的报名,无法编排");
var candidates = approvedRegistrations.Select(registration =>
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId)
?? throw Error($"报名 {registration.Id} 缺少考生档案");
var boundary = ScopeValue(profile, mixingScope);
if (boundary.Length == 0)
{
var scopeLabel = MixingScopeNames[mixingScope].Replace("内混编", "", StringComparison.Ordinal);
throw Error($"{Text(profile.Data, "name")} 缺少{scopeLabel}信息");
}
var requestedSubjectIds = (registration.Data["subjectIds"] as JsonArray ?? [])
.Select(item => item?.GetValue<string>() ?? "")
.ToArray();
var subjects = requestedSubjectIds
.Select(id => exam.Subjects.FirstOrDefault(subject => Text(subject, "id") == id))
.Where(item => item is not null)
.Cast<JsonObject>()
.ToArray();
if (subjects.Length != requestedSubjectIds.Length || subjects.Length == 0)
throw Error($"{Text(profile.Data, "name")} 的报考科目无效");
var overlap = OverlappingSubjects(subjects);
if (overlap is not null)
throw Error($"{Text(profile.Data, "name")} 报考的“{Text(overlap.Value.Left, "name")}”与“{Text(overlap.Value.Right, "name")}”时间冲突");
var orderedSubjectIds = subjects
.OrderBy(subject => subjectOrder.GetValueOrDefault(Text(subject, "id")))
.Select(subject => Text(subject, "id"))
.ToArray();
return new CandidateWork(
registration,
profile,
orderedSubjectIds,
string.Join('|', orderedSubjectIds),
$"{mixingScope}:{boundary}");
}).ToList();
var activeCenters = centers.Centers
.Where(center => center.Status == "active")
.Select(center =>
{
var allRooms = centers.Rooms
.Where(room => room.CenterId == center.Id && room.Status == "active")
.ToArray();
var regularRooms = allRooms.Where(room => room.RoomType != "spare").ToArray();
var rooms = regularRooms.Length > 0 ? regularRooms : allRooms;
if (regularRooms.Length == 0 && allRooms.Length > 0)
Warn($"{center.Name} 没有普通启用考场,本次将使用备用考场");
return new CenterWork(center, rooms, allRooms);
})
.Where(center => center.Rooms.Count > 0)
.ToArray();
if (activeCenters.Length == 0) throw Error("没有可用的启用考点和考场");
var remaining = activeCenters.ToDictionary(
center => center.Center.Id,
center => exam.Subjects.ToDictionary(
subject => Text(subject, "id"),
_ => center.Rooms.Sum(room => room.Capacity),
StringComparer.Ordinal),
StringComparer.Ordinal);
var assignedCounts = activeCenters.ToDictionary(item => item.Center.Id, _ => 0, StringComparer.Ordinal);
var affinity = new Dictionary<string, int>(StringComparer.Ordinal);
var groups = candidates
.GroupBy(candidate => $"{candidate.ScopeKey}:{candidate.Signature}", StringComparer.Ordinal)
.OrderByDescending(group => group.Count())
.ThenBy(group => group.Key, StringComparer.Ordinal)
.ToArray();
foreach (var group in groups)
{
var ordered = group.ToList();
ordered.Sort((left, right) => StableCompare(seed, left, right));
foreach (var candidate in ordered)
{
var eligible = activeCenters.Where(center =>
candidate.SubjectIds.All(subjectId =>
remaining[center.Center.Id].GetValueOrDefault(subjectId) > 0)).ToArray();
if (eligible.Length == 0)
throw Error($"{Text(candidate.Profile.Data, "name")} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围");
var local = eligible.Where(center => CenterMatchesProfile(center.Center, candidate.Profile, mixingScope)).ToArray();
if (local.Length == 0)
Warn($"{Text(candidate.Profile.Data, "name")} 所属范围内没有足够考点,已跨范围使用可用考点");
var pool = local.Length > 0 ? local : eligible;
var selected = pool
.OrderByDescending(center => CenterScore(center, candidate, remaining, affinity, assignedCounts))
.ThenBy(center => center.Center.Code, StringComparer.Ordinal)
.First();
candidate.CenterId = selected.Center.Id;
foreach (var subjectId in candidate.SubjectIds) remaining[selected.Center.Id][subjectId]--;
assignedCounts[selected.Center.Id]++;
var affinityKey = $"{selected.Center.Id}:{candidate.Signature}";
affinity[affinityKey] = affinity.GetValueOrDefault(affinityKey) + 1;
}
}
var assignments = candidates.ToDictionary(
item => item.Registration.Id,
_ => new List<ArrangementAssignment>(),
StringComparer.Ordinal);
var roomUses = new List<RoomUse>();
foreach (var subject in exam.Subjects)
{
var subjectId = Text(subject, "id");
foreach (var center in activeCenters)
{
var subjectCandidates = candidates
.Where(candidate => candidate.CenterId == center.Center.Id && candidate.SubjectIds.Contains(subjectId, StringComparer.Ordinal))
.ToList();
if (subjectCandidates.Count == 0) continue;
subjectCandidates.Sort((left, right) =>
{
var scope = string.Compare(left.ScopeKey, right.ScopeKey, StringComparison.Ordinal);
if (scope != 0) return scope;
var signature = string.Compare(left.Signature, right.Signature, StringComparison.Ordinal);
return signature != 0 ? signature : StableCompare(seed, left, right);
});
var roomIndex = 0;
var seatIndex = 0;
var currentScopeKey = "";
var rooms = center.Rooms
.OrderBy(room => room.Code, StringComparer.Ordinal)
.ThenBy(room => room.Id, StringComparer.Ordinal)
.ToArray();
foreach (var candidate in subjectCandidates)
{
if (currentScopeKey.Length > 0 && candidate.ScopeKey != currentScopeKey && seatIndex > 0)
{
roomIndex++;
seatIndex = 0;
}
currentScopeKey = candidate.ScopeKey;
while (roomIndex < rooms.Length && seatIndex >= rooms[roomIndex].Capacity)
{
roomIndex++;
seatIndex = 0;
}
if (roomIndex >= rooms.Length)
throw Error($"{center.Center.Name} 在“{Text(subject, "name")}”科目下按{MixingScopeNames[mixingScope]}隔离后考场不足;请增加考场或扩大混编范围");
var room = rooms[roomIndex];
var seat = (room.SeatStart + seatIndex).ToString("00", CultureInfo.InvariantCulture);
assignments[candidate.Registration.Id].Add(new ArrangementAssignment(
candidate.Registration.Id,
subjectId,
center.Center.Id,
center.Center.Name,
room.Id,
room.Name,
room.Code,
room.Building,
room.Floor,
"",
seat,
candidate.Signature));
roomUses.Add(new(center.Center.Code, room.Code, room.Id));
seatIndex++;
}
}
}
var examRoomCodes = roomUses
.GroupBy(item => item.RoomId, StringComparer.Ordinal)
.Select(group => group.Last())
.OrderBy(item => item.CenterCode, StringComparer.Ordinal)
.ThenBy(item => item.RoomCode, StringComparer.Ordinal)
.ThenBy(item => item.RoomId, StringComparer.Ordinal)
.Select((item, index) => new { item.RoomId, Code = (index + 1).ToString("000", CultureInfo.InvariantCulture) })
.ToDictionary(item => item.RoomId, item => item.Code, StringComparer.Ordinal);
foreach (var registrationId in assignments.Keys.ToArray())
{
assignments[registrationId] = assignments[registrationId]
.OrderBy(item => subjectOrder.GetValueOrDefault(item.SubjectId))
.Select(item => item with { ExamRoomCode = examRoomCodes[item.RoomId] })
.ToList();
}
var sequenceCounters = new Dictionary<string, int>(StringComparer.Ordinal);
var numbers = new HashSet<string>(StringComparer.Ordinal);
candidates.Sort((left, right) =>
{
var scope = string.Compare(left.ScopeKey, right.ScopeKey, StringComparison.Ordinal);
return scope != 0 ? scope : StableCompare(seed, left, right);
});
var cards = candidates.Select(candidate =>
{
var center = activeCenters.First(item => item.Center.Id == candidate.CenterId);
var primary = assignments[candidate.Registration.Id][0];
var candidateSchool = directory.Schools.FirstOrDefault(item => item.Id == candidate.Profile.SchoolId);
var centerSchool = directory.Schools.FirstOrDefault(item => item.Id == center.Center.SchoolId);
var sources = new Dictionary<string, string>(StringComparer.Ordinal)
{
["district_code"] = NormalizedCode(First(Text(candidate.Profile.Data, "districtCode"), center.Center.DistrictCode)),
["center_school_code"] = NormalizedCode(centerSchool?.Code ?? ""),
["candidate_school_code"] = NormalizedCode(candidateSchool?.Code ?? ""),
["exam_room_code"] = primary.ExamRoomCode,
["seat"] = primary.Seat
};
var counterKey = SequenceGroup(rule, sources);
var sequence = sequenceCounters.GetValueOrDefault(counterKey) + 1;
sequenceCounters[counterKey] = sequence;
var number = FormatAdmissionNumber(rule, sources, sequence);
if (!numbers.Add(number))
throw Error($"规则“{rule.Name}”生成了重复准考证号 {number},请检查规则组成");
return new ArrangementCard(
candidate.Registration.Id,
number,
center.Center.Id,
center.Center.Name,
center.Center.Code,
string.Join(' ', new[]
{
center.Center.ProvinceName,
center.Center.CityName,
center.Center.DistrictName,
center.Center.Address
}.Where(value => value.Length > 0)),
generatedAt,
assignments[candidate.Registration.Id]);
}).ToArray();
var usedCenters = cards.Select(card => card.CenterId).ToHashSet(StringComparer.Ordinal);
var sameSchoolCenterCount = candidates.Count(candidate =>
activeCenters.First(center => center.Center.Id == candidate.CenterId).Center.SchoolId == candidate.Profile.SchoolId);
var sameSchoolRate = Math.Round(
sameSchoolCenterCount * 100d / candidates.Count,
1,
MidpointRounding.AwayFromZero);
var reservedSpareRooms = activeCenters.Sum(center =>
{
var usedRoomIds = center.Rooms.Select(room => room.Id).ToHashSet(StringComparer.Ordinal);
return center.AllRooms.Count(room => room.RoomType == "spare" && !usedRoomIds.Contains(room.Id));
});
var summary = new ArrangementSummary(
candidates.Count,
usedCenters.Count,
cards.Sum(card => card.Assignments.Count),
candidates.Select(candidate => candidate.Signature).Distinct(StringComparer.Ordinal).Count(),
sameSchoolCenterCount,
sameSchoolRate,
reservedSpareRooms);
return new(exam, rule, mixingScope, seed, warnings, summary, cards);
}
private static long CenterScore(
CenterWork center,
CandidateWork candidate,
IReadOnlyDictionary<string, Dictionary<string, int>> remaining,
IReadOnlyDictionary<string, int> affinity,
IReadOnlyDictionary<string, int> assignedCounts)
{
var affinityValue = affinity.GetValueOrDefault($"{center.Center.Id}:{candidate.Signature}");
var capacity = candidate.SubjectIds.Min(subjectId => remaining[center.Center.Id][subjectId]);
return affinityValue * 100000L + LocalityScore(center.Center, candidate.Profile) + capacity -
assignedCounts.GetValueOrDefault(center.Center.Id);
}
private static bool CenterMatchesProfile(AdminCenter center, OperationalProfile profile, string scope) => scope switch
{
"class" or "school" => center.SchoolId == profile.SchoolId,
"district" => Text(profile.Data, "districtCode") is { Length: > 0 } district && center.DistrictCode == district,
"city" => Text(profile.Data, "cityCode") is { Length: > 0 } city && center.CityCode == city,
_ => Text(profile.Data, "provinceCode") is { Length: > 0 } province && center.ProvinceCode == province
};
private static int LocalityScore(AdminCenter center, OperationalProfile profile)
{
if (center.SchoolId == profile.SchoolId) return 5000;
if (Text(profile.Data, "districtCode") is { Length: > 0 } district && center.DistrictCode == district) return 1000;
if (Text(profile.Data, "cityCode") is { Length: > 0 } city && center.CityCode == city) return 300;
return Text(profile.Data, "provinceCode") is { Length: > 0 } province && center.ProvinceCode == province ? 100 : 0;
}
private static string ScopeValue(OperationalProfile profile, string scope) => scope switch
{
"class" => profile.ClassId ?? "",
"school" => profile.SchoolId ?? "",
"district" => Text(profile.Data, "districtCode"),
"city" => Text(profile.Data, "cityCode"),
_ => Text(profile.Data, "provinceCode")
};
private static (JsonObject Left, JsonObject Right)? OverlappingSubjects(IReadOnlyList<JsonObject> subjects)
{
for (var left = 0; left < subjects.Count; left++)
{
for (var right = left + 1; right < subjects.Count; right++)
{
var first = subjects[left];
var second = subjects[right];
if (Text(first, "date") != Text(second, "date")) continue;
var firstStart = Minutes(Text(first, "start"));
var firstEnd = Minutes(Text(first, "end"));
var secondStart = Minutes(Text(second, "start"));
var secondEnd = Minutes(Text(second, "end"));
if (firstStart is not null && firstEnd is not null && secondStart is not null && secondEnd is not null &&
firstStart < secondEnd && secondStart < firstEnd)
return (first, second);
}
}
return null;
}
private static int? Minutes(string value)
{
var parts = value.Split(':');
return parts.Length >= 2 &&
int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var hour) &&
int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var minute)
? hour * 60 + minute
: null;
}
private static string FormatAdmissionNumber(
AdmissionNumberRule rule,
IReadOnlyDictionary<string, string> sources,
int sequence)
{
if (rule.Segments.Count == 0) throw Error("所选准考证号规则没有可用的组成段");
return string.Join(rule.Separator, rule.Segments.Select(segment =>
{
var value = segment.Source == "sequence"
? sequence.ToString(CultureInfo.InvariantCulture)
: sources.GetValueOrDefault(segment.Source) ?? "";
if (value.Length == 0)
throw Error($"准考证号规则需要“{First(segment.Label, segment.Source)}”,但考生或考点档案中缺少该值");
return segment.Width is > 0 ? value.PadLeft(segment.Width.Value, '0') : value;
}));
}
private static string SequenceGroup(AdmissionNumberRule rule, IReadOnlyDictionary<string, string> sources)
{
if (rule.Code.StartsWith("district_", StringComparison.Ordinal)) return sources["district_code"];
if (rule.Code.StartsWith("center_school_", StringComparison.Ordinal)) return sources["center_school_code"];
if (rule.Code.StartsWith("candidate_school_", StringComparison.Ordinal)) return sources["candidate_school_code"];
return "all";
}
private static int StableCompare(string seed, CandidateWork left, CandidateWork right)
{
var leftHash = HashText($"{seed}:{left.Registration.Id}");
var rightHash = HashText($"{seed}:{right.Registration.Id}");
var comparison = leftHash.CompareTo(rightHash);
return comparison != 0
? comparison
: string.Compare(left.Registration.Id, right.Registration.Id, StringComparison.Ordinal);
}
private static uint HashText(string value)
{
unchecked
{
uint hash = 2166136261;
foreach (var character in value)
{
hash ^= character;
hash *= 16777619;
}
return hash;
}
}
private static string NormalizedCode(string value) =>
NonAlphaNumeric().Replace(value, "").ToUpperInvariant();
private static string Text(JsonObject value, string property) =>
value[property]?.ToString() ?? "";
private static string First(params string[] values) =>
values.FirstOrDefault(value => value.Length > 0) ?? "";
private static AdminArrangementException Error(string message, int status = 409) => new(status, message);
[GeneratedRegex("[^0-9A-Z]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex NonAlphaNumeric();
private sealed class CandidateWork(
OperationalRegistration registration,
OperationalProfile profile,
IReadOnlyList<string> subjectIds,
string signature,
string scopeKey)
{
public OperationalRegistration Registration { get; } = registration;
public OperationalProfile Profile { get; } = profile;
public IReadOnlyList<string> SubjectIds { get; } = subjectIds;
public string Signature { get; } = signature;
public string ScopeKey { get; } = scopeKey;
public string? CenterId { get; set; }
}
private sealed record CenterWork(
AdminCenter Center,
IReadOnlyList<AdminCenterRoom> Rooms,
IReadOnlyList<AdminCenterRoom> AllRooms);
private sealed record RoomUse(string CenterCode, string RoomCode, string RoomId);
}
@@ -0,0 +1,154 @@
using System.Data.Common;
using System.Text.Json;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminArrangementRepository(IRelationalConnectionFactory connectionFactory)
{
public async Task SaveAsync(
StoredArrangementPlan plan,
IReadOnlyList<ArrangementCard> cards,
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,
"DELETE FROM exam_arrangement_plans WHERE exam_id = @examId",
[new("@examId", plan.ExamId)],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO exam_arrangement_plans (
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
subject_assignment_count, subject_combination_count, same_school_center_rate,
warnings_json, generated_by, generated_at
) VALUES (
@id, @examId, @numberRuleId, @mixingScope, @randomSeed, @candidateCount, @centerCount,
@subjectAssignmentCount, @subjectCombinationCount, @sameSchoolCenterRate,
@warningsJson, @generatedBy, @generatedAt
)
""",
[
new("@id", plan.Id),
new("@examId", plan.ExamId),
new("@numberRuleId", plan.NumberRuleId),
new("@mixingScope", plan.MixingScope),
new("@randomSeed", plan.RandomSeed),
new("@candidateCount", plan.CandidateCount),
new("@centerCount", plan.CenterCount),
new("@subjectAssignmentCount", plan.SubjectAssignmentCount),
new("@subjectCombinationCount", plan.SubjectCombinationCount),
new("@sameSchoolCenterRate", plan.SameSchoolCenterRate),
new("@warningsJson", JsonSerializer.Serialize(plan.Warnings)),
new("@generatedBy", plan.GeneratedBy),
new("@generatedAt", plan.GeneratedAt)
],
cancellationToken);
foreach (var card in cards)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO admit_cards (
registration_id, plan_id, card_number, center_id, test_center, center_code,
center_address, generated_at
) VALUES (
@registrationId, @planId, @cardNumber, @centerId, @testCenter, @centerCode,
@centerAddress, @generatedAt
)
""",
[
new("@registrationId", card.RegistrationId),
new("@planId", plan.Id),
new("@cardNumber", card.Number),
new("@centerId", card.CenterId),
new("@testCenter", card.TestCenter),
new("@centerCode", card.CenterCode),
new("@centerAddress", card.CenterAddress),
new("@generatedAt", card.GeneratedAt)
],
cancellationToken);
foreach (var assignment in card.Assignments)
{
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO admit_card_subjects (
registration_id, subject_id, room_id, room, room_code, exam_room_code,
building, floor, seat, subject_signature
) VALUES (
@registrationId, @subjectId, @roomId, @room, @roomCode, @examRoomCode,
@building, @floor, @seat, @subjectSignature
)
""",
[
new("@registrationId", card.RegistrationId),
new("@subjectId", assignment.SubjectId),
new("@roomId", assignment.RoomId),
new("@room", assignment.RoomName),
new("@roomCode", assignment.RoomCode),
new("@examRoomCode", assignment.ExamRoomCode),
new("@building", assignment.Building),
new("@floor", assignment.Floor),
new("@seat", assignment.Seat),
new("@subjectSignature", assignment.SubjectSignature)
],
cancellationToken);
}
}
await ExecuteAsync(
connection,
transaction,
"""
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);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static async Task ExecuteAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
IReadOnlyList<SqlParameterValue> parameters,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = sql;
foreach (var item in 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 SqlParameterValue(string Name, object? Value);
}
@@ -0,0 +1,809 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminArrangementService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminOperationalSnapshotLoader operationalSnapshotLoader,
AdminCenterSnapshotLoader centerSnapshotLoader,
AdminArrangementSnapshotLoader arrangementSnapshotLoader,
AdminAccountBatchSnapshotLoader directorySnapshotLoader,
AdminArrangementRepository repository) : IAdminArrangementService
{
public async Task<AdminEndpointResult> GetAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var scopedRegistrations = operational.Registrations
.Where(item => item.Status == "approved")
.Where(item =>
{
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
})
.ToArray();
var registrations = scopedRegistrations.Select(registration =>
{
var output = RegistrationJson(directory, operational, registration);
var profile = operational.Profiles.First(item => item.UserId == registration.UserId);
var candidate = profile.Data.DeepClone().AsObject();
candidate["idNumber"] = MaskId(Text(candidate, "idNumber"));
output["candidate"] = candidate;
return output;
}).ToArray();
var plans = arrangements.Plans.Select(plan => PlanJson(arrangements, operational, plan)).ToArray();
var visibleExams = Level(user) == "super"
? operational.Exams
: operational.Exams.Where(exam => scopedRegistrations.Any(item => item.ExamId == exam.Id)).ToArray();
var exams = visibleExams.Select(exam =>
{
var output = PublicExamJson(exam);
output["approvedCount"] = scopedRegistrations.Count(item => item.ExamId == exam.Id);
output["arrangedCount"] = scopedRegistrations.Count(item =>
item.ExamId == exam.Id && item.Data["admitCard"] is not null);
var plan = arrangements.Plans.FirstOrDefault(item => item.ExamId == exam.Id);
output["plan"] = plan is null ? null : PlanJson(arrangements, operational, plan);
return output;
}).ToArray();
var centerScope = Level(user) switch
{
"super" => centers.Centers,
"school" => centers.Centers.Where(item => item.SchoolId == user.SchoolId).ToArray(),
_ => []
};
var centerOutput = centerScope
.Where(item => item.Status == "active")
.Select(center => CenterJson(centers, center))
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["canArrange"] = Level(user) == "super",
["canExportCenterMaterials"] = Level(user) == "school",
["scopeLabel"] = ScopeLabel(user, directory),
["exams"] = new JsonArray(exams),
["registrations"] = new JsonArray(registrations),
["plans"] = Level(user) == "super" ? new JsonArray(plans) : [],
["rules"] = Level(user) == "super"
? new JsonArray(arrangements.Rules.Where(item => item.Active).Select(RuleJson).ToArray())
: [],
["mixingScopes"] = Level(user) == "super" ? MixingScopesJson() : [],
["centers"] = new JsonArray(centerOutput)
});
}
public Task<AdminEndpointResult> PreviewAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken) =>
ArrangeAsync(sessionToken, examId, body, preview: true, cancellationToken);
public Task<AdminEndpointResult> ApplyAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken) =>
ArrangeAsync(sessionToken, examId, body, preview: false, cancellationToken);
public async Task<AdminEndpointResult> RetiredSingleAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
return context.Error ?? Error(
410,
"单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成");
}
public async Task<AdminDocumentResult> ExportAsync(
string sessionToken,
string type,
string examId,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
if (context.Error is not null) return DocumentError(context.Error);
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (exam is null) return DocumentError(Error(404, "请选择有效考试"));
var scopeLabel = ScopeLabel(user, directory);
if (type == "center-materials")
{
if (Level(user) != "school")
return DocumentError(Error(403, "考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出"));
var centerIds = centers.Centers
.Where(item => item.SchoolId == user.SchoolId)
.Select(item => item.Id)
.ToHashSet(StringComparer.Ordinal);
var registrations = operational.Registrations
.Where(item => item.ExamId == exam.Id && item.Data["admitCard"] is JsonObject card &&
centerIds.Contains(Text(card, "centerId")))
.ToArray();
var rows = BuildExportRows(operational, centers, directory, registrations);
if (rows.Count == 0)
return DocumentError(Error(404, "本校维护考点暂无该考试的已编排考生"));
var content = AdminArrangementExporter.CenterMaterials(rows, $"{Text(exam.Data, "name")}{scopeLabel}");
return Document(
content,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"{Text(exam.Data, "name")}-{scopeLabel}-考点桌贴门贴签名单.xlsx");
}
var scoped = operational.Registrations.Where(item =>
{
if (item.ExamId != exam.Id || item.Data["admitCard"] is null) return false;
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
}).ToArray();
if (scoped.Length == 0) return DocumentError(Error(404, "当前范围暂无已生成的准考证"));
if (type == "admit-cards")
{
var html = RenderAdmitCards(
operational,
centers,
arrangements.OrganizationName,
scoped,
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证");
return Document(
Encoding.UTF8.GetBytes(html),
"text/html; charset=utf-8",
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证批量打印.html");
}
if (type != "info") return DocumentError(Error(404, "不支持的准考证导出类型"));
var exportRows = BuildExportRows(operational, centers, directory, scoped);
var workbook = AdminArrangementExporter.AdmitCardInformation(
exportRows,
$"{Text(exam.Data, "name")}{scopeLabel}");
return Document(
workbook,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"{Text(exam.Data, "name")}-{scopeLabel}-准考证信息.xlsx");
}
private async Task<AdminEndpointResult> ArrangeAsync(
string sessionToken,
string examId,
JsonObject body,
bool preview,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var generatedAt = NowIso();
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var exam = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (exam?.Data["archivedAt"] is not null)
return Error(409, "该考试已归档,不能重新编排准考证");
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
var arrangements = await arrangementSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
ArrangementBuildResult result;
try
{
result = AdminArrangementPlanner.Build(
operational,
centers,
arrangements,
directory,
examId,
Clean(Text(body, "mixingScope"), 20),
Clean(Text(body, "numberRuleId"), 64),
Clean(Text(body, "seed"), 80),
generatedAt);
}
catch (AdminArrangementException exception)
{
return Error(exception.StatusCode, exception.Message);
}
if (preview)
{
return Success(new JsonObject
{
["ok"] = true,
["preview"] = true,
["summary"] = SummaryJson(result.Summary),
["warnings"] = StringArray(result.Warnings),
["samples"] = new JsonArray(result.Cards.Take(5).Select(CardJson).ToArray())
});
}
var plan = new StoredArrangementPlan(
Uid("arrangement"),
result.Exam.Id,
result.Rule.Id,
result.MixingScope,
result.Seed,
result.Summary.CandidateCount,
result.Summary.CenterCount,
result.Summary.SubjectAssignmentCount,
result.Summary.SubjectCombinationCount,
result.Summary.SameSchoolCenterRate,
result.Warnings,
user.Id,
generatedAt);
var replacing = arrangements.Plans.Any(item => item.ExamId == result.Exam.Id);
await repository.SaveAsync(
plan,
result.Cards,
Audit(
user,
replacing ? "重新编排准考证" : "批量编排准考证",
$"{Text(result.Exam.Data, "name")} · {plan.CandidateCount} 人 · {plan.CenterCount} 个考点 · {result.Rule.Name}"),
cancellationToken);
var planOutput = PlanJson(plan);
AddSummaryFields(planOutput, result.Summary);
return Success(new JsonObject
{
["ok"] = true,
["plan"] = planOutput,
["summary"] = SummaryJson(result.Summary),
["warnings"] = StringArray(result.Warnings),
["cards"] = new JsonArray(result.Cards.Select(CardJson).ToArray())
});
}
private async Task<ResolvedAdmin> ResolveAsync(
string token,
bool superOnly,
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, "当前账号无权执行此操作"));
if (superOnly && Level(user) != "super")
return ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作"));
return new(user, null);
}
private static IReadOnlyList<ArrangementExportRow> BuildExportRows(
AdminOperationalSnapshot operational,
AdminCenterSnapshot centers,
AdminAccountBatchSnapshot directory,
IReadOnlyList<OperationalRegistration> registrations)
{
var rows = new List<ArrangementExportRow>();
foreach (var registration in registrations)
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var card = registration.Data["admitCard"] as JsonObject;
if (profile is null || exam is null || card is null) continue;
var school = directory.Schools.FirstOrDefault(item => item.Id == profile.SchoolId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
var center = centers.Centers.FirstOrDefault(item => item.Id == Text(card, "centerId"));
foreach (var assignment in (card["assignments"] as JsonArray ?? []).OfType<JsonObject>())
{
var subject = exam.Subjects.FirstOrDefault(item => Text(item, "id") == Text(assignment, "subjectId"));
var room = centers.Rooms.FirstOrDefault(item => item.Id == Text(assignment, "roomId"));
rows.Add(new ArrangementExportRow(
school?.Name ?? Text(profile.Data, "school"),
schoolClass?.Name ?? Text(profile.Data, "grade"),
First(account?.CandidateNumber ?? "", Text(registration.Data, "registrationNumber")),
First(Text(profile.Data, "name"), account?.DisplayName ?? ""),
Text(profile.Data, "idNumber"),
Text(exam.Data, "code"),
Text(exam.Data, "name"),
Text(card, "number"),
First(Text(card, "centerCode"), center?.Code ?? ""),
First(Text(card, "testCenter"), center?.Name ?? ""),
First(
Text(card, "centerAddress"),
center is null
? ""
: string.Join(' ', new[]
{
center.ProvinceName,
center.CityName,
center.DistrictName,
center.Address
}.Where(value => value.Length > 0))),
subject is null ? "" : Text(subject, "name"),
subject is null ? "" : Text(subject, "date"),
subject is null
? ""
: string.Join('—', new[] { Text(subject, "start"), Text(subject, "end") }.Where(value => value.Length > 0)),
Text(assignment, "examRoomCode"),
First(Text(assignment, "roomName"), Text(assignment, "room"), room?.Name ?? ""),
First(Text(assignment, "roomCode"), room?.Code ?? ""),
First(Text(assignment, "building"), room?.Building ?? ""),
First(Text(assignment, "floor"), room?.Floor ?? ""),
Text(assignment, "seat")));
}
}
return rows;
}
private static string RenderAdmitCards(
AdminOperationalSnapshot operational,
AdminCenterSnapshot centers,
string organizationName,
IReadOnlyList<OperationalRegistration> registrations,
string title)
{
var sections = new StringBuilder();
foreach (var registration in registrations)
{
var user = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var card = registration.Data["admitCard"] as JsonObject;
if (profile is null || exam is null || card is null) continue;
var assignments = (card["assignments"] as JsonArray ?? [])
.OfType<JsonObject>()
.ToDictionary(item => Text(item, "subjectId"), StringComparer.Ordinal);
var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? [])
.Select(item => item?.GetValue<string>() ?? "")
.ToHashSet(StringComparer.Ordinal);
var rows = new StringBuilder();
foreach (var subject in exam.Subjects.Where(item => subjectIds.Contains(Text(item, "id"))))
{
assignments.TryGetValue(Text(subject, "id"), out var assignment);
var room = assignment is null
? null
: centers.Rooms.FirstOrDefault(item => item.Id == Text(assignment, "roomId"));
rows.Append("<tr><td>").Append(E(Text(subject, "name")))
.Append("</td><td>").Append(E(Text(subject, "date")))
.Append("</td><td>").Append(E(Text(subject, "start"))).Append('—').Append(E(Text(subject, "end")))
.Append("</td><td class=\"room-no\">").Append(E(First(Text(assignment, "examRoomCode"), "待定")))
.Append("</td><td><strong>").Append(E(First(Text(assignment, "roomName"), Text(assignment, "room"), room?.Name ?? "", "待定")))
.Append("</strong><small>场地代码:").Append(E(First(Text(assignment, "roomCode"), room?.Code ?? "", "—")))
.Append("</small></td><td>").Append(E(First(Text(assignment, "building"), room?.Building ?? "", "—")))
.Append("<small>").Append(E(First(Text(assignment, "floor"), room?.Floor ?? "", "楼层待定")))
.Append("</small></td><td>").Append(E(First(Text(assignment, "seat"), "—"))).Append("</td></tr>");
}
var center = centers.Centers.FirstOrDefault(item => item.Id == Text(card, "centerId"));
var centerCode = First(Text(card, "centerCode"), center?.Code ?? "");
var centerAddress = First(
Text(card, "centerAddress"),
center is null
? ""
: string.Join(' ', new[]
{
center.ProvinceName,
center.CityName,
center.DistrictName,
center.Address
}.Where(value => value.Length > 0)));
var generatedAt = DateTimeOffset.TryParse(
Text(card, "generatedAt"),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var parsed)
? parsed.ToLocalTime().ToString("yyyy/M/d HH:mm:ss", CultureInfo.GetCultureInfo("zh-CN"))
: Text(card, "generatedAt");
sections.Append("<main class=\"card\"><header class=\"head\"><div><div class=\"brand\">衡准 · 准考证</div><p>")
.Append(E(Text(exam.Data, "name")))
.Append("</p></div><div><small>准考证号</small><div class=\"code\">").Append(E(Text(card, "number")))
.Append("</div></div></header><section class=\"meta\"><div><span>姓名</span><strong>")
.Append(E(First(Text(profile.Data, "name"), user?.DisplayName ?? "")))
.Append("</strong></div><div><span>证件号码</span><strong>").Append(E(MaskId(Text(profile.Data, "idNumber"))))
.Append("</strong></div><div><span>报名号</span><strong>")
.Append(E(First(user?.CandidateNumber ?? "", Text(registration.Data, "registrationNumber"), "—")))
.Append("</strong></div></section><section class=\"center\"><div><span>固定考点</span><strong>")
.Append(E(Text(card, "testCenter"))).Append("</strong><small>考点代码:").Append(E(First(centerCode, "—")))
.Append("</small></div><div><span>考点详细地址</span><strong>").Append(E(First(centerAddress, "地址待公布")))
.Append("</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位号</th></tr></thead><tbody>")
.Append(rows)
.Append("</tbody></table><div class=\"explain\"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class=\"notice\">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class=\"footer\"><span>")
.Append(E(organizationName)).Append("</span><span>生成时间:").Append(E(generatedAt))
.Append("</span></footer></main>");
}
const string styles = "@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:\"Microsoft YaHei\",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}";
return $"<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><title>{E(title)}</title><style>{styles}</style></head><body>{sections}</body></html>";
}
private static JsonObject RegistrationJson(
AdminAccountBatchSnapshot directory,
AdminOperationalSnapshot operational,
OperationalRegistration registration)
{
var output = registration.Data.DeepClone().AsObject();
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var subjectIds = (output["subjectIds"] as JsonArray ?? [])
.Select(item => item?.GetValue<string>() ?? "")
.ToHashSet(StringComparer.Ordinal);
output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray((exam?.Subjects ?? [])
.Where(item => subjectIds.Contains(Text(item, "id")))
.Select(item => item.DeepClone())
.ToArray());
output["amountDue"] = Math.Round(
(output["subjects"] as JsonArray ?? []).OfType<JsonObject>().Sum(item => Number(item, "fee")),
2,
MidpointRounding.AwayFromZero);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" &&
item.BusinessId == registration.Id &&
item.Status == "pending") ??
directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(directory, instance);
return output;
}
private static JsonObject WorkflowJson(
AdminAccountBatchSnapshot snapshot,
AccountWorkflowInstance instance)
{
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
return new JsonObject
{
["id"] = instance.Id,
["workflowId"] = instance.WorkflowId,
["businessType"] = instance.BusinessType,
["businessId"] = instance.BusinessId,
["status"] = instance.Status,
["currentStep"] = instance.CurrentStep,
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
["createdAt"] = instance.CreatedAt,
["completedAt"] = JsonValue.Create(instance.CompletedAt),
["workflowName"] = workflow?.Name ?? "未命名流程",
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step
? StepJson(step)
: null,
["assignee"] = assignee is null ? null : SafeUser(assignee),
["actions"] = new JsonArray(snapshot.Actions
.Where(item => item.InstanceId == instance.Id)
.Select(item => ActionJson(snapshot, item))
.ToArray())
};
}
private static JsonObject PublicExamJson(OperationalExam exam)
{
var output = exam.Data.DeepClone().AsObject();
output["totalScore"] = exam.Subjects.Sum(item => Number(item, "fullScore"));
var now = DateTimeOffset.UtcNow;
var state = output["archivedAt"] is not null
? "archived"
: TryDate(Text(output, "registrationStart"), out var start) && now < start
? "upcoming"
: TryDate(Text(output, "registrationEnd"), out var end) && now > end
? "closed"
: "open";
output["registrationState"] = state;
return output;
}
private static JsonObject PlanJson(
AdminArrangementSnapshot arrangements,
AdminOperationalSnapshot operational,
StoredArrangementPlan plan)
{
var output = PlanJson(plan);
output["examName"] = operational.Exams.FirstOrDefault(item => item.Id == plan.ExamId) is { } exam
? Text(exam.Data, "name")
: "";
output["ruleName"] = arrangements.Rules.FirstOrDefault(item => item.Id == plan.NumberRuleId)?.Name ?? "";
output["mixingScopeName"] = MixingScopeName(plan.MixingScope);
return output;
}
private static JsonObject PlanJson(StoredArrangementPlan plan) => new()
{
["id"] = plan.Id,
["examId"] = plan.ExamId,
["numberRuleId"] = plan.NumberRuleId,
["mixingScope"] = plan.MixingScope,
["randomSeed"] = plan.RandomSeed,
["candidateCount"] = plan.CandidateCount,
["centerCount"] = plan.CenterCount,
["subjectAssignmentCount"] = plan.SubjectAssignmentCount,
["subjectCombinationCount"] = plan.SubjectCombinationCount,
["sameSchoolCenterRate"] = plan.SameSchoolCenterRate,
["warnings"] = StringArray(plan.Warnings),
["generatedBy"] = JsonValue.Create(plan.GeneratedBy),
["generatedAt"] = plan.GeneratedAt
};
private static JsonObject CenterJson(AdminCenterSnapshot snapshot, AdminCenter center) => new()
{
["id"] = center.Id,
["schoolId"] = center.SchoolId,
["code"] = center.Code,
["name"] = center.Name,
["provinceCode"] = center.ProvinceCode,
["provinceName"] = center.ProvinceName,
["cityCode"] = center.CityCode,
["cityName"] = center.CityName,
["districtCode"] = center.DistrictCode,
["districtName"] = center.DistrictName,
["address"] = center.Address,
["contact"] = center.Contact,
["managerName"] = center.ManagerName,
["managerPhone"] = center.ManagerPhone,
["emergencyPhone"] = center.EmergencyPhone,
["gateOpenTime"] = center.GateOpenTime,
["transport"] = center.Transport,
["status"] = center.Status,
["notes"] = center.Notes,
["rooms"] = center.Rooms,
["updatedAt"] = center.UpdatedAt,
["roomCount"] = snapshot.Rooms.Count(item => item.CenterId == center.Id && item.Status == "active"),
["capacity"] = snapshot.Rooms
.Where(item => item.CenterId == center.Id && item.Status == "active" && item.RoomType != "spare")
.Sum(item => item.Capacity)
};
private static JsonObject RuleJson(AdmissionNumberRule rule) => new()
{
["id"] = rule.Id,
["code"] = rule.Code,
["name"] = rule.Name,
["description"] = rule.Description,
["separator"] = rule.Separator,
["segments"] = new JsonArray(rule.Segments.Select(segment =>
{
var output = new JsonObject
{
["source"] = segment.Source,
["label"] = segment.Label
};
if (segment.Width is not null) output["width"] = segment.Width;
return output;
}).ToArray()),
["example"] = rule.Example,
["active"] = rule.Active,
["createdAt"] = rule.CreatedAt
};
private static JsonArray MixingScopesJson() => new(
new JsonObject
{
["code"] = "class",
["name"] = "班内混编",
["description"] = "以班级为边界,同班考生按科目组合穿插编排。"
},
new JsonObject
{
["code"] = "school",
["name"] = "校内混编",
["description"] = "同校跨班混编,优先安排在本校考点。"
},
new JsonObject
{
["code"] = "district",
["name"] = "县区内混编",
["description"] = "同县区跨学校混编,优先使用本县区考点。"
},
new JsonObject
{
["code"] = "city",
["name"] = "市内混编",
["description"] = "同市跨县区混编,优先使用本市考点。"
},
new JsonObject
{
["code"] = "province",
["name"] = "省内混编",
["description"] = "全省范围混编,按容量和科目组合选择考点。"
});
private static JsonObject SummaryJson(ArrangementSummary summary) => new()
{
["candidateCount"] = summary.CandidateCount,
["centerCount"] = summary.CenterCount,
["subjectAssignmentCount"] = summary.SubjectAssignmentCount,
["subjectCombinationCount"] = summary.SubjectCombinationCount,
["sameSchoolCenterCount"] = summary.SameSchoolCenterCount,
["sameSchoolCenterRate"] = summary.SameSchoolCenterRate,
["reservedSpareRooms"] = summary.ReservedSpareRooms
};
private static void AddSummaryFields(JsonObject output, ArrangementSummary summary)
{
output["sameSchoolCenterCount"] = summary.SameSchoolCenterCount;
output["reservedSpareRooms"] = summary.ReservedSpareRooms;
}
private static JsonObject CardJson(ArrangementCard card) => new()
{
["registrationId"] = card.RegistrationId,
["number"] = card.Number,
["centerId"] = card.CenterId,
["testCenter"] = card.TestCenter,
["centerCode"] = card.CenterCode,
["centerAddress"] = card.CenterAddress,
["generatedAt"] = card.GeneratedAt,
["assignments"] = new JsonArray(card.Assignments.Select(AssignmentJson).ToArray())
};
private static JsonObject AssignmentJson(ArrangementAssignment item) => new()
{
["registrationId"] = item.RegistrationId,
["subjectId"] = item.SubjectId,
["centerId"] = item.CenterId,
["centerName"] = item.CenterName,
["roomId"] = item.RoomId,
["roomName"] = item.RoomName,
["roomCode"] = item.RoomCode,
["building"] = item.Building,
["floor"] = item.Floor,
["examRoomCode"] = item.ExamRoomCode,
["seat"] = item.Seat,
["subjectSignature"] = item.SubjectSignature
};
private static JsonArray StringArray(IEnumerable<string> values) =>
new(values.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray());
private static string ScopeLabel(AuthenticationUser user, AdminAccountBatchSnapshot directory)
{
if (Level(user) == "super") return "全部学校与班级";
var school = directory.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
if (Level(user) == "school") return school;
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
return $"{school} · {schoolClass}";
}
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
_ => user.ClassId is not null && profile.ClassId == user.ClassId
};
private static JsonObject StepJson(AccountWorkflowStep item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["adminLevel"] = item.AdminLevel,
["position"] = item.Position
};
private static JsonObject SafeUser(AdminUser item) => new()
{
["id"] = item.Id,
["username"] = item.Username,
["role"] = item.Role,
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
["schoolId"] = JsonValue.Create(item.SchoolId),
["classId"] = JsonValue.Create(item.ClassId),
["displayName"] = item.DisplayName,
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
["mustChangePassword"] = item.MustChangePassword,
["totpEnabled"] = item.TotpEnabled,
["archived"] = item.ArchivedAt is not null
};
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new()
{
["id"] = item.Id,
["instanceId"] = item.InstanceId,
["actorId"] = JsonValue.Create(item.ActorId),
["action"] = item.Action,
["note"] = item.Note,
["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId),
["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId),
["createdAt"] = item.CreatedAt,
["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统",
["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "",
["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? ""
};
private static string MixingScopeName(string value) => value switch
{
"class" => "班内混编",
"school" => "校内混编",
"district" => "县区内混编",
"city" => "市内混编",
"province" => "省内混编",
_ => value
};
private static bool TryDate(string value, out DateTimeOffset result) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result);
private static double Number(JsonObject value, string property) =>
double.TryParse(Text(value, property), NumberStyles.Float, CultureInfo.InvariantCulture, out var number)
? number
: 0;
private static string Text(JsonObject? value, string property) =>
value?[property]?.ToString() ?? "";
private static string First(params string[] values) =>
values.FirstOrDefault(value => value.Length > 0) ?? "";
private static string Clean(string value, int maximum)
{
var output = value.Trim();
return output[..Math.Min(output.Length, maximum)];
}
private static string MaskId(string value) =>
value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value;
private static string E(string? value) => WebUtility.HtmlEncode(value ?? "");
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
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) => new(200, body);
private static AdminEndpointResult Error(int status, string message) =>
new(status, new JsonObject { ["ok"] = false, ["message"] = message });
private static AdminDocumentResult Document(byte[] content, string contentType, string fileName) =>
new(200, content, contentType, fileName, null);
private static AdminDocumentResult DocumentError(AdminEndpointResult error) =>
new(error.StatusCode, null, null, null, error.Body);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
}
}
internal sealed record ArrangementExportRow(
string SchoolName,
string ClassName,
string CandidateNumber,
string CandidateName,
string IdNumber,
string ExamCode,
string ExamName,
string CardNumber,
string CenterCode,
string CenterName,
string CenterAddress,
string SubjectName,
string SubjectDate,
string SubjectTime,
string ExamRoomCode,
string RoomName,
string RoomCode,
string Building,
string Floor,
string Seat);
@@ -0,0 +1,207 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminArrangementSnapshot(
IReadOnlyList<AdmissionNumberRule> Rules,
IReadOnlyList<StoredArrangementPlan> Plans,
string OrganizationName);
internal sealed record AdmissionNumberRule(
string Id,
string Code,
string Name,
string Description,
string Separator,
IReadOnlyList<AdmissionNumberSegment> Segments,
string Example,
bool Active,
string CreatedAt);
internal sealed record AdmissionNumberSegment(string Source, string Label, int? Width);
internal sealed record StoredArrangementPlan(
string Id,
string ExamId,
string NumberRuleId,
string MixingScope,
string RandomSeed,
int CandidateCount,
int CenterCount,
int SubjectAssignmentCount,
int SubjectCombinationCount,
double SameSchoolCenterRate,
IReadOnlyList<string> Warnings,
string? GeneratedBy,
string GeneratedAt);
internal sealed class AdminArrangementSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminArrangementSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var rules = await QueryAsync(
connection,
"""
SELECT id, code, name, description, `separator`, segments_json, example, active, created_at
FROM admission_number_rules ORDER BY created_at, id
""",
reader => new AdmissionNumberRule(
Text(reader, "id"),
Text(reader, "code"),
Text(reader, "name"),
Text(reader, "description"),
Optional(reader, "separator") ?? "",
ParseSegments(Optional(reader, "segments_json")),
Optional(reader, "example") ?? "",
Boolean(reader, "active"),
Text(reader, "created_at")),
cancellationToken);
var plans = await QueryAsync(
connection,
"""
SELECT id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json,
generated_by, generated_at
FROM exam_arrangement_plans ORDER BY generated_at DESC, id
""",
reader => new StoredArrangementPlan(
Text(reader, "id"),
Text(reader, "exam_id"),
Text(reader, "number_rule_id"),
Text(reader, "mixing_scope"),
Text(reader, "random_seed"),
Integer(reader, "candidate_count"),
Integer(reader, "center_count"),
Integer(reader, "subject_assignment_count"),
Integer(reader, "subject_combination_count"),
Number(reader, "same_school_center_rate"),
ParseStrings(Optional(reader, "warnings_json")),
Optional(reader, "generated_by"),
Text(reader, "generated_at")),
cancellationToken);
await using var organizationCommand = connection.CreateCommand();
organizationCommand.CommandText = "SELECT name FROM organization WHERE id = 1";
var organizationValue = await organizationCommand.ExecuteScalarAsync(cancellationToken);
var organizationName = organizationValue is null or DBNull
? "考试服务平台"
: Convert.ToString(organizationValue, CultureInfo.InvariantCulture) ?? "考试服务平台";
return new(rules, plans, organizationName);
}
private static IReadOnlyList<AdmissionNumberSegment> ParseSegments(string? value)
{
try
{
return (JsonNode.Parse(value ?? "[]") as JsonArray ?? [])
.OfType<JsonObject>()
.Select(item => new AdmissionNumberSegment(
item["source"]?.GetValue<string>() ?? "",
item["label"]?.GetValue<string>() ?? "",
item.ContainsKey("width") ? item["width"]?.GetValue<int>() ?? 0 : null))
.ToArray();
}
catch
{
return [];
}
}
private static IReadOnlyList<string> ParseStrings(string? value)
{
try
{
return (JsonNode.Parse(value ?? "[]") as JsonArray ?? [])
.Select(item => item?.GetValue<string>() ?? "")
.ToArray();
}
catch
{
return [];
}
}
private static async Task<IReadOnlyList<T>> QueryAsync<T>(
DbConnection connection,
string sql,
Func<DbDataReader, T> map,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<T>();
while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader));
return output;
}
private static string Text(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static string? Optional(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
private static int Integer(DbDataReader reader, string name) =>
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static double Number(DbDataReader reader, string name) =>
Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private static bool Boolean(DbDataReader reader, string name) =>
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
}
internal sealed record ArrangementSummary(
int CandidateCount,
int CenterCount,
int SubjectAssignmentCount,
int SubjectCombinationCount,
int SameSchoolCenterCount,
double SameSchoolCenterRate,
int ReservedSpareRooms);
internal sealed record ArrangementCard(
string RegistrationId,
string Number,
string CenterId,
string TestCenter,
string CenterCode,
string CenterAddress,
string GeneratedAt,
IReadOnlyList<ArrangementAssignment> Assignments);
internal sealed record ArrangementAssignment(
string RegistrationId,
string SubjectId,
string CenterId,
string CenterName,
string RoomId,
string RoomName,
string RoomCode,
string Building,
string Floor,
string ExamRoomCode,
string Seat,
string SubjectSignature);
internal sealed record ArrangementBuildResult(
OperationalExam Exam,
AdmissionNumberRule Rule,
string MixingScope,
string Seed,
IReadOnlyList<string> Warnings,
ArrangementSummary Summary,
IReadOnlyList<ArrangementCard> Cards);
internal sealed class AdminArrangementException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
@@ -11,7 +11,8 @@ public sealed record AdminMigrationOptions(
bool NativeCandidateManagementEnabled = false, bool NativeCandidateManagementEnabled = false,
bool NativeRegistrationPaymentWritesEnabled = false, bool NativeRegistrationPaymentWritesEnabled = false,
bool NativeWorkflowOperationsEnabled = false, bool NativeWorkflowOperationsEnabled = false,
bool NativeExamManagementEnabled = false) bool NativeExamManagementEnabled = false,
bool NativeArrangementsEnabled = false)
{ {
public static AdminMigrationOptions FromEnvironment( public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled, bool configuredNativeReadsEnabled,
@@ -26,7 +27,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeCandidateManagementEnabled = false, bool configuredNativeCandidateManagementEnabled = false,
bool configuredNativeRegistrationPaymentWritesEnabled = false, bool configuredNativeRegistrationPaymentWritesEnabled = false,
bool configuredNativeWorkflowOperationsEnabled = false, bool configuredNativeWorkflowOperationsEnabled = false,
bool configuredNativeExamManagementEnabled = false) bool configuredNativeExamManagementEnabled = false,
bool configuredNativeArrangementsEnabled = false)
{ {
var readsEnabled = ParseBoolean( var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -62,7 +64,10 @@ public sealed record AdminMigrationOptions(
var examManagementEnabled = ParseBoolean( var examManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
configuredNativeExamManagementEnabled); configuredNativeExamManagementEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled) && !readsEnabled) var arrangementsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
configuredNativeArrangementsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled) && !readsEnabled)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
@@ -72,7 +77,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 || examManagementEnabled; var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -88,7 +93,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
} }
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled); return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled);
} }
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -64,6 +64,8 @@ public static class DependencyInjection
services.AddScoped<AdminWorkflowResultSnapshotLoader>(); services.AddScoped<AdminWorkflowResultSnapshotLoader>();
services.AddScoped<AdminWorkflowRepository>(); services.AddScoped<AdminWorkflowRepository>();
services.AddScoped<AdminExamRepository>(); services.AddScoped<AdminExamRepository>();
services.AddScoped<AdminArrangementSnapshotLoader>();
services.AddScoped<AdminArrangementRepository>();
services.AddScoped<IAdminReadService, AdminReadService>(); services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>(); services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>(); services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
@@ -75,6 +77,7 @@ public static class DependencyInjection
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>(); services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>(); services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>(); services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
services.AddSingleton<NoticeContentFormatter>(); services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
@@ -9,6 +9,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" /> <PackageReference Include="AngleSharp" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="MySqlConnector" /> <PackageReference Include="MySqlConnector" />
<PackageReference Include="QRCoder" /> <PackageReference Include="QRCoder" />
@@ -139,6 +139,31 @@ public static class NativeAdminReadEndpoints
endpoints.MapPost("/api/admin/exams/{examId}/archive", (HttpContext context, string examId, IAdminExamManagementService service, CancellationToken cancellationToken) => endpoints.MapPost("/api/admin/exams/{examId}/archive", (HttpContext context, string examId, IAdminExamManagementService service, CancellationToken cancellationToken) =>
Execute(context, service.ArchiveAsync(Token(context), examId, cancellationToken))); Execute(context, service.ArchiveAsync(Token(context), examId, cancellationToken)));
} }
if (options.NativeArrangementsEnabled)
{
endpoints.MapGet("/api/admin/admission-arrangements",
(HttpContext context, IAdminArrangementService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/exams/{examId}/admission-arrangement/preview",
(HttpContext context, string examId, JsonObject body, IAdminArrangementService service, CancellationToken cancellationToken) =>
Execute(context, service.PreviewAsync(Token(context), examId, body, cancellationToken)));
endpoints.MapPost("/api/admin/exams/{examId}/admission-arrangement",
(HttpContext context, string examId, JsonObject body, IAdminArrangementService service, CancellationToken cancellationToken) =>
Execute(context, service.ApplyAsync(Token(context), examId, body, cancellationToken)));
endpoints.MapGet("/api/admin/admission-exports/{type:regex(^(admit-cards|info|center-materials)$)}",
(HttpContext context, string type, IAdminArrangementService service, CancellationToken cancellationToken) =>
ExecuteDocument(
context,
service.ExportAsync(
Token(context),
type,
context.Request.Query["examId"].ToString(),
cancellationToken)));
endpoints.MapPost("/api/admin/registrations/{registrationId}/admit-card",
(HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) =>
Execute(context, service.RetiredSingleAsync(Token(context), cancellationToken)));
}
return endpoints; return endpoints;
} }
@@ -150,6 +175,25 @@ public static class NativeAdminReadEndpoints
return Results.Json(result.Body, statusCode: result.StatusCode); return Results.Json(result.Body, statusCode: result.StatusCode);
} }
private static async Task<IResult> ExecuteDocument(
HttpContext context,
Task<AdminDocumentResult> operation)
{
var result = await operation;
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
if (result.Error is not null)
{
return Results.Json(result.Error, statusCode: result.StatusCode);
}
return Results.File(
result.Content ?? [],
result.ContentType ?? "application/octet-stream",
result.FileName,
enableRangeProcessing: false);
}
private static string Token(HttpContext context) => private static string Token(HttpContext context) =>
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
} }
+10 -1
View File
@@ -55,7 +55,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
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.Configuration.GetValue<bool>("AdminMigration:NativeExamManagementEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeArrangementsEnabled"));
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()),
@@ -120,6 +121,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled,
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled, nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled,
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" }
: []) : [])
@@ -157,6 +159,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
.Concat(adminMigrationOptions.NativeExamManagementEnabled .Concat(adminMigrationOptions.NativeExamManagementEnabled
? new[] { "POST/PATCH exams", "POST exam archive" } ? new[] { "POST/PATCH exams", "POST exam archive" }
: []) : [])
.Concat(adminMigrationOptions.NativeArrangementsEnabled
? new[]
{
"GET admission-arrangements", "POST arrangement preview/apply",
"GET admission exports"
}
: [])
.ToArray() .ToArray()
}, },
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
+2 -1
View File
@@ -20,7 +20,8 @@
"NativeCandidateManagementEnabled": false, "NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false, "NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false, "NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false "NativeExamManagementEnabled": false,
"NativeArrangementsEnabled": false
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -271,6 +271,21 @@ public sealed class AdminMigrationOptionsTests
}, examManagementEnabled: "true"); }, examManagementEnabled: "true");
} }
[Fact]
public void EnablesArrangementsWithBaseReadsAndSharedSessions()
{
WithEnvironment("true", null, null, () =>
{
var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: true);
Assert.True(options.NativeReadsEnabled);
Assert.True(options.NativeArrangementsEnabled);
}, arrangementsEnabled: "true");
}
private static void WithEnvironment( private static void WithEnvironment(
string? nativeReadsEnabled, string? nativeReadsEnabled,
string? nativeOrganizationWritesEnabled, string? nativeOrganizationWritesEnabled,
@@ -285,7 +300,8 @@ public sealed class AdminMigrationOptionsTests
string? candidateManagementEnabled = null, string? candidateManagementEnabled = null,
string? registrationPaymentWritesEnabled = null, string? registrationPaymentWritesEnabled = null,
string? workflowOperationsEnabled = null, string? workflowOperationsEnabled = null,
string? examManagementEnabled = null) string? examManagementEnabled = null,
string? arrangementsEnabled = 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");
@@ -299,6 +315,7 @@ public sealed class AdminMigrationOptionsTests
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 previousExamManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED");
var previousArrangementsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED");
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
try try
{ {
@@ -314,6 +331,7 @@ public sealed class AdminMigrationOptionsTests
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_EXAM_MANAGEMENT_ENABLED", examManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", arrangementsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
test(); test();
} }
@@ -331,6 +349,7 @@ public sealed class AdminMigrationOptionsTests
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_EXAM_MANAGEMENT_ENABLED", previousExamManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", previousArrangementsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
} }
} }
@@ -0,0 +1,13 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
const database = new DatabaseSync(databasePath);
database.prepare(`
UPDATE test_rooms
SET capacity = 2000,
seat_end = seat_start + 1999
WHERE status = 'active'
`).run();
database.close();