按 examId 延迟加载成绩工作区,未选择考试时不执行排名计算
单条及批量成绩草稿、发布 特长分单条及批量维护 成绩复议多级审批、终审改分、归档锁定 排名、等级、单科达线和考试汇总 成绩台账 Excel 导出 管理范围、超级管理员写权限及事务审计 独立开关 ADMIN_NATIVE_RESULTS_ENABLED
This commit is contained in:
@@ -49,6 +49,8 @@ ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false
|
|||||||
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false
|
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false
|
||||||
# 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。
|
# 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。
|
||||||
ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false
|
ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false
|
||||||
|
# 成绩工作区、成绩录入发布、特征分、复议及成绩台账导出;必须同时启用管理端基础读取和业务读取。
|
||||||
|
ADMIN_NATIVE_RESULTS_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+4
-3
@@ -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 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。第十三批覆盖按考试延迟加载的成绩工作区、单条及批量成绩草稿/发布、特征分维护、成绩复议多级流转和成绩台账 Excel 导出;排名、单科达线、归档锁定、数据范围和写入审计均由 ASP.NET Core 原生处理。
|
||||||
|
|
||||||
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
@@ -72,9 +72,10 @@ $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'
|
$env:ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true'
|
||||||
|
$env:ADMIN_NATIVE_RESULTS_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.nativeArrangementsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的各 `administration.native*Enabled` 字段和 `administration.nativeRoutes` 会报告这些端点是否已切换,其中成绩域对应 `administration.nativeResultsEnabled`。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ try {
|
|||||||
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_ARRANGEMENTS_ENABLED = 'true'
|
||||||
|
ADMIN_NATIVE_RESULTS_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -576,6 +577,9 @@ try {
|
|||||||
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
||||||
$nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
$nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||||
Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content
|
Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content
|
||||||
|
$appealResultId = $null
|
||||||
|
$appealExamId = $null
|
||||||
|
$appealOriginalScore = $null
|
||||||
$appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0]
|
$appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0]
|
||||||
if ($null -ne $appealableResult) {
|
if ($null -ne $appealableResult) {
|
||||||
$shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
$shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
||||||
@@ -588,6 +592,9 @@ try {
|
|||||||
if ($appealResponse.StatusCode -ne 201 -or $appealResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $appeal.workflow.status -ne 'pending') {
|
if ($appealResponse.StatusCode -ne 201 -or $appealResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $appeal.workflow.status -ne 'pending') {
|
||||||
throw 'Native score appeal did not persist a pending workflow'
|
throw 'Native score appeal did not persist a pending workflow'
|
||||||
}
|
}
|
||||||
|
$appealResultId = $appealableResult.id
|
||||||
|
$appealExamId = $appealableResult.examId
|
||||||
|
$appealOriginalScore = $appealableResult.score
|
||||||
}
|
}
|
||||||
$admitProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
$admitProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
||||||
'tests/helpers/prepare-candidate-admit-smoke.mjs', $smokeDatabasePath, '2026-HZ01-F-0001'
|
'tests/helpers/prepare-candidate-admit-smoke.mjs', $smokeDatabasePath, '2026-HZ01-F-0001'
|
||||||
@@ -932,6 +939,118 @@ try {
|
|||||||
throw 'Native center-material workbook export is invalid'
|
throw 'Native center-material workbook export is invalid'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$legacyEmptyResults = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/results" -WebSession $session
|
||||||
|
$nativeEmptyResults = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/results" -WebSession $nativeSession
|
||||||
|
if ($nativeEmptyResults.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native empty result workspace did not use ASP.NET Core'
|
||||||
|
}
|
||||||
|
Assert-JsonEquivalent -Expected $legacyEmptyResults.Content -Actual $nativeEmptyResults.Content -Label 'Empty result workspace'
|
||||||
|
|
||||||
|
$resultsUri = "/api/admin/results?examId=$($arrangementExam.id)"
|
||||||
|
$legacyResults = Invoke-WebRequest -Uri "$legacyBaseUrl$resultsUri" -WebSession $session
|
||||||
|
$nativeResults = Invoke-WebRequest -Uri "$nativeAuthBaseUrl$resultsUri" -WebSession $nativeSession
|
||||||
|
if ($nativeResults.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native result workspace did not use ASP.NET Core'
|
||||||
|
}
|
||||||
|
Assert-JsonEquivalent -Expected $legacyResults.Content -Actual $nativeResults.Content -Label 'Super-admin result workspace'
|
||||||
|
$nativeResultsState = $nativeResults.Content | ConvertFrom-Json
|
||||||
|
$resultRegistration = @($nativeResultsState.registrations | Where-Object { @($_.subjects).Count -gt 0 } | Select-Object -First 1)[0]
|
||||||
|
if ($null -eq $resultRegistration) {
|
||||||
|
throw 'Seed data did not provide an approved registration for native result writes'
|
||||||
|
}
|
||||||
|
$resultSubject = @($resultRegistration.subjects)[0]
|
||||||
|
$schoolResultWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/results" -Method Post -ContentType 'application/json' -Body '{}' -WebSession $nativeSchoolSession -SkipHttpErrorCheck
|
||||||
|
if ($schoolResultWrite.StatusCode -ne 403 -or $schoolResultWrite.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native result writes did not preserve the super-admin boundary'
|
||||||
|
}
|
||||||
|
$invalidResultBody = @{
|
||||||
|
registrationId = $resultRegistration.id
|
||||||
|
subjectId = $resultSubject.id
|
||||||
|
score = [double] $resultSubject.fullScore + 1
|
||||||
|
published = $false
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$invalidResult = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/results" -Method Post -ContentType 'application/json' -Body $invalidResultBody -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidResult.StatusCode -ne 400) {
|
||||||
|
throw 'Native result write accepted a score above the subject full score'
|
||||||
|
}
|
||||||
|
$score = [Math]::Min([double] $resultSubject.fullScore, 88.5)
|
||||||
|
$bulkDraftBody = @{
|
||||||
|
examId = $arrangementExam.id
|
||||||
|
subjectId = $resultSubject.id
|
||||||
|
published = $false
|
||||||
|
rows = @(@{ registrationId = $resultRegistration.id; score = $score })
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
$bulkDraftResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/results/bulk" -Method Post -ContentType 'application/json' -Body $bulkDraftBody -WebSession $nativeSession
|
||||||
|
$bulkDraft = $bulkDraftResponse.Content | ConvertFrom-Json
|
||||||
|
if ($bulkDraftResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $bulkDraft.count -ne 1 -or $bulkDraft.published -ne $false) {
|
||||||
|
throw 'Native result bulk draft did not save the requested score'
|
||||||
|
}
|
||||||
|
$bulkPublishBody = @{
|
||||||
|
examId = $arrangementExam.id
|
||||||
|
subjectId = $resultSubject.id
|
||||||
|
published = $true
|
||||||
|
rows = @(@{ registrationId = $resultRegistration.id; score = $score })
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
$bulkPublish = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/results/bulk" -Method Post -ContentType 'application/json' -Body $bulkPublishBody -WebSession $nativeSession
|
||||||
|
if ($bulkPublish.count -ne 1 -or $bulkPublish.published -ne $true -or $bulkPublish.summary.publish -ne 1) {
|
||||||
|
throw 'Native result bulk publication did not publish the requested score'
|
||||||
|
}
|
||||||
|
$invalidFeatureBody = @{
|
||||||
|
examId = $arrangementExam.id
|
||||||
|
rows = @(@{ registrationId = $resultRegistration.id; featureScore = 1001 })
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
$invalidFeature = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/feature-scores/bulk" -Method Post -ContentType 'application/json' -Body $invalidFeatureBody -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidFeature.StatusCode -ne 400) {
|
||||||
|
throw 'Native feature-score bulk write accepted a score above 1000'
|
||||||
|
}
|
||||||
|
$featureBody = @{ featureScore = 19.25 } | ConvertTo-Json -Compress
|
||||||
|
$featureResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($resultRegistration.id)/feature-score" -Method Patch -ContentType 'application/json' -Body $featureBody -WebSession $nativeSession
|
||||||
|
$featureRegistration = ($featureResponse.Content | ConvertFrom-Json).registration
|
||||||
|
if ($featureResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $featureRegistration.featureScore -ne 19.25) {
|
||||||
|
throw 'Native feature-score write did not persist the rounded score'
|
||||||
|
}
|
||||||
|
$invalidAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/score-appeals/missing-result" -Method Patch -ContentType 'application/json' -Body '{"status":"invalid"}' -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidAppeal.StatusCode -ne 400 -or $invalidAppeal.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native score-appeal review accepted an invalid status'
|
||||||
|
}
|
||||||
|
if ($null -ne $appealResultId) {
|
||||||
|
$appealReviewIterations = 0
|
||||||
|
do {
|
||||||
|
$appealReviewIterations += 1
|
||||||
|
if ($appealReviewIterations -gt 10) {
|
||||||
|
throw 'Native score-appeal workflow did not reach a terminal state'
|
||||||
|
}
|
||||||
|
$appealReviewBody = @{
|
||||||
|
status = 'approved'
|
||||||
|
reviewedScore = $appealOriginalScore
|
||||||
|
reviewNote = "原生成绩复议第 $appealReviewIterations 步"
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$appealReviewResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/score-appeals/$appealResultId" -Method Patch -ContentType 'application/json' -Body $appealReviewBody -WebSession $nativeSession
|
||||||
|
$appealReview = $appealReviewResponse.Content | ConvertFrom-Json
|
||||||
|
if ($appealReviewResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native score-appeal review did not use ASP.NET Core'
|
||||||
|
}
|
||||||
|
} while ($appealReview.workflow.status -eq 'pending')
|
||||||
|
if ($appealReview.workflow.status -ne 'approved' -or $appealReview.result.score -ne $appealOriginalScore) {
|
||||||
|
throw 'Native score-appeal review did not complete the workflow and preserve the reviewed score'
|
||||||
|
}
|
||||||
|
if ($appealExamId) {
|
||||||
|
$appealResultsUri = "/api/admin/results?examId=$appealExamId"
|
||||||
|
$legacyAppealResults = Invoke-WebRequest -Uri "$legacyBaseUrl$appealResultsUri" -WebSession $session
|
||||||
|
$nativeAppealResults = Invoke-WebRequest -Uri "$nativeAuthBaseUrl$appealResultsUri" -WebSession $nativeSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyAppealResults.Content -Actual $nativeAppealResults.Content -Label 'Score-appeal review follow-up'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$resultExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/excel/results?examId=$($arrangementExam.id)" -WebSession $nativeSession
|
||||||
|
if ($resultExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
|
||||||
|
$resultExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or
|
||||||
|
$resultExport.RawContentLength -lt 1000) {
|
||||||
|
throw 'Native result workbook export is invalid'
|
||||||
|
}
|
||||||
|
$legacyResultsAfterWrites = Invoke-WebRequest -Uri "$legacyBaseUrl$resultsUri" -WebSession $session
|
||||||
|
$nativeResultsAfterWrites = Invoke-WebRequest -Uri "$nativeAuthBaseUrl$resultsUri" -WebSession $nativeSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyResultsAfterWrites.Content -Actual $nativeResultsAfterWrites.Content -Label 'Result writes follow-up'
|
||||||
|
|
||||||
$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 },
|
||||||
@@ -1712,6 +1831,7 @@ try {
|
|||||||
NativeAdminWorkflowOperations = 'passed'
|
NativeAdminWorkflowOperations = 'passed'
|
||||||
NativeAdminExamManagement = 'passed'
|
NativeAdminExamManagement = 'passed'
|
||||||
NativeAdminArrangements = 'passed'
|
NativeAdminArrangements = 'passed'
|
||||||
|
NativeAdminResults = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Eis.Application.Administration;
|
||||||
|
|
||||||
|
public interface IAdminResultService
|
||||||
|
{
|
||||||
|
Task<AdminEndpointResult> GetAsync(string sessionToken, string examId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> SaveFeatureBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> RefreshCacheAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
Task<AdminDocumentResult> ExportAsync(string sessionToken, string examId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool NativeRegistrationPaymentWritesEnabled = false,
|
bool NativeRegistrationPaymentWritesEnabled = false,
|
||||||
bool NativeWorkflowOperationsEnabled = false,
|
bool NativeWorkflowOperationsEnabled = false,
|
||||||
bool NativeExamManagementEnabled = false,
|
bool NativeExamManagementEnabled = false,
|
||||||
bool NativeArrangementsEnabled = false)
|
bool NativeArrangementsEnabled = false,
|
||||||
|
bool NativeResultsEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -28,7 +29,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool configuredNativeRegistrationPaymentWritesEnabled = false,
|
bool configuredNativeRegistrationPaymentWritesEnabled = false,
|
||||||
bool configuredNativeWorkflowOperationsEnabled = false,
|
bool configuredNativeWorkflowOperationsEnabled = false,
|
||||||
bool configuredNativeExamManagementEnabled = false,
|
bool configuredNativeExamManagementEnabled = false,
|
||||||
bool configuredNativeArrangementsEnabled = false)
|
bool configuredNativeArrangementsEnabled = false,
|
||||||
|
bool configuredNativeResultsEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -67,17 +69,20 @@ public sealed record AdminMigrationOptions(
|
|||||||
var arrangementsEnabled = ParseBoolean(
|
var arrangementsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
|
||||||
configuredNativeArrangementsEnabled);
|
configuredNativeArrangementsEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled) && !readsEnabled)
|
var resultsEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"),
|
||||||
|
configuredNativeResultsEnabled);
|
||||||
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
}
|
}
|
||||||
if ((candidateManagementEnabled || registrationPaymentWritesEnabled) && !operationalReadsEnabled)
|
if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled) && !operationalReadsEnabled)
|
||||||
{
|
{
|
||||||
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 || arrangementsEnabled;
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -93,7 +98,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled);
|
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminResultExportRow(
|
||||||
|
string CandidateNumber,
|
||||||
|
string CardNumber,
|
||||||
|
string CandidateName,
|
||||||
|
string SchoolName,
|
||||||
|
string ClassName,
|
||||||
|
string ExamCode,
|
||||||
|
string ExamName,
|
||||||
|
string SubjectName,
|
||||||
|
object FullScore,
|
||||||
|
string PassRule,
|
||||||
|
object PassScore,
|
||||||
|
object Score,
|
||||||
|
object Rank,
|
||||||
|
object RankPercent,
|
||||||
|
string Qualified,
|
||||||
|
string Grade,
|
||||||
|
string Published,
|
||||||
|
string UpdatedAt);
|
||||||
|
|
||||||
|
internal static class AdminResultExporter
|
||||||
|
{
|
||||||
|
private static readonly string[] Headers =
|
||||||
|
[
|
||||||
|
"报名号*", "准考证号(只读参考)", "姓名(只读参考)", "学校(只读参考)", "班级(只读参考)",
|
||||||
|
"考试代码*", "考试名称(只读参考)", "科目*", "科目满分(只读参考)", "单科及格规则(只读参考)",
|
||||||
|
"实际及格分(只读参考)", "成绩*", "本科排名(导出计算)", "排名百分位(导出计算)",
|
||||||
|
"单科达线(导出计算)", "排名等级(自动计算)", "发布状态*", "更新时间(只读参考)"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static byte[] Build(IReadOnlyList<AdminResultExportRow> rows)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var sheet = workbook.Worksheets.Add("成绩");
|
||||||
|
sheet.Cell(1, 1).Value = "考试成绩台账";
|
||||||
|
sheet.Range(1, 1, 1, Headers.Length).Merge();
|
||||||
|
sheet.Range(1, 1, 1, Headers.Length).Style
|
||||||
|
.Font.SetBold().Font.SetFontSize(16).Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(XLColor.FromHtml("#182552"));
|
||||||
|
for (var index = 0; index < Headers.Length; index++)
|
||||||
|
{
|
||||||
|
sheet.Cell(2, index + 1).Value = Headers[index];
|
||||||
|
}
|
||||||
|
sheet.Range(2, 1, 2, Headers.Length).Style
|
||||||
|
.Font.SetBold().Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(XLColor.FromHtml("#245365"));
|
||||||
|
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
var item = rows[rowIndex];
|
||||||
|
object[] values =
|
||||||
|
[
|
||||||
|
item.CandidateNumber, item.CardNumber, item.CandidateName, item.SchoolName, item.ClassName,
|
||||||
|
item.ExamCode, item.ExamName, item.SubjectName, item.FullScore, item.PassRule, item.PassScore,
|
||||||
|
item.Score, item.Rank, item.RankPercent, item.Qualified, item.Grade, item.Published, item.UpdatedAt
|
||||||
|
];
|
||||||
|
for (var column = 0; column < values.Length; column++)
|
||||||
|
{
|
||||||
|
sheet.Cell(rowIndex + 3, column + 1).Value = XLCellValue.FromObject(values[column]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.SheetView.FreezeRows(2);
|
||||||
|
sheet.RangeUsed()?.SetAutoFilter();
|
||||||
|
double[] widths =
|
||||||
|
[
|
||||||
|
24, 22, 14, 22, 16, 18, 26, 14, 16, 22, 18, 12, 16, 18, 16, 18, 14, 22
|
||||||
|
];
|
||||||
|
for (var index = 0; index < widths.Length; index++)
|
||||||
|
{
|
||||||
|
sheet.Column(index + 1).Width = widths[index];
|
||||||
|
}
|
||||||
|
sheet.Columns(9, 14).Style.NumberFormat.Format = "0.00";
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record ResultWrite(AdminWorkflowResult Result, bool IsNew, AdminAuditEntry Audit);
|
||||||
|
internal sealed record FeatureScoreWrite(string RegistrationId, double Score, AdminAuditEntry Audit);
|
||||||
|
|
||||||
|
internal sealed class AdminResultRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) =>
|
||||||
|
SaveManyAsync([entry], cancellationToken);
|
||||||
|
|
||||||
|
public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await TransactionAsync(async (connection, transaction) =>
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var result = entry.Result;
|
||||||
|
await ExecuteAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
entry.IsNew
|
||||||
|
? """
|
||||||
|
INSERT INTO results (
|
||||||
|
id, registration_id, subject_id, score, grade, published, updated_at, published_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @registrationId, @subjectId, @score, @grade, @published, @updatedAt, @publishedAt
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
UPDATE results SET score = @score, grade = @grade, published = @published,
|
||||||
|
updated_at = @updatedAt, published_at = @publishedAt WHERE id = @id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@id", result.Id),
|
||||||
|
new("@registrationId", result.RegistrationId),
|
||||||
|
new("@subjectId", result.SubjectId),
|
||||||
|
new("@score", result.Score),
|
||||||
|
new("@grade", result.Grade),
|
||||||
|
new("@published", result.Published ? 1 : 0),
|
||||||
|
new("@updatedAt", result.UpdatedAt),
|
||||||
|
new("@publishedAt", result.PublishedAt)
|
||||||
|
],
|
||||||
|
cancellationToken);
|
||||||
|
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveFeatureScoresAsync(
|
||||||
|
IReadOnlyList<FeatureScoreWrite> entries,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await TransactionAsync(async (connection, transaction) =>
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
await ExecuteAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
"UPDATE registrations SET feature_score = @score WHERE id = @id",
|
||||||
|
[new("@score", entry.Score), new("@id", entry.RegistrationId)],
|
||||||
|
cancellationToken);
|
||||||
|
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReviewAppealAsync(
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action,
|
||||||
|
AdminWorkflowResult? result,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await TransactionAsync(async (connection, transaction) =>
|
||||||
|
{
|
||||||
|
await ExecuteAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
"""
|
||||||
|
UPDATE workflow_instances SET status = @status, current_step = @currentStep,
|
||||||
|
assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@status", instance.Status),
|
||||||
|
new("@currentStep", instance.CurrentStep),
|
||||||
|
new("@assigneeId", instance.AssigneeId),
|
||||||
|
new("@completedAt", instance.CompletedAt),
|
||||||
|
new("@id", instance.Id)
|
||||||
|
],
|
||||||
|
cancellationToken);
|
||||||
|
await ExecuteAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
"""
|
||||||
|
INSERT INTO workflow_actions (
|
||||||
|
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@id", action.Id),
|
||||||
|
new("@instanceId", action.InstanceId),
|
||||||
|
new("@actorId", action.ActorId),
|
||||||
|
new("@action", action.Action),
|
||||||
|
new("@note", action.Note),
|
||||||
|
new("@fromAssigneeId", action.FromAssigneeId),
|
||||||
|
new("@toAssigneeId", action.ToAssigneeId),
|
||||||
|
new("@createdAt", action.CreatedAt)
|
||||||
|
],
|
||||||
|
cancellationToken);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
await ExecuteAsync(
|
||||||
|
connection,
|
||||||
|
transaction,
|
||||||
|
"""
|
||||||
|
UPDATE results SET score = @score, grade = @grade, updated_at = @updatedAt
|
||||||
|
WHERE id = @id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@score", result.Score),
|
||||||
|
new("@grade", result.Grade),
|
||||||
|
new("@updatedAt", result.UpdatedAt),
|
||||||
|
new("@id", result.Id)
|
||||||
|
],
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TransactionAsync(
|
||||||
|
Func<DbConnection, DbTransaction, Task> operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await operation(connection, transaction);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task InsertAuditAsync(
|
||||||
|
DbConnection connection,
|
||||||
|
DbTransaction transaction,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,86 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminRankInfo(int? Rank, int CohortSize, double? RankPercent, string Grade);
|
||||||
|
internal sealed record AdminPassInfo(bool? Qualified, double? PassScore, int? CutoffRank, AdminRankInfo Rank);
|
||||||
|
|
||||||
|
internal static class AdminResultScoring
|
||||||
|
{
|
||||||
|
public static AdminRankInfo Rank(
|
||||||
|
IReadOnlyList<AdminWorkflowResult> results,
|
||||||
|
AdminWorkflowResult result,
|
||||||
|
double? scoreOverride = null)
|
||||||
|
{
|
||||||
|
var score = scoreOverride ?? result.Score;
|
||||||
|
if (!double.IsFinite(score) || result.SubjectId.Length == 0)
|
||||||
|
return new(null, 0, null, "");
|
||||||
|
var scores = results
|
||||||
|
.Where(item => item.SubjectId == result.SubjectId && item.Published)
|
||||||
|
.Select(item => item.Score)
|
||||||
|
.Order()
|
||||||
|
.ToArray();
|
||||||
|
var cohortSize = scores.Length + (result.Published ? 0 : 1);
|
||||||
|
if (cohortSize == 0) return new(null, 0, null, "");
|
||||||
|
var greater = scores.Count(item => item > score);
|
||||||
|
if (result.Published && result.Score > score) greater--;
|
||||||
|
var rank = 1 + Math.Max(0, greater);
|
||||||
|
var percent = Math.Round((double)rank / cohortSize * 100, 2, MidpointRounding.AwayFromZero);
|
||||||
|
return new(rank, cohortSize, percent, Grade(rank, cohortSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AdminPassInfo Pass(
|
||||||
|
IReadOnlyList<AdminWorkflowResult> results,
|
||||||
|
AdminWorkflowResult result,
|
||||||
|
JsonObject? subject,
|
||||||
|
double? scoreOverride = null)
|
||||||
|
{
|
||||||
|
var score = scoreOverride ?? result.Score;
|
||||||
|
var rank = Rank(results, result, score);
|
||||||
|
var rule = Text(subject?["passRule"]);
|
||||||
|
if (rule.Length == 0) rule = "fixed_score";
|
||||||
|
if (rule == "none") return new(null, null, null, rank);
|
||||||
|
if (rule == "rank_percent")
|
||||||
|
{
|
||||||
|
var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (Number(subject?["passValue"]) ?? 60) / 100));
|
||||||
|
return new(rank.Rank <= cutoff, null, cutoff, rank);
|
||||||
|
}
|
||||||
|
var threshold = Math.Round(
|
||||||
|
Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0,
|
||||||
|
2,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
return new(score >= threshold, threshold, null, rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string PassText(JsonObject? subject)
|
||||||
|
{
|
||||||
|
var rule = Text(subject?["passRule"]);
|
||||||
|
if (rule == "none") return "不设单科线";
|
||||||
|
if (rule == "rank_percent")
|
||||||
|
return $"本科排名前 {Format(Number(subject?["passValue"]) ?? 60)}% 达线";
|
||||||
|
return $"固定 {Format(Math.Round(Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero))} 分";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static double? Number(JsonNode? node) =>
|
||||||
|
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string Text(JsonNode? node) =>
|
||||||
|
node is JsonValue value && value.TryGetValue<string>(out var text)
|
||||||
|
? text
|
||||||
|
: node?.ToString() ?? "";
|
||||||
|
|
||||||
|
private static string Grade(int rank, int cohortSize)
|
||||||
|
{
|
||||||
|
int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio));
|
||||||
|
if (rank <= Cutoff(0.1)) return "A+";
|
||||||
|
if (rank <= Cutoff(0.25)) return "A";
|
||||||
|
if (rank <= Cutoff(0.5)) return "B+";
|
||||||
|
if (rank <= Cutoff(0.7)) return "B";
|
||||||
|
return rank <= Cutoff(0.9) ? "C" : "D";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Administration;
|
||||||
|
using Eis.Infrastructure.Authentication;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed class AdminResultService(
|
||||||
|
IAuthenticationStateStore authenticationState,
|
||||||
|
AuthenticationRepository authenticationRepository,
|
||||||
|
AdminOperationalSnapshotLoader operationalSnapshotLoader,
|
||||||
|
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
|
||||||
|
AdminAccountBatchSnapshotLoader directorySnapshotLoader,
|
||||||
|
AdminResultRepository repository) : IAdminResultService
|
||||||
|
{
|
||||||
|
public async Task<AdminEndpointResult> GetAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string examId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
if (examId.Length == 0)
|
||||||
|
{
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["selectedExamId"] = "",
|
||||||
|
["results"] = new JsonArray(),
|
||||||
|
["appeals"] = new JsonArray(),
|
||||||
|
["registrations"] = new JsonArray(),
|
||||||
|
["exams"] = new JsonArray(),
|
||||||
|
["resultCache"] = CacheStatus()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = context.User!;
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId);
|
||||||
|
if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩");
|
||||||
|
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var scoped = operational.Registrations.Where(item =>
|
||||||
|
{
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
|
||||||
|
return profile is not null && InScope(user, profile);
|
||||||
|
}).ToArray();
|
||||||
|
var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray();
|
||||||
|
if (Level(user) != "super" && selectedScoped.Length == 0)
|
||||||
|
return Error(404, "该考试不在当前管理范围内");
|
||||||
|
var approved = selectedScoped.Where(item => item.Status == "approved").ToArray();
|
||||||
|
var selectedIds = selectedScoped.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var examRegistrations = operational.Registrations
|
||||||
|
.Where(item => item.ExamId == selectedExam.Id && item.Status == "approved")
|
||||||
|
.ToArray();
|
||||||
|
var examIds = examRegistrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var examResults = allResults.Where(item => examIds.Contains(item.RegistrationId)).ToArray();
|
||||||
|
var rawScopedResults = examResults.Where(item => selectedIds.Contains(item.RegistrationId)).ToArray();
|
||||||
|
var resultViews = rawScopedResults
|
||||||
|
.Select(item => ResultJson(operational, directory, selectedExam, examResults, item))
|
||||||
|
.ToArray();
|
||||||
|
var resultById = resultViews.ToDictionary(
|
||||||
|
item => Text(item["id"]),
|
||||||
|
item => item,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
var appealViews = directory.Instances
|
||||||
|
.Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var output = WorkflowJson(directory, item);
|
||||||
|
output["result"] = resultById[item.BusinessId].DeepClone();
|
||||||
|
output["reason"] = directory.Actions
|
||||||
|
.FirstOrDefault(action => action.InstanceId == item.Id && action.Action == "submit")?.Note ?? "";
|
||||||
|
return output;
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
var exams = operational.Exams.Select(exam =>
|
||||||
|
{
|
||||||
|
var output = PublicExamJson(exam);
|
||||||
|
if (exam.Id != selectedExam.Id) return output;
|
||||||
|
var summaries = approved.Select(item => Summary(selectedExam, examRegistrations, examResults, item)).ToArray();
|
||||||
|
var enrolled = approved.Sum(item => SubjectIds(item).Count);
|
||||||
|
output["registrationCount"] = approved.Length;
|
||||||
|
output["enrolledSubjects"] = enrolled;
|
||||||
|
output["scored"] = rawScopedResults.Length;
|
||||||
|
output["published"] = rawScopedResults.Count(item => item.Published);
|
||||||
|
output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length);
|
||||||
|
output["complete"] = summaries.Count(item => item.Complete);
|
||||||
|
output["qualified"] = summaries.Count(item => item.Complete && item.Qualified is true);
|
||||||
|
output["unqualified"] = summaries.Count(item => item.Complete && item.Qualified is false);
|
||||||
|
output["appeals"] = appealViews.Length;
|
||||||
|
return output;
|
||||||
|
}).ToArray();
|
||||||
|
var registrations = Level(user) == "super"
|
||||||
|
? approved.Select(item => RegistrationJson(operational, directory, item)).ToArray()
|
||||||
|
: [];
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["selectedExamId"] = selectedExam.Id,
|
||||||
|
["results"] = new JsonArray(resultViews),
|
||||||
|
["appeals"] = new JsonArray(appealViews),
|
||||||
|
["registrations"] = new JsonArray(registrations),
|
||||||
|
["exams"] = new JsonArray(exams),
|
||||||
|
["resultCache"] = CacheStatus()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> SaveAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item =>
|
||||||
|
item.Id == Text(body["registrationId"]) && item.Status == "approved");
|
||||||
|
if (registration is null) return Error(404, "已通过的报名记录不存在");
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
if (exam?.Data["archivedAt"] is not null) return Error(409, "该考试已归档,成绩已永久锁定");
|
||||||
|
var subjectId = Text(body["subjectId"]);
|
||||||
|
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == subjectId);
|
||||||
|
if (subject is null || !SubjectIds(registration).Contains(subjectId))
|
||||||
|
return Error(400, "该考生未报名此科目");
|
||||||
|
var score = Number(body["score"]);
|
||||||
|
var fullScore = Number(subject["fullScore"]);
|
||||||
|
if (!double.IsFinite(score) || score < 0 || score > fullScore)
|
||||||
|
return Error(400, $"成绩必须在 0—{AdminResultScoring.Format(fullScore)} 之间");
|
||||||
|
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
|
||||||
|
var existing = allResults.FirstOrDefault(item =>
|
||||||
|
item.RegistrationId == registration.Id && item.SubjectId == subjectId);
|
||||||
|
var published = JsBoolean(body["published"]);
|
||||||
|
var now = NowIso();
|
||||||
|
var candidate = new AdminWorkflowResult(
|
||||||
|
existing?.Id ?? Uid("result"),
|
||||||
|
registration.Id,
|
||||||
|
subjectId,
|
||||||
|
score,
|
||||||
|
published ? "" : "待发布",
|
||||||
|
published,
|
||||||
|
now,
|
||||||
|
published ? existing?.PublishedAt ?? now : null);
|
||||||
|
Replace(allResults, candidate);
|
||||||
|
candidate = candidate with
|
||||||
|
{
|
||||||
|
Grade = published ? AdminResultScoring.Rank(allResults, candidate).Grade : "待发布"
|
||||||
|
};
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
await repository.SaveAsync(
|
||||||
|
new ResultWrite(
|
||||||
|
candidate,
|
||||||
|
existing is null,
|
||||||
|
Audit(
|
||||||
|
user,
|
||||||
|
published ? "发布成绩" : "保存成绩",
|
||||||
|
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")),
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> SaveBulkAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item =>
|
||||||
|
item.Id == Clean(Text(body["examId"]), 64) && item.Data["archivedAt"] is null);
|
||||||
|
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == Clean(Text(body["subjectId"]), 64));
|
||||||
|
if (exam is null || subject is null) return Error(400, "请选择有效且未归档的考试科目");
|
||||||
|
var sourceRows = body["rows"] as JsonArray ?? [];
|
||||||
|
if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩");
|
||||||
|
var published = JsBoolean(body["published"]);
|
||||||
|
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
|
||||||
|
var prepared = new List<PreparedResult>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (var index = 0; index < sourceRows.Count; index++)
|
||||||
|
{
|
||||||
|
var row = sourceRows[index] as JsonObject ?? new JsonObject();
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item =>
|
||||||
|
item.Id == Text(row["registrationId"]) &&
|
||||||
|
item.ExamId == exam.Id &&
|
||||||
|
item.Status == "approved" &&
|
||||||
|
SubjectIds(item).Contains(Text(subject["id"])));
|
||||||
|
if (registration is null || !seen.Add(registration.Id))
|
||||||
|
return Error(400, $"第 {index + 1} 条考生成绩无效或重复");
|
||||||
|
var score = Number(row["score"]);
|
||||||
|
var fullScore = Number(subject["fullScore"]);
|
||||||
|
if (!double.IsFinite(score) || score < 0 || score > fullScore)
|
||||||
|
return Error(400, $"第 {index + 3} 行:成绩须在 0—{AdminResultScoring.Format(fullScore)} 之间;请返回预览修正后重试");
|
||||||
|
var existing = allResults.FirstOrDefault(item =>
|
||||||
|
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
|
||||||
|
var now = NowIso();
|
||||||
|
var result = new AdminWorkflowResult(
|
||||||
|
existing?.Id ?? Uid("result"),
|
||||||
|
registration.Id,
|
||||||
|
Text(subject["id"]),
|
||||||
|
score,
|
||||||
|
published ? "" : "待发布",
|
||||||
|
published,
|
||||||
|
now,
|
||||||
|
published ? existing?.PublishedAt ?? now : null);
|
||||||
|
Replace(allResults, result);
|
||||||
|
prepared.Add(new(registration, existing, result));
|
||||||
|
}
|
||||||
|
var writes = prepared.Select(item =>
|
||||||
|
{
|
||||||
|
var result = item.Result with
|
||||||
|
{
|
||||||
|
Grade = published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
|
||||||
|
};
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId);
|
||||||
|
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
|
||||||
|
return new ResultWrite(
|
||||||
|
result,
|
||||||
|
item.Existing is null,
|
||||||
|
Audit(
|
||||||
|
user,
|
||||||
|
published ? "Excel 批量发布成绩" : "Excel 批量保存成绩",
|
||||||
|
$"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
|
||||||
|
}).ToArray();
|
||||||
|
await repository.SaveManyAsync(writes, cancellationToken);
|
||||||
|
var create = prepared.Count(item => item.Existing is null);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["published"] = published,
|
||||||
|
["count"] = writes.Length,
|
||||||
|
["summary"] = new JsonObject
|
||||||
|
{
|
||||||
|
["total"] = writes.Length,
|
||||||
|
["valid"] = writes.Length,
|
||||||
|
["invalid"] = 0,
|
||||||
|
["create"] = create,
|
||||||
|
["update"] = writes.Length - create,
|
||||||
|
["publish"] = published ? writes.Length : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> SaveFeatureBulkAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item =>
|
||||||
|
item.Id == Clean(Text(body["examId"]), 64) && item.Data["archivedAt"] is null);
|
||||||
|
if (exam is null) return Error(400, "请选择有效且未归档的考试");
|
||||||
|
var rows = body["rows"] as JsonArray ?? [];
|
||||||
|
if (rows.Count == 0) return Error(400, "没有需要保存的特征分");
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var entries = new List<FeatureScoreWrite>();
|
||||||
|
for (var index = 0; index < rows.Count; index++)
|
||||||
|
{
|
||||||
|
var row = rows[index] as JsonObject ?? new JsonObject();
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item =>
|
||||||
|
item.Id == Text(row["registrationId"]) && item.ExamId == exam.Id && item.Status == "approved");
|
||||||
|
if (registration is null || !seen.Add(registration.Id))
|
||||||
|
return Error(400, $"第 {index + 1} 条考生记录无效或重复");
|
||||||
|
var score = Number(row["featureScore"]);
|
||||||
|
if (!double.IsFinite(score) || score < 0 || score > 1000)
|
||||||
|
return Error(400, $"第 {index + 1} 条特征分必须在 0—1000 之间");
|
||||||
|
score = Math.Round(score, 2, MidpointRounding.AwayFromZero);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
entries.Add(new(
|
||||||
|
registration.Id,
|
||||||
|
score,
|
||||||
|
Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultScoring.Format(score)}")));
|
||||||
|
}
|
||||||
|
await repository.SaveFeatureScoresAsync(entries, cancellationToken);
|
||||||
|
return Success(new JsonObject { ["ok"] = true, ["count"] = entries.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> SaveFeatureAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string registrationId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item =>
|
||||||
|
item.Id == registrationId && item.Status == "approved");
|
||||||
|
if (registration is null) return Error(404, "已通过的报名记录不存在");
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
if (exam?.Data["archivedAt"] is not null) return Error(409, "该考试已归档,特征分已永久锁定");
|
||||||
|
var score = Number(body["featureScore"]);
|
||||||
|
if (!double.IsFinite(score) || score < 0 || score > 1000)
|
||||||
|
return Error(400, "特征分必须在 0—1000 之间");
|
||||||
|
score = Math.Round(score, 2, MidpointRounding.AwayFromZero);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
await repository.SaveFeatureScoresAsync(
|
||||||
|
[new(
|
||||||
|
registration.Id,
|
||||||
|
score,
|
||||||
|
Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultScoring.Format(score)}"))],
|
||||||
|
cancellationToken);
|
||||||
|
var output = registration.Data.DeepClone().AsObject();
|
||||||
|
output["featureScore"] = score;
|
||||||
|
return Success(new JsonObject { ["ok"] = true, ["registration"] = output });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> RefreshCacheAsync(
|
||||||
|
string sessionToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["refreshed"] = false,
|
||||||
|
["cacheStatus"] = "disabled",
|
||||||
|
["message"] = "Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminDocumentResult> ExportAsync(
|
||||||
|
string sessionToken,
|
||||||
|
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 directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var registrations = operational.Registrations.Where(item =>
|
||||||
|
{
|
||||||
|
if (item.Status != "approved" || (examId.Length > 0 && item.ExamId != examId)) return false;
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
|
||||||
|
return profile is not null && InScope(user, profile);
|
||||||
|
}).ToArray();
|
||||||
|
var rows = new List<AdminResultExportRow>();
|
||||||
|
foreach (var registration in registrations)
|
||||||
|
{
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
|
||||||
|
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
|
||||||
|
foreach (var subject in (exam?.Subjects ?? []).Where(item => SubjectIds(registration).Contains(Text(item["id"]))))
|
||||||
|
{
|
||||||
|
var result = allResults.FirstOrDefault(item =>
|
||||||
|
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
|
||||||
|
var pass = result is null ? null : AdminResultScoring.Pass(allResults, result, subject);
|
||||||
|
var card = registration.Data["admitCard"] as JsonObject;
|
||||||
|
rows.Add(new(
|
||||||
|
account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]),
|
||||||
|
Text(card?["number"]),
|
||||||
|
Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
|
||||||
|
Text(profile?.Data["school"]),
|
||||||
|
schoolClass?.Name ?? Text(profile?.Data["grade"]),
|
||||||
|
Text(exam?.Data["code"]),
|
||||||
|
Text(exam?.Data["name"]),
|
||||||
|
Text(subject["name"]),
|
||||||
|
Number(subject["fullScore"]),
|
||||||
|
AdminResultScoring.PassText(subject),
|
||||||
|
pass?.PassScore is double passScore ? passScore : "",
|
||||||
|
result is null ? "" : result.Score,
|
||||||
|
pass?.Rank.Rank is int rank ? rank : "",
|
||||||
|
pass?.Rank.RankPercent is double percent ? percent : "",
|
||||||
|
result is null || pass?.Qualified is null ? "" : pass.Qualified.Value ? "达线" : "未达线",
|
||||||
|
result is null ? "" : result.Published ? pass?.Rank.Grade ?? "" : "待发布",
|
||||||
|
result?.Published is true ? "发布" : "不发布",
|
||||||
|
result?.UpdatedAt ?? result?.PublishedAt ?? ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var content = AdminResultExporter.Build(rows);
|
||||||
|
return new(
|
||||||
|
200,
|
||||||
|
content,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"考试成绩台账.xlsx",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> ReviewAppealAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string resultId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var requestedStatus = Text(body["status"]);
|
||||||
|
if (requestedStatus is not ("approved" or "rejected"))
|
||||||
|
return Error(400, "复议处理状态无效");
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
|
||||||
|
var result = allResults.FirstOrDefault(item => item.Id == resultId && item.Published);
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
|
||||||
|
if (result is null || registration is null || profile is null)
|
||||||
|
return Error(404, "待处理的成绩复议不存在");
|
||||||
|
if (!InScope(user, profile)) return Error(403, "该成绩复议不在你的数据范围内");
|
||||||
|
var instance = directory.Instances.FirstOrDefault(item =>
|
||||||
|
item.BusinessType == "score_appeal" &&
|
||||||
|
item.BusinessId == result.Id &&
|
||||||
|
item.Status == "pending");
|
||||||
|
var workflow = directory.Workflows.FirstOrDefault(item => item.Id == instance?.WorkflowId);
|
||||||
|
var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance?.CurrentStep);
|
||||||
|
if (instance is null || workflow is null || step is null)
|
||||||
|
return Error(409, "成绩复议流程状态异常");
|
||||||
|
if (Level(user) != "super" &&
|
||||||
|
(instance.AssigneeId != user.Id || step.AdminLevel != Level(user)))
|
||||||
|
return Error(403, "该流程当前未分配给你,可由当前处理人转交");
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
if (exam?.Data["archivedAt"] is not null)
|
||||||
|
return Error(409, "该考试已归档,成绩及复议流程已永久锁定");
|
||||||
|
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
|
||||||
|
var finalStep = instance.CurrentStep >= workflow.Steps.Count;
|
||||||
|
var reviewedScore = double.NaN;
|
||||||
|
if (requestedStatus == "approved" && finalStep)
|
||||||
|
{
|
||||||
|
reviewedScore = Number(body["reviewedScore"]);
|
||||||
|
var fullScore = Number(subject?["fullScore"]);
|
||||||
|
if (!double.IsFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > fullScore)
|
||||||
|
return Error(400, $"最终审批必须填写 0—{AdminResultScoring.Format(fullScore)} 之间的复核后分数");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = NowIso();
|
||||||
|
var note = Clean(Text(body["reviewNote"]), 300);
|
||||||
|
string actionName;
|
||||||
|
string? nextAssigneeId = null;
|
||||||
|
AccountWorkflowInstance updatedInstance;
|
||||||
|
AdminWorkflowResult? updatedResult = null;
|
||||||
|
if (requestedStatus == "rejected")
|
||||||
|
{
|
||||||
|
actionName = "reject";
|
||||||
|
updatedInstance = instance with { Status = "rejected", CompletedAt = now, AssigneeId = null };
|
||||||
|
}
|
||||||
|
else if (!finalStep)
|
||||||
|
{
|
||||||
|
actionName = "approve";
|
||||||
|
var nextStep = workflow.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep + 1);
|
||||||
|
var nextAssignee = nextStep is null
|
||||||
|
? null
|
||||||
|
: SelectAssignee(directory, nextStep.AdminLevel, profile);
|
||||||
|
if (nextAssignee is null)
|
||||||
|
return Error(409, $"没有可承接“{nextStep?.Name ?? "下一审批步骤"}”的管理员");
|
||||||
|
nextAssigneeId = nextAssignee.Id;
|
||||||
|
updatedInstance = instance with
|
||||||
|
{
|
||||||
|
CurrentStep = instance.CurrentStep + 1,
|
||||||
|
AssigneeId = nextAssignee.Id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
actionName = "approve";
|
||||||
|
updatedInstance = instance with { Status = "approved", CompletedAt = now, AssigneeId = null };
|
||||||
|
var originalScore = result.Score;
|
||||||
|
updatedResult = result with { Score = reviewedScore, UpdatedAt = now };
|
||||||
|
Replace(allResults, updatedResult);
|
||||||
|
updatedResult = updatedResult with
|
||||||
|
{
|
||||||
|
Grade = AdminResultScoring.Rank(allResults, updatedResult, reviewedScore).Grade
|
||||||
|
};
|
||||||
|
Replace(allResults, updatedResult);
|
||||||
|
var pass = AdminResultScoring.Pass(allResults, updatedResult, subject, reviewedScore);
|
||||||
|
var conclusion = pass.Qualified is null
|
||||||
|
? "本科不判定单科达线"
|
||||||
|
: pass.Qualified.Value
|
||||||
|
? $"达到{PassConclusion(subject, pass)}"
|
||||||
|
: $"未达到{PassConclusion(subject, pass)}";
|
||||||
|
note = string.Join(
|
||||||
|
';',
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
$"原分 {AdminResultScoring.Format(originalScore)} → 复核后 {AdminResultScoring.Format(reviewedScore)}",
|
||||||
|
conclusion,
|
||||||
|
note
|
||||||
|
}.Where(item => item.Length > 0));
|
||||||
|
}
|
||||||
|
var action = new AccountWorkflowAction(
|
||||||
|
Uid("flow_action"),
|
||||||
|
instance.Id,
|
||||||
|
user.Id,
|
||||||
|
actionName,
|
||||||
|
note,
|
||||||
|
instance.AssigneeId,
|
||||||
|
nextAssigneeId,
|
||||||
|
now);
|
||||||
|
var auditAction = requestedStatus == "rejected"
|
||||||
|
? "退回成绩复议"
|
||||||
|
: finalStep ? "复议终审并更新成绩" : "处理成绩复议";
|
||||||
|
var detail = $"{Text(profile.Data["name"])} · {Text(exam?.Data["name"])} · {Text(subject?["name"])}" +
|
||||||
|
(requestedStatus == "approved" && finalStep ? $" · {AdminResultScoring.Format(reviewedScore)} 分" : "");
|
||||||
|
await repository.ReviewAppealAsync(
|
||||||
|
updatedInstance,
|
||||||
|
action,
|
||||||
|
updatedResult,
|
||||||
|
Audit(user, auditAction, detail),
|
||||||
|
cancellationToken);
|
||||||
|
var responseResult = updatedResult ?? result;
|
||||||
|
var rank = AdminResultScoring.Rank(allResults, responseResult);
|
||||||
|
var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject);
|
||||||
|
var resultOutput = RawResultJson(responseResult);
|
||||||
|
resultOutput["rank"] = JsonValue.Create(rank.Rank);
|
||||||
|
resultOutput["cohortSize"] = rank.CohortSize;
|
||||||
|
resultOutput["rankPercent"] = JsonValue.Create(rank.RankPercent);
|
||||||
|
resultOutput["grade"] = rank.Grade;
|
||||||
|
resultOutput["passScore"] = JsonValue.Create(responsePass.PassScore);
|
||||||
|
resultOutput["cutoffRank"] = JsonValue.Create(responsePass.CutoffRank);
|
||||||
|
resultOutput["qualified"] = JsonValue.Create(responsePass.Qualified);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["workflow"] = WorkflowJson(directory, updatedInstance, action),
|
||||||
|
["result"] = resultOutput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject ResultJson(
|
||||||
|
AdminOperationalSnapshot operational,
|
||||||
|
AdminAccountBatchSnapshot directory,
|
||||||
|
OperationalExam exam,
|
||||||
|
IReadOnlyList<AdminWorkflowResult> cohortResults,
|
||||||
|
AdminWorkflowResult result)
|
||||||
|
{
|
||||||
|
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
|
||||||
|
var account = operational.Users.FirstOrDefault(item => item.Id == registration?.UserId);
|
||||||
|
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
|
||||||
|
var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
|
||||||
|
var pass = AdminResultScoring.Pass(cohortResults, result, subject);
|
||||||
|
var output = RawResultJson(result);
|
||||||
|
output["grade"] = result.Published ? pass.Rank.Grade : result.Grade;
|
||||||
|
output["rank"] = JsonValue.Create(pass.Rank.Rank);
|
||||||
|
output["cohortSize"] = pass.Rank.CohortSize;
|
||||||
|
output["rankPercent"] = JsonValue.Create(pass.Rank.RankPercent);
|
||||||
|
output["candidateName"] = Text(profile?.Data["name"]);
|
||||||
|
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]);
|
||||||
|
output["schoolName"] = Text(profile?.Data["school"]);
|
||||||
|
output["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]);
|
||||||
|
output["examId"] = exam.Id;
|
||||||
|
output["examCode"] = Text(exam.Data["code"]);
|
||||||
|
output["examName"] = Text(exam.Data["name"]);
|
||||||
|
output["subjectName"] = Text(subject?["name"]);
|
||||||
|
output["fullScore"] = subject?["fullScore"]?.DeepClone();
|
||||||
|
output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score";
|
||||||
|
output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone();
|
||||||
|
output["passScore"] = JsonValue.Create(pass.PassScore);
|
||||||
|
output["cutoffRank"] = JsonValue.Create(pass.CutoffRank);
|
||||||
|
output["passText"] = AdminResultScoring.PassText(subject);
|
||||||
|
output["qualified"] = JsonValue.Create(pass.Qualified);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject RegistrationJson(
|
||||||
|
AdminOperationalSnapshot operational,
|
||||||
|
AdminAccountBatchSnapshot directory,
|
||||||
|
OperationalRegistration registration)
|
||||||
|
{
|
||||||
|
var output = registration.Data.DeepClone().AsObject();
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
var subjects = (exam?.Subjects ?? [])
|
||||||
|
.Where(item => SubjectIds(registration).Contains(Text(item["id"])))
|
||||||
|
.Select(item => item.DeepClone())
|
||||||
|
.ToArray();
|
||||||
|
output["exam"] = exam?.Data.DeepClone();
|
||||||
|
output["subjects"] = new JsonArray(subjects);
|
||||||
|
output["amountDue"] = Math.Round(subjects.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);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
|
||||||
|
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
|
||||||
|
output["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "";
|
||||||
|
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]);
|
||||||
|
output["schoolName"] = Text(profile?.Data["school"]);
|
||||||
|
output["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]);
|
||||||
|
var category = Text(profile?.Data["specialtyCategory"]);
|
||||||
|
var type = Text(profile?.Data["specialtyType"]);
|
||||||
|
output["specialtyCategory"] = category;
|
||||||
|
output["specialtyType"] = type;
|
||||||
|
output["specialtyLabel"] = SpecialtyLabel(category, type);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResultSummary Summary(
|
||||||
|
OperationalExam exam,
|
||||||
|
IReadOnlyList<OperationalRegistration> examRegistrations,
|
||||||
|
IReadOnlyList<AdminWorkflowResult> examResults,
|
||||||
|
OperationalRegistration registration)
|
||||||
|
{
|
||||||
|
var subjects = exam.Subjects.Where(item => SubjectIds(registration).Contains(Text(item["id"]))).ToArray();
|
||||||
|
var published = examResults.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
|
||||||
|
var bySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal);
|
||||||
|
var complete = subjects.Length > 0 && subjects.All(item => bySubject.ContainsKey(Text(item["id"])));
|
||||||
|
if (!complete) return new(false, null);
|
||||||
|
var total = subjects.Sum(item => bySubject[Text(item["id"])].Score);
|
||||||
|
var policy = Text(exam.Data["passPolicy"]);
|
||||||
|
if (policy == "score_ratio") policy = "rank_percent";
|
||||||
|
if (policy.Length == 0) policy = "rank_percent";
|
||||||
|
var value = Number(exam.Data["passValue"]);
|
||||||
|
if (policy == "none") return new(true, null);
|
||||||
|
if (policy == "fixed_score") return new(true, total >= value);
|
||||||
|
if (policy == "subject_scores")
|
||||||
|
return new(true, subjects.All(item =>
|
||||||
|
AdminResultScoring.Pass(examResults, bySubject[Text(item["id"])], item).Qualified is not false));
|
||||||
|
var key = string.Join('|', SubjectIds(registration).Order(StringComparer.Ordinal));
|
||||||
|
var totals = examRegistrations.Where(item =>
|
||||||
|
string.Join('|', SubjectIds(item).Order(StringComparer.Ordinal)) == key)
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var itemResults = examResults.Where(result => result.RegistrationId == item.Id && result.Published)
|
||||||
|
.ToDictionary(result => result.SubjectId, StringComparer.Ordinal);
|
||||||
|
return subjects.All(subject => itemResults.ContainsKey(Text(subject["id"])))
|
||||||
|
? subjects.Sum(subject => itemResults[Text(subject["id"])].Score)
|
||||||
|
: (double?)null;
|
||||||
|
})
|
||||||
|
.Where(item => item.HasValue)
|
||||||
|
.Select(item => item!.Value)
|
||||||
|
.ToArray();
|
||||||
|
var rank = 1 + totals.Count(item => item > total);
|
||||||
|
var cutoff = Math.Max(1, (int)Math.Ceiling(totals.Length * value / 100));
|
||||||
|
return new(true, rank <= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject WorkflowJson(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction? extraAction = null)
|
||||||
|
{
|
||||||
|
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
|
||||||
|
var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id)
|
||||||
|
.Concat(extraAction is null ? [] : [extraAction])
|
||||||
|
.Select(item => ActionJson(snapshot, item))
|
||||||
|
.ToArray();
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = instance.Id,
|
||||||
|
["workflowId"] = instance.WorkflowId,
|
||||||
|
["businessType"] = instance.BusinessType,
|
||||||
|
["businessId"] = instance.BusinessId,
|
||||||
|
["status"] = instance.Status,
|
||||||
|
["currentStep"] = instance.CurrentStep,
|
||||||
|
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
|
||||||
|
["createdAt"] = instance.CreatedAt,
|
||||||
|
["completedAt"] = JsonValue.Create(instance.CompletedAt),
|
||||||
|
["workflowName"] = workflow?.Name ?? "未命名流程",
|
||||||
|
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
|
||||||
|
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step ? StepJson(step) : null,
|
||||||
|
["assignee"] = assignee is null ? null : SafeUser(assignee),
|
||||||
|
["actions"] = new JsonArray(actions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<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 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 HashSet<string> SubjectIds(OperationalRegistration registration) =>
|
||||||
|
registration.Data["subjectIds"] is JsonArray items
|
||||||
|
? items.Select(Text).Where(item => item.Length > 0).ToHashSet(StringComparer.Ordinal)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
private static JsonObject RawResultJson(AdminWorkflowResult result) => new()
|
||||||
|
{
|
||||||
|
["id"] = result.Id,
|
||||||
|
["registrationId"] = result.RegistrationId,
|
||||||
|
["subjectId"] = result.SubjectId,
|
||||||
|
["score"] = result.Score,
|
||||||
|
["grade"] = result.Grade,
|
||||||
|
["published"] = result.Published,
|
||||||
|
["updatedAt"] = JsonValue.Create(result.UpdatedAt),
|
||||||
|
["publishedAt"] = JsonValue.Create(result.PublishedAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
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 starts = DateTimeOffset.TryParse(
|
||||||
|
Text(exam.Data["registrationStart"]),
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal,
|
||||||
|
out var start)
|
||||||
|
? start
|
||||||
|
: DateTimeOffset.MinValue;
|
||||||
|
var ends = DateTimeOffset.TryParse(
|
||||||
|
Text(exam.Data["registrationEnd"]),
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal,
|
||||||
|
out var end)
|
||||||
|
? end
|
||||||
|
: DateTimeOffset.MinValue;
|
||||||
|
output["registrationState"] = exam.Data["archivedAt"] is not null
|
||||||
|
? "archived"
|
||||||
|
: now < starts ? "upcoming" : now > ends ? "closed" : "open";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject CacheStatus() => new()
|
||||||
|
{
|
||||||
|
["enabled"] = false,
|
||||||
|
["status"] = "disabled"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void Replace(List<AdminWorkflowResult> results, AdminWorkflowResult result)
|
||||||
|
{
|
||||||
|
var index = results.FindIndex(item => item.Id == result.Id);
|
||||||
|
if (index >= 0) results[index] = result;
|
||||||
|
else results.Add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SpecialtyLabel(string category, string type)
|
||||||
|
{
|
||||||
|
var categoryName = category switch { "sports" => "体育", "arts" => "艺术", _ => "" };
|
||||||
|
var typeName = type switch
|
||||||
|
{
|
||||||
|
"track_field" => "田径",
|
||||||
|
"basketball" => "篮球",
|
||||||
|
"football" => "足球",
|
||||||
|
"volleyball" => "排球",
|
||||||
|
"table_tennis" => "乒乓球",
|
||||||
|
"badminton" => "羽毛球",
|
||||||
|
"swimming" => "游泳",
|
||||||
|
"martial_arts" => "武术",
|
||||||
|
"vocal_music" => "声乐",
|
||||||
|
"instrumental_music" => "器乐",
|
||||||
|
"dance" => "舞蹈",
|
||||||
|
"fine_arts" => "美术",
|
||||||
|
"calligraphy" => "书法",
|
||||||
|
"broadcasting" => "播音主持",
|
||||||
|
"drama" => "戏剧表演",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
return categoryName.Length > 0 && typeName.Length > 0 ? $"{categoryName}·{typeName}" : "普通生";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminUser? SelectAssignee(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
string level,
|
||||||
|
OperationalProfile profile)
|
||||||
|
{
|
||||||
|
var eligible = snapshot.Users.Where(item =>
|
||||||
|
item.Role == "admin" &&
|
||||||
|
item.Active &&
|
||||||
|
item.AdminLevel == level &&
|
||||||
|
level switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => profile.SchoolId is not null && item.SchoolId == profile.SchoolId,
|
||||||
|
"class" => profile.SchoolId is not null && profile.ClassId is not null &&
|
||||||
|
item.SchoolId == profile.SchoolId && item.ClassId == profile.ClassId,
|
||||||
|
_ => false
|
||||||
|
});
|
||||||
|
var pending = snapshot.Instances
|
||||||
|
.Where(item => item.Status == "pending" && item.AssigneeId is not null)
|
||||||
|
.GroupBy(item => item.AssigneeId!)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
var assigned = snapshot.Actions
|
||||||
|
.Where(item => item.ToAssigneeId is not null)
|
||||||
|
.GroupBy(item => item.ToAssigneeId!)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
return eligible
|
||||||
|
.OrderBy(item => pending.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => assigned.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
|
||||||
|
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PassConclusion(JsonObject? subject, AdminPassInfo pass)
|
||||||
|
{
|
||||||
|
if (Text(subject?["passRule"]) == "rank_percent")
|
||||||
|
return $"排名前 {AdminResultScoring.Format(Number(subject?["passValue"]))}%(当前第 {pass.Rank.Rank} 名,截止第 {pass.CutoffRank} 名)";
|
||||||
|
return $"固定及格线 {AdminResultScoring.Format(pass.PassScore ?? 0)} 分";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject StepJson(AccountWorkflowStep item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["name"] = item.Name,
|
||||||
|
["adminLevel"] = item.AdminLevel,
|
||||||
|
["position"] = item.Position
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["instanceId"] = item.InstanceId,
|
||||||
|
["actorId"] = JsonValue.Create(item.ActorId),
|
||||||
|
["action"] = item.Action,
|
||||||
|
["note"] = item.Note,
|
||||||
|
["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId),
|
||||||
|
["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId),
|
||||||
|
["createdAt"] = item.CreatedAt,
|
||||||
|
["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统",
|
||||||
|
["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "",
|
||||||
|
["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SafeUser(AdminUser item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["username"] = item.Username,
|
||||||
|
["role"] = item.Role,
|
||||||
|
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
|
||||||
|
["schoolId"] = JsonValue.Create(item.SchoolId),
|
||||||
|
["classId"] = JsonValue.Create(item.ClassId),
|
||||||
|
["displayName"] = item.DisplayName,
|
||||||
|
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
|
||||||
|
["mustChangePassword"] = item.MustChangePassword,
|
||||||
|
["totpEnabled"] = item.TotpEnabled,
|
||||||
|
["archived"] = item.ArchivedAt is not null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
|
||||||
|
private static bool JsBoolean(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node is null) return false;
|
||||||
|
if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean;
|
||||||
|
if (node is JsonValue text && text.TryGetValue<string>(out var stringValue)) return stringValue.Length > 0;
|
||||||
|
if (node is JsonValue number && number.TryGetValue<double>(out var numberValue)) return numberValue != 0 && !double.IsNaN(numberValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
private static double Number(JsonNode? node) =>
|
||||||
|
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||||
|
? value
|
||||||
|
: double.NaN;
|
||||||
|
private static string Text(JsonNode? node) =>
|
||||||
|
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
|
||||||
|
private static string Clean(string value, int maximum)
|
||||||
|
{
|
||||||
|
var cleaned = value.Trim();
|
||||||
|
return cleaned[..Math.Min(cleaned.Length, maximum)];
|
||||||
|
}
|
||||||
|
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 DocumentError(AdminEndpointResult error) =>
|
||||||
|
new(error.StatusCode, null, null, null, error.Body);
|
||||||
|
|
||||||
|
private sealed record PreparedResult(
|
||||||
|
OperationalRegistration Registration,
|
||||||
|
AdminWorkflowResult? Existing,
|
||||||
|
AdminWorkflowResult Result);
|
||||||
|
private sealed record ResultSummary(bool Complete, bool? Qualified);
|
||||||
|
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
|
||||||
|
{
|
||||||
|
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ internal sealed record AdminWorkflowResult(
|
|||||||
string SubjectId,
|
string SubjectId,
|
||||||
double Score,
|
double Score,
|
||||||
string Grade,
|
string Grade,
|
||||||
bool Published);
|
bool Published,
|
||||||
|
string? UpdatedAt,
|
||||||
|
string? PublishedAt);
|
||||||
|
|
||||||
internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
{
|
{
|
||||||
@@ -18,7 +20,7 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
|
|||||||
{
|
{
|
||||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
await using var command = connection.CreateCommand();
|
await using var command = connection.CreateCommand();
|
||||||
command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published FROM results ORDER BY id";
|
command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id";
|
||||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
var output = new List<AdminWorkflowResult>();
|
var output = new List<AdminWorkflowResult>();
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
@@ -29,7 +31,9 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
|
|||||||
Text(reader, "subject_id"),
|
Text(reader, "subject_id"),
|
||||||
Number(reader, "score"),
|
Number(reader, "score"),
|
||||||
Text(reader, "grade"),
|
Text(reader, "grade"),
|
||||||
Boolean(reader, "published")));
|
Boolean(reader, "published"),
|
||||||
|
Optional(reader, "updated_at"),
|
||||||
|
Optional(reader, "published_at")));
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -42,4 +46,12 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
|
|||||||
|
|
||||||
private static bool Boolean(DbDataReader reader, string name) =>
|
private static bool Boolean(DbDataReader reader, string name) =>
|
||||||
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<AdminExamRepository>();
|
services.AddScoped<AdminExamRepository>();
|
||||||
services.AddScoped<AdminArrangementSnapshotLoader>();
|
services.AddScoped<AdminArrangementSnapshotLoader>();
|
||||||
services.AddScoped<AdminArrangementRepository>();
|
services.AddScoped<AdminArrangementRepository>();
|
||||||
|
services.AddScoped<AdminResultRepository>();
|
||||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||||
@@ -78,6 +79,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
|
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
|
||||||
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
|
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
|
||||||
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
|
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
|
||||||
|
services.AddScoped<IAdminResultService, AdminResultService>();
|
||||||
services.AddSingleton<NoticeContentFormatter>();
|
services.AddSingleton<NoticeContentFormatter>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -164,6 +164,44 @@ public static class NativeAdminReadEndpoints
|
|||||||
(HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) =>
|
(HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) =>
|
||||||
Execute(context, service.RetiredSingleAsync(Token(context), cancellationToken)));
|
Execute(context, service.RetiredSingleAsync(Token(context), cancellationToken)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.NativeResultsEnabled)
|
||||||
|
{
|
||||||
|
endpoints.MapGet("/api/admin/results",
|
||||||
|
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(
|
||||||
|
context,
|
||||||
|
service.GetAsync(
|
||||||
|
Token(context),
|
||||||
|
context.Request.Query["examId"].ToString(),
|
||||||
|
cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/admin/results",
|
||||||
|
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SaveAsync(Token(context), body, cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/admin/results/bulk",
|
||||||
|
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SaveBulkAsync(Token(context), body, cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/admin/feature-scores/bulk",
|
||||||
|
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SaveFeatureBulkAsync(Token(context), body, cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/registrations/{registrationId}/feature-score",
|
||||||
|
(HttpContext context, string registrationId, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SaveFeatureAsync(Token(context), registrationId, body, cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/score-appeals/{resultId}",
|
||||||
|
(HttpContext context, string resultId, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.ReviewAppealAsync(Token(context), resultId, body, cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/admin/results/cache/refresh",
|
||||||
|
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.RefreshCacheAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/excel/results",
|
||||||
|
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteDocument(
|
||||||
|
context,
|
||||||
|
service.ExportAsync(
|
||||||
|
Token(context),
|
||||||
|
context.Request.Query["examId"].ToString(),
|
||||||
|
cancellationToken)));
|
||||||
|
}
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -56,7 +56,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
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.Configuration.GetValue<bool>("AdminMigration:NativeArrangementsEnabled"),
|
||||||
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeResultsEnabled"));
|
||||||
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()),
|
||||||
@@ -122,6 +123,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
|
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
|
||||||
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
|
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
|
||||||
nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled,
|
nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled,
|
||||||
|
nativeResultsEnabled = adminMigrationOptions.NativeResultsEnabled,
|
||||||
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" }
|
||||||
: [])
|
: [])
|
||||||
@@ -166,6 +168,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
"GET admission exports"
|
"GET admission exports"
|
||||||
}
|
}
|
||||||
: [])
|
: [])
|
||||||
|
.Concat(adminMigrationOptions.NativeResultsEnabled
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
"GET/POST results", "POST result bulk", "POST/PATCH feature scores",
|
||||||
|
"PATCH score appeals", "GET result Excel export"
|
||||||
|
}
|
||||||
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"NativeRegistrationPaymentWritesEnabled": false,
|
"NativeRegistrationPaymentWritesEnabled": false,
|
||||||
"NativeWorkflowOperationsEnabled": false,
|
"NativeWorkflowOperationsEnabled": false,
|
||||||
"NativeExamManagementEnabled": false,
|
"NativeExamManagementEnabled": false,
|
||||||
"NativeArrangementsEnabled": false
|
"NativeArrangementsEnabled": false,
|
||||||
|
"NativeResultsEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -286,6 +286,36 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}, arrangementsEnabled: "true");
|
}, arrangementsEnabled: "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResultsRequireOperationalReads()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true));
|
||||||
|
|
||||||
|
Assert.Contains("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true", exception.Message);
|
||||||
|
}, resultsEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnablesResultsWithOperationalReadsAndSharedSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeOperationalReadsEnabled);
|
||||||
|
Assert.True(options.NativeResultsEnabled);
|
||||||
|
}, operationalReadsEnabled: "true", resultsEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
private static void WithEnvironment(
|
private static void WithEnvironment(
|
||||||
string? nativeReadsEnabled,
|
string? nativeReadsEnabled,
|
||||||
string? nativeOrganizationWritesEnabled,
|
string? nativeOrganizationWritesEnabled,
|
||||||
@@ -301,7 +331,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
string? registrationPaymentWritesEnabled = null,
|
string? registrationPaymentWritesEnabled = null,
|
||||||
string? workflowOperationsEnabled = null,
|
string? workflowOperationsEnabled = null,
|
||||||
string? examManagementEnabled = null,
|
string? examManagementEnabled = null,
|
||||||
string? arrangementsEnabled = null)
|
string? arrangementsEnabled = null,
|
||||||
|
string? resultsEnabled = 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");
|
||||||
@@ -316,6 +347,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
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 previousArrangementsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED");
|
||||||
|
var previousResultsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED");
|
||||||
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -332,6 +364,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
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_ARRANGEMENTS_ENABLED", arrangementsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED", resultsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -350,6 +383,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
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_ARRANGEMENTS_ENABLED", previousArrangementsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED", previousResultsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user