招生计划、资格、录取、分数线及报到公示

成绩单 HMAC 防伪验真
录取通知书 HMAC 防伪验真
防伪码常量时间比较
生产环境文书密钥强度检查
旧文书防伪码完全兼容
This commit is contained in:
2026-07-22 19:12:38 +08:00 Unverified
parent 38e42ad734
commit 017cacc6f9
14 changed files with 824 additions and 6 deletions
@@ -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");
}
}
+4 -1
View File
@@ -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();
+22
View File
@@ -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;
}
}