原生成绩查询、科目/总分排名、达线判断、等级计算及成绩单防伪码:[CandidateService.Results.cs (line 11)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs:11)
原生成绩复议提交,工作流、分配人与审计日志同事务写入。 原生准考证下载窗口校验及 HTML 文书生成:[CandidateAdmitCardRenderer.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs:9) 三个路由已切换到 ASP.NET Core:[NativeCandidateEndpoints.cs (line 61)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:61) 更新迁移说明:[MIGRATION.md (line 50)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:50)
This commit is contained in:
+1
-1
@@ -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_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
|
||||||
|
|
||||||
考生域核心端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。成绩、准考证、复议和志愿填报仍会继续转发给 Node:
|
考生域核心端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。志愿填报与录取查询仍会继续转发给 Node:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:AUTH_NATIVE_ENABLED = 'true'
|
$env:AUTH_NATIVE_ENABLED = 'true'
|
||||||
|
|||||||
@@ -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 {
|
function Find-JsonDifference {
|
||||||
param(
|
param(
|
||||||
[AllowNull()]
|
[AllowNull()]
|
||||||
@@ -407,6 +429,51 @@ try {
|
|||||||
}
|
}
|
||||||
Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'"
|
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]
|
$registrationSchool = @($homePayload.schools | Select-Object -First 1)[0]
|
||||||
$registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | 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'
|
NativeAuthentication = 'passed'
|
||||||
NativeCandidateReads = 'passed'
|
NativeCandidateReads = 'passed'
|
||||||
NativeCandidateWrites = 'passed'
|
NativeCandidateWrites = 'passed'
|
||||||
|
NativeCandidateDocuments = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ namespace Eis.Application.Candidate;
|
|||||||
|
|
||||||
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
|
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
|
||||||
|
|
||||||
|
public sealed record CandidateDocumentResult(
|
||||||
|
int StatusCode,
|
||||||
|
string? Content,
|
||||||
|
string? FileName,
|
||||||
|
JsonObject? Error);
|
||||||
|
|
||||||
public interface ICandidateService
|
public interface ICandidateService
|
||||||
{
|
{
|
||||||
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
@@ -16,6 +22,11 @@ public interface ICandidateService
|
|||||||
|
|
||||||
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> GetResultsAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string verificationBaseUrl,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<CandidateEndpointResult> UpdateProfileAsync(
|
Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
JsonObject body,
|
JsonObject body,
|
||||||
@@ -25,4 +36,15 @@ public interface ICandidateService
|
|||||||
string sessionToken,
|
string sessionToken,
|
||||||
JsonObject body,
|
JsonObject body,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> CreateScoreAppealAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string resultId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateDocumentResult> GetAdmitCardAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string registrationId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("<tr><td>").Append(E(subject.Name))
|
||||||
|
.Append("</td><td>").Append(E(subject.Date))
|
||||||
|
.Append("</td><td>").Append(E(subject.Start)).Append('—').Append(E(subject.End))
|
||||||
|
.Append("</td><td class=\"room-no\">").Append(E(First(assignment?.ExamRoomCode, "待定")))
|
||||||
|
.Append("</td><td><strong>").Append(E(First(assignment?.RoomName, room?.Name, "待定")))
|
||||||
|
.Append("</strong><small>场地代码:").Append(E(First(assignment?.RoomCode, room?.Code, "—")))
|
||||||
|
.Append("</small></td><td>").Append(E(First(assignment?.Building, room?.Building, "—")))
|
||||||
|
.Append("<small>").Append(E(First(assignment?.Floor, room?.Floor, "楼层待定")))
|
||||||
|
.Append("</small></td><td>").Append(E(First(assignment?.Seat, "—"))).Append("</td></tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = $"<main class=\"card\"><header class=\"head\"><div><div class=\"brand\">衡准 · 准考证</div><p>{E(exam.Name)}</p></div><div><small>准考证号</small><div class=\"code\">{E(card.Number)}</div></div></header><section class=\"meta\"><div><span>姓名</span><strong>{E(First(profileName, user.DisplayName))}</strong></div><div><span>证件号码</span><strong>{E(MaskId(Text(profile, "idNumber")))}</strong></div><div><span>报名号</span><strong>{E(First(user.CandidateNumber, registration.RegistrationNumber, "—"))}</strong></div></section><section class=\"center\"><div><span>固定考点</span><strong>{E(card.TestCenter)}</strong><small>考点代码:{E(First(centerCode, "—"))}</small></div><div><span>考点详细地址</span><strong>{E(First(centerAddress, "地址待公布"))}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位号</th></tr></thead><tbody>{rows}</tbody></table><div class=\"explain\"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class=\"notice\">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class=\"footer\"><span>{E(snapshot.OrganizationName)}</span><span>生成时间:{E(generatedAt)}</span></footer></main>";
|
||||||
|
return $"<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><title>{E(title)}</title><style>{Styles}</style></head><body>{section}</body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -8,10 +8,15 @@ internal sealed record CandidateReadSnapshot(
|
|||||||
IReadOnlyList<CandidateExam> Exams,
|
IReadOnlyList<CandidateExam> Exams,
|
||||||
IReadOnlyList<CandidateRegistration> Registrations,
|
IReadOnlyList<CandidateRegistration> Registrations,
|
||||||
IReadOnlyList<CandidateResult> Results,
|
IReadOnlyList<CandidateResult> Results,
|
||||||
|
IReadOnlyList<CandidateRegistration> AllRegistrations,
|
||||||
|
IReadOnlyList<CandidateResult> AllResults,
|
||||||
IReadOnlyList<CandidateWorkflow> Workflows,
|
IReadOnlyList<CandidateWorkflow> Workflows,
|
||||||
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
|
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
|
||||||
IReadOnlyList<CandidateWorkflowAction> WorkflowActions,
|
IReadOnlyList<CandidateWorkflowAction> WorkflowActions,
|
||||||
IReadOnlyDictionary<string, CandidateUserSummary> Users);
|
IReadOnlyDictionary<string, CandidateUserSummary> Users,
|
||||||
|
IReadOnlyDictionary<string, CandidateTestCenter> TestCenters,
|
||||||
|
IReadOnlyDictionary<string, CandidateTestRoom> TestRooms,
|
||||||
|
string OrganizationName);
|
||||||
|
|
||||||
internal sealed record CandidateExam(
|
internal sealed record CandidateExam(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -86,6 +91,22 @@ internal sealed record CandidateAdmitAssignment(
|
|||||||
string Seat,
|
string Seat,
|
||||||
string SubjectSignature);
|
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(
|
internal sealed record CandidateResult(
|
||||||
string Id,
|
string Id,
|
||||||
string RegistrationId,
|
string RegistrationId,
|
||||||
@@ -149,7 +170,10 @@ internal sealed record CandidateUserSummary(
|
|||||||
|
|
||||||
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
{
|
{
|
||||||
public async Task<CandidateReadSnapshot> LoadAsync(string userId, CancellationToken cancellationToken)
|
public async Task<CandidateReadSnapshot> LoadAsync(
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
bool includeCohort = false)
|
||||||
{
|
{
|
||||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
var subjects = await QueryAsync(connection,
|
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",
|
"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),
|
reader => ReadExam(reader, subjectsByExam),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var registrationRows = await QueryAsync(connection,
|
var registrationRows = includeCohort
|
||||||
"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",
|
? await QueryAsync(connection,
|
||||||
ReadRegistrationRow,
|
"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",
|
||||||
cancellationToken,
|
ReadRegistrationRow,
|
||||||
new QueryParameter("@userId", userId));
|
cancellationToken)
|
||||||
var registrationIds = registrationRows.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
: 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,
|
var registrationSubjects = await QueryAsync(connection,
|
||||||
"SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id",
|
"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")),
|
reader => new RegistrationSubject(ReadString(reader, "registration_id"), ReadString(reader, "subject_id")),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var subjectIdsByRegistration = registrationSubjects
|
var subjectIdsByRegistration = registrationSubjects
|
||||||
.Where(item => registrationIds.Contains(item.RegistrationId))
|
|
||||||
.GroupBy(item => item.RegistrationId)
|
.GroupBy(item => item.RegistrationId)
|
||||||
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal);
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -185,7 +212,6 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
|||||||
ReadAssignment,
|
ReadAssignment,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var assignmentsByRegistration = assignments
|
var assignmentsByRegistration = assignments
|
||||||
.Where(item => registrationIds.Contains(item.RegistrationId))
|
|
||||||
.GroupBy(item => item.RegistrationId)
|
.GroupBy(item => item.RegistrationId)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
group => group.Key,
|
group => group.Key,
|
||||||
@@ -199,10 +225,9 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
|||||||
reader => ReadCard(reader, assignmentsByRegistration),
|
reader => ReadCard(reader, assignmentsByRegistration),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var cardsByRegistration = cards
|
var cardsByRegistration = cards
|
||||||
.Where(item => registrationIds.Contains(item.RegistrationId))
|
|
||||||
.ToDictionary(item => item.RegistrationId, item => item.Card, StringComparer.Ordinal);
|
.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.Id,
|
||||||
row.UserId,
|
row.UserId,
|
||||||
row.ExamId,
|
row.ExamId,
|
||||||
@@ -218,16 +243,26 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
|||||||
row.NumberRuleId,
|
row.NumberRuleId,
|
||||||
row.FeatureScore,
|
row.FeatureScore,
|
||||||
cardsByRegistration.GetValueOrDefault(row.Id))).ToArray();
|
cardsByRegistration.GetValueOrDefault(row.Id))).ToArray();
|
||||||
|
var registrations = allRegistrations.Where(item => item.UserId == userId).ToArray();
|
||||||
|
|
||||||
var results = await QueryAsync(connection,
|
var allResults = includeCohort
|
||||||
"""
|
? await QueryAsync(connection,
|
||||||
SELECT r.id, r.registration_id, r.subject_id, r.score, r.grade, r.published, r.updated_at, r.published_at
|
"SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id",
|
||||||
FROM results r JOIN registrations registration ON registration.id = r.registration_id
|
ReadResult,
|
||||||
WHERE registration.user_id = @userId ORDER BY r.id
|
cancellationToken)
|
||||||
""",
|
: await QueryAsync(connection,
|
||||||
ReadResult,
|
"""
|
||||||
cancellationToken,
|
SELECT result.id, result.registration_id, result.subject_id, result.score, result.grade,
|
||||||
new QueryParameter("@userId", userId));
|
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,
|
var workflowSteps = await QueryAsync(connection,
|
||||||
"SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id",
|
"SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id",
|
||||||
reader => new WorkflowStepRow(
|
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",
|
"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,
|
ReadUserSummary,
|
||||||
cancellationToken);
|
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(
|
return new CandidateReadSnapshot(
|
||||||
exams,
|
exams,
|
||||||
registrations,
|
registrations,
|
||||||
results,
|
results,
|
||||||
|
allRegistrations,
|
||||||
|
allResults,
|
||||||
workflows,
|
workflows,
|
||||||
instances,
|
instances,
|
||||||
actions,
|
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<IReadOnlyList<T>> QueryAsync<T>(
|
private static async Task<IReadOnlyList<T>> QueryAsync<T>(
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Eis.Application.Candidate;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed partial class CandidateService
|
||||||
|
{
|
||||||
|
public async Task<CandidateDocumentResult> 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);
|
||||||
|
}
|
||||||
@@ -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<CandidateEndpointResult> 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<JsonObject>();
|
||||||
|
foreach (var registration in snapshot.Registrations)
|
||||||
|
{
|
||||||
|
var summary = ExamResultSummary(snapshot, registration);
|
||||||
|
if (summary is null || summary["publishedSubjects"]?.GetValue<int>() 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<CandidateEndpointResult> 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<JsonObject>()
|
||||||
|
.FirstOrDefault(item => item["action"]?.GetValue<string>() == "submit")?["note"]?.GetValue<string>() ?? 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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
|||||||
using Eis.Application.Candidate;
|
using Eis.Application.Candidate;
|
||||||
using Eis.Application.Public;
|
using Eis.Application.Public;
|
||||||
using Eis.Infrastructure.Authentication;
|
using Eis.Infrastructure.Authentication;
|
||||||
|
using Eis.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Eis.Infrastructure.Candidate;
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ internal sealed partial class CandidateService(
|
|||||||
CandidateReadSnapshotLoader snapshotLoader,
|
CandidateReadSnapshotLoader snapshotLoader,
|
||||||
IPublicQueryService publicQueries,
|
IPublicQueryService publicQueries,
|
||||||
CandidateWriteRepository writeRepository,
|
CandidateWriteRepository writeRepository,
|
||||||
RegionCatalog regionCatalog) : ICandidateService
|
RegionCatalog regionCatalog,
|
||||||
|
DocumentVerificationCodeService documentCodes) : ICandidateService
|
||||||
{
|
{
|
||||||
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ internal sealed record SchoolClassSelection(
|
|||||||
string ClassId,
|
string ClassId,
|
||||||
string ClassName);
|
string ClassName);
|
||||||
|
|
||||||
|
internal sealed record CandidateAuditLog(
|
||||||
|
string Id,
|
||||||
|
string ActorId,
|
||||||
|
string Action,
|
||||||
|
string Detail,
|
||||||
|
string CreatedAt);
|
||||||
|
|
||||||
internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory)
|
internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
{
|
{
|
||||||
public async Task<SchoolClassSelection?> FindSchoolClassAsync(
|
public async Task<SchoolClassSelection?> 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(
|
private static async Task InsertWorkflowAsync(
|
||||||
DbConnection connection,
|
DbConnection connection,
|
||||||
DbTransaction transaction,
|
DbTransaction transaction,
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ public static class NativeCandidateEndpoints
|
|||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.CreateRegistrationAsync(SessionToken(context), request, cancellationToken)));
|
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;
|
return endpoints;
|
||||||
}
|
}
|
||||||
@@ -65,10 +93,32 @@ public static class NativeCandidateEndpoints
|
|||||||
private static string SessionToken(HttpContext context) =>
|
private static string SessionToken(HttpContext context) =>
|
||||||
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
|
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)
|
private static IResult ToResult(HttpContext context, CandidateEndpointResult result)
|
||||||
{
|
{
|
||||||
context.Response.Headers.CacheControl = "no-store";
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||||
return Results.Json(result.Body, statusCode: result.StatusCode);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
{
|
{
|
||||||
nativeEnabled = candidateMigrationOptions.NativeEnabled,
|
nativeEnabled = candidateMigrationOptions.NativeEnabled,
|
||||||
nativeRoutes = 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)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user