diff --git a/.env.example b/.env.example index d08e100..211fc27 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,8 @@ ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false # 成绩工作区、成绩录入发布、特征分、复议及成绩台账导出;必须同时启用管理端基础读取和业务读取。 ADMIN_NATIVE_RESULTS_ENABLED=false +# 指标资格、招生账户、志愿设置、计划审核、投档录取、报到补录与退档监督。 +ADMIN_NATIVE_ADMISSIONS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index d362031..e477d57 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入、工作流调度、考试维护、准考证编排导出及成绩管理已原生化) +- [x] 管理后台、审批流和考务编排 - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,9 +54,9 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。第十一批覆盖超级管理员创建和修改考试、配置科目与合格线、切换发布状态及不可逆归档;考试与科目替换、归档锁定和审计日志在同一事务中提交,并在已有报名或待处理成绩复议时保留原系统的安全拦截。第十二批覆盖准考证全场预检与正式编排:支持五级混编范围、稳定种子、固定考点、逐科考场座位及四种号码规则;重新编排会在同一事务中替换整场计划、准考证、科目座位和审计日志。超级、校级、班级管理员仍按原范围读取和批量打印准考证,校级管理员可直接从 .NET 导出桌贴、门贴和考场签名单 Excel。第十三批覆盖按考试延迟加载的成绩工作区、单条及批量成绩草稿/发布、特征分维护、成绩复议多级流转和成绩台账 Excel 导出;排名、单科达线、归档锁定、数据范围和写入审计均由 ASP.NET Core 原生处理。 +管理后台第一至十三批已经覆盖基础读取、组织账户、批量建号、流程定义与收件箱、通知公示、考点审批、报名缴费、考试维护、准考证编排和成绩管理。第十四批补齐指标资格确认、招生学校账号、志愿设置、招生计划审核、分数优先投档、逐轮录取公示、报到补录审批、退档监督和成绩预览提交;管理后台、审批流及考务编排的业务 API 至此全部由 ASP.NET Core 原生处理。Excel 文件解析、文书生成和缓存替换仍作为独立迁移阶段。 -其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: +管理端各功能域均可独立切换;在兼容运行期仍要求原生认证和共享 Redis: ```powershell $env:AUTH_NATIVE_ENABLED = 'true' @@ -73,9 +73,10 @@ $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' +$env:ADMIN_NATIVE_ADMISSIONS_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的各 `administration.native*Enabled` 字段和 `administration.nativeRoutes` 会报告这些端点是否已切换,其中成绩域对应 `administration.nativeResultsEnabled`。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的各 `administration.native*Enabled` 字段和 `administration.nativeRoutes` 会报告这些端点是否已切换,其中成绩与招生管理域分别对应 `administration.nativeResultsEnabled` 和 `administration.nativeAdmissionsEnabled`。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 80bade7..70af77c 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -485,6 +485,7 @@ try { ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true' ADMIN_NATIVE_RESULTS_ENABLED = 'true' + ADMIN_NATIVE_ADMISSIONS_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -851,6 +852,54 @@ try { throw 'Native admins route did not preserve the class-admin boundary' } + $legacyAdmissions = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/admissions" -WebSession $session + $nativeAdmissions = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admissions" -WebSession $nativeSession + if ($nativeAdmissions.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native admission management did not use ASP.NET Core' + } + Assert-JsonEquivalent -Expected $legacyAdmissions.Content -Actual $nativeAdmissions.Content -Label 'Admission management workspace' + $schoolAdmissionsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admissions" -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolAdmissionsForbidden.StatusCode -ne 403) { + throw 'Native admission management did not preserve the super-admin boundary' + } + $legacyQualifications = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/indicator-qualifications" -WebSession $legacySchoolSession + $nativeQualifications = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/indicator-qualifications" -WebSession $nativeSchoolSession + if ($nativeQualifications.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native indicator-qualification workspace did not use ASP.NET Core' + } + Assert-JsonEquivalent -Expected $legacyQualifications.Content -Actual $nativeQualifications.Content -Label 'Indicator qualification workspace' + $invalidAdmissionSetting = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admissions/missing-exam/setting" -Method Put -ContentType 'application/json' -Body '{}' -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidAdmissionSetting.StatusCode -ne 404 -or $invalidAdmissionSetting.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native admission setting did not reject a missing exam' + } + $nativeAdmissionState = $nativeAdmissions.Content | ConvertFrom-Json + $admissionExam = @($nativeAdmissionState.exams | Select-Object -First 1)[0] + $admissionSchool = @($nativeAdmissionState.admissionSchools | Select-Object -First 1)[0] + if ($null -ne $admissionExam -and $null -ne $admissionSchool) { + $invalidAdmissionPlanBody = @{ + examId = $admissionExam.id + schoolId = $admissionSchool.id + categories = @( + @{ + code = 'invalid-specialty' + name = '无效特长类别' + quota = 1 + specialtyCategory = 'sports' + specialtyType = 'vocal_music' + indicatorAllocations = @() + } + ) + } | ConvertTo-Json -Depth 6 -Compress + $invalidAdmissionPlan = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admission-plans" -Method Post -ContentType 'application/json' -Body $invalidAdmissionPlanBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidAdmissionPlan.StatusCode -ne 400 -or $invalidAdmissionPlan.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native admission plan accepted a mismatched specialty category and type' + } + } + $unsafeAdminDelete = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins/unsafe-delete" -Method Delete -WebSession $nativeSession -SkipHttpErrorCheck + if ($unsafeAdminDelete.StatusCode -ne 405 -or $unsafeAdminDelete.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native admin management did not preserve the no-delete safety boundary' + } + $arrangementSessions = @( @{ Level = 'super'; Legacy = $session; Native = $nativeSession }, @{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession }, @@ -973,6 +1022,21 @@ try { if ($invalidResult.StatusCode -ne 400) { throw 'Native result write accepted a score above the subject full score' } + $invalidResultImportBody = @{ + rows = @( + @{ + candidateNumber = '' + examCode = '' + subjectName = '' + score = 0 + published = $false + } + ) + } | ConvertTo-Json -Depth 4 -Compress + $invalidResultImport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/results/import" -Method Post -ContentType 'application/json' -Body $invalidResultImportBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidResultImport.StatusCode -ne 400 -or $invalidResultImport.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native result import accepted an invalid preview row' + } $score = [Math]::Min([double] $resultSubject.fullScore, 88.5) $bulkDraftBody = @{ examId = $arrangementExam.id @@ -1832,6 +1896,7 @@ try { NativeAdminExamManagement = 'passed' NativeAdminArrangements = 'passed' NativeAdminResults = 'passed' + NativeAdminAdmissions = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminAdmissionService.cs b/src/Eis.Application/Administration/IAdminAdmissionService.cs new file mode 100644 index 0000000..13bbfb4 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminAdmissionService.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminAdmissionService +{ + Task GetAsync(string sessionToken, CancellationToken cancellationToken); + Task GetQualificationsAsync(string sessionToken, CancellationToken cancellationToken); + Task SaveQualificationAsync(string sessionToken, string examId, string userId, JsonObject body, CancellationToken cancellationToken); + Task SaveQualificationsAsync(string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken); + Task CreateAccountAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task UpdateAccountAsync(string sessionToken, string accountId, JsonObject body, CancellationToken cancellationToken); + Task ResetAccountPasswordAsync(string sessionToken, string accountId, CancellationToken cancellationToken); + Task SaveSettingAsync(string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken); + Task SavePlanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task ReviewPlanAsync(string sessionToken, string planId, JsonObject body, CancellationToken cancellationToken); + Task RunActionAsync(string sessionToken, string examId, string action, JsonObject body, CancellationToken cancellationToken); + Task ReviewReportingAsync(string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken); + Task ReviewWithdrawalAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Application/Administration/IAdminResultService.cs b/src/Eis.Application/Administration/IAdminResultService.cs index 555c343..462b8e0 100644 --- a/src/Eis.Application/Administration/IAdminResultService.cs +++ b/src/Eis.Application/Administration/IAdminResultService.cs @@ -7,6 +7,7 @@ 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 ImportAsync(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); diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs new file mode 100644 index 0000000..bfe1db4 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs @@ -0,0 +1,133 @@ +using System.Data.Common; +using Eis.Infrastructure.Candidate; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminAdmissionRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task SaveRecordsAsync( + IReadOnlyList records, + AdminAuditEntry audit, + CancellationToken cancellationToken) => + TransactionAsync(async (connection, transaction) => + { + foreach (var record in records) + { + await ExecuteAsync(connection, transaction, "DELETE FROM admission_records WHERE id = @id", + [new("@id", record.Id)], cancellationToken); + await ExecuteAsync(connection, transaction, + """ + INSERT INTO admission_records ( + id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at + ) VALUES ( + @id, @kind, @examId, @userId, @schoolId, @status, @payload, @createdAt, @updatedAt + ) + """, + [ + new("@id", record.Id), new("@kind", record.Kind), new("@examId", record.ExamId), + new("@userId", record.UserId), new("@schoolId", record.SchoolId), + new("@status", record.Status), new("@payload", record.Payload.ToJsonString()), + new("@createdAt", record.CreatedAt), new("@updatedAt", record.UpdatedAt) + ], cancellationToken); + } + await InsertAuditAsync(connection, transaction, audit, cancellationToken); + }, cancellationToken); + + public Task CreateAccountAsync( + AdminUser account, + string passwordHash, + AdminAuditEntry audit, + CancellationToken cancellationToken) => + TransactionAsync(async (connection, transaction) => + { + await ExecuteAsync(connection, transaction, + """ + INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, + display_name, active, must_change_password, created_at + ) VALUES ( + @id, @username, @passwordHash, 'admission_school', NULL, @schoolId, NULL, + @displayName, 1, 0, @createdAt + ) + """, + [ + new("@id", account.Id), new("@username", account.Username), new("@passwordHash", passwordHash), + new("@schoolId", account.SchoolId), new("@displayName", account.DisplayName), + new("@createdAt", account.CreatedAt) + ], cancellationToken); + await InsertAuditAsync(connection, transaction, audit, cancellationToken); + }, cancellationToken); + + public Task UpdateAccountAsync( + string accountId, + string displayName, + bool active, + string? passwordHash, + AdminAuditEntry audit, + CancellationToken cancellationToken) => + TransactionAsync(async (connection, transaction) => + { + await ExecuteAsync(connection, transaction, + passwordHash is null + ? "UPDATE users SET display_name = @displayName, active = @active WHERE id = @id" + : "UPDATE users SET display_name = @displayName, active = @active, password_hash = @passwordHash WHERE id = @id", + [ + new("@displayName", displayName), new("@active", active ? 1 : 0), + new("@passwordHash", passwordHash), new("@id", accountId) + ], 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 values, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + foreach (var value in values) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private sealed record SqlValue(string Name, object? Value); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs new file mode 100644 index 0000000..4ff2032 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs @@ -0,0 +1,1369 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Candidate; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminAdmissionService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + PasswordCompatibilityService passwords, + CandidateAdmissionRepository admissionRepository, + AdminOperationalSnapshotLoader operationalLoader, + AdminAccountBatchSnapshotLoader directoryLoader, + AdminWorkflowResultSnapshotLoader resultLoader, + AdminAdmissionRepository repository) : IAdminAdmissionService +{ + private static readonly HashSet Phases = + ["draft", "filling", "closed", "matching", "school_review", "reporting", "supplementary", "completed"]; + private static readonly IReadOnlyDictionary SpecialtyTypes = + new Dictionary(StringComparer.Ordinal) + { + ["sports"] = + [ + "track_field", "basketball", "football", "volleyball", "table_tennis", "badminton", + "swimming", "martial_arts", "aerobics_cheer" + ], + ["arts"] = + [ + "vocal_music", "instrumental_music", "dance", "fine_arts", "calligraphy", + "drama_broadcasting" + ] + }; + private static readonly IReadOnlyDictionary LegacySpecialties = + new Dictionary(StringComparer.Ordinal) + { + ["田径"] = ("sports", "track_field"), + ["篮球"] = ("sports", "basketball"), + ["足球"] = ("sports", "football"), + ["排球"] = ("sports", "volleyball"), + ["乒乓球"] = ("sports", "table_tennis"), + ["羽毛球"] = ("sports", "badminton"), + ["游泳"] = ("sports", "swimming"), + ["武术"] = ("sports", "martial_arts"), + ["健美操与啦啦操"] = ("sports", "aerobics_cheer"), + ["声乐"] = ("arts", "vocal_music"), + ["器乐"] = ("arts", "instrumental_music"), + ["舞蹈"] = ("arts", "dance"), + ["美术"] = ("arts", "fine_arts"), + ["书法"] = ("arts", "calligraphy"), + ["戏剧与播音"] = ("arts", "drama_broadcasting") + }; + + public async Task GetAsync(string sessionToken, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以查看志愿与录取数据", cancellationToken); + if (context.Error is not null) return context.Error; + var data = await LoadAsync(cancellationToken); + var settings = Records(data, "setting").Select(item => + { + var output = RecordJson(item); + output["exam"] = data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data.DeepClone(); + return output; + }).ToArray(); + var plans = Records(data, "plan").Select(item => + { + var output = RecordJson(item); + output["schoolName"] = School(data, item.SchoolId)?.Name ?? ""; + output["examName"] = data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]?.GetValue() ?? ""; + output["remainingCategories"] = RemainingPlanQuota(data, item); + output["progress"] = PlanProgress(data, item); + return output; + }).ToArray(); + var preferences = Records(data, "preference").Select(item => PreferenceJson(data, item)).ToArray(); + var placements = Records(data, "placement").Select(item => PlacementJson(data, item)).ToArray(); + var reporting = Records(data, "notification") + .Where(item => item.UserId is null && Text(item.Payload["type"]) == "admission_reporting") + .Select(item => + { + var output = RecordJson(item); + output["schoolName"] = School(data, item.SchoolId)?.Name ?? ""; + output["examName"] = data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]?.GetValue() ?? ""; + var plan = Records(data, "plan", item.ExamId).FirstOrDefault(plan => plan.SchoolId == item.SchoolId) + ?? EmptyPlan(item.ExamId, item.SchoolId); + output["progress"] = PlanProgress(data, plan); + return output; + }).ToArray(); + var accounts = data.Directory.Users.Where(item => item.Role == "admission_school").Select(item => + { + var output = SafeUser(item); + output["active"] = item.Active; + output["createdAt"] = item.CreatedAt; + output["schoolName"] = School(data, item.SchoolId)?.Name ?? ""; + output["schoolCode"] = School(data, item.SchoolId)?.Code ?? ""; + return output; + }).ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["settings"] = new JsonArray(settings), + ["plans"] = new JsonArray(plans), + ["preferences"] = new JsonArray(preferences), + ["preferenceRows"] = new JsonArray(PreferenceSnapshotRows(data)), + ["placements"] = new JsonArray(placements), + ["reportingRequests"] = new JsonArray(reporting), + ["schoolAccounts"] = new JsonArray(accounts), + ["schools"] = new JsonArray(data.Directory.Schools.Where(item => item.Active).Select(SchoolJson).ToArray()), + ["admissionSchools"] = new JsonArray(data.Directory.Schools.Where(item => item.Active && item.IsAdmissionSchool).Select(SchoolJson).ToArray()), + ["sourceSchools"] = new JsonArray(data.Directory.Schools.Where(item => item.Active && item.IsSourceSchool).Select(SchoolJson).ToArray()), + ["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null).Select(item => item.Data.DeepClone()).ToArray()) + }); + } + + public async Task GetQualificationsAsync(string sessionToken, CancellationToken cancellationToken) + { + var context = await ResolveSchoolAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var data = await LoadAsync(cancellationToken); + var school = School(data, context.User!.SchoolId); + if (school is not { Active: true, IsSourceSchool: true }) + return Error(403, "当前学校未设置为生源学校"); + var exams = Records(data, "setting").Where(item => Boolean(item.Payload["enabled"])).Select(setting => + { + var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == setting.ExamId); + if (exam is null) return null; + var output = RecordJson(setting); + output["exam"] = PublicExam(exam); + output["qualificationStatus"] = QualificationStatus(data, setting.ExamId, school.Id); + return output; + }).Where(item => item is not null).Cast().ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["school"] = SchoolJson(school), + ["exams"] = new JsonArray(exams) + }); + } + + public Task SaveQualificationAsync( + string sessionToken, string examId, string userId, JsonObject body, CancellationToken cancellationToken) => + SaveQualificationsCoreAsync(sessionToken, examId, [userId], body, false, cancellationToken); + + public async Task SaveQualificationsAsync( + string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken) + { + var userIds = (body["userIds"] as JsonArray ?? []) + .Select(Text).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray(); + if (userIds.Length == 0) return Error(400, "请至少选择一名考生"); + return await SaveQualificationsCoreAsync(sessionToken, examId, userIds, body, true, cancellationToken); + } + + public async Task CreateAccountAsync( + string sessionToken, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以创建招生学校账号", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var data = await LoadAsync(cancellationToken); + var school = data.Directory.Schools.FirstOrDefault(item => + item.Id == Clean(Text(body["schoolId"]), 64) && item.Active && item.IsAdmissionSchool); + var username = Clean(Text(body["username"]), 80); + var password = Text(body["password"]); + if (school is null || username.Length == 0 || password.Length < 8) + return Error(400, "请选择学校,并填写登录账号和至少 8 位密码"); + if (data.Directory.Users.Any(item => item.Username.Equals(username, StringComparison.OrdinalIgnoreCase))) + return Error(409, "登录账号已存在"); + var now = NowIso(); + var account = new AdminUser( + Uid("usr"), username, "admission_school", null, school.Id, null, + Clean(Text(body["displayName"]), 80) is { Length: > 0 } name ? name : $"{school.Name}招生办", + null, true, false, false, null, now); + await repository.CreateAccountAsync( + account, passwords.Hash(password), + Audit(user, "创建招生学校账号", $"{school.Name} · {username}"), cancellationToken); + return Created(new JsonObject { ["ok"] = true, ["account"] = SafeUser(account) }); + } + + public async Task UpdateAccountAsync( + string sessionToken, string accountId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以维护招生学校账号", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var directory = await directoryLoader.LoadAsync(cancellationToken); + var target = directory.Users.FirstOrDefault(item => item.Id == accountId && item.Role == "admission_school"); + if (target is null) return Error(404, "招生学校账号不存在"); + var active = body.ContainsKey("active") ? Boolean(body["active"]) : target.Active; + var displayName = Clean(Text(body["displayName"]), 80); + if (displayName.Length == 0) displayName = target.DisplayName; + await repository.UpdateAccountAsync( + target.Id, displayName, active, null, + Audit(user, active ? "启用招生学校账号" : "停用招生学校账号", $"{displayName} · {target.Username}"), + cancellationToken); + if (!active) await authenticationState.DeleteUserSessionsAsync(target.Id); + return Success(new JsonObject + { + ["ok"] = true, + ["account"] = SafeUser(target with { Active = active, DisplayName = displayName }) + }); + } + + public async Task ResetAccountPasswordAsync( + string sessionToken, string accountId, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以重置招生学校账号密码", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var directory = await directoryLoader.LoadAsync(cancellationToken); + var target = directory.Users.FirstOrDefault(item => item.Id == accountId && item.Role == "admission_school"); + if (target is null) return Error(404, "招生学校账号不存在"); + var temporaryPassword = $"Reset-{Base64Url(RandomNumberGenerator.GetBytes(7))}"; + await repository.UpdateAccountAsync( + target.Id, target.DisplayName, true, passwords.Hash(temporaryPassword), + Audit(user, "重置招生学校账号密码", $"{target.DisplayName} · {target.Username}"), + cancellationToken); + await authenticationState.DeleteUserSessionsAsync(target.Id); + return Success(new JsonObject + { + ["ok"] = true, + ["username"] = target.Username, + ["temporaryPassword"] = temporaryPassword + }); + } + + public async Task SaveSettingAsync( + string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以设置志愿填报", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var data = await LoadAsync(cancellationToken); + var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == examId && item.Data["archivedAt"] is null); + if (exam is null) return Error(404, "考试不存在或已经归档"); + var now = NowIso(); + var existing = Records(data, "setting", examId).FirstOrDefault(); + var requested = Text(body["status"]); + var manual = new HashSet(["draft", "filling", "closed"], StringComparer.Ordinal); + var status = existing is not null && !manual.Contains(existing.Status) + ? existing.Status + : manual.Contains(requested) ? requested : existing?.Status ?? "draft"; + var payload = existing?.Payload.DeepClone().AsObject() ?? new JsonObject(); + payload["enabled"] = body["enabled"] is JsonValue enabled && enabled.TryGetValue(out var enabledValue) && enabledValue; + payload["preferenceStart"] = Clean(Text(body["preferenceStart"]), 35); + payload["preferenceEnd"] = Clean(Text(body["preferenceEnd"]), 35); + payload["maxChoices"] = Math.Clamp(Integer(body["maxChoices"], 5), 1, 20); + payload["maxSubmissions"] = Math.Clamp(Integer(body["maxSubmissions"], 3), 1, 50); + payload["round"] = Math.Max(1, Integer(body["round"], Integer(existing?.Payload["round"], 1))); + payload["autoPublish"] = body["autoPublish"] is not JsonValue auto || !auto.TryGetValue(out var autoValue) || autoValue; + payload["progress"] = Clean(Text(body["progress"]), 200) is { Length: > 0 } progress ? progress : "等待志愿填报开始"; + if (TryDate(Text(payload["preferenceStart"]), out var start) && + TryDate(Text(payload["preferenceEnd"]), out var end) && start >= end) + return Error(400, "志愿填报结束时间必须晚于开始时间"); + var setting = new CandidateAdmissionRecord( + existing?.Id ?? Uid("admission_setting"), "setting", examId, existing?.UserId ?? user.Id, null, + status, payload, existing?.CreatedAt ?? now, now); + await repository.SaveRecordsAsync( + [setting], Audit(user, "设置志愿填报", $"{Text(exam.Data["name"])} · {status}"), cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["setting"] = RecordJson(setting) }); + } + + public async Task SavePlanAsync( + string sessionToken, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以代招生学校上传计划", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var data = await LoadAsync(cancellationToken); + var exam = data.Operational.Exams.FirstOrDefault(item => + item.Id == Clean(Text(body["examId"]), 64) && item.Data["archivedAt"] is null); + var school = data.Directory.Schools.FirstOrDefault(item => + item.Id == Clean(Text(body["schoolId"]), 64) && item.Active && item.IsAdmissionSchool); + var categories = NormalizeCategories(body["categories"]); + if (exam is null || school is null || categories.Count == 0) + return Error(400, "请选择考试、招生学校并填写有效计划"); + var codes = categories.Select(item => Text(item["code"])).ToArray(); + if (codes.Distinct(StringComparer.Ordinal).Count() != codes.Length) + return Error(400, "招生类别代码不能重复"); + if (categories.Any(item => !ValidSpecialty( + Text(item["specialtyCategory"]), + Text(item["specialtyType"])))) + return Error(400, "特长生招生类别的大类与小类不对应"); + foreach (var category in categories) + { + var allocations = category["indicatorAllocations"] as JsonArray ?? []; + var ids = allocations.Select(item => Text(item?["sourceSchoolId"])).ToArray(); + if (ids.Distinct(StringComparer.Ordinal).Count() != ids.Length) + return Error(400, "同一招生类别不能重复分配同一生源校指标"); + if (allocations.Sum(item => Number(item?["quota"])) > Number(category["quota"])) + return Error(400, "指标分配合计不能超过类别计划人数"); + if (ids.Any(id => !data.Directory.Schools.Any(item => item.Id == id && item.Active && item.IsSourceSchool))) + return Error(400, "指标分配中包含无效的生源学校"); + } + var existing = Records(data, "plan", exam.Id).FirstOrDefault(item => item.SchoolId == school.Id); + if (Records(data, "placement", exam.Id).Any(item => item.SchoolId == school.Id && item.Status != "withdrawn")) + return Error(409, "已经产生投档记录,不能再修改该校本轮招生计划"); + var now = NowIso(); + var payload = existing?.Payload.DeepClone().AsObject() ?? new JsonObject(); + payload["categories"] = new JsonArray(categories.Select(item => item.DeepClone()).ToArray()); + payload["note"] = Clean(Text(body["note"]), 500); + payload["submittedBy"] = user.DisplayName; + payload["reviewedBy"] = user.DisplayName; + payload["reviewedAt"] = now; + payload["reviewNote"] = "超级管理员代上传并审核通过"; + var plan = new CandidateAdmissionRecord( + existing?.Id ?? Uid("plan"), "plan", exam.Id, user.Id, school.Id, "approved", + payload, existing?.CreatedAt ?? now, now); + await repository.SaveRecordsAsync( + [plan], Audit(user, "代上传招生计划", $"{school.Name} · {Text(exam.Data["name"])}"), cancellationToken); + return new(existing is null ? 201 : 200, new JsonObject { ["ok"] = true, ["plan"] = RecordJson(plan) }); + } + + public async Task ReviewPlanAsync( + string sessionToken, string planId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以审核招生计划", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var admission = await admissionRepository.LoadAsync(cancellationToken); + var plan = admission.Records.FirstOrDefault(item => item.Id == planId && item.Kind == "plan"); + if (plan is null) return Error(404, "招生计划不存在"); + var status = Text(body["status"]); + if (status is not ("approved" or "rejected")) return Error(400, "审核状态无效"); + var now = NowIso(); + var payload = plan.Payload.DeepClone().AsObject(); + payload["reviewNote"] = Clean(Text(body["reviewNote"]), 500); + payload["reviewedBy"] = user.DisplayName; + payload["reviewedAt"] = now; + plan = plan with { Status = status, UpdatedAt = now, Payload = payload }; + await repository.SaveRecordsAsync( + [plan], Audit(user, status == "approved" ? "审核通过招生计划" : "退回招生计划", plan.Id), cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["plan"] = RecordJson(plan) }); + } + + public async Task RunActionAsync( + string sessionToken, string examId, string action, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以执行投档与录取操作", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var data = await LoadAsync(cancellationToken); + var setting = Records(data, "setting", examId).FirstOrDefault(); + if (setting is null || !Boolean(setting.Payload["enabled"])) return Error(404, "该考试未开启志愿填报"); + if (action == "supplementary") + return Error(409, "补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启"); + if (action == "match") + { + if (setting.Status is not ("closed" or "supplementary")) + return Error(409, "请先结束当前填报阶段再投档"); + var placements = BuildPlacements(data, setting); + var payload = setting.Payload.DeepClone().AsObject(); + payload["progress"] = $"第 {Integer(payload["round"], 1)} 轮投档完成,{placements.Count} 人已发送招生学校审核"; + setting = setting with { Status = "school_review", UpdatedAt = NowIso(), Payload = payload }; + await repository.SaveRecordsAsync( + [setting, .. placements], + Audit(user, "执行分数优先志愿投档", $"{setting.ExamId} · {placements.Count} 人"), + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["setting"] = RecordJson(setting), + ["placementCount"] = placements.Count + }); + } + if (action != "finalize") return Error(404, "录取操作不存在"); + if (setting.Status != "school_review") + return Error(409, "只有招生学校审核阶段可以签发录取通知书并开启报到"); + var placementsForExam = Records(data, "placement", examId).ToArray(); + if (placementsForExam.Any(item => item.Status is "school_review" or "withdrawal_pending")) + return Error(409, "仍有招生学校审核或退档申请未处理"); + var now = NowIso(); + var round = Integer(setting.Payload["round"], 1); + var admitted = AssignNoticeNumbers( + data, + placementsForExam.Where(item => item.Status == "admitted") + .Select(item => + { + var payload = item.Payload.DeepClone().AsObject(); + payload["finalizedRound"] = round; + return item with { Status = "final", UpdatedAt = now, Payload = payload }; + }).ToArray()); + var notifications = admitted.Select(item => new CandidateAdmissionRecord( + Uid("notification"), "notification", examId, item.UserId, item.SchoolId, "unread", + new JsonObject + { + ["title"] = "录取结果通知", + ["message"] = $"你已被{School(data, item.SchoolId)?.Name ?? "招生学校"}录取", + ["placementId"] = item.Id + }, now, now)).ToArray(); + var reporting = Records(data, "plan", examId).Where(item => item.Status == "approved").Select(plan => + { + var existing = ReportingRecord(data, examId, plan.SchoolId, round); + var rows = existing?.Payload["rows"]?.DeepClone().AsArray() ?? []; + var ids = rows.Select(item => Text(item?["placementId"])).ToHashSet(StringComparer.Ordinal); + foreach (var placement in admitted.Where(item => item.SchoolId == plan.SchoolId && !ids.Contains(item.Id))) + rows.Add(new JsonObject { ["placementId"] = placement.Id, ["status"] = "pending", ["note"] = "", ["updatedAt"] = now, ["source"] = "system" }); + return new CandidateAdmissionRecord( + existing?.Id ?? Uid("admission_reporting"), "notification", examId, null, plan.SchoolId, + "draft", new JsonObject + { + ["type"] = "admission_reporting", + ["round"] = round, + ["rows"] = rows, + ["openedAt"] = now, + ["openedBy"] = user.DisplayName + }, existing?.CreatedAt ?? now, now); + }).ToArray(); + var publicRows = PublicAdmissionRows(data, admitted, round); + var publication = Records(data, "notification", examId).FirstOrDefault(item => + item.UserId is null && Text(item.Payload["type"]) == "admission_round_publication" && + Integer(item.Payload["round"], 1) == round); + var publicationPayload = publication?.Payload.DeepClone().AsObject() ?? new JsonObject(); + publicationPayload["type"] = "admission_round_publication"; + publicationPayload["round"] = round; + publicationPayload["publishedAt"] = now; + publicationPayload["publishedBy"] = user.DisplayName; + publicationPayload["rows"] = publicRows; + publication = new CandidateAdmissionRecord( + publication?.Id ?? Uid("admission_round_publication"), "notification", examId, null, null, + "published", publicationPayload, publication?.CreatedAt ?? now, now); + var settingPayload = setting.Payload.DeepClone().AsObject(); + settingPayload["roundPublishedAt"] = now; + settingPayload["progress"] = $"第 {round} 轮录取结束,{admitted.Count} 名考生已签发通知书,录取名单已公示,招生学校正在登记报到"; + setting = setting with { Status = "reporting", UpdatedAt = now, Payload = settingPayload }; + await repository.SaveRecordsAsync( + [setting, .. admitted, .. notifications, .. reporting, publication], + Audit(user, "签发录取通知书并公示本轮录取名单", $"{examId} · 第 {round} 轮 · {admitted.Count} 人"), + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["admittedCount"] = admitted.Count, + ["reportingSchoolCount"] = reporting.Length, + ["publicationId"] = publication.Id + }); + } + + public async Task ReviewReportingAsync( + string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以审批学校报到与补录决定", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var data = await LoadAsync(cancellationToken); + var record = Records(data, "notification").FirstOrDefault(item => + item.Id == recordId && item.UserId is null && Text(item.Payload["type"]) == "admission_reporting"); + if (record is null || record.Status != "pending_approval") + return Error(404, "待审批的报到与补录决定不存在"); + var now = NowIso(); + var payload = record.Payload.DeepClone().AsObject(); + var approvalNote = Clean(Text(body["approvalNote"]), 500); + if (!ExactlyTrue(body["approved"])) + { + payload["approvalNote"] = approvalNote; + payload["rejectedAt"] = now; + payload["rejectedBy"] = user.DisplayName; + record = record with { Status = "rejected", UpdatedAt = now, Payload = payload }; + await repository.SaveRecordsAsync( + [record], Audit(user, "退回报到与补录决定", $"{record.SchoolId} · 第 {Integer(payload["round"], 1)} 轮"), cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["record"] = RecordJson(record) }); + } + var supplement = Text(payload["supplementDecision"]) == "supplement"; + var preferenceEnd = Clean(Text(body["preferenceEnd"]), 35); + if (supplement && (!TryDate(preferenceEnd, out var end) || end <= DateTimeOffset.UtcNow)) + return Error(400, "批准补录时必须设置晚于当前时间的补录志愿截止时间"); + payload["approvalNote"] = approvalNote; + payload["approvedAt"] = now; + payload["approvedBy"] = user.DisplayName; + payload["approvedPreferenceEnd"] = supplement ? preferenceEnd : ""; + record = record with { Status = "approved", UpdatedAt = now, Payload = payload }; + var reportPlan = Records(data, "plan", record.ExamId) + .FirstOrDefault(item => item.SchoolId == record.SchoolId) + ?? EmptyPlan(record.ExamId, record.SchoolId); + var recordsWithApproval = data.Admission.Records + .Select(item => item.Id == record.Id ? record : item).ToArray(); + var approvedData = data with + { + Admission = data.Admission with { Records = recordsWithApproval } + }; + payload["statistics"] = PlanProgress(approvedData, reportPlan); + record = record with { Payload = payload }; + var changed = new List(); + if (supplement) + { + var notReported = (payload["rows"] as JsonArray ?? []) + .Where(item => Text(item?["status"]) == "not_reported") + .Select(item => Text(item?["placementId"])).ToHashSet(StringComparer.Ordinal); + foreach (var placement in Records(data, "placement", record.ExamId) + .Where(item => item.SchoolId == record.SchoolId && item.Status == "final" && notReported.Contains(item.Id))) + { + var placementPayload = placement.Payload.DeepClone().AsObject(); + placementPayload["forfeitedAt"] = now; + placementPayload["forfeitedReason"] = "未按规定完成报到,学校补录申请已获批准"; + changed.Add(placement with { Status = "forfeited", UpdatedAt = now, Payload = placementPayload }); + } + } + var replacements = new Dictionary(StringComparer.Ordinal) + { + [record.Id] = record + }; + foreach (var placement in changed) replacements[placement.Id] = placement; + var nextData = data with + { + Admission = data.Admission with + { + Records = data.Admission.Records + .Select(item => replacements.GetValueOrDefault(item.Id) ?? item).ToArray() + } + }; + var setting = Records(nextData, "setting", record.ExamId).FirstOrDefault(); + var round = Integer(payload["round"], 1); + var plans = Records(nextData, "plan", record.ExamId).Where(item => item.Status == "approved").ToArray(); + var currentRecords = plans.Select(plan => + ReportingRecord(nextData, record.ExamId, plan.SchoolId, round)).ToArray(); + var allApproved = currentRecords.Length > 0 && currentRecords.All(item => item?.Status == "approved"); + List records = [record, .. changed]; + var completed = false; + if (allApproved && setting is not null) + { + var settingPayload = setting.Payload.DeepClone().AsObject(); + var supplementRecords = currentRecords + .Where(item => Text(item?.Payload["supplementDecision"]) == "supplement").ToArray(); + if (supplementRecords.Length > 0) + { + var supplementEnd = supplementRecords + .Select(item => Text(item?.Payload["approvedPreferenceEnd"])) + .Where(value => value.Length > 0) + .OrderBy(value => value, StringComparer.Ordinal) + .LastOrDefault() ?? preferenceEnd; + settingPayload["round"] = round + 1; + settingPayload["preferenceStart"] = now; + settingPayload["preferenceEnd"] = supplementEnd; + settingPayload["progress"] = $"第 {round + 1} 轮补录志愿填报进行中,截止 {SupplementDeadline(supplementEnd)}"; + setting = setting with { Status = "supplementary", UpdatedAt = now, Payload = settingPayload }; + } + else + { + settingPayload["completedAt"] = now; + settingPayload["progress"] = "全部招生学校报到情况与补录决定已审批,录取工作完成"; + setting = setting with { Status = "completed", UpdatedAt = now, Payload = settingPayload }; + var existingCutoff = Records(nextData, "cutoff_publication", record.ExamId).FirstOrDefault(); + var cutoffPayload = existingCutoff?.Payload.DeepClone().AsObject() ?? new JsonObject(); + cutoffPayload["publishedAt"] = now; + cutoffPayload["rows"] = AdmissionCutoffRows(nextData, record.ExamId); + records.Add(new CandidateAdmissionRecord( + existingCutoff?.Id ?? Uid("cutoff_publication"), + "cutoff_publication", + record.ExamId, + existingCutoff?.UserId ?? user.Id, + null, + "published", + cutoffPayload, + existingCutoff?.CreatedAt ?? now, + now)); + completed = true; + } + records.Add(setting); + } + await repository.SaveRecordsAsync( + records, + Audit(user, supplement ? "批准补录申请并公开报到情况" : "批准不补录决定并公开报到情况", $"{record.SchoolId} · 第 {round} 轮"), + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["record"] = RecordJson(record), + ["forfeitedCount"] = changed.Count, + ["phase"] = JsonValue.Create(setting?.Status), + ["completed"] = completed + }); + } + + public async Task ReviewWithdrawalAsync( + string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, "只有超级管理员可以审核退档", cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var admission = await admissionRepository.LoadAsync(cancellationToken); + var placement = admission.Records.FirstOrDefault(item => + item.Id == placementId && item.Kind == "placement" && item.Status == "withdrawal_pending"); + if (placement is null) return Error(404, "待审核退档申请不存在"); + var payload = placement.Payload.DeepClone().AsObject(); + payload["withdrawalReviewNote"] = Clean(Text(body["reviewNote"]), 500); + placement = placement with + { + Status = ExactlyTrue(body["approved"]) ? "withdrawn" : "admitted", + UpdatedAt = NowIso(), + Payload = payload + }; + await repository.SaveRecordsAsync( + [placement], + Audit(user, placement.Status == "withdrawn" ? "批准退档" : "驳回退档", placement.Id), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["placement"] = RecordJson(placement) }); + } + + private async Task SaveQualificationsCoreAsync( + string sessionToken, string examId, IReadOnlyList userIds, JsonObject body, bool bulk, + CancellationToken cancellationToken) + { + var context = await ResolveSchoolAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (body["eligible"] is not JsonValue value || !value.TryGetValue(out var eligible)) + return Error(400, bulk ? "请选择批量设置为有资格或无资格" : "请选择有或无指标分配资格"); + var data = await LoadAsync(cancellationToken); + var setting = Records(data, "setting", examId).FirstOrDefault(); + if (setting is null || !Boolean(setting.Payload["enabled"])) return Error(404, "该考试未启用志愿填报"); + var profiles = userIds.Select(id => data.Operational.Profiles.FirstOrDefault(item => + item.UserId == id && item.SchoolId == user.SchoolId && Boolean(item.Data["profileCompleted"]))).ToArray(); + if (profiles.Any(item => item is null) || profiles.Any(profile => + !data.Directory.Users.Any(account => account.Id == profile!.UserId && account.Role == "candidate" && account.Active))) + return Error(bulk ? 403 : 404, bulk ? "批量名单中包含不属于本校的有效考生" : "本校有效考生不存在"); + var now = NowIso(); + var qualifications = profiles.Select(profile => + { + var existing = Records(data, "indicator_qualification", examId).FirstOrDefault(item => item.UserId == profile!.UserId); + return new CandidateAdmissionRecord( + existing?.Id ?? Uid("indicator_qualification"), "indicator_qualification", examId, + profile!.UserId, user.SchoolId, "confirmed", + new JsonObject + { + ["eligible"] = eligible, + ["confirmedBy"] = user.DisplayName, + ["confirmedAt"] = now + }, existing?.CreatedAt ?? now, now); + }).ToList(); + var replaced = data.Admission.Records.Where(item => qualifications.All(entry => entry.Id != item.Id)) + .Concat(qualifications).ToArray(); + data = data with { Admission = new CandidateAdmissionSnapshot(replaced, data.Admission.Schools) }; + var status = QualificationStatus(data, examId, user.SchoolId!); + var records = new List(qualifications); + if (Boolean(status["complete"])) + { + var existing = Records(data, "qualification_publication", examId).FirstOrDefault(item => item.SchoolId == user.SchoolId); + var payload = existing?.Payload.DeepClone().AsObject() ?? new JsonObject(); + payload["publishedAt"] = now; + payload["rows"] = status["rows"]!.DeepClone(); + records.Add(new CandidateAdmissionRecord( + existing?.Id ?? Uid("qualification_publication"), "qualification_publication", examId, + user.Id, user.SchoolId, "published", payload, existing?.CreatedAt ?? now, now)); + } + await repository.SaveRecordsAsync( + records, + Audit(user, bulk ? "批量确认指标分配资格" : "确认指标分配资格", + $"{qualifications.Count} 人 · {(eligible ? "有资格" : "无资格")}"), + cancellationToken); + var response = new JsonObject + { + ["ok"] = true, + ["qualificationStatus"] = status, + ["published"] = Boolean(status["complete"]) + }; + if (bulk) response["count"] = qualifications.Count; + else + { + response["qualification"] = RecordJson(qualifications[0]); + response["message"] = Boolean(status["complete"]) + ? "资格已确认;本校全部考生确认完成,公示已自动发布" + : "资格已确认"; + } + return Success(response); + } + + private async Task LoadAsync(CancellationToken cancellationToken) => + new( + await admissionRepository.LoadAsync(cancellationToken), + await operationalLoader.LoadAsync(cancellationToken), + await directoryLoader.LoadAsync(cancellationToken), + await resultLoader.LoadAsync(cancellationToken)); + + private static IEnumerable Records(AdmissionData data, string kind, string? examId = null) => + data.Admission.Records.Where(item => item.Kind == kind && (examId is null || item.ExamId == examId)); + + private static JsonObject QualificationStatus(AdmissionData data, string examId, string schoolId) + { + var profiles = data.Operational.Profiles.Where(profile => + profile.SchoolId == schoolId && Boolean(profile.Data["profileCompleted"]) && + data.Directory.Users.Any(user => user.Id == profile.UserId && user.Role == "candidate" && user.Active)); + var byUser = Records(data, "indicator_qualification", examId) + .Where(item => item.SchoolId == schoolId && item.Status == "confirmed") + .ToDictionary(item => item.UserId ?? "", StringComparer.Ordinal); + var rows = profiles.Select(profile => + { + byUser.TryGetValue(profile.UserId, out var qualification); + var account = data.Directory.Users.FirstOrDefault(item => item.Id == profile.UserId); + var category = Text(profile.Data["specialtyCategory"]); + var type = Text(profile.Data["specialtyType"]); + return new JsonObject + { + ["userId"] = profile.UserId, + ["registrationNumber"] = account?.CandidateNumber ?? "", + ["name"] = Text(profile.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "", + ["eligible"] = Boolean(qualification?.Payload["eligible"]), + ["confirmed"] = qualification is not null, + ["confirmedAt"] = qualification is null ? "" : Text(qualification.Payload["confirmedAt"]) is { Length: > 0 } at ? at : qualification.UpdatedAt, + ["specialtyCategory"] = category, + ["specialtyType"] = type, + ["specialtyLabel"] = SpecialtyLabel(category, type) + }; + }).OrderBy(item => Text(item["registrationNumber"]), StringComparer.Ordinal).ToArray(); + return new JsonObject + { + ["total"] = rows.Length, + ["confirmed"] = rows.Count(item => Boolean(item["confirmed"])), + ["complete"] = rows.Length > 0 && rows.All(item => Boolean(item["confirmed"])), + ["rows"] = new JsonArray(rows) + }; + } + + private static JsonObject PreferenceJson(AdmissionData data, CandidateAdmissionRecord item) + { + var output = RecordJson(item); + var account = data.Directory.Users.FirstOrDefault(user => user.Id == item.UserId); + var profile = data.Operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId); + output["candidate"] = new JsonObject + { + ["registrationNumber"] = account?.CandidateNumber ?? "", + ["name"] = Text(profile?.Data["name"]) + }; + var choices = item.Payload["choices"] as JsonArray ?? []; + output["choices"] = new JsonArray(choices.Select(choice => + { + var result = choice?.DeepClone().AsObject() ?? new JsonObject(); + var schoolId = Text(result["schoolId"]); + var code = Text(result["categoryCode"]); + var school = School(data, schoolId); + var category = Records(data, "plan", item.ExamId).Where(plan => plan.SchoolId == schoolId) + .SelectMany(PlanCategories).FirstOrDefault(entry => Text(entry["code"]) == code); + result["schoolCode"] = Text(result["schoolCode"]) is { Length: > 0 } schoolCode ? schoolCode : school?.Code ?? ""; + result["schoolName"] = Text(result["schoolName"]) is { Length: > 0 } schoolName ? schoolName : school?.Name ?? ""; + result["categoryName"] = Text(result["categoryName"]) is { Length: > 0 } categoryName ? categoryName : Text(category?["name"]); + return (JsonNode)result; + }).ToArray()); + return output; + } + + private static JsonObject PlacementJson(AdmissionData data, CandidateAdmissionRecord item) + { + var output = RecordJson(item); + var account = data.Directory.Users.FirstOrDefault(user => user.Id == item.UserId); + var profile = data.Operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId); + var school = School(data, item.SchoolId); + var schoolClass = data.Directory.Classes.FirstOrDefault(entry => entry.Id == profile?.ClassId); + output["candidate"] = new JsonObject + { + ["registrationNumber"] = account?.CandidateNumber ?? "", + ["name"] = Text(profile?.Data["name"]), + ["idNumberMasked"] = MaskId(Text(profile?.Data["idNumber"])), + ["specialtyLabel"] = SpecialtyLabel(Text(profile?.Data["specialtyCategory"]), Text(profile?.Data["specialtyType"])) + }; + output["sourceSchoolId"] = School(data, profile?.SchoolId)?.Id ?? ""; + output["sourceSchoolCode"] = School(data, profile?.SchoolId)?.Code ?? ""; + output["sourceSchoolName"] = School(data, profile?.SchoolId)?.Name ?? Text(profile?.Data["school"]); + output["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]); + output["schoolCode"] = school?.Code ?? ""; + output["schoolName"] = school?.Name ?? ""; + output["examCode"] = Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["code"]); + output["examName"] = Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]); + output["admissionStatusLabel"] = item.Status switch + { + "school_review" => "学校审核中", + "admitted" => "拟录取", + "final" => "正式录取", + "withdrawal_pending" => "退档待审", + "withdrawn" => "已退档", + "forfeited" => "未报到放弃", + _ => item.Status + }; + var reportingStatus = ReportingStatusCode(data, item); + output["reportingStatus"] = reportingStatus; + output["reportingStatusLabel"] = reportingStatus switch + { + "reported" => "已报到", + "not_reported" => "未报到", + "pending" => "待确认", + _ => "—" + }; + return output; + } + + private static JsonObject[] PreferenceSnapshotRows(AdmissionData data) => + Records(data, "setting").Where(setting => Boolean(setting.Payload["enabled"])).SelectMany(setting => + { + var round = Integer(setting.Payload["round"], 1); + var maxSubmissions = Integer(setting.Payload["maxSubmissions"], 3); + return data.Operational.Registrations.Where(item => item.ExamId == setting.ExamId && item.Status == "approved") + .Select(registration => + { + var profile = data.Operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); + var account = data.Directory.Users.FirstOrDefault(item => item.Id == registration.UserId); + if (profile is null || account is not { Active: true, Role: "candidate" }) return null; + var preference = Records(data, "preference", setting.ExamId).FirstOrDefault(item => + item.UserId == profile.UserId && Integer(item.Payload["round"], 1) == round); + var qualification = Records(data, "indicator_qualification", setting.ExamId).FirstOrDefault(item => item.UserId == profile.UserId); + var exam = data.Operational.Exams.First(item => item.Id == setting.ExamId); + var school = School(data, profile.SchoolId); + var schoolClass = data.Directory.Classes.FirstOrDefault(item => item.Id == profile.ClassId); + var submissionCount = Integer(preference?.Payload["submissionCount"]); + var blocking = setting.Status == "supplementary" && Records(data, "placement", setting.ExamId).Any(item => + item.UserId == profile.UserId && item.Status is "school_review" or "admitted" or "final" or "withdrawal_pending" or "forfeited"); + var status = blocking ? "ineligible" + : preference is not null && submissionCount >= maxSubmissions ? "locked" + : preference is not null ? "submitted" + : CandidateTotalScore(data, setting.ExamId, profile.UserId) is null ? "unavailable" : "unfilled"; + var fillStatus = status switch + { + "submitted" => "已填报", + "locked" => "已锁定", + "unavailable" => "成绩未齐", + "ineligible" => "不可补录", + _ => "尚未填报" + }; + var choices = preference is null + ? new JsonArray() + : PreferenceJson(data, preference)["choices"]!.DeepClone().AsArray(); + return new JsonObject + { + ["id"] = $"{setting.ExamId}:{round}:{registration.UserId}", + ["examId"] = setting.ExamId, + ["examCode"] = Text(exam.Data["code"]), + ["examName"] = Text(exam.Data["name"]), + ["round"] = round, + ["phase"] = setting.Status, + ["status"] = status, + ["fillStatus"] = fillStatus, + ["lockStatus"] = status == "locked" ? "已锁定" : status == "ineligible" ? "不可填报" : "未锁定", + ["submissionCount"] = submissionCount, + ["maxSubmissions"] = maxSubmissions, + ["submittedAt"] = Text(preference?.Payload["submittedAt"]) is { Length: > 0 } submitted ? submitted : preference?.UpdatedAt ?? "", + ["sourceSchoolId"] = school?.Id ?? "", + ["sourceSchoolCode"] = school?.Code ?? "", + ["sourceSchoolName"] = school?.Name ?? Text(profile.Data["school"]), + ["className"] = schoolClass?.Name ?? Text(profile.Data["grade"]), + ["specialty"] = SpecialtyLabel(Text(profile.Data["specialtyCategory"]), Text(profile.Data["specialtyType"])), + ["indicatorStatus"] = qualification is null ? "未确认" : Boolean(qualification.Payload["eligible"]) ? "有资格" : "无资格", + ["candidate"] = new JsonObject + { + ["userId"] = registration.UserId, + ["registrationNumber"] = account.CandidateNumber ?? "", + ["name"] = Text(profile.Data["name"]) is { Length: > 0 } candidateName ? candidateName : account.DisplayName + }, + ["choices"] = choices + }; + }); + }).Where(item => item is not null).Cast() + .OrderBy(item => Text(item["examName"]), StringComparer.Create(CultureInfo.GetCultureInfo("zh-CN"), false)) + .ThenBy(item => Text(item["candidate"]?["registrationNumber"]), StringComparer.Ordinal).ToArray(); + + private static List BuildPlacements(AdmissionData data, CandidateAdmissionRecord setting) + { + var round = Integer(setting.Payload["round"], 1); + HashSet? supplementarySchools = null; + if (setting.Status == "supplementary") + { + supplementarySchools = Records(data, "notification", setting.ExamId) + .Where(item => item.UserId is null && + item.Status == "approved" && + Text(item.Payload["type"]) == "admission_reporting" && + Integer(item.Payload["round"], 1) == Math.Max(1, round - 1) && + Text(item.Payload["supplementDecision"]) == "supplement") + .Select(item => item.SchoolId) + .OfType() + .ToHashSet(StringComparer.Ordinal); + if (supplementarySchools.Count == 0) supplementarySchools = null; + } + var plans = Records(data, "plan", setting.ExamId) + .Where(item => item.Status == "approved" && + (supplementarySchools is null || supplementarySchools.Contains(item.SchoolId ?? ""))) + .ToArray(); + var categories = plans.SelectMany(plan => PlanCategories(plan).Select(category => (Plan: plan, Category: category))) + .ToDictionary(item => $"{item.Plan.SchoolId}|{Text(item.Category["code"])}", StringComparer.Ordinal); + var existing = Records(data, "placement", setting.ExamId).ToArray(); + var occupied = existing.Where(item => item.Status is not ("withdrawn" or "forfeited")) + .GroupBy(item => $"{item.SchoolId}|{Text(item.Payload["categoryCode"])}|{Text(item.Payload["quotaBucket"])}") + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var candidates = new List(); + foreach (var preference in Records(data, "preference", setting.ExamId) + .Where(item => item.Status == "submitted" && Integer(item.Payload["round"], 1) == round)) + { + if (existing.Any(item => item.UserId == preference.UserId && item.Status is "school_review" or "admitted" or "final" or "withdrawal_pending" or "forfeited")) + continue; + var score = CandidateTotalScore(data, setting.ExamId, preference.UserId); + if (score is null) continue; + var profile = data.Operational.Profiles.FirstOrDefault(item => item.UserId == preference.UserId); + var registration = data.Operational.Registrations.FirstOrDefault(item => + item.UserId == preference.UserId && item.ExamId == setting.ExamId && item.Status == "approved"); + if (profile is null || registration is null) continue; + candidates.Add(new( + preference, profile, registration, score.Value, Number(registration.Data["featureScore"]), 0)); + } + string NumberOf(AdmissionCandidate candidate) => + data.Directory.Users.FirstOrDefault(item => item.Id == candidate.Preference.UserId)?.CandidateNumber ?? ""; + var queue = new Queue( + candidates.OrderByDescending(item => item.CulturalScore).ThenBy(NumberOf, StringComparer.Ordinal)); + var accepted = new Dictionary>(StringComparer.Ordinal); + while (queue.Count > 0) + { + var candidate = queue.Dequeue(); + var choices = candidate.Preference.Payload["choices"] as JsonArray ?? []; + while (candidate.NextChoiceIndex < choices.Count) + { + var index = candidate.NextChoiceIndex; + var choice = choices[candidate.NextChoiceIndex++]; + if (!categories.TryGetValue($"{Text(choice?["schoolId"])}|{Text(choice?["categoryCode"])}", out var target)) + continue; + var usesFeature = Text(target.Category["specialtyCategory"]).Length > 0 || Text(target.Category["specialtyType"]).Length > 0; + if (!CandidateEligibleForCategory(candidate.Profile.Data, target.Category)) + continue; + var bucket = "general"; + var capacity = Number(target.Category["quota"]) - (target.Category["indicatorAllocations"] as JsonArray ?? []).Sum(item => Number(item?["quota"])); + if (Text(choice?["preferenceType"]) == "indicator") + { + var qualification = Records(data, "indicator_qualification", setting.ExamId).FirstOrDefault(item => item.UserId == candidate.Preference.UserId); + var allocation = (target.Category["indicatorAllocations"] as JsonArray ?? []) + .FirstOrDefault(item => Text(item?["sourceSchoolId"]) == candidate.Profile.SchoolId); + if (!Boolean(qualification?.Payload["eligible"]) || allocation is null) continue; + bucket = $"indicator:{candidate.Profile.SchoolId}"; + capacity = Number(allocation["quota"]); + } + var bucketKey = $"{target.Plan.SchoolId}|{Text(target.Category["code"])}|{bucket}"; + var available = Math.Max(0, (int)capacity - occupied.GetValueOrDefault(bucketKey)); + if (available == 0) continue; + var proposal = new Proposal( + candidate, target.Plan, target.Category, index + 1, bucket, + candidate.CulturalScore, candidate.FeatureScore, + Math.Round(candidate.CulturalScore + (usesFeature ? candidate.FeatureScore : 0), 2, MidpointRounding.AwayFromZero), + available); + var bucketRows = accepted.GetValueOrDefault(bucketKey) ?? []; + bucketRows.Add(proposal); + bucketRows.Sort((left, right) => + { + var scoreOrder = right.TotalScore.CompareTo(left.TotalScore); + return scoreOrder != 0 + ? scoreOrder + : StringComparer.Ordinal.Compare(NumberOf(left.Candidate), NumberOf(right.Candidate)); + }); + Proposal? rejected = null; + if (bucketRows.Count > available) + { + rejected = bucketRows[^1]; + bucketRows.RemoveAt(bucketRows.Count - 1); + } + accepted[bucketKey] = bucketRows; + if (rejected == proposal) continue; + if (rejected is not null) queue.Enqueue(rejected.Candidate); + break; + } + } + var selected = accepted.Values.SelectMany(item => item) + .OrderByDescending(item => item.TotalScore) + .ThenBy(item => NumberOf(item.Candidate), StringComparer.Ordinal).ToArray(); + var now = NowIso(); + return selected.Select(item => new CandidateAdmissionRecord( + Uid("placement"), "placement", setting.ExamId, item.Candidate.Preference.UserId, item.Plan.SchoolId, + "school_review", new JsonObject + { + ["round"] = round, + ["categoryCode"] = Text(item.Category["code"]), + ["categoryName"] = Text(item.Category["name"]), + ["preferenceOrder"] = item.PreferenceOrder, + ["culturalScore"] = item.CulturalScore, + ["totalScore"] = item.TotalScore, + ["featureScore"] = item.FeatureScore, + ["specialtyQualification"] = new JsonObject + { + ["category"] = ResolveSpecialty(item.Candidate.Profile.Data).Category, + ["type"] = ResolveSpecialty(item.Candidate.Profile.Data).Type + }, + ["quotaBucket"] = item.Bucket, + ["schoolDecisionNote"] = "", + ["withdrawalReason"] = "", + ["withdrawalReviewNote"] = "" + }, now, now)).ToList(); + } + + private static double? CandidateTotalScore(AdmissionData data, string examId, string? userId) + { + var registration = data.Operational.Registrations.FirstOrDefault(item => + item.ExamId == examId && item.UserId == userId && item.Status == "approved"); + if (registration is null) return null; + var subjectIds = (registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToArray(); + var results = data.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); + if (subjectIds.Length == 0 || subjectIds.Any(id => results.All(result => result.SubjectId != id))) return null; + return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero); + } + + private static List AssignNoticeNumbers(AdmissionData data, IReadOnlyList placements) + { + var counters = Records(data, "placement").Where(item => Number(item.Payload["noticeSerial"]) > 0) + .GroupBy(item => $"{item.SchoolId}\0{item.ExamId}") + .ToDictionary(group => group.Key, group => (int)group.Max(item => Number(item.Payload["noticeSerial"])), StringComparer.Ordinal); + var output = new List(); + foreach (var group in placements.GroupBy(item => $"{item.SchoolId}\0{item.ExamId}")) + { + var serial = counters.GetValueOrDefault(group.Key); + foreach (var placement in group.OrderBy(item => + data.Directory.Users.FirstOrDefault(user => user.Id == item.UserId)?.CandidateNumber, StringComparer.Ordinal)) + { + if (Text(placement.Payload["noticeNumber"]).Length > 0) { output.Add(placement); continue; } + serial++; + var payload = placement.Payload.DeepClone().AsObject(); + payload["noticeSerial"] = serial; + payload["noticeNumber"] = $"{CodePart(School(data, placement.SchoolId)?.Code, "SCHOOL")}-{CodePart(Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == placement.ExamId)?.Data["code"]), "EXAM")}-{serial:000000}"; + output.Add(placement with { Payload = payload }); + } + } + return output; + } + + private static JsonArray PublicAdmissionRows(AdmissionData data, IReadOnlyList admitted, int round) => + new(admitted.Where(item => Integer(item.Payload["finalizedRound"], 1) == round).Select(item => + { + var account = data.Directory.Users.FirstOrDefault(user => user.Id == item.UserId); + var profile = data.Operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId); + return (JsonNode)new JsonObject + { + ["registrationNumber"] = account?.CandidateNumber ?? "", + ["name"] = Text(profile?.Data["name"]), + ["totalScore"] = Number(item.Payload["totalScore"]), + ["admittedSchool"] = School(data, item.SchoolId)?.Name ?? "", + ["categoryName"] = Text(item.Payload["categoryName"]), + ["idNumberMasked"] = PublicMaskId(Text(profile?.Data["idNumber"])), + ["phoneMasked"] = MaskPhone(Text(profile?.Data["phone"])) + }; + }).OrderByDescending(item => Number(item["totalScore"])) + .ThenBy(item => Text(item["registrationNumber"]), StringComparer.Ordinal).ToArray()); + + private static JsonArray AdmissionCutoffRows(AdmissionData data, string examId) + { + var rows = Records(data, "placement", examId) + .Where(item => item.Status == "final") + .GroupBy(item => $"{item.SchoolId}|{Text(item.Payload["categoryCode"])}", StringComparer.Ordinal) + .Select(group => + { + var first = group.First(); + var scores = group.Select(item => Number(item.Payload["totalScore"])).ToArray(); + var plan = Records(data, "plan", examId).FirstOrDefault(item => + item.Status == "approved" && item.SchoolId == first.SchoolId); + var category = plan is null + ? null + : PlanCategories(plan).FirstOrDefault(item => + Text(item["code"]) == Text(first.Payload["categoryCode"])); + return (JsonNode)new JsonObject + { + ["schoolId"] = JsonValue.Create(first.SchoolId), + ["schoolName"] = School(data, first.SchoolId)?.Name ?? "", + ["categoryCode"] = Text(first.Payload["categoryCode"]), + ["categoryName"] = Text(first.Payload["categoryName"]), + ["admittedCount"] = scores.Length, + ["planQuota"] = Number(category?["quota"]), + ["highestScore"] = scores.Max(), + ["cutoffScore"] = scores.Min() + }; + }) + .OrderBy(item => Text(item?["schoolName"]), + StringComparer.Create(CultureInfo.GetCultureInfo("zh-CN"), false)) + .ThenBy(item => Text(item?["categoryName"]), + StringComparer.Create(CultureInfo.GetCultureInfo("zh-CN"), false)) + .ToArray(); + return new JsonArray(rows); + } + + private static JsonObject PlanProgress(AdmissionData data, CandidateAdmissionRecord plan) + { + var quota = PlanCategories(plan).Sum(item => Number(item["quota"])); + var placements = Records(data, "placement", plan.ExamId) + .Where(item => item.SchoolId == plan.SchoolId && item.Status is not ("withdrawn" or "forfeited")).ToArray(); + var finals = placements.Where(item => item.Status == "final").ToArray(); + var reporting = Records(data, "notification", plan.ExamId) + .Where(item => item.SchoolId == plan.SchoolId && item.UserId is null && Text(item.Payload["type"]) == "admission_reporting") + .OrderByDescending(item => Integer(item.Payload["round"], 1)).ThenByDescending(item => item.UpdatedAt, StringComparer.Ordinal).ToArray(); + var rowMap = new Dictionary(StringComparer.Ordinal); + foreach (var record in reporting.Reverse()) + foreach (var row in record.Payload["rows"] as JsonArray ?? []) rowMap[Text(row?["placementId"])] = row; + var reported = finals.Count(item => Text(rowMap.GetValueOrDefault(item.Id)?["status"]) == "reported"); + var notReported = finals.Count(item => Text(rowMap.GetValueOrDefault(item.Id)?["status"]) == "not_reported"); + double Percent(int value) => quota == 0 ? 0 : Math.Round(value / quota * 100, 1, MidpointRounding.AwayFromZero); + return new JsonObject + { + ["examId"] = plan.ExamId, + ["schoolId"] = JsonValue.Create(plan.SchoolId), + ["totalQuota"] = quota, + ["placedCount"] = placements.Length, + ["finalCount"] = finals.Length, + ["reportedCount"] = reported, + ["notReportedCount"] = notReported, + ["pendingReportingCount"] = Math.Max(0, finals.Length - reported - notReported), + ["admissionRate"] = Percent(finals.Length), + ["reportingRate"] = Percent(reported), + ["remainingQuota"] = Math.Max(0, quota - finals.Length), + ["reportingGap"] = Math.Max(0, quota - reported), + ["reportingStatus"] = reporting.FirstOrDefault()?.Status ?? "not_started", + ["supplementDecision"] = Text(reporting.FirstOrDefault()?.Payload["supplementDecision"]), + ["reportingUpdatedAt"] = JsonValue.Create(reporting.FirstOrDefault()?.UpdatedAt) + }; + } + + private static JsonArray RemainingPlanQuota(AdmissionData data, CandidateAdmissionRecord plan) => + new(PlanCategories(plan).Select(category => + { + var output = category.DeepClone().AsObject(); + var used = Records(data, "placement", plan.ExamId).Count(item => + item.SchoolId == plan.SchoolId && Text(item.Payload["categoryCode"]) == Text(category["code"]) && + item.Status is not ("withdrawn" or "forfeited")); + output["used"] = used; + output["remaining"] = Math.Max(0, Number(category["quota"]) - used); + return (JsonNode)output; + }).ToArray()); + + private static CandidateAdmissionRecord? ReportingRecord(AdmissionData data, string examId, string? schoolId, int round) => + Records(data, "notification", examId).Where(item => + item.SchoolId == schoolId && item.UserId is null && Text(item.Payload["type"]) == "admission_reporting") + .OrderByDescending(item => Integer(item.Payload["round"], 1)).ThenByDescending(item => item.UpdatedAt, StringComparer.Ordinal) + .FirstOrDefault(item => Integer(item.Payload["round"], 1) == round); + + private static string ReportingStatusCode(AdmissionData data, CandidateAdmissionRecord placement) + { + foreach (var record in Records(data, "notification", placement.ExamId) + .Where(item => item.SchoolId == placement.SchoolId && Text(item.Payload["type"]) == "admission_reporting") + .OrderByDescending(item => Integer(item.Payload["round"], 1))) + { + var row = (record.Payload["rows"] as JsonArray ?? []).FirstOrDefault(item => Text(item?["placementId"]) == placement.Id); + if (row is not null) return Text(row["status"]); + } + return placement.Status == "final" ? "pending" : ""; + } + + private async Task ResolveAsync(string token, string forbidden, CancellationToken cancellationToken) + { + var context = await ResolveAnyAdminAsync(token, cancellationToken); + if (context.Error is not null) return context; + return context.User!.AdminLevel == "super" ? context : ResolvedAdmin.Failed(Error(403, forbidden)); + } + + private async Task ResolveSchoolAsync(string token, CancellationToken cancellationToken) + { + var context = await ResolveAnyAdminAsync(token, cancellationToken); + if (context.Error is not null) return context; + return context.User!.AdminLevel == "school" && context.User.SchoolId is not null + ? context + : ResolvedAdmin.Failed(Error(403, "只有生源校学校管理员可以确认指标分配资格")); + } + + private async Task ResolveAnyAdminAsync(string token, CancellationToken cancellationToken) + { + if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var userId = await authenticationState.GetSessionUserIdAsync(token); + if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken); + if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录")); + return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作")); + } + + private static IReadOnlyList PlanCategories(CandidateAdmissionRecord plan) => + (plan.Payload["categories"] as JsonArray ?? []).OfType().ToArray(); + + private static List NormalizeCategories(JsonNode? node) => + (node as JsonArray ?? []).OfType().Select((item, index) => + { + var allocations = (item["indicatorAllocations"] as JsonArray ?? []).OfType() + .Select(allocation => (JsonNode)new JsonObject + { + ["sourceSchoolId"] = Clean(Text(allocation["sourceSchoolId"]), 64), + ["quota"] = Math.Max(0, Integer(allocation["quota"])) + }).Where(allocation => Text(allocation?["sourceSchoolId"]).Length > 0 && Number(allocation?["quota"]) > 0).ToArray(); + return new JsonObject + { + ["code"] = Clean(Text(item["code"]), 40) is { Length: > 0 } code ? code : $"category-{index + 1}", + ["name"] = Clean(Text(item["name"]), 80), + ["quota"] = Math.Max(0, Integer(item["quota"])), + ["specialtyCategory"] = Clean(Text(item["specialtyCategory"]), 30), + ["specialtyType"] = Clean(Text(item["specialtyType"]), 30), + ["indicatorAllocations"] = new JsonArray(allocations) + }; + }).Where(item => Text(item["name"]).Length > 0 && Number(item["quota"]) > 0).ToList(); + + private static JsonObject RecordJson(CandidateAdmissionRecord item) => new() + { + ["id"] = item.Id, + ["kind"] = item.Kind, + ["examId"] = item.ExamId, + ["userId"] = JsonValue.Create(item.UserId), + ["schoolId"] = JsonValue.Create(item.SchoolId), + ["status"] = item.Status, + ["payload"] = item.Payload.DeepClone(), + ["createdAt"] = item.CreatedAt, + ["updatedAt"] = item.UpdatedAt + }; + + private static JsonObject PublicExam(OperationalExam exam) + { + var output = exam.Data.DeepClone().AsObject(); + output["totalScore"] = exam.Subjects.Sum(item => Number(item["fullScore"])); + var now = DateTimeOffset.UtcNow; + output["registrationState"] = exam.Data["archivedAt"] is not null ? "archived" + : TryDate(Text(exam.Data["registrationStart"]), out var start) && now < start ? "upcoming" + : TryDate(Text(exam.Data["registrationEnd"]), out var end) && now > end ? "closed" : "open"; + return output; + } + + private static AdminSchool? School(AdmissionData data, string? id) => + data.Directory.Schools.FirstOrDefault(item => item.Id == id); + private static JsonObject SchoolJson(AdminSchool item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["code"] = item.Code, + ["address"] = item.Address, + ["isSourceSchool"] = item.IsSourceSchool, + ["isAdmissionSchool"] = item.IsAdmissionSchool, + ["active"] = item.Active + }; + 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 CandidateAdmissionRecord EmptyPlan(string examId, string? schoolId) => + new("", "plan", examId, null, schoolId, "", new JsonObject { ["categories"] = new JsonArray() }, "", ""); + 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" => "武术", + "aerobics_cheer" => "健美操与啦啦操", + "vocal_music" => "声乐", + "instrumental_music" => "器乐", + "dance" => "舞蹈", + "fine_arts" => "美术", + "calligraphy" => "书法", + "drama_broadcasting" => "戏剧与播音", + _ => "" + }; + return categoryName.Length > 0 && typeName.Length > 0 ? $"{categoryName}·{typeName}" : "普通生"; + } + private static bool ValidSpecialty(string category, string type) + { + if (category.Length == 0 && type.Length == 0) return true; + return SpecialtyTypes.TryGetValue(category, out var types) && + types.Contains(type, StringComparer.Ordinal); + } + private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category) + { + var requiredCategory = Text(category["specialtyCategory"]); + var requiredType = Text(category["specialtyType"]); + if (requiredCategory.Length == 0 && LegacySpecialties.TryGetValue(requiredType, out var legacy)) + (requiredCategory, requiredType) = legacy; + if (requiredCategory.Length == 0) return true; + var qualification = ResolveSpecialty(profile); + return qualification.Category == requiredCategory && + (requiredType.Length == 0 || qualification.Type == requiredType); + } + private static (string Category, string Type) ResolveSpecialty(JsonObject profile) + { + var category = Text(profile["specialtyCategory"]); + var type = Text(profile["specialtyType"]); + if (ValidSpecialty(category, type) && category.Length > 0) return (category, type); + foreach (var value in profile["specialtyTypes"] as JsonArray ?? []) + if (LegacySpecialties.TryGetValue(Text(value), out var legacy)) return legacy; + return ("", ""); + } + private static string SupplementDeadline(string value) => + TryDate(value, out var parsed) + ? parsed.ToLocalTime().ToString("yyyy/M/d HH:mm:ss", CultureInfo.GetCultureInfo("zh-CN")) + : value; + private static string MaskId(string value) => value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : ""; + private static string PublicMaskId(string value) => value.Length > 5 ? $"{value[..3]}***********{value[^2..]}" : ""; + private static string MaskPhone(string value) => value.Length > 7 ? $"{value[..3]}****{value[^4..]}" : ""; + private static string CodePart(string? value, string fallback) + { + var chars = (value ?? "").Trim().ToUpperInvariant().Where(character => + character is >= 'A' and <= 'Z' or >= '0' and <= '9' or '-').ToArray(); + return chars.Length == 0 ? fallback : new string(chars); + } + private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static bool ExactlyTrue(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var boolean) && boolean; + private static bool Boolean(JsonNode? node) + { + if (node is null) return false; + if (node is JsonValue value && value.TryGetValue(out var boolean)) return boolean; + if (node is JsonValue number && number.TryGetValue(out var numeric)) return numeric != 0; + return node.ToString().Length > 0; + } + private static int Integer(JsonNode? node, int fallback = 0) => + int.TryParse(Text(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; + private static double Number(JsonNode? node) => + double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0; + 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 bool TryDate(string value, out DateTimeOffset parsed) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsed); + 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 Created(JsonObject body) => new(201, body); + private static AdminEndpointResult Error(int status, string message) => + new(status, new JsonObject { ["ok"] = false, ["message"] = message }); + + private sealed record AdmissionData( + CandidateAdmissionSnapshot Admission, + AdminOperationalSnapshot Operational, + AdminAccountBatchSnapshot Directory, + IReadOnlyList Results); + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) + { + public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); + } + private sealed class AdmissionCandidate( + CandidateAdmissionRecord preference, + OperationalProfile profile, + OperationalRegistration registration, + double culturalScore, + double featureScore, + int nextChoiceIndex) + { + public CandidateAdmissionRecord Preference { get; } = preference; + public OperationalProfile Profile { get; } = profile; + public OperationalRegistration Registration { get; } = registration; + public double CulturalScore { get; } = culturalScore; + public double FeatureScore { get; } = featureScore; + public int NextChoiceIndex { get; set; } = nextChoiceIndex; + } + private sealed record Proposal( + AdmissionCandidate Candidate, + CandidateAdmissionRecord Plan, + JsonObject Category, + int PreferenceOrder, + string Bucket, + double CulturalScore, + double FeatureScore, + double TotalScore, + int Capacity); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index cd2c2d4..df49d03 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -13,7 +13,8 @@ public sealed record AdminMigrationOptions( bool NativeWorkflowOperationsEnabled = false, bool NativeExamManagementEnabled = false, bool NativeArrangementsEnabled = false, - bool NativeResultsEnabled = false) + bool NativeResultsEnabled = false, + bool NativeAdmissionsEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -30,7 +31,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeWorkflowOperationsEnabled = false, bool configuredNativeExamManagementEnabled = false, bool configuredNativeArrangementsEnabled = false, - bool configuredNativeResultsEnabled = false) + bool configuredNativeResultsEnabled = false, + bool configuredNativeAdmissionsEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -72,17 +74,20 @@ public sealed record AdminMigrationOptions( var resultsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"), configuredNativeResultsEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled) && !readsEnabled) + var admissionsEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"), + configuredNativeAdmissionsEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled) && !operationalReadsEnabled) + if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled || admissionsEnabled) && !operationalReadsEnabled) { throw new InvalidOperationException( "启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -98,7 +103,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled, admissionsEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/Administration/AdminResultService.cs b/src/Eis.Infrastructure/Administration/AdminResultService.cs index 28d079f..24b5d2a 100644 --- a/src/Eis.Infrastructure/Administration/AdminResultService.cs +++ b/src/Eis.Infrastructure/Administration/AdminResultService.cs @@ -244,6 +244,104 @@ internal sealed class AdminResultService( }); } + public async Task ImportAsync( + 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 allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList(); + var rows = body["rows"] as JsonArray ?? []; + var prepared = new List(); + var seen = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < rows.Count; index++) + { + var row = rows[index] as JsonObject ?? new JsonObject(); + var sourceRow = Integer(row["__row"] ?? row["sourceRow"], index + 3); + var candidateNumber = Clean(Text(row["candidateNumber"]), 120); + var examCode = Clean(Text(row["examCode"]), 60); + var subjectName = Clean(Text(row["subjectName"]), 50); + var account = operational.Users.FirstOrDefault(item => item.CandidateNumber == candidateNumber); + var exam = operational.Exams.FirstOrDefault(item => + Text(item.Data["code"]).Equals(examCode, StringComparison.OrdinalIgnoreCase)); + var registration = operational.Registrations.FirstOrDefault(item => + item.UserId == account?.Id && item.ExamId == exam?.Id && item.Status == "approved"); + var subject = exam?.Subjects.FirstOrDefault(item => + Text(item["name"]).Equals(subjectName, StringComparison.OrdinalIgnoreCase)); + string? message = null; + if (candidateNumber.Length == 0) message = "报名号不能为空"; + else if (account is null) message = "报名号不存在"; + else if (examCode.Length == 0) message = "考试代码不能为空"; + else if (exam is null) message = "考试代码不存在"; + else if (exam.Data["archivedAt"] is not null) message = "该考试已归档,成绩已永久锁定"; + else if (subjectName.Length == 0) message = "科目不能为空"; + else if (subject is null) message = "该考试中不存在此科目"; + else if (registration is null) message = "该考生没有已通过的本场考试报名"; + else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目"; + var score = Number(row["score"]); + if (message is null && (!double.IsFinite(score) || score < 0 || score > Number(subject?["fullScore"]))) + message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间"; + var publishText = Text(row["published"]); + bool published; + if (row["published"] is JsonValue publishedValue && + publishedValue.TryGetValue(out var publishedBoolean)) + published = publishedBoolean; + else if (publishText is "" or "不发布") + published = false; + else if (publishText == "发布") + published = true; + else + { + published = false; + message = "发布状态只能是“发布”或“不发布”"; + } + var key = registration is null || subject is null + ? $"{candidateNumber}|{examCode}|{subjectName}" + : $"{registration.Id}|{Text(subject["id"])}"; + if (!seen.Add(key) && message is null) message = "同一考生、考试和科目在文件中重复"; + if (message is not null) return Error(400, $"第 {sourceRow} 行:{message};请返回预览修正后重试"); + 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, exam!, subject!, existing, result, candidateNumber)); + } + var writes = prepared.Select(item => + { + var result = item.Result with + { + Grade = item.Result.Published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布" + }; + return new ResultWrite( + result, + item.Existing is null, + Audit(user, result.Published ? "Excel 批量发布成绩" : "Excel 批量保存成绩", + $"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.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, + ["count"] = writes.Length, + ["summary"] = new JsonObject + { + ["total"] = writes.Length, + ["valid"] = writes.Length, + ["invalid"] = 0, + ["create"] = create, + ["update"] = writes.Length - create, + ["publish"] = prepared.Count(item => item.Result.Published) + } + }); + } + public async Task SaveFeatureBulkAsync( string sessionToken, JsonObject body, @@ -862,6 +960,8 @@ internal sealed class AdminResultService( double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN; + private static int Integer(JsonNode? node, int fallback = 0) => + int.TryParse(Text(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; 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) @@ -898,6 +998,13 @@ internal sealed class AdminResultService( OperationalRegistration Registration, AdminWorkflowResult? Existing, AdminWorkflowResult Result); + private sealed record PreparedImport( + OperationalRegistration Registration, + OperationalExam Exam, + JsonObject Subject, + AdminWorkflowResult? Existing, + AdminWorkflowResult Result, + string CandidateNumber); private sealed record ResultSummary(bool Complete, bool? Qualified); private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index f8ee2d7..4cc98f0 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -67,6 +67,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -80,6 +81,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 3465ccc..ac71372 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -181,6 +181,9 @@ public static class NativeAdminReadEndpoints 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/results/import", + (HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) => + Execute(context, service.ImportAsync(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))); @@ -202,6 +205,55 @@ public static class NativeAdminReadEndpoints context.Request.Query["examId"].ToString(), cancellationToken))); } + + if (options.NativeAdmissionsEnabled) + { + endpoints.MapGet("/api/admin/admissions", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapGet("/api/admin/indicator-qualifications", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.GetQualificationsAsync(Token(context), cancellationToken))); + endpoints.MapPut("/api/admin/indicator-qualifications/{examId}/bulk", + (HttpContext context, string examId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.SaveQualificationsAsync(Token(context), examId, body, cancellationToken))); + endpoints.MapPut("/api/admin/indicator-qualifications/{examId}/{userId}", + (HttpContext context, string examId, string userId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.SaveQualificationAsync(Token(context), examId, userId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admission-school-accounts", + (HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAccountAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/admission-school-accounts/{accountId}", + (HttpContext context, string accountId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAccountAsync(Token(context), accountId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admission-school-accounts/{accountId}/reset-password", + (HttpContext context, string accountId, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.ResetAccountPasswordAsync(Token(context), accountId, cancellationToken))); + endpoints.MapPut("/api/admin/admissions/{examId}/setting", + (HttpContext context, string examId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.SaveSettingAsync(Token(context), examId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admission-plans", + (HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.SavePlanAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/admission-plans/{planId}", + (HttpContext context, string planId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewPlanAsync(Token(context), planId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admissions/{examId}/{action:regex(^(match|finalize|supplementary)$)}", + (HttpContext context, string examId, string action, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.RunActionAsync(Token(context), examId, action, body, cancellationToken))); + endpoints.MapPatch("/api/admin/admission-reporting/{recordId}", + (HttpContext context, string recordId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewReportingAsync(Token(context), recordId, body, cancellationToken))); + endpoints.MapPatch("/api/admin/admission-withdrawals/{placementId}", + (HttpContext context, string placementId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewWithdrawalAsync(Token(context), placementId, body, cancellationToken))); + } + if (options.NativeReadsEnabled) + { + endpoints.MapDelete("/api/admin/admins/{**path}", NativeDeleteNotAllowed); + endpoints.MapDelete("/api/admin/candidates/{**path}", NativeDeleteNotAllowed); + endpoints.MapDelete("/api/admin/candidate-accounts/{**path}", NativeDeleteNotAllowed); + } return endpoints; } @@ -234,4 +286,13 @@ public static class NativeAdminReadEndpoints private static string Token(HttpContext context) => context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; + + private static IResult NativeDeleteNotAllowed(HttpContext context) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; + return Results.Json( + new JsonObject { ["ok"] = false, ["message"] = "账户不得删除;考生账户请由校方归档,管理员账户可停用" }, + statusCode: StatusCodes.Status405MethodNotAllowed); + } } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 90b96ff..1f2b507 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -57,7 +57,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled"), builder.Configuration.GetValue("AdminMigration:NativeExamManagementEnabled"), builder.Configuration.GetValue("AdminMigration:NativeArrangementsEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeResultsEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeResultsEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeAdmissionsEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -124,6 +125,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled, nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled, nativeResultsEnabled = adminMigrationOptions.NativeResultsEnabled, + nativeAdmissionsEnabled = adminMigrationOptions.NativeAdmissionsEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -175,6 +177,15 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c "PATCH score appeals", "GET result Excel export" } : []) + .Concat(adminMigrationOptions.NativeAdmissionsEnabled + ? new[] + { + "GET admissions/indicator qualifications", "PUT qualification single/bulk", + "POST/PATCH/reset admission-school accounts", "PUT admission settings", + "POST/PATCH admission plans", "POST admission match/finalize/supplementary", + "PATCH admission reporting/withdrawals" + } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 2e98512..9c67ea8 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -22,7 +22,8 @@ "NativeWorkflowOperationsEnabled": false, "NativeExamManagementEnabled": false, "NativeArrangementsEnabled": false, - "NativeResultsEnabled": false + "NativeResultsEnabled": false, + "NativeAdmissionsEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 33b99cf..b90dcd4 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -316,6 +316,21 @@ public sealed class AdminMigrationOptionsTests }, operationalReadsEnabled: "true", resultsEnabled: "true"); } + [Fact] + public void EnablesAdmissionsWithOperationalReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeOperationalReadsEnabled); + Assert.True(options.NativeAdmissionsEnabled); + }, operationalReadsEnabled: "true", admissionsEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -332,7 +347,8 @@ public sealed class AdminMigrationOptionsTests string? workflowOperationsEnabled = null, string? examManagementEnabled = null, string? arrangementsEnabled = null, - string? resultsEnabled = null) + string? resultsEnabled = null, + string? admissionsEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -348,6 +364,7 @@ public sealed class AdminMigrationOptionsTests 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 previousAdmissionsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -365,6 +382,7 @@ public sealed class AdminMigrationOptionsTests 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_ADMISSIONS_ENABLED", admissionsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -384,6 +402,7 @@ public sealed class AdminMigrationOptionsTests 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_ADMISSIONS_ENABLED", previousAdmissionsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }