招生计划、资格、录取、分数线及报到公示
成绩单 HMAC 防伪验真 录取通知书 HMAC 防伪验真 防伪码常量时间比较 生产环境文书密钥强度检查 旧文书防伪码完全兼容
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@
|
||||
- [x] 旧 API 兼容转发,包含 Cookie、请求体、文件下载和状态码
|
||||
- [x] 存活与迁移就绪检查
|
||||
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
|
||||
- [ ] 招生公示与文书验真公开接口
|
||||
- [x] 招生公示与 HMAC 文书验真公开接口
|
||||
- [ ] 登录、Session 与 TOTP
|
||||
- [ ] 考生业务
|
||||
- [ ] 管理后台、审批流和考务编排
|
||||
|
||||
@@ -137,6 +137,7 @@ try {
|
||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||
DATABASE_CLIENT = 'sqlite'
|
||||
SQLITE_PATH = $smokeDatabasePath
|
||||
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
|
||||
}
|
||||
|
||||
Wait-ForUrl -Uri "$webBaseUrl/health/live" -Processes @($nodeProcess, $dotnetProcess)
|
||||
@@ -177,6 +178,25 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
$announcementResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/announcements"
|
||||
if ($announcementResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw 'Public announcements request did not use the native ASP.NET Core endpoint'
|
||||
}
|
||||
$nativeAnnouncements = $announcementResponse.Content | ConvertFrom-Json
|
||||
$legacyAnnouncements = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/announcements"
|
||||
foreach ($section in @('plans', 'qualifications', 'admissions', 'cutoffs', 'reports')) {
|
||||
$nativeIds = @($nativeAnnouncements.$section | ForEach-Object id)
|
||||
$legacyIds = @($legacyAnnouncements.$section | ForEach-Object id)
|
||||
if (($nativeIds -join "`0") -ne ($legacyIds -join "`0")) {
|
||||
throw "Native public announcement section '$section' does not match the legacy API"
|
||||
}
|
||||
$nativeSectionJson = $nativeAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress
|
||||
$legacySectionJson = $legacyAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress
|
||||
if ($nativeSectionJson -ne $legacySectionJson) {
|
||||
throw "Native public announcement payload '$section' differs from the legacy API"
|
||||
}
|
||||
}
|
||||
|
||||
$session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
$loginBody = @{ username = 'admin'; password = '12345678' } | ConvertTo-Json -Compress
|
||||
$login = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session
|
||||
@@ -206,12 +226,68 @@ try {
|
||||
throw 'Native public notice endpoint did not preserve data or sanitize unsafe content'
|
||||
}
|
||||
|
||||
$candidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
$candidateLoginBody = @{ username = '2026-HZ01-F-0001'; password = '12345678' } | ConvertTo-Json -Compress
|
||||
$candidateLogin = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $candidateSession
|
||||
if ($candidateLogin.user.username -ne '2026-HZ01-F-0001') {
|
||||
throw 'Could not sign in as the migration verification candidate'
|
||||
}
|
||||
|
||||
$candidateResults = Invoke-RestMethod -Uri "$webBaseUrl/api/candidate/results" -WebSession $candidateSession
|
||||
$scoreCode = @($candidateResults.summaries | Where-Object verificationCode | Select-Object -First 1).verificationCode
|
||||
if (-not $scoreCode) {
|
||||
throw 'Seed data did not provide a score-report verification code'
|
||||
}
|
||||
$scoreVerificationResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/verifications/$scoreCode"
|
||||
if ($scoreVerificationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw 'Document verification request did not use the native ASP.NET Core endpoint'
|
||||
}
|
||||
$nativeScoreVerification = $scoreVerificationResponse.Content | ConvertFrom-Json
|
||||
$legacyScoreVerification = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/verifications/$scoreCode"
|
||||
if ($nativeScoreVerification.document.type -ne 'score-report' -or
|
||||
$nativeScoreVerification.document.examName -ne $legacyScoreVerification.document.examName -or
|
||||
$nativeScoreVerification.document.totalScore -ne $legacyScoreVerification.document.totalScore -or
|
||||
$nativeScoreVerification.document.subjectCount -ne $legacyScoreVerification.document.subjectCount) {
|
||||
throw 'Native score-report verification does not match the legacy API'
|
||||
}
|
||||
|
||||
$admissionCodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
||||
'tests/helpers/create-admission-verification.mjs', $smokeDatabasePath
|
||||
) -Environment $nodeEnvironment
|
||||
if (-not $admissionCodeProcess.WaitForExit(10000)) {
|
||||
$admissionCodeProcess.Kill($true)
|
||||
throw 'Timed out while reading an admission-notice verification code'
|
||||
}
|
||||
$noticeCode = $admissionCodeProcess.StandardOutput.ReadToEnd().Trim()
|
||||
$admissionCodeError = $admissionCodeProcess.StandardError.ReadToEnd()
|
||||
if ($admissionCodeProcess.ExitCode -ne 0) {
|
||||
throw "Could not read an admission-notice verification code`n$admissionCodeError"
|
||||
}
|
||||
$admissionCodeProcess.Dispose()
|
||||
if (-not $noticeCode) {
|
||||
throw "Seed data did not provide an admission-notice verification code`n$admissionCodeError"
|
||||
}
|
||||
$nativeNoticeVerification = Invoke-RestMethod -Uri "$webBaseUrl/api/public/verifications/$noticeCode"
|
||||
$legacyNoticeVerification = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/verifications/$noticeCode"
|
||||
if ($nativeNoticeVerification.document.type -ne 'admission-notice' -or
|
||||
$nativeNoticeVerification.document.noticeNumber -ne $legacyNoticeVerification.document.noticeNumber -or
|
||||
$nativeNoticeVerification.document.schoolName -ne $legacyNoticeVerification.document.schoolName) {
|
||||
throw 'Native admission-notice verification does not match the legacy API'
|
||||
}
|
||||
|
||||
$invalidVerification = Invoke-WebRequest -Uri "$webBaseUrl/api/public/verifications/SR-000000000000000000000000" -SkipHttpErrorCheck
|
||||
if ($invalidVerification.StatusCode -ne 404 -or $invalidVerification.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw 'Native document verification did not reject an invalid code'
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
AspNetCoreHost = 'passed'
|
||||
StaticAssets = 'passed'
|
||||
JsonProxy = 'passed'
|
||||
SessionCookie = 'passed'
|
||||
NativePublicApi = 'passed'
|
||||
PublicParity = 'passed'
|
||||
DocumentCodes = 'passed'
|
||||
} | Format-List
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -7,4 +7,8 @@ public interface IPublicQueryService
|
||||
Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<JsonObject?> GetNoticeAsync(string id, CancellationToken cancellationToken);
|
||||
|
||||
Task<JsonObject> GetAnnouncementsAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<JsonObject?> VerifyDocumentAsync(string code, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure.Data;
|
||||
using Eis.Infrastructure.Public;
|
||||
using Eis.Infrastructure.Security;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Eis.Infrastructure;
|
||||
@@ -9,10 +10,13 @@ public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddEisInfrastructure(
|
||||
this IServiceCollection services,
|
||||
DatabaseOptions databaseOptions)
|
||||
DatabaseOptions databaseOptions,
|
||||
DocumentVerificationOptions documentVerificationOptions)
|
||||
{
|
||||
services.AddSingleton(databaseOptions);
|
||||
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
||||
services.AddSingleton(documentVerificationOptions);
|
||||
services.AddSingleton<DocumentVerificationCodeService>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public static class MigrationFeatureCatalog
|
||||
{
|
||||
public static IReadOnlyList<MigrationFeature> Current { get; } =
|
||||
[
|
||||
new(FeatureArea.Public, false, "/api/public/home, /api/public/notices/{id}(部分原生)"),
|
||||
new(FeatureArea.Public, true, "/api/public"),
|
||||
new(FeatureArea.Authentication, false, "/api/auth"),
|
||||
new(FeatureArea.Candidate, false, "/api/candidate"),
|
||||
new(FeatureArea.Administration, false, "/api/admin"),
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Eis.Infrastructure.Public;
|
||||
|
||||
public sealed partial class PublicQueryService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> SpecialtyCategories =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["sports"] = "体育",
|
||||
["arts"] = "艺术"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, (string Category, string Name)> SpecialtyTypes =
|
||||
new Dictionary<string, (string, string)>(StringComparer.Ordinal)
|
||||
{
|
||||
["track_field"] = ("sports", "田径"),
|
||||
["basketball"] = ("sports", "篮球"),
|
||||
["football"] = ("sports", "足球"),
|
||||
["volleyball"] = ("sports", "排球"),
|
||||
["table_tennis"] = ("sports", "乒乓球"),
|
||||
["badminton"] = ("sports", "羽毛球"),
|
||||
["swimming"] = ("sports", "游泳"),
|
||||
["martial_arts"] = ("sports", "武术"),
|
||||
["aerobics_cheer"] = ("sports", "健美操与啦啦操"),
|
||||
["vocal_music"] = ("arts", "声乐"),
|
||||
["instrumental_music"] = ("arts", "器乐"),
|
||||
["dance"] = ("arts", "舞蹈"),
|
||||
["fine_arts"] = ("arts", "美术"),
|
||||
["calligraphy"] = ("arts", "书法"),
|
||||
["drama_broadcasting"] = ("arts", "戏剧与播音")
|
||||
};
|
||||
|
||||
public async Task<JsonObject> GetAnnouncementsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var exams = await QueryAsync(connection,
|
||||
"SELECT id, name FROM exams ORDER BY created_at, id",
|
||||
reader => new NamedRow(ReadString(reader, "id"), ReadString(reader, "name")), cancellationToken);
|
||||
var schools = await QueryAsync(connection,
|
||||
"SELECT id, name FROM schools ORDER BY name, id",
|
||||
reader => new NamedRow(ReadString(reader, "id"), ReadString(reader, "name")), cancellationToken);
|
||||
var users = await QueryAsync(connection,
|
||||
"SELECT id, role, active, candidate_number, display_name FROM users ORDER BY created_at, id",
|
||||
ReadAnnouncementUser, cancellationToken);
|
||||
var profiles = await QueryAsync(connection,
|
||||
"SELECT user_id, name, school_id, profile_completed, id_number, phone FROM candidate_profiles ORDER BY updated_at, id",
|
||||
ReadAnnouncementProfile, cancellationToken);
|
||||
var records = await QueryAsync(connection,
|
||||
"SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id",
|
||||
ReadAdmissionRecord, cancellationToken);
|
||||
|
||||
var examNames = exams.ToDictionary(item => item.Id, item => item.Name, StringComparer.Ordinal);
|
||||
var schoolNames = schools.ToDictionary(item => item.Id, item => item.Name, StringComparer.Ordinal);
|
||||
var usersById = users.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||
var profilesByUser = profiles.ToDictionary(item => item.UserId, StringComparer.Ordinal);
|
||||
var settings = records.Where(item => item.Kind == "setting")
|
||||
.GroupBy(item => item.ExamId)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
|
||||
|
||||
var plans = records
|
||||
.Where(item => item.Kind == "plan" && item.Status == "approved" && GetBoolean(item.Payload, "publicVisible", true))
|
||||
.Select(item => BuildPlanAnnouncement(item, examNames, schoolNames))
|
||||
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
|
||||
.ToArray();
|
||||
|
||||
var qualifications = records
|
||||
.Where(item => item.Kind == "qualification_publication" && item.Status == "published" &&
|
||||
GetBoolean(item.Payload, "publicVisible", true) &&
|
||||
QualificationComplete(item, users, profiles, records))
|
||||
.Select(item => BuildQualificationAnnouncement(item, examNames, schoolNames))
|
||||
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
|
||||
.ToArray();
|
||||
|
||||
var roundPublications = BuildRoundPublications(records, settings, examNames, schoolNames, usersById, profilesByUser);
|
||||
var finalPublications = records
|
||||
.Where(item => item.Kind == "setting" && item.Status == "completed" &&
|
||||
GetBoolean(item.Payload, "autoPublish", true) && GetBoolean(item.Payload, "publicVisible", true))
|
||||
.Select(item => new JsonObject
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
|
||||
["title"] = $"{examNames.GetValueOrDefault(item.ExamId) ?? string.Empty}最终录取名单",
|
||||
["publishedAt"] = GetString(item.Payload, "completedAt") ?? item.UpdatedAt,
|
||||
["rows"] = BuildPublicAdmissionRows(records, item.ExamId, 0, schoolNames, usersById, profilesByUser)
|
||||
});
|
||||
var admissions = roundPublications.Concat(finalPublications)
|
||||
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
|
||||
.ToArray();
|
||||
|
||||
var cutoffs = records
|
||||
.Where(item => item.Kind == "cutoff_publication" && item.Status == "published" &&
|
||||
GetBoolean(item.Payload, "publicVisible", true) &&
|
||||
(!settings.TryGetValue(item.ExamId, out var setting) || GetBoolean(setting.Payload, "autoPublish", true)))
|
||||
.Select(item => new JsonObject
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
|
||||
["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt,
|
||||
["rows"] = GetArray(item.Payload, "rows").DeepClone()
|
||||
})
|
||||
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
|
||||
.ToArray();
|
||||
|
||||
var reports = records
|
||||
.Where(item => item.Kind == "notification" && item.UserId is null && item.Status == "approved" &&
|
||||
GetString(item.Payload, "type") == "admission_reporting" &&
|
||||
GetBoolean(item.Payload, "publicVisible", true))
|
||||
.Select(item => BuildReportingAnnouncement(item, examNames, schoolNames))
|
||||
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
|
||||
.ToArray();
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["plans"] = new JsonArray(plans),
|
||||
["qualifications"] = new JsonArray(qualifications),
|
||||
["admissions"] = new JsonArray(admissions),
|
||||
["cutoffs"] = new JsonArray(cutoffs),
|
||||
["reports"] = new JsonArray(reports)
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildPlanAnnouncement(
|
||||
AdmissionRecordRow item,
|
||||
IReadOnlyDictionary<string, string> examNames,
|
||||
IReadOnlyDictionary<string, string> schoolNames)
|
||||
{
|
||||
var rows = GetArray(item.Payload, "categories").OfType<JsonObject>().Select(category =>
|
||||
{
|
||||
var allocations = GetArray(category, "indicatorAllocations").OfType<JsonObject>().ToArray();
|
||||
var categoryCode = GetString(category, "specialtyCategory") ?? string.Empty;
|
||||
var typeCode = GetString(category, "specialtyType") ?? string.Empty;
|
||||
return new JsonObject
|
||||
{
|
||||
["code"] = GetString(category, "code") ?? string.Empty,
|
||||
["name"] = GetString(category, "name") ?? string.Empty,
|
||||
["quota"] = GetNumber(category, "quota"),
|
||||
["specialtyCategory"] = categoryCode,
|
||||
["specialtyType"] = typeCode,
|
||||
["specialtyLabel"] = SpecialtyLabel(categoryCode, typeCode) ?? "普通 / 政策类",
|
||||
["indicatorQuota"] = allocations.Sum(allocation => GetNumber(allocation, "quota")),
|
||||
["indicatorAllocations"] = new JsonArray(allocations.Select(allocation => new JsonObject
|
||||
{
|
||||
["sourceSchoolName"] = schoolNames.GetValueOrDefault(GetString(allocation, "sourceSchoolId") ?? string.Empty)
|
||||
?? GetString(allocation, "sourceSchoolId") ?? string.Empty,
|
||||
["quota"] = GetNumber(allocation, "quota")
|
||||
}).ToArray())
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
|
||||
["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty,
|
||||
["publishedAt"] = GetString(item.Payload, "reviewedAt") ?? item.UpdatedAt,
|
||||
["note"] = GetString(item.Payload, "note") ?? string.Empty,
|
||||
["rows"] = new JsonArray(rows)
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildQualificationAnnouncement(
|
||||
AdmissionRecordRow item,
|
||||
IReadOnlyDictionary<string, string> examNames,
|
||||
IReadOnlyDictionary<string, string> schoolNames) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
|
||||
["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty,
|
||||
["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt,
|
||||
["rows"] = new JsonArray(GetArray(item.Payload, "rows").OfType<JsonObject>().Select(row => new JsonObject
|
||||
{
|
||||
["registrationNumber"] = GetString(row, "registrationNumber") ?? string.Empty,
|
||||
["name"] = GetString(row, "name") ?? string.Empty,
|
||||
["eligible"] = GetBoolean(row, "eligible", false),
|
||||
["specialtyLabel"] = GetString(row, "specialtyLabel") ?? "普通生"
|
||||
}).ToArray())
|
||||
};
|
||||
|
||||
private static bool QualificationComplete(
|
||||
AdmissionRecordRow publication,
|
||||
IReadOnlyCollection<AnnouncementUserRow> users,
|
||||
IReadOnlyCollection<AnnouncementProfileRow> profiles,
|
||||
IReadOnlyCollection<AdmissionRecordRow> records)
|
||||
{
|
||||
if (publication.SchoolId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var activeCandidates = users.Where(user => user.Role == "candidate" && user.Active)
|
||||
.Select(user => user.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var expectedUsers = profiles
|
||||
.Where(profile => profile.SchoolId == publication.SchoolId && profile.ProfileCompleted && activeCandidates.Contains(profile.UserId))
|
||||
.Select(profile => profile.UserId).ToArray();
|
||||
if (expectedUsers.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var confirmed = records
|
||||
.Where(item => item.Kind == "indicator_qualification" && item.ExamId == publication.ExamId &&
|
||||
item.SchoolId == publication.SchoolId && item.Status == "confirmed" && item.UserId is not null)
|
||||
.Select(item => item.UserId!).ToHashSet(StringComparer.Ordinal);
|
||||
return expectedUsers.All(confirmed.Contains);
|
||||
}
|
||||
|
||||
private static IEnumerable<JsonObject> BuildRoundPublications(
|
||||
IReadOnlyCollection<AdmissionRecordRow> records,
|
||||
IReadOnlyDictionary<string, AdmissionRecordRow> settings,
|
||||
IReadOnlyDictionary<string, string> examNames,
|
||||
IReadOnlyDictionary<string, string> schoolNames,
|
||||
IReadOnlyDictionary<string, AnnouncementUserRow> users,
|
||||
IReadOnlyDictionary<string, AnnouncementProfileRow> profiles)
|
||||
{
|
||||
var output = new List<RoundPublication>();
|
||||
foreach (var item in records.Where(item => item.Kind == "notification" && item.UserId is null &&
|
||||
item.Status == "published" && GetString(item.Payload, "type") == "admission_round_publication"))
|
||||
{
|
||||
output.Add(new RoundPublication(
|
||||
item.Id, item.ExamId, Math.Max(1, (int)GetNumber(item.Payload, "round", 1)),
|
||||
GetString(item.Payload, "publishedAt") ?? item.UpdatedAt,
|
||||
GetArray(item.Payload, "rows").DeepClone().AsArray(), GetBoolean(item.Payload, "publicVisible", true)));
|
||||
}
|
||||
var keys = output.Select(item => $"{item.ExamId}:{item.Round}").ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var setting in records.Where(item => item.Kind == "setting" && item.Status is "reporting" or "supplementary" or "completed"))
|
||||
{
|
||||
var rounds = records.Where(item => item.Kind == "placement" && item.ExamId == setting.ExamId && item.Status is "final" or "forfeited")
|
||||
.Select(item => Math.Max(1, (int)GetNumber(item.Payload, "finalizedRound", 1))).ToArray();
|
||||
var round = rounds.Length == 0 ? 0 : rounds.Max();
|
||||
if (round == 0 || keys.Contains($"{setting.ExamId}:{round}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
output.Add(new RoundPublication(
|
||||
$"{setting.Id}-round-{round}", setting.ExamId, round,
|
||||
GetString(setting.Payload, "roundPublishedAt") ?? setting.UpdatedAt,
|
||||
BuildPublicAdmissionRows(records, setting.ExamId, round, schoolNames, users, profiles),
|
||||
GetBoolean(setting.Payload, "publicVisible", true)));
|
||||
}
|
||||
|
||||
return output
|
||||
.Where(item => item.Visible && (!settings.TryGetValue(item.ExamId, out var setting) || GetBoolean(setting.Payload, "autoPublish", true)))
|
||||
.Select(item => new JsonObject
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
|
||||
["round"] = item.Round,
|
||||
["title"] = $"{examNames.GetValueOrDefault(item.ExamId) ?? string.Empty}第 {item.Round} 轮录取名单公示",
|
||||
["publishedAt"] = item.PublishedAt,
|
||||
["rows"] = item.Rows.DeepClone()
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonArray BuildPublicAdmissionRows(
|
||||
IReadOnlyCollection<AdmissionRecordRow> records,
|
||||
string examId,
|
||||
int round,
|
||||
IReadOnlyDictionary<string, string> schoolNames,
|
||||
IReadOnlyDictionary<string, AnnouncementUserRow> users,
|
||||
IReadOnlyDictionary<string, AnnouncementProfileRow> profiles)
|
||||
{
|
||||
var rows = records.Where(item => item.Kind == "placement" && item.ExamId == examId &&
|
||||
(round > 0
|
||||
? (item.Status is "final" or "forfeited") && (int)GetNumber(item.Payload, "finalizedRound", 1) == round
|
||||
: item.Status == "final"))
|
||||
.Select(item =>
|
||||
{
|
||||
users.TryGetValue(item.UserId ?? string.Empty, out var user);
|
||||
profiles.TryGetValue(item.UserId ?? string.Empty, out var profile);
|
||||
return new JsonObject
|
||||
{
|
||||
["registrationNumber"] = user?.CandidateNumber ?? string.Empty,
|
||||
["name"] = profile?.Name ?? user?.DisplayName ?? string.Empty,
|
||||
["totalScore"] = GetNumber(item.Payload, "totalScore"),
|
||||
["admittedSchool"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty,
|
||||
["categoryName"] = GetString(item.Payload, "categoryName") ?? string.Empty,
|
||||
["idNumberMasked"] = MaskIdNumber(profile?.IdNumber),
|
||||
["phoneMasked"] = MaskPhone(profile?.Phone)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(item => item["totalScore"]?.GetValue<double>() ?? 0)
|
||||
.ThenBy(item => item["registrationNumber"]?.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return new JsonArray(rows);
|
||||
}
|
||||
|
||||
private static JsonObject BuildReportingAnnouncement(
|
||||
AdmissionRecordRow item,
|
||||
IReadOnlyDictionary<string, string> examNames,
|
||||
IReadOnlyDictionary<string, string> schoolNames)
|
||||
{
|
||||
var statistics = GetObject(item.Payload, "statistics");
|
||||
var supplement = GetString(item.Payload, "supplementDecision") == "supplement";
|
||||
var examName = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty;
|
||||
var schoolName = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty;
|
||||
var summary = $"计划 {GetNumber(statistics, "totalQuota"):0.##} 人,已报到 {GetNumber(statistics, "reportedCount"):0.##} 人,完成率 {GetNumber(statistics, "reportingRate"):0.##}%。";
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["examId"] = item.ExamId,
|
||||
["schoolId"] = JsonValue.Create(item.SchoolId),
|
||||
["examName"] = examName,
|
||||
["schoolName"] = schoolName,
|
||||
["title"] = supplement ? $"{examName} · {schoolName}考生报到情况及补录说明" : $"{examName} · {schoolName}考生报到情况公示",
|
||||
["summary"] = summary,
|
||||
["publishedAt"] = GetString(item.Payload, "approvedAt") ?? item.UpdatedAt,
|
||||
["statistics"] = statistics.DeepClone(),
|
||||
["supplementDecision"] = GetString(item.Payload, "supplementDecision") ?? string.Empty,
|
||||
["decisionNote"] = GetString(item.Payload, "decisionNote") ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string? SpecialtyLabel(string categoryCode, string typeCode)
|
||||
{
|
||||
if (!SpecialtyCategories.TryGetValue(categoryCode, out var categoryName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return SpecialtyTypes.TryGetValue(typeCode, out var type) && type.Category == categoryCode
|
||||
? $"{categoryName}·{type.Name}"
|
||||
: categoryName;
|
||||
}
|
||||
|
||||
private static string MaskIdNumber(string? value) => string.IsNullOrEmpty(value)
|
||||
? string.Empty
|
||||
: $"{value[..Math.Min(3, value.Length)]}***********{value[Math.Max(0, value.Length - 2)..]}";
|
||||
|
||||
private static string MaskPhone(string? value) => string.IsNullOrEmpty(value)
|
||||
? string.Empty
|
||||
: $"{value[..Math.Min(3, value.Length)]}****{value[Math.Max(0, value.Length - 4)..]}";
|
||||
|
||||
private static AnnouncementUserRow ReadAnnouncementUser(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"), ReadString(reader, "role"), ReadBoolean(reader, "active"),
|
||||
ReadNullableString(reader, "candidate_number") ?? string.Empty, ReadString(reader, "display_name"));
|
||||
|
||||
private static AnnouncementProfileRow ReadAnnouncementProfile(DbDataReader reader) => new(
|
||||
ReadString(reader, "user_id"), ReadString(reader, "name"), ReadNullableString(reader, "school_id"),
|
||||
ReadBoolean(reader, "profile_completed"), ReadString(reader, "id_number"), ReadString(reader, "phone"));
|
||||
|
||||
private sealed record NamedRow(string Id, string Name);
|
||||
private sealed record AnnouncementUserRow(string Id, string Role, bool Active, string CandidateNumber, string DisplayName);
|
||||
private sealed record AnnouncementProfileRow(string UserId, string Name, string? SchoolId, bool ProfileCompleted, string IdNumber, string Phone);
|
||||
private sealed record RoundPublication(string Id, string ExamId, int Round, string PublishedAt, JsonArray Rows, bool Visible);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Infrastructure.Security;
|
||||
|
||||
namespace Eis.Infrastructure.Public;
|
||||
|
||||
public sealed partial class PublicQueryService
|
||||
{
|
||||
public async Task<JsonObject?> VerifyDocumentAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedCode = (code ?? string.Empty).ToUpperInvariant();
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
return normalizedCode.StartsWith("SR-", StringComparison.Ordinal)
|
||||
? await VerifyScoreReportAsync(connection, normalizedCode, cancellationToken)
|
||||
: normalizedCode.StartsWith("AN-", StringComparison.Ordinal)
|
||||
? await VerifyAdmissionNoticeAsync(connection, normalizedCode, cancellationToken)
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<JsonObject?> VerifyScoreReportAsync(
|
||||
DbConnection connection,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var registrations = await QueryAsync(connection,
|
||||
"SELECT id, user_id, exam_id, registration_number FROM registrations ORDER BY created_at, id",
|
||||
reader => new VerificationRegistrationRow(
|
||||
ReadString(reader, "id"), ReadString(reader, "user_id"), ReadString(reader, "exam_id"),
|
||||
ReadNullableString(reader, "registration_number") ?? string.Empty), cancellationToken);
|
||||
var results = await QueryAsync(connection,
|
||||
"SELECT registration_id, subject_id, score, updated_at, published_at FROM results WHERE published = 1 ORDER BY id",
|
||||
reader => new VerificationResultRow(
|
||||
ReadString(reader, "registration_id"), ReadString(reader, "subject_id"), ReadDouble(reader, "score"),
|
||||
ReadNullableString(reader, "updated_at"), ReadNullableString(reader, "published_at")), cancellationToken);
|
||||
var examNames = await ReadNamesAsync(connection, "exams", cancellationToken);
|
||||
var users = await QueryAsync(connection,
|
||||
"SELECT id, candidate_number, display_name FROM users ORDER BY id",
|
||||
reader => new VerificationUserRow(
|
||||
ReadString(reader, "id"), ReadNullableString(reader, "candidate_number") ?? string.Empty,
|
||||
ReadString(reader, "display_name")), cancellationToken);
|
||||
var profiles = await QueryAsync(connection,
|
||||
"SELECT user_id, name FROM candidate_profiles ORDER BY id",
|
||||
reader => new VerificationProfileRow(ReadString(reader, "user_id"), ReadString(reader, "name")), cancellationToken);
|
||||
var usersById = users.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||
var profilesByUser = profiles.ToDictionary(item => item.UserId, StringComparer.Ordinal);
|
||||
|
||||
foreach (var registration in registrations)
|
||||
{
|
||||
var reportResults = results.Where(item => item.RegistrationId == registration.Id).ToArray();
|
||||
if (reportResults.Length == 0 || !examNames.ContainsKey(registration.ExamId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var expected = _documentCodes.ScoreReportCode(
|
||||
registration.Id, registration.UserId, registration.ExamId,
|
||||
reportResults.Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)));
|
||||
if (!DocumentVerificationCodeService.SafeEquals(code, expected))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
usersById.TryGetValue(registration.UserId, out var user);
|
||||
profilesByUser.TryGetValue(registration.UserId, out var profile);
|
||||
var candidateNumber = user?.CandidateNumber ?? registration.RegistrationNumber;
|
||||
var latestResult = reportResults.OrderByDescending(item => ParseDate(item.PublishedAt ?? item.UpdatedAt)).First();
|
||||
return new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["verified"] = true,
|
||||
["document"] = new JsonObject
|
||||
{
|
||||
["type"] = "score-report",
|
||||
["typeName"] = "考生成绩单",
|
||||
["candidateName"] = HideName(profile?.Name ?? user?.DisplayName ?? string.Empty),
|
||||
["candidateNumber"] = MaskCandidateNumber(candidateNumber),
|
||||
["examName"] = examNames[registration.ExamId],
|
||||
["subjectCount"] = reportResults.Length,
|
||||
["totalScore"] = Math.Round(reportResults.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero),
|
||||
["issuedAt"] = JsonValue.Create(latestResult.PublishedAt)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<JsonObject?> VerifyAdmissionNoticeAsync(
|
||||
DbConnection connection,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var placements = await QueryAsync(connection,
|
||||
"SELECT id, exam_id, user_id, school_id, payload_json, updated_at FROM admission_records WHERE kind = 'placement' AND status = 'final' ORDER BY created_at, id",
|
||||
reader =>
|
||||
{
|
||||
JsonObject payload;
|
||||
try
|
||||
{
|
||||
payload = JsonNode.Parse(ReadString(reader, "payload_json"))?.AsObject() ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
payload = new JsonObject();
|
||||
}
|
||||
return new VerificationPlacementRow(
|
||||
ReadString(reader, "id"), ReadString(reader, "exam_id"), ReadString(reader, "user_id"),
|
||||
ReadString(reader, "school_id"), payload, ReadString(reader, "updated_at"));
|
||||
}, cancellationToken);
|
||||
var examNames = await ReadNamesAsync(connection, "exams", cancellationToken);
|
||||
var schoolNames = await ReadNamesAsync(connection, "schools", cancellationToken);
|
||||
var profiles = await QueryAsync(connection,
|
||||
"SELECT user_id, name FROM candidate_profiles ORDER BY id",
|
||||
reader => new VerificationProfileRow(ReadString(reader, "user_id"), ReadString(reader, "name")), cancellationToken);
|
||||
var profilesByUser = profiles.ToDictionary(item => item.UserId, StringComparer.Ordinal);
|
||||
|
||||
foreach (var placement in placements)
|
||||
{
|
||||
if (!examNames.ContainsKey(placement.ExamId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var expected = _documentCodes.AdmissionNoticeCode(
|
||||
placement.Id, placement.UserId, placement.SchoolId, placement.ExamId,
|
||||
GetString(placement.Payload, "categoryCode") ?? string.Empty,
|
||||
GetString(placement.Payload, "noticeNumber") ?? string.Empty,
|
||||
placement.UpdatedAt);
|
||||
if (!DocumentVerificationCodeService.SafeEquals(code, expected))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
profilesByUser.TryGetValue(placement.UserId, out var profile);
|
||||
return new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["verified"] = true,
|
||||
["document"] = new JsonObject
|
||||
{
|
||||
["type"] = "admission-notice",
|
||||
["typeName"] = "录取通知书",
|
||||
["noticeNumber"] = GetString(placement.Payload, "noticeNumber") ?? string.Empty,
|
||||
["candidateName"] = HideName(profile?.Name ?? string.Empty),
|
||||
["examName"] = examNames[placement.ExamId],
|
||||
["schoolName"] = schoolNames.GetValueOrDefault(placement.SchoolId) ?? string.Empty,
|
||||
["categoryName"] = GetString(placement.Payload, "categoryName") ?? string.Empty,
|
||||
["issuedAt"] = placement.UpdatedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, string>> ReadNamesAsync(
|
||||
DbConnection connection,
|
||||
string table,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedTable = table switch
|
||||
{
|
||||
"exams" => "exams",
|
||||
"schools" => "schools",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(table))
|
||||
};
|
||||
var rows = await QueryAsync(connection, $"SELECT id, name FROM {allowedTable} ORDER BY id",
|
||||
reader => new KeyValuePair<string, string>(ReadString(reader, "id"), ReadString(reader, "name")), cancellationToken);
|
||||
return rows.ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string HideName(string value) => value.Length == 0
|
||||
? string.Empty
|
||||
: $"{value[..1]}{new string('*', Math.Max(1, value.Length - 1))}";
|
||||
|
||||
private static string MaskCandidateNumber(string value) => value.Length > 6
|
||||
? $"{value[..3]}****{value[^3..]}"
|
||||
: value;
|
||||
|
||||
private sealed record VerificationRegistrationRow(string Id, string UserId, string ExamId, string RegistrationNumber);
|
||||
private sealed record VerificationResultRow(string RegistrationId, string SubjectId, double Score, string? UpdatedAt, string? PublishedAt);
|
||||
private sealed record VerificationUserRow(string Id, string CandidateNumber, string DisplayName);
|
||||
private sealed record VerificationProfileRow(string UserId, string Name);
|
||||
private sealed record VerificationPlacementRow(string Id, string ExamId, string UserId, string SchoolId, JsonObject Payload, string UpdatedAt);
|
||||
}
|
||||
@@ -5,14 +5,17 @@ using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure.Data;
|
||||
using Eis.Infrastructure.Security;
|
||||
|
||||
namespace Eis.Infrastructure.Public;
|
||||
|
||||
public sealed class PublicQueryService(
|
||||
public sealed partial class PublicQueryService(
|
||||
IRelationalConnectionFactory connectionFactory,
|
||||
IPublicSiteConfiguration siteConfiguration) : IPublicQueryService
|
||||
IPublicSiteConfiguration siteConfiguration,
|
||||
DocumentVerificationCodeService documentCodes) : IPublicQueryService
|
||||
{
|
||||
private readonly NoticeContentFormatter _noticeFormatter = new();
|
||||
private readonly DocumentVerificationCodeService _documentCodes = documentCodes;
|
||||
|
||||
public async Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Eis.Infrastructure.Security;
|
||||
|
||||
public sealed class DocumentVerificationCodeService(DocumentVerificationOptions options)
|
||||
{
|
||||
public string ScoreReportCode(
|
||||
string registrationId,
|
||||
string userId,
|
||||
string examId,
|
||||
IEnumerable<ScoreSignatureItem> results)
|
||||
{
|
||||
var scores = results
|
||||
.OrderBy(item => item.SubjectId, StringComparer.Ordinal)
|
||||
.Select(item => $"{item.SubjectId}:{FormatNumber(item.Score)}:{item.PublishedAt ?? item.UpdatedAt ?? string.Empty}");
|
||||
return $"SR-{Signature("score-report", [registrationId, userId, examId, .. scores])}";
|
||||
}
|
||||
|
||||
public string AdmissionNoticeCode(
|
||||
string placementId,
|
||||
string userId,
|
||||
string schoolId,
|
||||
string examId,
|
||||
string categoryCode,
|
||||
string noticeNumber,
|
||||
string updatedAt) =>
|
||||
$"AN-{Signature("admission-notice", [placementId, userId, schoolId, examId, categoryCode, noticeNumber, updatedAt])}";
|
||||
|
||||
public static bool SafeEquals(string left, string right)
|
||||
{
|
||||
var leftBytes = Encoding.UTF8.GetBytes((left ?? string.Empty).ToUpperInvariant());
|
||||
var rightBytes = Encoding.UTF8.GetBytes((right ?? string.Empty).ToUpperInvariant());
|
||||
return leftBytes.Length == rightBytes.Length && CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
private string Signature(string type, IEnumerable<string> parts)
|
||||
{
|
||||
var payload = string.Join('\u001f', new[] { type }.Concat(parts));
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret));
|
||||
var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
|
||||
return signature[..24];
|
||||
}
|
||||
|
||||
private static string FormatNumber(double value) =>
|
||||
value.ToString("0.#############################", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public sealed record ScoreSignatureItem(
|
||||
string SubjectId,
|
||||
double Score,
|
||||
string? PublishedAt,
|
||||
string? UpdatedAt);
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Eis.Infrastructure.Security;
|
||||
|
||||
public sealed class DocumentVerificationOptions
|
||||
{
|
||||
private DocumentVerificationOptions(string secret)
|
||||
{
|
||||
Secret = secret;
|
||||
}
|
||||
|
||||
public string Secret { get; }
|
||||
|
||||
internal static DocumentVerificationOptions FromSecret(string secret) => new(secret);
|
||||
|
||||
public static DocumentVerificationOptions FromEnvironment(bool production)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("DOCUMENT_VERIFICATION_SECRET") ?? string.Empty;
|
||||
var nodeProduction = string.Equals(
|
||||
Environment.GetEnvironmentVariable("NODE_ENV"),
|
||||
"production",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if ((production || nodeProduction) && configured.Length < 32)
|
||||
{
|
||||
throw new InvalidOperationException("生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET");
|
||||
}
|
||||
|
||||
var secret = configured;
|
||||
if (secret.Length == 0)
|
||||
{
|
||||
secret = Environment.GetEnvironmentVariable("SESSION_SECRET") ?? string.Empty;
|
||||
}
|
||||
return new DocumentVerificationOptions(
|
||||
secret.Length > 0 ? secret : "development-document-verification-secret");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Eis.Application.Public;
|
||||
using Eis.Infrastructure;
|
||||
using Eis.Infrastructure.Data;
|
||||
using Eis.Infrastructure.Migration;
|
||||
using Eis.Infrastructure.Security;
|
||||
using Eis.Web.Configuration;
|
||||
using Eis.Web.Frontend;
|
||||
using Eis.Web.Legacy;
|
||||
@@ -28,7 +29,9 @@ builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
|
||||
builder.Services.AddEisInfrastructure(DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()));
|
||||
builder.Services.AddEisInfrastructure(
|
||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -28,6 +28,28 @@ public static class PublicEndpoints
|
||||
: Results.Json(new { ok = true, notice });
|
||||
});
|
||||
|
||||
endpoints.MapGet("/api/public/announcements", async (
|
||||
HttpContext context,
|
||||
IPublicQueryService queries,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||
return Results.Json(await queries.GetAnnouncementsAsync(cancellationToken));
|
||||
});
|
||||
|
||||
endpoints.MapGet("/api/public/verifications/{code}", async (
|
||||
string code,
|
||||
HttpContext context,
|
||||
IPublicQueryService queries,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||
var verification = await queries.VerifyDocumentAsync(code, cancellationToken);
|
||||
return verification is null
|
||||
? Results.Json(new { ok = false, message = "未查询到有效文书,请核对防伪码" }, statusCode: StatusCodes.Status404NotFound)
|
||||
: Results.Json(verification);
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Eis.Infrastructure.Security;
|
||||
|
||||
namespace Eis.Infrastructure.Tests.Security;
|
||||
|
||||
public sealed class DocumentVerificationCodeServiceTests
|
||||
{
|
||||
private const string Secret = "test-document-verification-secret-32-characters";
|
||||
private readonly DocumentVerificationCodeService _codes = new(DocumentVerificationOptions.FromSecret(Secret));
|
||||
|
||||
[Fact]
|
||||
public void ScoreReportCode_MatchesLegacyNodeImplementation()
|
||||
{
|
||||
var results = new[]
|
||||
{
|
||||
new ScoreSignatureItem("math", 118, "2026-07-20T08:00:00.000Z", null),
|
||||
new ScoreSignatureItem("chinese", 112, "2026-07-20T08:00:00.000Z", null)
|
||||
};
|
||||
|
||||
var code = _codes.ScoreReportCode("registration_1", "candidate_1", "exam_1", results);
|
||||
var reordered = _codes.ScoreReportCode("registration_1", "candidate_1", "exam_1", results.Reverse());
|
||||
|
||||
Assert.Equal("SR-38D987280CE4FBE78307B5F3", code);
|
||||
Assert.Equal(code, reordered);
|
||||
Assert.True(DocumentVerificationCodeService.SafeEquals(code, code.ToLowerInvariant()));
|
||||
Assert.False(DocumentVerificationCodeService.SafeEquals(code, $"{code}0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdmissionNoticeCode_MatchesLegacyNodeImplementation()
|
||||
{
|
||||
var code = _codes.AdmissionNoticeCode(
|
||||
"placement_1", "candidate_1", "school_1", "exam_1", "general",
|
||||
"AD01-EX-2026-000001", "2026-07-21T08:00:00.000Z");
|
||||
|
||||
Assert.Equal("AN-E907684020E21579B854C167", code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { admissionNoticeCode } from '../../src/security/document-verification.mjs';
|
||||
|
||||
const databasePath = process.argv[2];
|
||||
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
|
||||
|
||||
const database = new DatabaseSync(databasePath);
|
||||
const rows = database.prepare(
|
||||
"SELECT id, exam_id, user_id, school_id, payload_json, updated_at FROM admission_records WHERE kind = 'placement' ORDER BY id"
|
||||
).all();
|
||||
let row = rows.map(item => ({ ...item, payload: JSON.parse(item.payload_json || '{}') }))[0];
|
||||
|
||||
if (!row) {
|
||||
const exam = database.prepare('SELECT id FROM exams ORDER BY id LIMIT 1').get();
|
||||
const user = database.prepare("SELECT id FROM users WHERE role = 'candidate' ORDER BY id LIMIT 1").get();
|
||||
const school = database.prepare('SELECT id FROM schools WHERE is_admission_school = 1 ORDER BY id LIMIT 1').get();
|
||||
if (!exam || !user || !school) throw new Error('测试数据库缺少考试、考生或招生学校');
|
||||
row = {
|
||||
id: 'smoke_final_placement',
|
||||
exam_id: exam.id,
|
||||
user_id: user.id,
|
||||
school_id: school.id,
|
||||
updated_at: '2026-07-22T00:00:00.000Z',
|
||||
payload: { categoryCode: 'general', categoryName: '普通生', noticeNumber: 'SMOKE-EXAM-000001' }
|
||||
};
|
||||
database.prepare(
|
||||
"INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at) VALUES (?, 'placement', ?, ?, ?, 'final', ?, ?, ?)"
|
||||
).run(row.id, row.exam_id, row.user_id, row.school_id, JSON.stringify(row.payload), row.updated_at, row.updated_at);
|
||||
} else {
|
||||
row.payload.noticeNumber ||= 'SMOKE-EXAM-000001';
|
||||
row.updated_at = '2026-07-22T00:00:00.000Z';
|
||||
database.prepare(
|
||||
"UPDATE admission_records SET status = 'final', payload_json = ?, updated_at = ? WHERE id = ?"
|
||||
).run(JSON.stringify(row.payload), row.updated_at, row.id);
|
||||
}
|
||||
database.close();
|
||||
|
||||
console.log(admissionNoticeCode(
|
||||
process.env.DOCUMENT_VERIFICATION_SECRET,
|
||||
{
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
schoolId: row.school_id,
|
||||
payload: row.payload,
|
||||
updatedAt: row.updated_at
|
||||
},
|
||||
{ id: row.exam_id }
|
||||
));
|
||||
Reference in New Issue
Block a user