From 017cacc6f92fac97c13ef2845086cfee1309fc05 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 19:12:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=9B=E7=94=9F=E8=AE=A1=E5=88=92=E3=80=81?= =?UTF-8?q?=E8=B5=84=E6=A0=BC=E3=80=81=E5=BD=95=E5=8F=96=E3=80=81=E5=88=86?= =?UTF-8?q?=E6=95=B0=E7=BA=BF=E5=8F=8A=E6=8A=A5=E5=88=B0=E5=85=AC=E7=A4=BA?= =?UTF-8?q?=20=E6=88=90=E7=BB=A9=E5=8D=95=20HMAC=20=E9=98=B2=E4=BC=AA?= =?UTF-8?q?=E9=AA=8C=E7=9C=9F=20=E5=BD=95=E5=8F=96=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E4=B9=A6=20HMAC=20=E9=98=B2=E4=BC=AA=E9=AA=8C=E7=9C=9F=20?= =?UTF-8?q?=E9=98=B2=E4=BC=AA=E7=A0=81=E5=B8=B8=E9=87=8F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=AF=94=E8=BE=83=20=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E6=96=87=E4=B9=A6=E5=AF=86=E9=92=A5=E5=BC=BA=E5=BA=A6=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=20=E6=97=A7=E6=96=87=E4=B9=A6=E9=98=B2=E4=BC=AA?= =?UTF-8?q?=E7=A0=81=E5=AE=8C=E5=85=A8=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MIGRATION.md | 2 +- scripts/smoke-dotnet-migration.ps1 | 76 ++++ .../Public/IPublicQueryService.cs | 4 + src/Eis.Infrastructure/DependencyInjection.cs | 6 +- .../Migration/MigrationFeatureCatalog.cs | 2 +- .../PublicQueryService.Announcements.cs | 350 ++++++++++++++++++ .../Public/PublicQueryService.Verification.cs | 183 +++++++++ .../Public/PublicQueryService.cs | 7 +- .../DocumentVerificationCodeService.cs | 54 +++ .../Security/DocumentVerificationOptions.cs | 34 ++ src/Eis.Web/Program.cs | 5 +- src/Eis.Web/Public/PublicEndpoints.cs | 22 ++ .../DocumentVerificationCodeServiceTests.cs | 37 ++ .../helpers/create-admission-verification.mjs | 48 +++ 14 files changed, 824 insertions(+), 6 deletions(-) create mode 100644 src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs create mode 100644 src/Eis.Infrastructure/Public/PublicQueryService.Verification.cs create mode 100644 src/Eis.Infrastructure/Security/DocumentVerificationCodeService.cs create mode 100644 src/Eis.Infrastructure/Security/DocumentVerificationOptions.cs create mode 100644 tests/Eis.Infrastructure.Tests/Security/DocumentVerificationCodeServiceTests.cs create mode 100644 tests/helpers/create-admission-verification.mjs diff --git a/MIGRATION.md b/MIGRATION.md index ae633e4..e0a1435 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -9,7 +9,7 @@ - [x] 旧 API 兼容转发,包含 Cookie、请求体、文件下载和状态码 - [x] 存活与迁移就绪检查 - [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取) -- [ ] 招生公示与文书验真公开接口 +- [x] 招生公示与 HMAC 文书验真公开接口 - [ ] 登录、Session 与 TOTP - [ ] 考生业务 - [ ] 管理后台、审批流和考务编排 diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 658c4c4..e119f68 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -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 { diff --git a/src/Eis.Application/Public/IPublicQueryService.cs b/src/Eis.Application/Public/IPublicQueryService.cs index 585cc77..179b0b6 100644 --- a/src/Eis.Application/Public/IPublicQueryService.cs +++ b/src/Eis.Application/Public/IPublicQueryService.cs @@ -7,4 +7,8 @@ public interface IPublicQueryService Task GetHomeAsync(CancellationToken cancellationToken); Task GetNoticeAsync(string id, CancellationToken cancellationToken); + + Task GetAnnouncementsAsync(CancellationToken cancellationToken); + + Task VerifyDocumentAsync(string code, CancellationToken cancellationToken); } diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index e5669a1..91e20db 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -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(); + services.AddSingleton(documentVerificationOptions); + services.AddSingleton(); services.AddScoped(); return services; } diff --git a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs index 45fb350..90a3271 100644 --- a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs +++ b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs @@ -7,7 +7,7 @@ public static class MigrationFeatureCatalog { public static IReadOnlyList 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"), diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs new file mode 100644 index 0000000..1b19f79 --- /dev/null +++ b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs @@ -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 SpecialtyCategories = + new Dictionary(StringComparer.Ordinal) + { + ["sports"] = "体育", + ["arts"] = "艺术" + }; + + private static readonly IReadOnlyDictionary SpecialtyTypes = + new Dictionary(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 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())) + .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())) + .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())) + .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())) + .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())) + .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 examNames, + IReadOnlyDictionary schoolNames) + { + var rows = GetArray(item.Payload, "categories").OfType().Select(category => + { + var allocations = GetArray(category, "indicatorAllocations").OfType().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 examNames, + IReadOnlyDictionary 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().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 users, + IReadOnlyCollection profiles, + IReadOnlyCollection 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 BuildRoundPublications( + IReadOnlyCollection records, + IReadOnlyDictionary settings, + IReadOnlyDictionary examNames, + IReadOnlyDictionary schoolNames, + IReadOnlyDictionary users, + IReadOnlyDictionary profiles) + { + var output = new List(); + 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 records, + string examId, + int round, + IReadOnlyDictionary schoolNames, + IReadOnlyDictionary users, + IReadOnlyDictionary 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() ?? 0) + .ThenBy(item => item["registrationNumber"]?.GetValue(), StringComparer.Ordinal) + .ToArray(); + return new JsonArray(rows); + } + + private static JsonObject BuildReportingAnnouncement( + AdmissionRecordRow item, + IReadOnlyDictionary examNames, + IReadOnlyDictionary 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); +} diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.Verification.cs b/src/Eis.Infrastructure/Public/PublicQueryService.Verification.cs new file mode 100644 index 0000000..2d8267f --- /dev/null +++ b/src/Eis.Infrastructure/Public/PublicQueryService.Verification.cs @@ -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 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 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 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> 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(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); +} diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.cs b/src/Eis.Infrastructure/Public/PublicQueryService.cs index 217056a..4857296 100644 --- a/src/Eis.Infrastructure/Public/PublicQueryService.cs +++ b/src/Eis.Infrastructure/Public/PublicQueryService.cs @@ -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 GetHomeAsync(CancellationToken cancellationToken) { diff --git a/src/Eis.Infrastructure/Security/DocumentVerificationCodeService.cs b/src/Eis.Infrastructure/Security/DocumentVerificationCodeService.cs new file mode 100644 index 0000000..a5257e4 --- /dev/null +++ b/src/Eis.Infrastructure/Security/DocumentVerificationCodeService.cs @@ -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 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 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); diff --git a/src/Eis.Infrastructure/Security/DocumentVerificationOptions.cs b/src/Eis.Infrastructure/Security/DocumentVerificationOptions.cs new file mode 100644 index 0000000..27f1133 --- /dev/null +++ b/src/Eis.Infrastructure/Security/DocumentVerificationOptions.cs @@ -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"); + } +} diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 2e9a63d..a9820a6 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -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((services, client) => }); builder.Services.AddProblemDetails(); builder.Services.AddSingleton(); -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(); diff --git a/src/Eis.Web/Public/PublicEndpoints.cs b/src/Eis.Web/Public/PublicEndpoints.cs index 2833652..0cab613 100644 --- a/src/Eis.Web/Public/PublicEndpoints.cs +++ b/src/Eis.Web/Public/PublicEndpoints.cs @@ -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; } } diff --git a/tests/Eis.Infrastructure.Tests/Security/DocumentVerificationCodeServiceTests.cs b/tests/Eis.Infrastructure.Tests/Security/DocumentVerificationCodeServiceTests.cs new file mode 100644 index 0000000..0c688e0 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Security/DocumentVerificationCodeServiceTests.cs @@ -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); + } +} diff --git a/tests/helpers/create-admission-verification.mjs b/tests/helpers/create-admission-verification.mjs new file mode 100644 index 0000000..36efda7 --- /dev/null +++ b/tests/helpers/create-admission-verification.mjs @@ -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 } +));