From 4417d6d674e093a3263bd7e3221a6102d60da3b4 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 20:08:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=88=90=E7=BB=A9=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E3=80=81=E7=A7=91=E7=9B=AE/=E6=80=BB=E5=88=86?= =?UTF-8?q?=E6=8E=92=E5=90=8D=E3=80=81=E8=BE=BE=E7=BA=BF=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E3=80=81=E7=AD=89=E7=BA=A7=E8=AE=A1=E7=AE=97=E5=8F=8A=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E5=8D=95=E9=98=B2=E4=BC=AA=E7=A0=81=EF=BC=9A[Candidat?= =?UTF-8?q?eService.Results.cs=20(line=2011)](C:/Users/BI/Documents/EIS-do?= =?UTF-8?q?tnet/src/Eis.Infrastructure/Candidate/CandidateService.Results.?= =?UTF-8?q?cs:11)=20=E5=8E=9F=E7=94=9F=E6=88=90=E7=BB=A9=E5=A4=8D=E8=AE=AE?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=EF=BC=8C=E5=B7=A5=E4=BD=9C=E6=B5=81=E3=80=81?= =?UTF-8?q?=E5=88=86=E9=85=8D=E4=BA=BA=E4=B8=8E=E5=AE=A1=E8=AE=A1=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=90=8C=E4=BA=8B=E5=8A=A1=E5=86=99=E5=85=A5=E3=80=82?= =?UTF-8?q?=20=E5=8E=9F=E7=94=9F=E5=87=86=E8=80=83=E8=AF=81=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E7=AA=97=E5=8F=A3=E6=A0=A1=E9=AA=8C=E5=8F=8A=20HTML?= =?UTF-8?q?=20=E6=96=87=E4=B9=A6=E7=94=9F=E6=88=90=EF=BC=9A[CandidateAdmit?= =?UTF-8?q?CardRenderer.cs=20(line=209)](C:/Users/BI/Documents/EIS-dotnet/?= =?UTF-8?q?src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs:?= =?UTF-8?q?9)=20=E4=B8=89=E4=B8=AA=E8=B7=AF=E7=94=B1=E5=B7=B2=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=88=B0=20ASP.NET=20Core=EF=BC=9A[NativeCandidateEnd?= =?UTF-8?q?points.cs=20(line=2061)](C:/Users/BI/Documents/EIS-dotnet/src/E?= =?UTF-8?q?is.Web/Candidate/NativeCandidateEndpoints.cs:61)=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=BF=81=E7=A7=BB=E8=AF=B4=E6=98=8E=EF=BC=9A[MIGRATIO?= =?UTF-8?q?N.md=20(line=2050)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.?= =?UTF-8?q?md:50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MIGRATION.md | 2 +- scripts/smoke-dotnet-migration.ps1 | 68 ++++ .../Candidate/ICandidateService.cs | 22 ++ .../Candidate/CandidateAdmitCardRenderer.cs | 62 ++++ .../Candidate/CandidateReadSnapshot.cs | 110 ++++-- .../Candidate/CandidateService.AdmitCards.cs | 52 +++ .../Candidate/CandidateService.Results.cs | 337 ++++++++++++++++++ .../Candidate/CandidateService.cs | 4 +- .../Candidate/CandidateWriteRepository.cs | 37 ++ .../Candidate/NativeCandidateEndpoints.cs | 50 +++ src/Eis.Web/Program.cs | 6 +- .../helpers/prepare-candidate-admit-smoke.mjs | 58 +++ 12 files changed, 783 insertions(+), 25 deletions(-) create mode 100644 src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs create mode 100644 src/Eis.Infrastructure/Candidate/CandidateService.AdmitCards.cs create mode 100644 src/Eis.Infrastructure/Candidate/CandidateService.Results.cs create mode 100644 tests/helpers/prepare-candidate-admit-smoke.mjs diff --git a/MIGRATION.md b/MIGRATION.md index c3baf86..baeed82 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 同时启用。志愿填报与录取查询仍会继续转发给 Node: ```powershell $env:AUTH_NATIVE_ENABLED = 'true' diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 01357cd..4aad362 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -104,6 +104,28 @@ function Assert-JsonEquivalent { } } +function Assert-CandidateResultsEquivalent { + 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 ($summary in $node['summaries'].AsArray()) { + $summary.AsObject().Remove('verificationQr') | Out-Null + } + } + if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { + $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' + throw "Candidate route 'results' JSON payload differs from the legacy API at $difference" + } +} + function Find-JsonDifference { param( [AllowNull()] @@ -407,6 +429,51 @@ try { } Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'" } + $legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $candidateSession + $nativeCandidateResultsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results" -WebSession $nativeCandidateSession + if ($nativeCandidateResultsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Candidate route 'results' did not use the native ASP.NET Core endpoint" + } + Assert-CandidateResultsEquivalent -Expected $legacyCandidateResultsResponse.Content -Actual $nativeCandidateResultsResponse.Content + $nativeCandidateResults = $nativeCandidateResultsResponse.Content | ConvertFrom-Json + 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' + } + $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 + if ($shortAppeal.StatusCode -ne 400 -or $shortAppeal.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native score appeal did not validate the minimum reason length' + } + $appealBody = @{ reason = '迁移冒烟验证:成绩与个人估分差异较大,请复核计分。' } | ConvertTo-Json -Compress + $appealResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body $appealBody -WebSession $nativeCandidateSession + $appeal = $appealResponse.Content | ConvertFrom-Json + if ($appealResponse.StatusCode -ne 201 -or $appealResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $appeal.workflow.status -ne 'pending') { + throw 'Native score appeal did not persist a pending workflow' + } + } + $admitProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @( + 'tests/helpers/prepare-candidate-admit-smoke.mjs', $smokeDatabasePath, '2026-HZ01-F-0001' + ) -Environment $nodeEnvironment + if (-not $admitProcess.WaitForExit(10000)) { + $admitProcess.Kill($true) + throw 'Timed out while preparing the candidate admit-card smoke data' + } + $admitRegistrationId = $admitProcess.StandardOutput.ReadToEnd().Trim() + $admitError = $admitProcess.StandardError.ReadToEnd() + if ($admitProcess.ExitCode -ne 0 -or -not $admitRegistrationId) { + throw "Could not prepare the candidate admit-card smoke data`n$admitError" + } + $admitProcess.Dispose() + $legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $candidateSession + $nativeAdmitResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $nativeCandidateSession + if ($nativeAdmitResponse.StatusCode -ne 200 -or + $nativeAdmitResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or + $nativeAdmitResponse.Headers['Content-Disposition'] -notmatch '^attachment;' -or + $nativeAdmitResponse.Content -notmatch '考试考场序号' -or + $nativeAdmitResponse.Content -ne $legacyAdmitResponse.Content) { + throw 'Native admit-card download did not match the legacy HTML document' + } $registrationSchool = @($homePayload.schools | Select-Object -First 1)[0] $registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | Select-Object -First 1)[0] @@ -631,6 +698,7 @@ try { NativeAuthentication = 'passed' NativeCandidateReads = 'passed' NativeCandidateWrites = 'passed' + NativeCandidateDocuments = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Candidate/ICandidateService.cs b/src/Eis.Application/Candidate/ICandidateService.cs index 56f6f48..cf01347 100644 --- a/src/Eis.Application/Candidate/ICandidateService.cs +++ b/src/Eis.Application/Candidate/ICandidateService.cs @@ -4,6 +4,12 @@ namespace Eis.Application.Candidate; public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body); +public sealed record CandidateDocumentResult( + int StatusCode, + string? Content, + string? FileName, + JsonObject? Error); + public interface ICandidateService { Task GetDashboardAsync(string sessionToken, CancellationToken cancellationToken); @@ -16,6 +22,11 @@ public interface ICandidateService Task GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken); + Task GetResultsAsync( + string sessionToken, + string verificationBaseUrl, + CancellationToken cancellationToken); + Task UpdateProfileAsync( string sessionToken, JsonObject body, @@ -25,4 +36,15 @@ public interface ICandidateService string sessionToken, JsonObject body, CancellationToken cancellationToken); + + Task CreateScoreAppealAsync( + string sessionToken, + string resultId, + JsonObject body, + CancellationToken cancellationToken); + + Task GetAdmitCardAsync( + string sessionToken, + string registrationId, + CancellationToken cancellationToken); } diff --git a/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs b/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs new file mode 100644 index 0000000..4b19e15 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Candidate; + +internal static class CandidateAdmitCardRenderer +{ + private const string Styles = "@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:\"Microsoft YaHei\",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}"; + + public static string Render( + CandidateReadSnapshot snapshot, + AuthenticationUser user, + JsonObject profile, + CandidateRegistration registration, + CandidateExam exam) + { + var card = registration.AdmitCard!; + var assignments = card.Assignments.ToDictionary(item => item.SubjectId, StringComparer.Ordinal); + var rows = new StringBuilder(); + foreach (var subject in exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal))) + { + assignments.TryGetValue(subject.Id, out var assignment); + var room = assignment?.RoomId is not null + ? snapshot.TestRooms.GetValueOrDefault(assignment.RoomId) + : null; + rows.Append("").Append(E(subject.Name)) + .Append("").Append(E(subject.Date)) + .Append("").Append(E(subject.Start)).Append('—').Append(E(subject.End)) + .Append("").Append(E(First(assignment?.ExamRoomCode, "待定"))) + .Append("").Append(E(First(assignment?.RoomName, room?.Name, "待定"))) + .Append("场地代码:").Append(E(First(assignment?.RoomCode, room?.Code, "—"))) + .Append("").Append(E(First(assignment?.Building, room?.Building, "—"))) + .Append("").Append(E(First(assignment?.Floor, room?.Floor, "楼层待定"))) + .Append("").Append(E(First(assignment?.Seat, "—"))).Append(""); + } + + var center = card.CenterId is not null ? snapshot.TestCenters.GetValueOrDefault(card.CenterId) : null; + var centerCode = First(card.CenterCode, center?.Code, string.Empty); + var centerAddress = First( + card.CenterAddress, + center is null ? string.Empty : string.Join(' ', new[] { center.ProvinceName, center.CityName, center.DistrictName, center.Address }.Where(value => value.Length > 0)), + string.Empty); + var profileName = Text(profile, "name"); + var title = $"{exam.Name}-{profileName}-准考证"; + var generatedAt = DateTimeOffset.TryParse(card.GeneratedAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed.ToLocalTime().ToString("yyyy/M/d HH:mm:ss", CultureInfo.GetCultureInfo("zh-CN")) + : card.GeneratedAt; + var section = $"
衡准 · 准考证

{E(exam.Name)}

准考证号
{E(card.Number)}
姓名{E(First(profileName, user.DisplayName))}
证件号码{E(MaskId(Text(profile, "idNumber")))}
报名号{E(First(user.CandidateNumber, registration.RegistrationNumber, "—"))}
固定考点{E(card.TestCenter)}考点代码:{E(First(centerCode, "—"))}
考点详细地址{E(First(centerAddress, "地址待公布"))}
{rows}
科目日期时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位号
编号说明“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。
所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
{E(snapshot.OrganizationName)}生成时间:{E(generatedAt)}
"; + return $"{E(title)}{section}"; + } + + private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty; + + private static string MaskId(string value) => value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value; + + private static string First(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty; + + private static string E(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs index 5e74633..73735bb 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs @@ -8,10 +8,15 @@ internal sealed record CandidateReadSnapshot( IReadOnlyList Exams, IReadOnlyList Registrations, IReadOnlyList Results, + IReadOnlyList AllRegistrations, + IReadOnlyList AllResults, IReadOnlyList Workflows, IReadOnlyList WorkflowInstances, IReadOnlyList WorkflowActions, - IReadOnlyDictionary Users); + IReadOnlyDictionary Users, + IReadOnlyDictionary TestCenters, + IReadOnlyDictionary TestRooms, + string OrganizationName); internal sealed record CandidateExam( string Id, @@ -86,6 +91,22 @@ internal sealed record CandidateAdmitAssignment( string Seat, string SubjectSignature); +internal sealed record CandidateTestCenter( + string Id, + string Code, + string Name, + string ProvinceName, + string CityName, + string DistrictName, + string Address); + +internal sealed record CandidateTestRoom( + string Id, + string Code, + string Name, + string Building, + string Floor); + internal sealed record CandidateResult( string Id, string RegistrationId, @@ -149,7 +170,10 @@ internal sealed record CandidateUserSummary( internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory) { - public async Task LoadAsync(string userId, CancellationToken cancellationToken) + public async Task LoadAsync( + string userId, + CancellationToken cancellationToken, + bool includeCohort = false) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); var subjects = await QueryAsync(connection, @@ -164,19 +188,22 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c "SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id", reader => ReadExam(reader, subjectsByExam), cancellationToken); - var registrationRows = await QueryAsync(connection, - "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id", - ReadRegistrationRow, - cancellationToken, - new QueryParameter("@userId", userId)); - var registrationIds = registrationRows.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + var registrationRows = includeCohort + ? await QueryAsync(connection, + "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations ORDER BY created_at, id", + ReadRegistrationRow, + cancellationToken) + : await QueryAsync(connection, + "SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note, registration_number, number_rule_id, feature_score FROM registrations WHERE user_id = @userId ORDER BY created_at, id", + ReadRegistrationRow, + cancellationToken, + new QueryParameter("@userId", userId)); var registrationSubjects = await QueryAsync(connection, "SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id", reader => new RegistrationSubject(ReadString(reader, "registration_id"), ReadString(reader, "subject_id")), cancellationToken); var subjectIdsByRegistration = registrationSubjects - .Where(item => registrationIds.Contains(item.RegistrationId)) .GroupBy(item => item.RegistrationId) .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal); @@ -185,7 +212,6 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c ReadAssignment, cancellationToken); var assignmentsByRegistration = assignments - .Where(item => registrationIds.Contains(item.RegistrationId)) .GroupBy(item => item.RegistrationId) .ToDictionary( group => group.Key, @@ -199,10 +225,9 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c reader => ReadCard(reader, assignmentsByRegistration), cancellationToken); var cardsByRegistration = cards - .Where(item => registrationIds.Contains(item.RegistrationId)) .ToDictionary(item => item.RegistrationId, item => item.Card, StringComparer.Ordinal); - var registrations = registrationRows.Select(row => new CandidateRegistration( + var allRegistrations = registrationRows.Select(row => new CandidateRegistration( row.Id, row.UserId, row.ExamId, @@ -218,16 +243,26 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c row.NumberRuleId, row.FeatureScore, cardsByRegistration.GetValueOrDefault(row.Id))).ToArray(); + var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray(); - var results = await QueryAsync(connection, - """ - SELECT r.id, r.registration_id, r.subject_id, r.score, r.grade, r.published, r.updated_at, r.published_at - FROM results r JOIN registrations registration ON registration.id = r.registration_id - WHERE registration.user_id = @userId ORDER BY r.id - """, - ReadResult, - cancellationToken, - new QueryParameter("@userId", userId)); + var allResults = includeCohort + ? await QueryAsync(connection, + "SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id", + ReadResult, + cancellationToken) + : await QueryAsync(connection, + """ + SELECT result.id, result.registration_id, result.subject_id, result.score, result.grade, + result.published, result.updated_at, result.published_at + FROM results result + JOIN registrations registration ON registration.id = result.registration_id + WHERE registration.user_id = @userId ORDER BY result.id + """, + ReadResult, + cancellationToken, + new QueryParameter("@userId", userId)); + var userRegistrationIds = registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + var results = allResults.Where(item => userRegistrationIds.Contains(item.RegistrationId)).ToArray(); var workflowSteps = await QueryAsync(connection, "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", reader => new WorkflowStepRow( @@ -267,15 +302,46 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c "SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id", ReadUserSummary, cancellationToken); + var centers = await QueryAsync(connection, + "SELECT id, code, name, province_name, city_name, district_name, address FROM test_centers ORDER BY id", + reader => new CandidateTestCenter( + ReadString(reader, "id"), + ReadString(reader, "code"), + ReadString(reader, "name"), + ReadString(reader, "province_name"), + ReadString(reader, "city_name"), + ReadString(reader, "district_name"), + ReadString(reader, "address")), + cancellationToken); + var rooms = await QueryAsync(connection, + "SELECT id, code, name, building, floor FROM test_rooms ORDER BY id", + reader => new CandidateTestRoom( + ReadString(reader, "id"), + ReadString(reader, "code"), + ReadString(reader, "name"), + ReadString(reader, "building"), + ReadOptionalString(reader, "floor") ?? string.Empty), + cancellationToken); + await using var organizationCommand = connection.CreateCommand(); + organizationCommand.CommandText = "SELECT name FROM organization WHERE id = 1"; + var organizationValue = await organizationCommand.ExecuteScalarAsync(cancellationToken); + var organizationName = organizationValue is null or DBNull + ? "考试服务平台" + : Convert.ToString(organizationValue, CultureInfo.InvariantCulture) ?? "考试服务平台"; return new CandidateReadSnapshot( exams, registrations, results, + allRegistrations, + allResults, workflows, instances, actions, - users.ToDictionary(item => item.Id, StringComparer.Ordinal)); + users.ToDictionary(item => item.Id, StringComparer.Ordinal), + centers.ToDictionary(item => item.Id, StringComparer.Ordinal), + rooms.ToDictionary(item => item.Id, StringComparer.Ordinal), + organizationName); } private static async Task> QueryAsync( diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.AdmitCards.cs b/src/Eis.Infrastructure/Candidate/CandidateService.AdmitCards.cs new file mode 100644 index 0000000..70de2a3 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateService.AdmitCards.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using Eis.Application.Candidate; + +namespace Eis.Infrastructure.Candidate; + +internal sealed partial class CandidateService +{ + public async Task GetAdmitCardAsync( + string sessionToken, + string registrationId, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); + if (context.Error is not null) + { + return DocumentError(context.Error); + } + + var user = context.User!; + var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); + var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == registrationId); + if (registration?.AdmitCard is null) + { + return DocumentError(Error(404, "准考证尚未生成")); + } + + var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); + var now = DateTimeOffset.UtcNow; + if (TryDate(exam.AdmitDownloadStart, out var start) && now < start) + { + return DocumentError(Error(403, "准考证下载尚未开放")); + } + + if (TryDate(exam.AdmitDownloadEnd, out var end) && now > end) + { + return DocumentError(Error(403, "准考证下载时间已结束")); + } + + var html = CandidateAdmitCardRenderer.Render(snapshot, user, context.Profile!, registration, exam); + return new CandidateDocumentResult( + 200, + html, + $"{exam.Name}-{Text(context.Profile!, "name")}-准考证.html", + null); + } + + private static bool TryDate(string value, out DateTimeOffset parsed) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsed); + + private static CandidateDocumentResult DocumentError(CandidateEndpointResult error) => + new(error.StatusCode, null, null, error.Body); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs new file mode 100644 index 0000000..dab8cc5 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs @@ -0,0 +1,337 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Eis.Application.Candidate; +using Eis.Infrastructure.Security; +using QRCoder; + +namespace Eis.Infrastructure.Candidate; + +internal sealed partial class CandidateService +{ + public async Task GetResultsAsync( + 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, includeCohort: true); + var registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal); + var results = snapshot.Results + .Where(item => item.Published && registrationById.ContainsKey(item.RegistrationId)) + .Select(item => ResultView(snapshot, item, registrationById[item.RegistrationId])) + .ToArray(); + var summaries = new List(); + foreach (var registration in snapshot.Registrations) + { + var summary = ExamResultSummary(snapshot, registration); + if (summary is null || summary["publishedSubjects"]?.GetValue() is not > 0) + { + continue; + } + + var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); + var reportResults = snapshot.AllResults + .Where(item => item.RegistrationId == registration.Id && item.Published) + .Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)); + var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults); + summary["verificationCode"] = code; + summary["verificationQr"] = CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(code)}"); + summaries.Add(summary); + } + + return Success(new JsonObject + { + ["ok"] = true, + ["results"] = new JsonArray(results), + ["summaries"] = new JsonArray(summaries.Select(item => (JsonNode)item).ToArray()), + ["candidate"] = new JsonObject + { + ["name"] = Text(profile, "name").Length > 0 ? Text(profile, "name") : user.DisplayName, + ["candidateNumber"] = user.CandidateNumber ?? string.Empty + } + }); + } + + public async Task CreateScoreAppealAsync( + string sessionToken, + string resultId, + 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 snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); + var result = snapshot.Results.FirstOrDefault(item => item.Id == resultId && item.Published); + var registration = result is null + ? null + : snapshot.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); + if (result is null || registration is null) + { + return Error(404, "已发布成绩不存在或不属于当前考生"); + } + + var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + if (exam?.ArchivedAt is not null) + { + return Error(409, "该考试已归档,成绩及复议入口已永久锁定"); + } + + if (FindInstance(snapshot, "score_appeal", result.Id) is { Status: "pending" }) + { + return Error(409, "该科成绩已有待处理复议,请勿重复提交"); + } + + var reason = Clean(body, "reason", 500); + if (reason.Length < 5) + { + return Error(400, "请至少填写 5 个字的复议理由"); + } + + var workflowResult = CreateWorkflowSubmission( + snapshot, + "score_appeal", + result.Id, + context.Profile!, + user.Id); + if (workflowResult.Error is not null) + { + return workflowResult.Error; + } + + var submission = workflowResult.Submission!; + var action = submission.Action with { Note = reason }; + var subject = exam?.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); + var log = new CandidateAuditLog( + Uid("log"), + user.Id, + "提交成绩复议", + $"{exam?.Name ?? string.Empty} · {subject?.Name ?? string.Empty}", + NowIso()); + await writeRepository.CreateWorkflowAsync(submission.Instance, action, log, cancellationToken); + + var responseSnapshot = snapshot with + { + WorkflowActions = [.. snapshot.WorkflowActions, action] + }; + return new CandidateEndpointResult(201, new JsonObject + { + ["ok"] = true, + ["workflow"] = WorkflowView(responseSnapshot, submission.Instance) + }); + } + + private static JsonObject ResultView( + CandidateReadSnapshot snapshot, + CandidateResult result, + CandidateRegistration registration) + { + var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); + var subject = exam.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); + var rank = ResultRankInfo(snapshot, result); + var pass = SubjectPassEvaluation(snapshot, result, subject); + var appealInstance = FindInstance(snapshot, "score_appeal", result.Id); + var appeal = WorkflowView(snapshot, appealInstance); + if (appeal is not null) + { + appeal["reason"] = appeal["actions"]?.AsArray().OfType() + .FirstOrDefault(item => item["action"]?.GetValue() == "submit")?["note"]?.GetValue() ?? string.Empty; + } + + var json = ResultJson(result); + json["rank"] = JsonValue.Create(rank.Rank); + json["cohortSize"] = rank.CohortSize; + json["rankPercent"] = JsonValue.Create(rank.RankPercent); + json["grade"] = rank.Grade; + json["examId"] = exam.Id; + json["examName"] = exam.Name; + json["examCode"] = exam.Code; + json["examStart"] = exam.ExamStart; + json["archivedAt"] = JsonValue.Create(exam.ArchivedAt); + json["subjectName"] = subject?.Name ?? result.SubjectId; + json["fullScore"] = subject?.FullScore ?? 150; + json["passRule"] = subject?.PassRule ?? "fixed_score"; + json["passValue"] = subject?.PassValue ?? subject?.PassScore; + json["passScore"] = JsonValue.Create(pass.PassScore); + json["cutoffRank"] = JsonValue.Create(pass.CutoffRank); + json["passText"] = SubjectPassText(subject); + json["qualified"] = JsonValue.Create(pass.Qualified); + json["appeal"] = appeal; + return json; + } + + private static JsonObject? ExamResultSummary( + CandidateReadSnapshot snapshot, + CandidateRegistration registration) + { + if (registration.Status != "approved") + { + return null; + } + + var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + if (exam is null) + { + return null; + } + + var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray(); + var published = snapshot.AllResults + .Where(item => item.RegistrationId == registration.Id && item.Published) + .ToArray(); + var resultsBySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal); + var complete = subjects.Length > 0 && subjects.All(item => resultsBySubject.ContainsKey(item.Id)); + var total = subjects.Sum(item => resultsBySubject.GetValueOrDefault(item.Id)?.Score ?? 0); + var fullScore = subjects.Sum(item => item.FullScore); + var scoreRatio = fullScore > 0 ? total / fullScore * 100 : 0; + var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy; + if (policy.Length == 0) + { + policy = "rank_percent"; + } + + var value = exam.PassValue; + bool? qualified = null; + int? rank = null; + int? cohortSize = null; + if (complete && policy == "fixed_score") + { + qualified = total >= value; + } + else if (complete && policy == "subject_scores") + { + qualified = subjects.All(item => SubjectPassEvaluation(snapshot, resultsBySubject[item.Id], item).Qualified != false); + } + else if (complete && policy == "rank_percent") + { + var subjectKey = string.Join('|', registration.SubjectIds.Order(StringComparer.Ordinal)); + var totals = snapshot.AllRegistrations + .Where(item => item.Status == "approved" && item.ExamId == exam.Id && + string.Join('|', item.SubjectIds.Order(StringComparer.Ordinal)) == subjectKey) + .Select(item => CompleteTotal(snapshot, exam, item)) + .Where(item => item.HasValue) + .Select(item => item!.Value) + .Order() + .ToArray(); + cohortSize = totals.Length; + rank = 1 + totals.Count(item => item > total); + qualified = rank <= Math.Max(1, (int)Math.Ceiling(cohortSize.Value * value / 100)); + } + + return new JsonObject + { + ["examId"] = exam.Id, + ["examName"] = exam.Name, + ["examCode"] = exam.Code, + ["examStart"] = exam.ExamStart, + ["archivedAt"] = JsonValue.Create(exam.ArchivedAt), + ["complete"] = complete, + ["publishedSubjects"] = published.Length, + ["subjectCount"] = subjects.Length, + ["featureScore"] = registration.FeatureScore, + ["total"] = total, + ["fullScore"] = fullScore, + ["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero), + ["passPolicy"] = policy, + ["passValue"] = value, + ["qualified"] = JsonValue.Create(qualified), + ["rank"] = JsonValue.Create(rank), + ["cohortSize"] = JsonValue.Create(cohortSize) + }; + } + + private static double? CompleteTotal( + CandidateReadSnapshot snapshot, + CandidateExam exam, + CandidateRegistration registration) + { + var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray(); + var scores = snapshot.AllResults + .Where(item => item.RegistrationId == registration.Id && item.Published) + .ToDictionary(item => item.SubjectId, item => item.Score, StringComparer.Ordinal); + return subjects.Length > 0 && subjects.All(item => scores.ContainsKey(item.Id)) + ? subjects.Sum(item => scores[item.Id]) + : null; + } + + private static ResultRank ResultRankInfo(CandidateReadSnapshot snapshot, CandidateResult result) + { + var scores = snapshot.AllResults + .Where(item => item.Published && item.SubjectId == result.SubjectId) + .Select(item => item.Score) + .Order() + .ToArray(); + var cohortSize = scores.Length; + var rank = 1 + scores.Count(item => item > result.Score); + var rankPercent = cohortSize == 0 + ? 0 + : Math.Round(rank / (double)cohortSize * 100, 2, MidpointRounding.AwayFromZero); + return new ResultRank(rank, cohortSize, rankPercent, GradeForRank(rank, cohortSize)); + } + + private static PassEvaluation SubjectPassEvaluation( + CandidateReadSnapshot snapshot, + CandidateResult result, + CandidateSubject? subject) + { + var rank = ResultRankInfo(snapshot, result); + var rule = subject?.PassRule ?? "fixed_score"; + if (rule == "none") + { + return new PassEvaluation(null, null, null, rank); + } + + if (rule == "rank_percent") + { + var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (subject?.PassValue ?? 60) / 100)); + return new PassEvaluation(rank.Rank <= cutoff, null, cutoff, rank); + } + + var passScore = Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero); + return new PassEvaluation(result.Score >= passScore, passScore, null, rank); + } + + private static string SubjectPassText(CandidateSubject? subject) => (subject?.PassRule ?? "fixed_score") switch + { + "none" => "不设单科线", + "rank_percent" => $"本科排名前 {FormatNumber(subject?.PassValue ?? 60)}% 达线", + _ => $"固定 {FormatNumber(Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero))} 分" + }; + + private static string GradeForRank(int rank, int cohortSize) + { + int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio)); + if (rank <= Cutoff(0.1)) return "A+"; + if (rank <= Cutoff(0.25)) return "A"; + if (rank <= Cutoff(0.5)) return "B+"; + if (rank <= Cutoff(0.7)) return "B"; + if (rank <= Cutoff(0.9)) return "C"; + return "D"; + } + + private static string FormatNumber(double value) => + value.ToString("0.#############################", CultureInfo.InvariantCulture); + + private static string CreateQrCodeDataUrl(string value) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.M); + using var qrCode = new PngByteQRCode(data); + return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}"; + } + + private sealed record ResultRank(int Rank, int CohortSize, double RankPercent, string Grade); + + private sealed record PassEvaluation(bool? Qualified, double? PassScore, int? CutoffRank, ResultRank Rank); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.cs b/src/Eis.Infrastructure/Candidate/CandidateService.cs index b941d6c..6216a2e 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Eis.Application.Candidate; using Eis.Application.Public; using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Security; namespace Eis.Infrastructure.Candidate; @@ -12,7 +13,8 @@ internal sealed partial class CandidateService( CandidateReadSnapshotLoader snapshotLoader, IPublicQueryService publicQueries, CandidateWriteRepository writeRepository, - RegionCatalog regionCatalog) : ICandidateService + RegionCatalog regionCatalog, + DocumentVerificationCodeService documentCodes) : ICandidateService { public async Task GetDashboardAsync( string sessionToken, diff --git a/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs b/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs index a7daa8c..b87a4c7 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs @@ -11,6 +11,13 @@ internal sealed record SchoolClassSelection( string ClassId, string ClassName); +internal sealed record CandidateAuditLog( + string Id, + string ActorId, + string Action, + string Detail, + string CreatedAt); + internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory) { public async Task FindSchoolClassAsync( @@ -220,6 +227,36 @@ internal sealed class CandidateWriteRepository(IRelationalConnectionFactory conn } } + public async Task CreateWorkflowAsync( + CandidateWorkflowInstance instance, + CandidateWorkflowAction action, + CandidateAuditLog log, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken); + await ExecuteAsync(connection, transaction, + "INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt)", + [ + new("@id", log.Id), + new("@actorId", log.ActorId), + new("@action", log.Action), + new("@detail", log.Detail), + new("@createdAt", log.CreatedAt) + ], + cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + private static async Task InsertWorkflowAsync( DbConnection connection, DbTransaction transaction, diff --git a/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs index 0edee1c..ef73c89 100644 --- a/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs +++ b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs @@ -58,6 +58,34 @@ public static class NativeCandidateEndpoints CancellationToken cancellationToken) => ToResult( context, await service.CreateRegistrationAsync(SessionToken(context), request, cancellationToken))); + endpoints.MapGet("/api/candidate/results", async ( + HttpContext context, + ICandidateService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetResultsAsync( + SessionToken(context), + VerificationBaseUrl(context), + cancellationToken))); + endpoints.MapPost("/api/candidate/results/{resultId}/appeals", async ( + HttpContext context, + string resultId, + System.Text.Json.Nodes.JsonObject request, + ICandidateService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.CreateScoreAppealAsync( + SessionToken(context), + resultId, + request, + cancellationToken))); + endpoints.MapGet("/api/candidate/registrations/{registrationId}/admit-card", async ( + HttpContext context, + string registrationId, + ICandidateService service, + CancellationToken cancellationToken) => ToDocumentResult( + context, + await service.GetAdmitCardAsync(SessionToken(context), registrationId, cancellationToken))); return endpoints; } @@ -65,10 +93,32 @@ public static class NativeCandidateEndpoints private static string SessionToken(HttpContext context) => context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; + private static string VerificationBaseUrl(HttpContext context) + { + var forwarded = context.Request.Headers["X-Forwarded-Proto"].ToString(); + var protocol = forwarded.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() + ?? context.Request.Scheme; + return $"{protocol}://{context.Request.Host.Value}"; + } + private static IResult ToResult(HttpContext context, CandidateEndpointResult result) { context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; return Results.Json(result.Body, statusCode: result.StatusCode); } + + private static IResult ToDocumentResult(HttpContext context, CandidateDocumentResult result) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; + if (result.Error is not null) + { + return Results.Json(result.Error, statusCode: result.StatusCode); + } + + context.Response.Headers.ContentDisposition = + $"attachment; filename*=UTF-8''{Uri.EscapeDataString(result.FileName ?? "admit-card.html")}"; + return Results.Content(result.Content ?? string.Empty, "text/html; charset=utf-8", statusCode: result.StatusCode); + } } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 8837ea5..3196dfd 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -83,7 +83,11 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c { nativeEnabled = candidateMigrationOptions.NativeEnabled, nativeRoutes = candidateMigrationOptions.NativeEnabled - ? new[] { "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations" } + ? new[] + { + "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations", + "GET results", "POST result appeals", "GET admit cards" + } : [] }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled) diff --git a/tests/helpers/prepare-candidate-admit-smoke.mjs b/tests/helpers/prepare-candidate-admit-smoke.mjs new file mode 100644 index 0000000..7952dd1 --- /dev/null +++ b/tests/helpers/prepare-candidate-admit-smoke.mjs @@ -0,0 +1,58 @@ +import { DatabaseSync } from 'node:sqlite'; + +const databasePath = process.argv[2]; +const candidateNumber = process.argv[3]; +if (!databasePath || !candidateNumber) throw new Error('缺少 SQLite 路径或报名号'); + +const database = new DatabaseSync(databasePath); +const registration = database.prepare(` + SELECT registration.id, registration.exam_id + FROM registrations registration + JOIN users user ON user.id = registration.user_id + WHERE user.candidate_number = ? AND registration.status = 'approved' + ORDER BY registration.created_at, registration.id LIMIT 1 +`).get(candidateNumber); +if (!registration) throw new Error('未找到已审核报名'); +const numberRule = database.prepare('SELECT id FROM admission_number_rules ORDER BY created_at, id LIMIT 1').get(); +const center = database.prepare("SELECT id, code, name, province_name, city_name, district_name, address FROM test_centers WHERE status = 'active' ORDER BY id LIMIT 1").get(); +const room = center ? database.prepare("SELECT id, code, name, building, floor FROM test_rooms WHERE center_id = ? AND status = 'active' ORDER BY code, id LIMIT 1").get(center.id) : null; +if (!numberRule || !center || !room) throw new Error('准考证冒烟数据缺少编号规则、考点或考场'); + +const now = '2026-07-22T00:00:00.000Z'; +database.exec('BEGIN IMMEDIATE'); +try { + database.prepare('UPDATE exams SET admit_download_start = ?, admit_download_end = ? WHERE id = ?') + .run('2000-01-01T00:00:00.000Z', '2099-12-31T23:59:59.999Z', registration.exam_id); + database.prepare(` + INSERT OR IGNORE INTO exam_arrangement_plans ( + id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count, + subject_assignment_count, subject_combination_count, same_school_center_rate, + warnings_json, generated_by, generated_at + ) VALUES (?, ?, ?, 'district', 'migration-smoke', 1, 1, 1, 1, 1, '[]', NULL, ?) + `).run('plan_migration_smoke', registration.exam_id, numberRule.id, now); + const plan = database.prepare('SELECT id FROM exam_arrangement_plans WHERE exam_id = ?').get(registration.exam_id); + const address = [center.province_name, center.city_name, center.district_name, center.address].filter(Boolean).join(' '); + database.prepare(` + INSERT OR REPLACE INTO admit_cards ( + registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(registration.id, plan.id, 'MIGRATION-SMOKE-001', center.id, center.name, center.code, address, now); + const subjects = database.prepare('SELECT subject_id FROM registration_subjects WHERE registration_id = ? ORDER BY subject_id').all(registration.id); + const insertAssignment = database.prepare(` + INSERT OR REPLACE INTO admit_card_subjects ( + registration_id, subject_id, room_id, room, room_code, exam_room_code, + building, floor, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + subjects.forEach((subject, index) => insertAssignment.run( + registration.id, subject.subject_id, room.id, room.name, room.code, '001', + room.building, room.floor || '', '01', `migration-smoke-${index + 1}` + )); + database.exec('COMMIT'); + console.log(registration.id); +} catch (error) { + database.exec('ROLLBACK'); + throw error; +} finally { + database.close(); +}