From d47c614b53d5545dd7e0e264147d3a73e46ff2f2 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 12:16:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=20examId=20=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=88=90=E7=BB=A9=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E9=80=89=E6=8B=A9=E8=80=83=E8=AF=95=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E6=89=A7=E8=A1=8C=E6=8E=92=E5=90=8D=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=20=E5=8D=95=E6=9D=A1=E5=8F=8A=E6=89=B9=E9=87=8F=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E8=8D=89=E7=A8=BF=E3=80=81=E5=8F=91=E5=B8=83=20?= =?UTF-8?q?=E7=89=B9=E9=95=BF=E5=88=86=E5=8D=95=E6=9D=A1=E5=8F=8A=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E7=BB=B4=E6=8A=A4=20=E6=88=90=E7=BB=A9=E5=A4=8D?= =?UTF-8?q?=E8=AE=AE=E5=A4=9A=E7=BA=A7=E5=AE=A1=E6=89=B9=E3=80=81=E7=BB=88?= =?UTF-8?q?=E5=AE=A1=E6=94=B9=E5=88=86=E3=80=81=E5=BD=92=E6=A1=A3=E9=94=81?= =?UTF-8?q?=E5=AE=9A=20=E6=8E=92=E5=90=8D=E3=80=81=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E3=80=81=E5=8D=95=E7=A7=91=E8=BE=BE=E7=BA=BF=E5=92=8C=E8=80=83?= =?UTF-8?q?=E8=AF=95=E6=B1=87=E6=80=BB=20=E6=88=90=E7=BB=A9=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=20Excel=20=E5=AF=BC=E5=87=BA=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E3=80=81=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=86=99=E6=9D=83=E9=99=90=E5=8F=8A=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=20=E7=8B=AC=E7=AB=8B=E5=BC=80=E5=85=B3=20ADM?= =?UTF-8?q?IN=5FNATIVE=5FRESULTS=5FENABLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + MIGRATION.md | 7 +- scripts/smoke-dotnet-migration.ps1 | 120 +++ .../Administration/IAdminResultService.cs | 15 + .../Administration/AdminMigrationOptions.cs | 17 +- .../Administration/AdminResultExporter.cs | 80 ++ .../Administration/AdminResultRepository.cs | 197 ++++ .../Administration/AdminResultScoring.cs | 86 ++ .../Administration/AdminResultService.cs | 906 ++++++++++++++++++ .../AdminWorkflowResultSnapshot.cs | 18 +- src/Eis.Infrastructure/DependencyInjection.cs | 2 + .../NativeAdminReadEndpoints.cs | 38 + src/Eis.Web/Program.cs | 11 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 36 +- 15 files changed, 1523 insertions(+), 15 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminResultService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultExporter.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultRepository.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultScoring.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminResultService.cs diff --git a/.env.example b/.env.example index 7a0708f..d08e100 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,8 @@ ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false # 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。 ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false +# 成绩工作区、成绩录入发布、特征分、复议及成绩台账导出;必须同时启用管理端基础读取和业务读取。 +ADMIN_NATIVE_RESULTS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 9a2b2f7..d362031 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入、工作流调度、考试维护及准考证编排导出已原生化) +- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入、工作流调度、考试维护、准考证编排导出及成绩管理已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。第十三批覆盖按考试延迟加载的成绩工作区、单条及批量成绩草稿/发布、特征分维护、成绩复议多级流转和成绩台账 Excel 导出;排名、单科达线、归档锁定、数据范围和写入审计均由 ASP.NET Core 原生处理。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -72,9 +72,10 @@ $env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' $env:ADMIN_NATIVE_EXAM_MANAGEMENT_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 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 6b7b800..80bade7 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -484,6 +484,7 @@ try { ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true' + ADMIN_NATIVE_RESULTS_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -576,6 +577,9 @@ try { $legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession $nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession 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] 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 @@ -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') { 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 @( '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' } + $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 = @( @{ Level = 'super'; Legacy = $session; Native = $nativeSession }, @{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession }, @@ -1712,6 +1831,7 @@ try { NativeAdminWorkflowOperations = 'passed' NativeAdminExamManagement = 'passed' NativeAdminArrangements = 'passed' + NativeAdminResults = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminResultService.cs b/src/Eis.Application/Administration/IAdminResultService.cs new file mode 100644 index 0000000..555c343 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminResultService.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminResultService +{ + Task GetAsync(string sessionToken, string examId, CancellationToken cancellationToken); + Task SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task SaveFeatureBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken); + Task ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken); + Task RefreshCacheAsync(string sessionToken, CancellationToken cancellationToken); + Task ExportAsync(string sessionToken, string examId, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 3127a7b..cd2c2d4 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -12,7 +12,8 @@ public sealed record AdminMigrationOptions( bool NativeRegistrationPaymentWritesEnabled = false, bool NativeWorkflowOperationsEnabled = false, bool NativeExamManagementEnabled = false, - bool NativeArrangementsEnabled = false) + bool NativeArrangementsEnabled = false, + bool NativeResultsEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -28,7 +29,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeRegistrationPaymentWritesEnabled = false, bool configuredNativeWorkflowOperationsEnabled = false, bool configuredNativeExamManagementEnabled = false, - bool configuredNativeArrangementsEnabled = false) + bool configuredNativeArrangementsEnabled = false, + bool configuredNativeResultsEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -67,17 +69,20 @@ public sealed record AdminMigrationOptions( var arrangementsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"), 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( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - if ((candidateManagementEnabled || registrationPaymentWritesEnabled) && !operationalReadsEnabled) + if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled) && !operationalReadsEnabled) { throw new InvalidOperationException( "启用原生考生、报名或缴费写接口前必须同时设置 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) { throw new InvalidOperationException( @@ -93,7 +98,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 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 diff --git a/src/Eis.Infrastructure/Administration/AdminResultExporter.cs b/src/Eis.Infrastructure/Administration/AdminResultExporter.cs new file mode 100644 index 0000000..0e5a18f --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultExporter.cs @@ -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 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(); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminResultRepository.cs b/src/Eis.Infrastructure/Administration/AdminResultRepository.cs new file mode 100644 index 0000000..17d5845 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultRepository.cs @@ -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 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 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 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 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); +} diff --git a/src/Eis.Infrastructure/Administration/AdminResultScoring.cs b/src/Eis.Infrastructure/Administration/AdminResultScoring.cs new file mode 100644 index 0000000..50f43e8 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultScoring.cs @@ -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 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 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(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"; + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminResultService.cs b/src/Eis.Infrastructure/Administration/AdminResultService.cs new file mode 100644 index 0000000..28d079f --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminResultService.cs @@ -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 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 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 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(); + var seen = new HashSet(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 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(StringComparer.Ordinal); + var entries = new List(); + 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 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 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 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(); + 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 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 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 examRegistrations, + IReadOnlyList 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 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 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 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(out var boolean)) return boolean; + if (node is JsonValue text && text.TryGetValue(out var stringValue)) return stringValue.Length > 0; + if (node is JsonValue number && number.TryGetValue(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(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 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); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs index f1c100a..6ab8590 100644 --- a/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs @@ -10,7 +10,9 @@ internal sealed record AdminWorkflowResult( string SubjectId, double Score, string Grade, - bool Published); + bool Published, + string? UpdatedAt, + string? PublishedAt); 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 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); var output = new List(); while (await reader.ReadAsync(cancellationToken)) @@ -29,7 +31,9 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac Text(reader, "subject_id"), Number(reader, "score"), Text(reader, "grade"), - Boolean(reader, "published"))); + Boolean(reader, "published"), + Optional(reader, "updated_at"), + Optional(reader, "published_at"))); } return output; } @@ -42,4 +46,12 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac private static bool Boolean(DbDataReader reader, string name) => 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); + } } diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 020062f..f8ee2d7 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -66,6 +66,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -78,6 +79,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 6bc4316..3465ccc 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -164,6 +164,44 @@ public static class NativeAdminReadEndpoints (HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken 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; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 3b7137b..90b96ff 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -56,7 +56,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled"), builder.Configuration.GetValue("AdminMigration:NativeExamManagementEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeArrangementsEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeArrangementsEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeResultsEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -122,6 +123,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled, nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled, + nativeResultsEnabled = adminMigrationOptions.NativeResultsEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? 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" } : []) + .Concat(adminMigrationOptions.NativeResultsEnabled + ? new[] + { + "GET/POST results", "POST result bulk", "POST/PATCH feature scores", + "PATCH score appeals", "GET result Excel export" + } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index eabb79b..2e98512 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -21,7 +21,8 @@ "NativeRegistrationPaymentWritesEnabled": false, "NativeWorkflowOperationsEnabled": false, "NativeExamManagementEnabled": false, - "NativeArrangementsEnabled": false + "NativeArrangementsEnabled": false, + "NativeResultsEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 700b96f..33b99cf 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -286,6 +286,36 @@ public sealed class AdminMigrationOptionsTests }, arrangementsEnabled: "true"); } + [Fact] + public void ResultsRequireOperationalReads() + { + WithEnvironment("true", null, null, () => + { + var exception = Assert.Throws(() => + 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( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -301,7 +331,8 @@ public sealed class AdminMigrationOptionsTests string? registrationPaymentWritesEnabled = null, string? workflowOperationsEnabled = null, string? examManagementEnabled = null, - string? arrangementsEnabled = null) + string? arrangementsEnabled = null, + string? resultsEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_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 previousExamManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_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"); try { @@ -332,6 +364,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", examManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", arrangementsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED", resultsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -350,6 +383,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED", previousExamManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED", previousArrangementsEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED", previousResultsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }