原生成绩查询、科目/总分排名、达线判断、等级计算及成绩单防伪码:[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 parent
aecb0a04e7
commit
4417d6d674
12 files changed
+783
-25
No files matched your search
@@ -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<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
||||
@@ -16,6 +22,11 @@ public interface ICandidateService
|
||||
|
||||
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<CandidateEndpointResult> GetResultsAsync(
|
||||
string sessionToken,
|
||||
string verificationBaseUrl,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
@@ -25,4 +36,15 @@ public interface ICandidateService
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
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<CandidateRegistration> Registrations,
|
||||
IReadOnlyList<CandidateResult> Results,
|
||||
IReadOnlyList<CandidateRegistration> AllRegistrations,
|
||||
IReadOnlyList<CandidateResult> AllResults,
|
||||
IReadOnlyList<CandidateWorkflow> Workflows,
|
||||
IReadOnlyList<CandidateWorkflowInstance> WorkflowInstances,
|
||||
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(
|
||||
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<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);
|
||||
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<string>)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<IReadOnlyList<T>> QueryAsync<T>(
|
||||
|
||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user