diff --git a/MIGRATION.md b/MIGRATION.md index baeed82..195dad7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -47,7 +47,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' 开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。 -考生域核心端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。志愿填报与录取查询仍会继续转发给 Node: +考生域全部端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载、志愿填报与录取查询)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用: ```powershell $env:AUTH_NATIVE_ENABLED = 'true' diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 4aad362..4698fc7 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -126,6 +126,28 @@ function Assert-CandidateResultsEquivalent { } } +function Assert-CandidateAdmissionsEquivalent { + param( + [Parameter(Mandatory)] + [string] $Expected, + + [Parameter(Mandatory)] + [string] $Actual + ) + + $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) + $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) + foreach ($node in @($expectedNode, $actualNode)) { + foreach ($admission in $node['admissions'].AsArray()) { + $admission.AsObject().Remove('noticeVerificationQr') | Out-Null + } + } + if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { + $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' + throw "Candidate route 'admissions' JSON payload differs from the legacy API at $difference" + } +} + function Find-JsonDifference { param( [AllowNull()] @@ -439,6 +461,58 @@ try { if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) { throw 'Native candidate results did not include a local PNG verification QR code' } + $legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession + $nativeCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession + if ($nativeCandidateAdmissionsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Candidate route 'admissions' did not use the native ASP.NET Core endpoint" + } + Assert-CandidateAdmissionsEquivalent -Expected $legacyCandidateAdmissionsResponse.Content -Actual $nativeCandidateAdmissionsResponse.Content + $admissionWriteProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @( + 'tests/helpers/prepare-candidate-admission-write-smoke.mjs', $smokeDatabasePath + ) -Environment $nodeEnvironment + if (-not $admissionWriteProcess.WaitForExit(10000)) { + $admissionWriteProcess.Kill($true) + throw 'Timed out while preparing the candidate admission preference smoke data' + } + $admissionWriteError = $admissionWriteProcess.StandardError.ReadToEnd() + if ($admissionWriteProcess.ExitCode -ne 0) { + throw "Could not prepare the candidate admission preference smoke data`n$admissionWriteError" + } + $admissionWriteProcess.Dispose() + $admissionContext = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession + $admissionSetting = @($admissionContext.admissions | Where-Object { $_.status -eq 'filling' } | Select-Object -First 1)[0] + $admissionPlan = @($admissionSetting.plans | Select-Object -First 1)[0] + $admissionCategory = @($admissionPlan.categories | Where-Object { $_.preferenceTypes -contains 'general' } | Select-Object -First 1)[0] + if ($null -eq $admissionSetting -or $null -eq $admissionPlan -or $null -eq $admissionCategory) { + throw 'Native candidate admissions did not expose an eligible plan for preference submission' + } + $emptyPreference = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body '{"choices":[]}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck + if ($emptyPreference.StatusCode -ne 400) { + throw 'Native admission preference API accepted an empty preference list' + } + $preferenceBody = @{ + choices = @(@{ + schoolId = $admissionPlan.schoolId + categoryCode = $admissionCategory.code + preferenceType = 'general' + }) + } | ConvertTo-Json -Depth 5 -Compress + $firstPreferenceResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession + $firstPreference = $firstPreferenceResponse.Content | ConvertFrom-Json + if ($firstPreferenceResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $firstPreference.remainingSubmissions -ne 1 -or $firstPreference.locked -ne $false) { + throw 'Native admission preference API did not persist the first submission count' + } + $secondPreference = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession + if ($secondPreference.remainingSubmissions -ne 0 -or $secondPreference.locked -ne $true) { + throw 'Native admission preference API did not lock at the configured submission limit' + } + $lockedPreference = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession -SkipHttpErrorCheck + if ($lockedPreference.StatusCode -ne 409) { + throw 'Native admission preference API accepted a submission after automatic locking' + } + $legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession + $nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession + Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content $appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0] if ($null -ne $appealableResult) { $shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck @@ -699,6 +773,7 @@ try { NativeCandidateReads = 'passed' NativeCandidateWrites = 'passed' NativeCandidateDocuments = 'passed' + NativeCandidateAdmissions = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Candidate/ICandidateService.cs b/src/Eis.Application/Candidate/ICandidateService.cs index cf01347..0f6d7a5 100644 --- a/src/Eis.Application/Candidate/ICandidateService.cs +++ b/src/Eis.Application/Candidate/ICandidateService.cs @@ -27,6 +27,11 @@ public interface ICandidateService string verificationBaseUrl, CancellationToken cancellationToken); + Task GetAdmissionsAsync( + string sessionToken, + string verificationBaseUrl, + CancellationToken cancellationToken); + Task UpdateProfileAsync( string sessionToken, JsonObject body, @@ -47,4 +52,10 @@ public interface ICandidateService string sessionToken, string registrationId, CancellationToken cancellationToken); + + Task UpdateAdmissionPreferencesAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken); } diff --git a/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs b/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs new file mode 100644 index 0000000..5c01ce3 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs @@ -0,0 +1,132 @@ +using System.Data.Common; +using System.Globalization; +using System.Text.Json.Nodes; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Candidate; + +internal sealed record CandidateAdmissionRecord( + string Id, + string Kind, + string ExamId, + string? UserId, + string? SchoolId, + string Status, + JsonObject Payload, + string CreatedAt, + string UpdatedAt); + +internal sealed record CandidateAdmissionSchool(string Id, string Code, string Name); + +internal sealed record CandidateAdmissionSnapshot( + IReadOnlyList Records, + IReadOnlyDictionary Schools); + +internal sealed class CandidateAdmissionRepository(IRelationalConnectionFactory connectionFactory) +{ + public async Task LoadAsync(CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var records = new List(); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id"; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + records.Add(new CandidateAdmissionRecord( + ReadString(reader, "id"), + ReadString(reader, "kind"), + ReadString(reader, "exam_id"), + ReadOptionalString(reader, "user_id"), + ReadOptionalString(reader, "school_id"), + ReadString(reader, "status"), + JsonNode.Parse(ReadString(reader, "payload_json"))?.AsObject() ?? new JsonObject(), + ReadString(reader, "created_at"), + ReadString(reader, "updated_at"))); + } + } + + var schools = new List(); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT id, code, name FROM schools ORDER BY id"; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + schools.Add(new CandidateAdmissionSchool( + ReadString(reader, "id"), + ReadString(reader, "code"), + ReadString(reader, "name"))); + } + } + + return new CandidateAdmissionSnapshot( + records, + schools.ToDictionary(item => item.Id, StringComparer.Ordinal)); + } + + public async Task SavePreferenceAsync( + CandidateAdmissionRecord record, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + await using (var delete = connection.CreateCommand()) + { + delete.Transaction = transaction; + delete.CommandText = "DELETE FROM admission_records WHERE id = @id"; + AddParameter(delete, "@id", record.Id); + await delete.ExecuteNonQueryAsync(cancellationToken); + } + + await using (var insert = connection.CreateCommand()) + { + insert.Transaction = transaction; + insert.CommandText = """ + 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 + ) + """; + AddParameter(insert, "@id", record.Id); + AddParameter(insert, "@kind", record.Kind); + AddParameter(insert, "@examId", record.ExamId); + AddParameter(insert, "@userId", record.UserId); + AddParameter(insert, "@schoolId", record.SchoolId); + AddParameter(insert, "@status", record.Status); + AddParameter(insert, "@payload", record.Payload.ToJsonString()); + AddParameter(insert, "@createdAt", record.CreatedAt); + AddParameter(insert, "@updatedAt", record.UpdatedAt); + await insert.ExecuteNonQueryAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private static string ReadString(DbDataReader reader, string name) => + Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty; + + private static string? ReadOptionalString(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs index 2ca0df7..19e3a4d 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs @@ -22,7 +22,7 @@ public sealed record CandidateMigrationOptions(bool NativeEnabled) if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting) { throw new InvalidOperationException( - "考生域尚有接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话"); + "应用仍有受保护接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话"); } return new CandidateMigrationOptions(enabled); diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs new file mode 100644 index 0000000..ca5251d --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs @@ -0,0 +1,573 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Eis.Application.Candidate; + +namespace Eis.Infrastructure.Candidate; + +internal sealed partial class CandidateService +{ + 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 GetAdmissionsAsync( + string sessionToken, + string verificationBaseUrl, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var user = context.User!; + var profile = context.Profile!; + var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); + var admission = await admissionRepository.LoadAsync(cancellationToken); + var settings = new List(); + foreach (var setting in Records(admission, "setting").Where(item => AdmissionBoolean(item.Payload["enabled"]))) + { + var exam = snapshot.Exams.FirstOrDefault(item => item.Id == setting.ExamId); + if (exam is null) + { + continue; + } + + var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1)); + var preference = ActivePreference(admission, setting.ExamId, user.Id, round); + var qualification = Records(admission, "indicator_qualification", setting.ExamId) + .FirstOrDefault(item => item.UserId == user.Id); + var placement = Records(admission, "placement", setting.ExamId) + .FirstOrDefault(item => item.UserId == user.Id && item.Status != "withdrawn"); + var blocking = setting.Status == "supplementary" + ? Records(admission, "placement", setting.ExamId).FirstOrDefault(item => + item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal)) + : null; + var supplementEligible = blocking is null; + var supplementarySchools = SupplementarySchoolIds(admission, setting); + var plans = supplementEligible + ? BuildAvailablePlans(admission, profile, setting.ExamId, qualification, supplementarySchools) + : []; + var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == setting.ExamId); + var submissionCount = AdmissionInteger(preference?.Payload["submissionCount"]); + var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3)); + var school = placement?.SchoolId is not null + ? admission.Schools.GetValueOrDefault(placement.SchoolId) + : null; + var templateRecord = placement?.SchoolId is not null + ? Records(admission, "notification").FirstOrDefault(item => item.SchoolId == placement.SchoolId && item.Status == "template") + : null; + var verificationCode = placement?.Status == "final" + ? documentCodes.AdmissionNoticeCode( + placement.Id, + placement.UserId ?? string.Empty, + placement.SchoolId ?? string.Empty, + exam.Id, + AdmissionText(placement.Payload["categoryCode"]), + AdmissionText(placement.Payload["noticeNumber"]), + placement.UpdatedAt) + : string.Empty; + + var item = AdmissionRecordJson(setting); + item["exam"] = PublicExamJson(exam); + item["preference"] = preference is null ? null : PreferenceView(admission, preference); + if (placement is not null) + { + item["placement"] = AdmissionRecordJson(placement); + } + item["placementSchool"] = school is null + ? null + : new JsonObject { ["id"] = school.Id, ["name"] = school.Name, ["code"] = school.Code }; + item["noticeTemplate"] = templateRecord?.Payload["template"]?.DeepClone(); + item["noticeVerificationCode"] = verificationCode; + item["noticeVerificationQr"] = verificationCode.Length == 0 + ? string.Empty + : CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(verificationCode)}"); + item["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]); + item["plans"] = new JsonArray(plans.Select(plan => (JsonNode)plan).ToArray()); + item["supplementEligible"] = supplementEligible; + item["supplementIneligibilityReason"] = blocking?.Status switch + { + "forfeited" => "因本轮未按规定完成报到,不能再次参加补录。", + not null => "你已被录取,本轮补录无需且不能再次填报。", + _ => string.Empty + }; + item["totalScore"] = JsonValue.Create(CandidateTotalScore(snapshot, setting.ExamId)); + item["featureScore"] = registration?.FeatureScore ?? 0; + item["specialtyQualification"] = SpecialtyQualification(profile); + item["indicatorQualification"] = qualification is null ? null : AdmissionRecordJson(qualification); + item["submissionCount"] = submissionCount; + item["maxSubmissions"] = maxSubmissions; + item["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount); + item["preferenceLocked"] = submissionCount >= maxSubmissions; + settings.Add(item); + } + + var notifications = Records(admission, "notification") + .Where(item => item.UserId == user.Id) + .OrderByDescending(item => ParseDate(item.CreatedAt)) + .Select(item => + { + var placement = Records(admission, "placement", item.ExamId) + .FirstOrDefault(entry => entry.Id == AdmissionText(item.Payload["placementId"])); + var schoolId = placement?.SchoolId ?? item.SchoolId; + var school = schoolId is not null ? admission.Schools.GetValueOrDefault(schoolId) : null; + var exam = snapshot.Exams.FirstOrDefault(entry => entry.Id == item.ExamId); + var output = AdmissionRecordJson(item); + output["examName"] = exam?.Name ?? string.Empty; + output["schoolName"] = school?.Name ?? string.Empty; + output["schoolCode"] = school?.Code ?? string.Empty; + output["categoryName"] = AdmissionText(placement?.Payload["categoryName"]); + output["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]); + output["placementStatus"] = placement?.Status ?? string.Empty; + return output; + }) + .ToArray(); + + return Success(new JsonObject + { + ["ok"] = true, + ["admissions"] = new JsonArray(settings.Select(item => (JsonNode)item).ToArray()), + ["notifications"] = new JsonArray(notifications.Select(item => (JsonNode)item).ToArray()) + }); + } + + public async Task UpdateAdmissionPreferencesAsync( + string sessionToken, + string examId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return context.Error; + } + + var user = context.User!; + var profile = context.Profile!; + var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); + var admission = await admissionRepository.LoadAsync(cancellationToken); + var setting = Records(admission, "setting", examId).FirstOrDefault(); + if (setting is null || !AdmissionBoolean(setting.Payload["enabled"])) + { + return Error(404, "该考试未开放志愿填报"); + } + + if (setting.Status is not ("filling" or "supplementary")) + { + return Error(409, "当前不在志愿填报阶段"); + } + + if (setting.Status == "supplementary") + { + var blocking = Records(admission, "placement", setting.ExamId).FirstOrDefault(item => + item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal)); + if (blocking?.Status == "forfeited") + { + return Error(403, "因未按规定完成报到,本轮不能再次参加补录"); + } + + if (blocking is not null) + { + return Error(403, "你已被录取,本轮补录不能再次填报"); + } + } + + var now = DateTimeOffset.UtcNow; + if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceStart"]), out var start) && now < start) + { + return Error(409, "志愿填报尚未开始"); + } + + if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceEnd"]), out var end) && now > end) + { + return Error(409, "志愿填报已经截止"); + } + + if (CandidateTotalScore(snapshot, setting.ExamId) is null) + { + return Error(403, "本场考试成绩全部发布后才能填报志愿"); + } + + var maxChoices = Math.Max(1, AdmissionInteger(setting.Payload["maxChoices"], 5)); + var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1)); + var current = ActivePreference(admission, setting.ExamId, user.Id, round); + var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3)); + var submissionCount = AdmissionInteger(current?.Payload["submissionCount"]); + if (submissionCount >= maxSubmissions) + { + return Error(409, $"志愿已达到 {maxSubmissions} 次提交上限,现已自动锁定"); + } + + var choices = body["choices"] is JsonArray requested + ? requested.Take(maxChoices + 1).OfType().Select(item => new AdmissionChoice( + Clean(item, "schoolId", 64), + Clean(item, "categoryCode", 40), + Text(item, "preferenceType") == "indicator" ? "indicator" : "general")).ToArray() + : []; + if (choices.Length == 0) + { + return Error(400, "请至少选择一个志愿"); + } + + var indicatorChoices = choices.Count(item => item.PreferenceType == "indicator"); + var generalChoices = choices.Count(item => item.PreferenceType == "general"); + if (indicatorChoices > 1 || generalChoices > maxChoices) + { + return Error(400, $"本轮最多填报 1 个指标分配志愿和 {maxChoices} 个普通志愿"); + } + + if (indicatorChoices > 0 && choices[0].PreferenceType != "indicator") + { + return Error(400, "指标分配志愿必须位于专用第一栏"); + } + + if (choices.Select(item => $"{item.PreferenceType}|{item.SchoolId}|{item.CategoryCode}") + .Distinct(StringComparer.Ordinal).Count() != choices.Length) + { + return Error(400, "同类志愿中同一学校和招生类别不能重复填报"); + } + + var supplementarySchools = SupplementarySchoolIds(admission, setting); + var plans = Records(admission, "plan", setting.ExamId) + .Where(item => item.Status == "approved" && + (supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId))) + .ToArray(); + var qualification = Records(admission, "indicator_qualification", setting.ExamId) + .FirstOrDefault(item => item.UserId == user.Id); + if (choices.Any(choice => !ValidAdmissionChoice(admission, profile, plans, qualification, choice))) + { + return Error(400, "志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别"); + } + + var nowValue = NowIso(); + var storedChoices = choices.Select(choice => + { + var school = admission.Schools.GetValueOrDefault(choice.SchoolId); + var category = PlanCategories(plans.First(item => item.SchoolId == choice.SchoolId)) + .First(item => AdmissionText(item["code"]) == choice.CategoryCode); + return new JsonObject + { + ["schoolId"] = choice.SchoolId, + ["categoryCode"] = choice.CategoryCode, + ["preferenceType"] = choice.PreferenceType, + ["schoolCode"] = school?.Code ?? string.Empty, + ["schoolName"] = school?.Name ?? string.Empty, + ["categoryName"] = AdmissionText(category["name"]) + }; + }).ToArray(); + var preference = new CandidateAdmissionRecord( + current?.Id ?? Uid("preference"), + "preference", + setting.ExamId, + user.Id, + null, + "submitted", + new JsonObject + { + ["round"] = round, + ["choices"] = new JsonArray(storedChoices.Select(item => (JsonNode)item).ToArray()), + ["submittedAt"] = nowValue, + ["submissionCount"] = submissionCount + 1 + }, + current?.CreatedAt ?? nowValue, + nowValue); + await admissionRepository.SavePreferenceAsync(preference, cancellationToken); + + var locked = submissionCount + 1 >= maxSubmissions; + return Success(new JsonObject + { + ["ok"] = true, + ["preference"] = AdmissionRecordJson(preference), + ["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount - 1), + ["locked"] = locked, + ["message"] = locked ? "志愿已保存并达到提交上限,现已自动锁定" : "志愿已由本人保存" + }); + } + + private static readonly string[] BlockingPlacementStatuses = + ["school_review", "admitted", "final", "withdrawal_pending", "forfeited"]; + + private static IEnumerable Records( + CandidateAdmissionSnapshot snapshot, + string kind, + string? examId = null) => snapshot.Records.Where(item => + item.Kind == kind && (examId is null || item.ExamId == examId)); + + private static CandidateAdmissionRecord? ActivePreference( + CandidateAdmissionSnapshot snapshot, + string examId, + string userId, + int round) => Records(snapshot, "preference", examId).FirstOrDefault(item => + item.UserId == userId && AdmissionInteger(item.Payload["round"], 1) == round); + + private static JsonObject PreferenceView( + CandidateAdmissionSnapshot snapshot, + CandidateAdmissionRecord preference) + { + var output = AdmissionRecordJson(preference); + var payload = preference.Payload.DeepClone().AsObject(); + var choices = preference.Payload["choices"]?.AsArray().OfType().Select(choice => + { + var item = choice.DeepClone().AsObject(); + var schoolId = AdmissionText(choice["schoolId"]); + var categoryCode = AdmissionText(choice["categoryCode"]); + var school = snapshot.Schools.GetValueOrDefault(schoolId); + var categories = Records(snapshot, "plan", preference.ExamId) + .Where(plan => plan.SchoolId == schoolId) + .SelectMany(PlanCategories) + .ToArray(); + var category = categories.FirstOrDefault(entry => AdmissionText(entry["code"]) == categoryCode) + ?? (categoryCode == "general" ? categories.FirstOrDefault(entry => + AdmissionText(entry["specialtyCategory"]).Length == 0 && AdmissionText(entry["specialtyType"]).Length == 0) : null) + ?? (categoryCode is "sport" or "sports" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "sports") : null) + ?? (categoryCode is "art" or "arts" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "arts") : null); + item["schoolCode"] = AdmissionText(choice["schoolCode"]).Length > 0 ? AdmissionText(choice["schoolCode"]) : school?.Code ?? string.Empty; + item["schoolName"] = AdmissionText(choice["schoolName"]).Length > 0 ? AdmissionText(choice["schoolName"]) : school?.Name ?? string.Empty; + item["categoryName"] = AdmissionText(choice["categoryName"]).Length > 0 ? AdmissionText(choice["categoryName"]) : AdmissionText(category?["name"]); + return item; + }).ToArray() ?? []; + payload["choices"] = new JsonArray(choices.Select(item => (JsonNode)item).ToArray()); + output["payload"] = payload; + return output; + } + + private static IReadOnlyList BuildAvailablePlans( + CandidateAdmissionSnapshot snapshot, + JsonObject profile, + string examId, + CandidateAdmissionRecord? qualification, + IReadOnlySet? supplementarySchools) + { + var output = new List(); + foreach (var plan in Records(snapshot, "plan", examId).Where(item => item.Status == "approved" && + (supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId)))) + { + var school = plan.SchoolId is not null ? snapshot.Schools.GetValueOrDefault(plan.SchoolId) : null; + var placements = Records(snapshot, "placement", examId).Where(item => + item.SchoolId == plan.SchoolId && item.Status is not ("withdrawn" or "forfeited")).ToArray(); + var categories = new List(); + foreach (var source in PlanCategories(plan)) + { + if (!CandidateEligibleForCategory(profile, source)) + { + continue; + } + + var category = source.DeepClone().AsObject(); + var categoryCode = AdmissionText(source["code"]); + var quota = AdmissionNumber(source["quota"]); + var allocations = source["indicatorAllocations"]?.AsArray().OfType().ToArray() ?? []; + var used = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode); + category["used"] = used; + category["remaining"] = Math.Max(0, quota - used); + var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == Text(profile, "schoolId")); + var indicatorUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode && + AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{Text(profile, "schoolId")}"); + var generalQuota = Math.Max(0, quota - allocations.Sum(item => AdmissionNumber(item["quota"]))); + var generalUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode && + AdmissionText(item.Payload["quotaBucket"]) == "general"); + var indicatorRemaining = Math.Max(0, AdmissionNumber(allocation?["quota"]) - indicatorUsed); + var generalRemaining = Math.Max(0, generalQuota - generalUsed); + var preferenceTypes = new List(); + if (generalRemaining > 0) preferenceTypes.Add(JsonValue.Create("general")!); + if (AdmissionBoolean(qualification?.Payload["eligible"]) && indicatorRemaining > 0) preferenceTypes.Add(JsonValue.Create("indicator")!); + category["generalRemaining"] = generalRemaining; + category["indicatorRemaining"] = indicatorRemaining; + category["preferenceTypes"] = new JsonArray(preferenceTypes.ToArray()); + if (preferenceTypes.Count > 0) + { + categories.Add(category); + } + } + + if (categories.Count > 0) + { + output.Add(new JsonObject + { + ["id"] = plan.Id, + ["schoolId"] = plan.SchoolId, + ["schoolCode"] = school?.Code ?? string.Empty, + ["schoolName"] = school?.Name ?? string.Empty, + ["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray()) + }); + } + } + + return output; + } + + private static bool ValidAdmissionChoice( + CandidateAdmissionSnapshot snapshot, + JsonObject profile, + IReadOnlyList plans, + CandidateAdmissionRecord? qualification, + AdmissionChoice choice) + { + var plan = plans.FirstOrDefault(item => item.SchoolId == choice.SchoolId); + var category = plan is null + ? null + : PlanCategories(plan).FirstOrDefault(item => AdmissionText(item["code"]) == choice.CategoryCode); + if (plan is null || category is null || !CandidateEligibleForCategory(profile, category)) + { + return false; + } + + var placements = Records(snapshot, "placement", plan.ExamId).Where(item => + item.SchoolId == plan.SchoolId && AdmissionText(item.Payload["categoryCode"]) == choice.CategoryCode && + item.Status is not ("withdrawn" or "forfeited")).ToArray(); + var allocations = category["indicatorAllocations"]?.AsArray().OfType().ToArray() ?? []; + if (choice.PreferenceType == "indicator") + { + var schoolId = Text(profile, "schoolId"); + var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == schoolId); + var used = placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{schoolId}"); + return AdmissionBoolean(qualification?.Payload["eligible"]) && AdmissionNumber(allocation?["quota"]) > used; + } + + var quota = AdmissionNumber(category["quota"]) - allocations.Sum(item => AdmissionNumber(item["quota"])); + return quota > placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == "general"); + } + + private static IReadOnlySet? SupplementarySchoolIds( + CandidateAdmissionSnapshot snapshot, + CandidateAdmissionRecord setting) + { + if (setting.Status != "supplementary") + { + return null; + } + + var sourceRound = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1) - 1); + var ids = Records(snapshot, "notification", setting.ExamId) + .Where(item => item.UserId is null && item.Status == "approved" && + AdmissionText(item.Payload["type"]) == "admission_reporting" && + AdmissionInteger(item.Payload["round"], 1) == sourceRound && + AdmissionText(item.Payload["supplementDecision"]) == "supplement" && item.SchoolId is not null) + .Select(item => item.SchoolId!) + .ToHashSet(StringComparer.Ordinal); + return ids.Count == 0 ? null : ids; + } + + private static double? CandidateTotalScore(CandidateReadSnapshot snapshot, string examId) + { + var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == examId && item.Status == "approved"); + if (registration is null || registration.SubjectIds.Count == 0) + { + return null; + } + + var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray(); + if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id))) + { + return null; + } + + return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero); + } + + private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category) + { + var requiredCategory = AdmissionText(category["specialtyCategory"]); + var requiredType = AdmissionText(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 JsonObject SpecialtyQualification(JsonObject profile) + { + var specialty = ResolveSpecialty(profile); + return new JsonObject { ["category"] = specialty.Category, ["type"] = specialty.Type }; + } + + 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"]?.AsArray() ?? []) + { + if (LegacySpecialties.TryGetValue(value?.ToString() ?? string.Empty, out var legacy)) + { + return legacy; + } + } + + return (string.Empty, string.Empty); + } + + private static JsonObject PublicExamJson(CandidateExam exam) + { + var output = ExamJson(exam); + output["totalScore"] = exam.Subjects.Sum(item => item.FullScore); + output["registrationState"] = exam.ArchivedAt is not null ? "archived" : RegistrationState(exam); + return output; + } + + private static JsonObject AdmissionRecordJson(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 IReadOnlyList PlanCategories(CandidateAdmissionRecord plan) => + plan.Payload["categories"]?.AsArray().OfType().ToArray() ?? []; + + private static string AdmissionText(JsonNode? value) => value?.ToString() ?? string.Empty; + + private static double AdmissionNumber(JsonNode? value, double fallback = 0) => + double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + + private static int AdmissionInteger(JsonNode? value, int fallback = 0) => + double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? (int)Math.Truncate(parsed) + : fallback; + + private static bool AdmissionBoolean(JsonNode? value) => + bool.TryParse(value?.ToString(), out var parsed) && parsed; + + private static bool TryAdmissionDate(string value, out DateTimeOffset parsed) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsed); + + private sealed record AdmissionChoice(string SchoolId, string CategoryCode, string PreferenceType); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.cs b/src/Eis.Infrastructure/Candidate/CandidateService.cs index 6216a2e..92a9503 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.cs @@ -14,7 +14,8 @@ internal sealed partial class CandidateService( IPublicQueryService publicQueries, CandidateWriteRepository writeRepository, RegionCatalog regionCatalog, - DocumentVerificationCodeService documentCodes) : ICandidateService + DocumentVerificationCodeService documentCodes, + CandidateAdmissionRepository admissionRepository) : ICandidateService { public async Task GetDashboardAsync( string sessionToken, diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 40aeb38..625ff3b 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -46,6 +46,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); return services; diff --git a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs index ce7eaad..34d608e 100644 --- a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs +++ b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs @@ -5,11 +5,11 @@ namespace Eis.Infrastructure.Migration; public static class MigrationFeatureCatalog { - public static IReadOnlyList Current(bool authenticationNative) => + public static IReadOnlyList Current(bool authenticationNative, bool candidateNative) => [ new(FeatureArea.Public, true, "/api/public"), new(FeatureArea.Authentication, authenticationNative, "/api/auth"), - new(FeatureArea.Candidate, false, "/api/candidate"), + new(FeatureArea.Candidate, candidateNative, "/api/candidate"), new(FeatureArea.Administration, false, "/api/admin"), new(FeatureArea.Admission, false, "/api/admission"), new(FeatureArea.Documents, false, "/api"), diff --git a/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs index ef73c89..3708a58 100644 --- a/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs +++ b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs @@ -67,6 +67,27 @@ public static class NativeCandidateEndpoints SessionToken(context), VerificationBaseUrl(context), cancellationToken))); + endpoints.MapGet("/api/candidate/admissions", async ( + HttpContext context, + ICandidateService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetAdmissionsAsync( + SessionToken(context), + VerificationBaseUrl(context), + cancellationToken))); + endpoints.MapPut("/api/candidate/admissions/{examId}/preferences", async ( + HttpContext context, + string examId, + System.Text.Json.Nodes.JsonObject request, + ICandidateService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.UpdateAdmissionPreferencesAsync( + SessionToken(context), + examId, + request, + cancellationToken))); endpoints.MapPost("/api/candidate/results/{resultId}/appeals", async ( HttpContext context, string resultId, diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 3196dfd..1e13996 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -86,11 +86,11 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c ? new[] { "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations", - "GET results", "POST result appeals", "GET admit cards" + "GET results", "POST result appeals", "GET admit cards", "GET admissions", "PUT admission preferences" } : [] }, - features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled) + features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) }, statusCode: statusCode); }); diff --git a/tests/Eis.Infrastructure.Tests/Migration/MigrationFeatureCatalogTests.cs b/tests/Eis.Infrastructure.Tests/Migration/MigrationFeatureCatalogTests.cs new file mode 100644 index 0000000..b866101 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Migration/MigrationFeatureCatalogTests.cs @@ -0,0 +1,17 @@ +using Eis.Domain.Migration; +using Eis.Infrastructure.Migration; + +namespace Eis.Infrastructure.Tests.Migration; + +public sealed class MigrationFeatureCatalogTests +{ + [Fact] + public void ReportsCandidateAreaFromItsIndependentMigrationFlag() + { + var features = MigrationFeatureCatalog.Current(authenticationNative: true, candidateNative: true); + + Assert.True(features.Single(item => item.Area == FeatureArea.Authentication).Native); + Assert.True(features.Single(item => item.Area == FeatureArea.Candidate).Native); + Assert.False(features.Single(item => item.Area == FeatureArea.Administration).Native); + } +} diff --git a/tests/helpers/prepare-candidate-admission-write-smoke.mjs b/tests/helpers/prepare-candidate-admission-write-smoke.mjs new file mode 100644 index 0000000..2c98503 --- /dev/null +++ b/tests/helpers/prepare-candidate-admission-write-smoke.mjs @@ -0,0 +1,21 @@ +import { DatabaseSync } from 'node:sqlite'; + +const databasePath = process.argv[2]; +if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径'); + +const database = new DatabaseSync(databasePath); +const setting = database.prepare("SELECT id, payload_json FROM admission_records WHERE kind = 'setting' ORDER BY created_at, id LIMIT 1").get(); +if (!setting) throw new Error('测试数据库缺少招生设置'); +const payload = { + ...JSON.parse(setting.payload_json || '{}'), + enabled: true, + preferenceStart: '2000-01-01T00:00:00.000Z', + preferenceEnd: '2099-12-31T23:59:59.999Z', + maxChoices: 3, + maxSubmissions: 2, + round: 2, + progress: 'ASP.NET Core 志愿提交冒烟验证' +}; +database.prepare("UPDATE admission_records SET status = 'filling', payload_json = ?, updated_at = ? WHERE id = ?") + .run(JSON.stringify(payload), '2026-07-22T00:00:00.000Z', setting.id); +database.close();