原生志愿、录取、计划余量、指标资格和通知查询:[CandidateService.Admissions.cs (line 29)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:29)
志愿提交、资格校验、补录限制、次数上限与自动锁定:[CandidateService.Admissions.cs (line 152)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:152) 通用招生记录读取及事务写入:[CandidateAdmissionRepository.cs (line 25)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs:25) 原生路由已接入:[NativeCandidateEndpoints.cs (line 70)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:70) 迁移状态现在会把 Candidate 标记为原生:[MigrationFeatureCatalog.cs (line 12)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs:12)
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
|
|
||||||
开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
|
开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
|
||||||
|
|
||||||
考生域核心端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。志愿填报与录取查询仍会继续转发给 Node:
|
考生域全部端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载、志愿填报与录取查询)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:AUTH_NATIVE_ENABLED = 'true'
|
$env:AUTH_NATIVE_ENABLED = 'true'
|
||||||
|
|||||||
@@ -126,6 +126,28 @@ function Assert-CandidateResultsEquivalent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Assert-CandidateAdmissionsEquivalent {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string] $Expected,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string] $Actual
|
||||||
|
)
|
||||||
|
|
||||||
|
$expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected)
|
||||||
|
$actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual)
|
||||||
|
foreach ($node in @($expectedNode, $actualNode)) {
|
||||||
|
foreach ($admission in $node['admissions'].AsArray()) {
|
||||||
|
$admission.AsObject().Remove('noticeVerificationQr') | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) {
|
||||||
|
$difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$'
|
||||||
|
throw "Candidate route 'admissions' JSON payload differs from the legacy API at $difference"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Find-JsonDifference {
|
function Find-JsonDifference {
|
||||||
param(
|
param(
|
||||||
[AllowNull()]
|
[AllowNull()]
|
||||||
@@ -439,6 +461,58 @@ try {
|
|||||||
if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) {
|
if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) {
|
||||||
throw 'Native candidate results did not include a local PNG verification QR code'
|
throw 'Native candidate results did not include a local PNG verification QR code'
|
||||||
}
|
}
|
||||||
|
$legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
||||||
|
$nativeCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||||
|
if ($nativeCandidateAdmissionsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw "Candidate route 'admissions' did not use the native ASP.NET Core endpoint"
|
||||||
|
}
|
||||||
|
Assert-CandidateAdmissionsEquivalent -Expected $legacyCandidateAdmissionsResponse.Content -Actual $nativeCandidateAdmissionsResponse.Content
|
||||||
|
$admissionWriteProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
||||||
|
'tests/helpers/prepare-candidate-admission-write-smoke.mjs', $smokeDatabasePath
|
||||||
|
) -Environment $nodeEnvironment
|
||||||
|
if (-not $admissionWriteProcess.WaitForExit(10000)) {
|
||||||
|
$admissionWriteProcess.Kill($true)
|
||||||
|
throw 'Timed out while preparing the candidate admission preference smoke data'
|
||||||
|
}
|
||||||
|
$admissionWriteError = $admissionWriteProcess.StandardError.ReadToEnd()
|
||||||
|
if ($admissionWriteProcess.ExitCode -ne 0) {
|
||||||
|
throw "Could not prepare the candidate admission preference smoke data`n$admissionWriteError"
|
||||||
|
}
|
||||||
|
$admissionWriteProcess.Dispose()
|
||||||
|
$admissionContext = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||||
|
$admissionSetting = @($admissionContext.admissions | Where-Object { $_.status -eq 'filling' } | Select-Object -First 1)[0]
|
||||||
|
$admissionPlan = @($admissionSetting.plans | Select-Object -First 1)[0]
|
||||||
|
$admissionCategory = @($admissionPlan.categories | Where-Object { $_.preferenceTypes -contains 'general' } | Select-Object -First 1)[0]
|
||||||
|
if ($null -eq $admissionSetting -or $null -eq $admissionPlan -or $null -eq $admissionCategory) {
|
||||||
|
throw 'Native candidate admissions did not expose an eligible plan for preference submission'
|
||||||
|
}
|
||||||
|
$emptyPreference = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body '{"choices":[]}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
||||||
|
if ($emptyPreference.StatusCode -ne 400) {
|
||||||
|
throw 'Native admission preference API accepted an empty preference list'
|
||||||
|
}
|
||||||
|
$preferenceBody = @{
|
||||||
|
choices = @(@{
|
||||||
|
schoolId = $admissionPlan.schoolId
|
||||||
|
categoryCode = $admissionCategory.code
|
||||||
|
preferenceType = 'general'
|
||||||
|
})
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
$firstPreferenceResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession
|
||||||
|
$firstPreference = $firstPreferenceResponse.Content | ConvertFrom-Json
|
||||||
|
if ($firstPreferenceResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $firstPreference.remainingSubmissions -ne 1 -or $firstPreference.locked -ne $false) {
|
||||||
|
throw 'Native admission preference API did not persist the first submission count'
|
||||||
|
}
|
||||||
|
$secondPreference = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession
|
||||||
|
if ($secondPreference.remainingSubmissions -ne 0 -or $secondPreference.locked -ne $true) {
|
||||||
|
throw 'Native admission preference API did not lock at the configured submission limit'
|
||||||
|
}
|
||||||
|
$lockedPreference = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions/$($admissionSetting.examId)/preferences" -Method Put -ContentType 'application/json' -Body $preferenceBody -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
||||||
|
if ($lockedPreference.StatusCode -ne 409) {
|
||||||
|
throw 'Native admission preference API accepted a submission after automatic locking'
|
||||||
|
}
|
||||||
|
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
||||||
|
$nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||||
|
Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content
|
||||||
$appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0]
|
$appealableResult = @($nativeCandidateResults.results | Where-Object { $null -eq $_.appeal } | Select-Object -First 1)[0]
|
||||||
if ($null -ne $appealableResult) {
|
if ($null -ne $appealableResult) {
|
||||||
$shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
$shortAppeal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results/$($appealableResult.id)/appeals" -Method Post -ContentType 'application/json' -Body '{"reason":"短"}' -WebSession $nativeCandidateSession -SkipHttpErrorCheck
|
||||||
@@ -699,6 +773,7 @@ try {
|
|||||||
NativeCandidateReads = 'passed'
|
NativeCandidateReads = 'passed'
|
||||||
NativeCandidateWrites = 'passed'
|
NativeCandidateWrites = 'passed'
|
||||||
NativeCandidateDocuments = 'passed'
|
NativeCandidateDocuments = 'passed'
|
||||||
|
NativeCandidateAdmissions = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public interface ICandidateService
|
|||||||
string verificationBaseUrl,
|
string verificationBaseUrl,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> GetAdmissionsAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string verificationBaseUrl,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<CandidateEndpointResult> UpdateProfileAsync(
|
Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
JsonObject body,
|
JsonObject body,
|
||||||
@@ -47,4 +52,10 @@ public interface ICandidateService
|
|||||||
string sessionToken,
|
string sessionToken,
|
||||||
string registrationId,
|
string registrationId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> UpdateAdmissionPreferencesAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string examId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed record CandidateAdmissionRecord(
|
||||||
|
string Id,
|
||||||
|
string Kind,
|
||||||
|
string ExamId,
|
||||||
|
string? UserId,
|
||||||
|
string? SchoolId,
|
||||||
|
string Status,
|
||||||
|
JsonObject Payload,
|
||||||
|
string CreatedAt,
|
||||||
|
string UpdatedAt);
|
||||||
|
|
||||||
|
internal sealed record CandidateAdmissionSchool(string Id, string Code, string Name);
|
||||||
|
|
||||||
|
internal sealed record CandidateAdmissionSnapshot(
|
||||||
|
IReadOnlyList<CandidateAdmissionRecord> Records,
|
||||||
|
IReadOnlyDictionary<string, CandidateAdmissionSchool> Schools);
|
||||||
|
|
||||||
|
internal sealed class CandidateAdmissionRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<CandidateAdmissionSnapshot> LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
var records = new List<CandidateAdmissionRecord>();
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = "SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id";
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
records.Add(new CandidateAdmissionRecord(
|
||||||
|
ReadString(reader, "id"),
|
||||||
|
ReadString(reader, "kind"),
|
||||||
|
ReadString(reader, "exam_id"),
|
||||||
|
ReadOptionalString(reader, "user_id"),
|
||||||
|
ReadOptionalString(reader, "school_id"),
|
||||||
|
ReadString(reader, "status"),
|
||||||
|
JsonNode.Parse(ReadString(reader, "payload_json"))?.AsObject() ?? new JsonObject(),
|
||||||
|
ReadString(reader, "created_at"),
|
||||||
|
ReadString(reader, "updated_at")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var schools = new List<CandidateAdmissionSchool>();
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = "SELECT id, code, name FROM schools ORDER BY id";
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
schools.Add(new CandidateAdmissionSchool(
|
||||||
|
ReadString(reader, "id"),
|
||||||
|
ReadString(reader, "code"),
|
||||||
|
ReadString(reader, "name")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CandidateAdmissionSnapshot(
|
||||||
|
records,
|
||||||
|
schools.ToDictionary(item => item.Id, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SavePreferenceAsync(
|
||||||
|
CandidateAdmissionRecord record,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var delete = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
delete.Transaction = transaction;
|
||||||
|
delete.CommandText = "DELETE FROM admission_records WHERE id = @id";
|
||||||
|
AddParameter(delete, "@id", record.Id);
|
||||||
|
await delete.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var insert = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
insert.Transaction = transaction;
|
||||||
|
insert.CommandText = """
|
||||||
|
INSERT INTO admission_records (
|
||||||
|
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @kind, @examId, @userId, @schoolId, @status, @payload, @createdAt, @updatedAt
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
AddParameter(insert, "@id", record.Id);
|
||||||
|
AddParameter(insert, "@kind", record.Kind);
|
||||||
|
AddParameter(insert, "@examId", record.ExamId);
|
||||||
|
AddParameter(insert, "@userId", record.UserId);
|
||||||
|
AddParameter(insert, "@schoolId", record.SchoolId);
|
||||||
|
AddParameter(insert, "@status", record.Status);
|
||||||
|
AddParameter(insert, "@payload", record.Payload.ToJsonString());
|
||||||
|
AddParameter(insert, "@createdAt", record.CreatedAt);
|
||||||
|
AddParameter(insert, "@updatedAt", record.UpdatedAt);
|
||||||
|
await insert.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddParameter(DbCommand command, string name, object? value)
|
||||||
|
{
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = name;
|
||||||
|
parameter.Value = value ?? DBNull.Value;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadString(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
private static string? ReadOptionalString(DbDataReader reader, string name)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(name);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ public sealed record CandidateMigrationOptions(bool NativeEnabled)
|
|||||||
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
|
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"考生域尚有接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
|
"应用仍有受保护接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CandidateMigrationOptions(enabled);
|
return new CandidateMigrationOptions(enabled);
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Candidate;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed partial class CandidateService
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, (string Category, string Type)> LegacySpecialties =
|
||||||
|
new Dictionary<string, (string Category, string Type)>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["田径"] = ("sports", "track_field"),
|
||||||
|
["篮球"] = ("sports", "basketball"),
|
||||||
|
["足球"] = ("sports", "football"),
|
||||||
|
["排球"] = ("sports", "volleyball"),
|
||||||
|
["乒乓球"] = ("sports", "table_tennis"),
|
||||||
|
["羽毛球"] = ("sports", "badminton"),
|
||||||
|
["游泳"] = ("sports", "swimming"),
|
||||||
|
["武术"] = ("sports", "martial_arts"),
|
||||||
|
["健美操与啦啦操"] = ("sports", "aerobics_cheer"),
|
||||||
|
["声乐"] = ("arts", "vocal_music"),
|
||||||
|
["器乐"] = ("arts", "instrumental_music"),
|
||||||
|
["舞蹈"] = ("arts", "dance"),
|
||||||
|
["美术"] = ("arts", "fine_arts"),
|
||||||
|
["书法"] = ("arts", "calligraphy"),
|
||||||
|
["戏剧与播音"] = ("arts", "drama_broadcasting")
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<CandidateEndpointResult> GetAdmissionsAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string verificationBaseUrl,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return context.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = context.User!;
|
||||||
|
var profile = context.Profile!;
|
||||||
|
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||||
|
var admission = await admissionRepository.LoadAsync(cancellationToken);
|
||||||
|
var settings = new List<JsonObject>();
|
||||||
|
foreach (var setting in Records(admission, "setting").Where(item => AdmissionBoolean(item.Payload["enabled"])))
|
||||||
|
{
|
||||||
|
var exam = snapshot.Exams.FirstOrDefault(item => item.Id == setting.ExamId);
|
||||||
|
if (exam is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1));
|
||||||
|
var preference = ActivePreference(admission, setting.ExamId, user.Id, round);
|
||||||
|
var qualification = Records(admission, "indicator_qualification", setting.ExamId)
|
||||||
|
.FirstOrDefault(item => item.UserId == user.Id);
|
||||||
|
var placement = Records(admission, "placement", setting.ExamId)
|
||||||
|
.FirstOrDefault(item => item.UserId == user.Id && item.Status != "withdrawn");
|
||||||
|
var blocking = setting.Status == "supplementary"
|
||||||
|
? Records(admission, "placement", setting.ExamId).FirstOrDefault(item =>
|
||||||
|
item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal))
|
||||||
|
: null;
|
||||||
|
var supplementEligible = blocking is null;
|
||||||
|
var supplementarySchools = SupplementarySchoolIds(admission, setting);
|
||||||
|
var plans = supplementEligible
|
||||||
|
? BuildAvailablePlans(admission, profile, setting.ExamId, qualification, supplementarySchools)
|
||||||
|
: [];
|
||||||
|
var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == setting.ExamId);
|
||||||
|
var submissionCount = AdmissionInteger(preference?.Payload["submissionCount"]);
|
||||||
|
var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3));
|
||||||
|
var school = placement?.SchoolId is not null
|
||||||
|
? admission.Schools.GetValueOrDefault(placement.SchoolId)
|
||||||
|
: null;
|
||||||
|
var templateRecord = placement?.SchoolId is not null
|
||||||
|
? Records(admission, "notification").FirstOrDefault(item => item.SchoolId == placement.SchoolId && item.Status == "template")
|
||||||
|
: null;
|
||||||
|
var verificationCode = placement?.Status == "final"
|
||||||
|
? documentCodes.AdmissionNoticeCode(
|
||||||
|
placement.Id,
|
||||||
|
placement.UserId ?? string.Empty,
|
||||||
|
placement.SchoolId ?? string.Empty,
|
||||||
|
exam.Id,
|
||||||
|
AdmissionText(placement.Payload["categoryCode"]),
|
||||||
|
AdmissionText(placement.Payload["noticeNumber"]),
|
||||||
|
placement.UpdatedAt)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var item = AdmissionRecordJson(setting);
|
||||||
|
item["exam"] = PublicExamJson(exam);
|
||||||
|
item["preference"] = preference is null ? null : PreferenceView(admission, preference);
|
||||||
|
if (placement is not null)
|
||||||
|
{
|
||||||
|
item["placement"] = AdmissionRecordJson(placement);
|
||||||
|
}
|
||||||
|
item["placementSchool"] = school is null
|
||||||
|
? null
|
||||||
|
: new JsonObject { ["id"] = school.Id, ["name"] = school.Name, ["code"] = school.Code };
|
||||||
|
item["noticeTemplate"] = templateRecord?.Payload["template"]?.DeepClone();
|
||||||
|
item["noticeVerificationCode"] = verificationCode;
|
||||||
|
item["noticeVerificationQr"] = verificationCode.Length == 0
|
||||||
|
? string.Empty
|
||||||
|
: CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(verificationCode)}");
|
||||||
|
item["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]);
|
||||||
|
item["plans"] = new JsonArray(plans.Select(plan => (JsonNode)plan).ToArray());
|
||||||
|
item["supplementEligible"] = supplementEligible;
|
||||||
|
item["supplementIneligibilityReason"] = blocking?.Status switch
|
||||||
|
{
|
||||||
|
"forfeited" => "因本轮未按规定完成报到,不能再次参加补录。",
|
||||||
|
not null => "你已被录取,本轮补录无需且不能再次填报。",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
item["totalScore"] = JsonValue.Create(CandidateTotalScore(snapshot, setting.ExamId));
|
||||||
|
item["featureScore"] = registration?.FeatureScore ?? 0;
|
||||||
|
item["specialtyQualification"] = SpecialtyQualification(profile);
|
||||||
|
item["indicatorQualification"] = qualification is null ? null : AdmissionRecordJson(qualification);
|
||||||
|
item["submissionCount"] = submissionCount;
|
||||||
|
item["maxSubmissions"] = maxSubmissions;
|
||||||
|
item["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount);
|
||||||
|
item["preferenceLocked"] = submissionCount >= maxSubmissions;
|
||||||
|
settings.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifications = Records(admission, "notification")
|
||||||
|
.Where(item => item.UserId == user.Id)
|
||||||
|
.OrderByDescending(item => ParseDate(item.CreatedAt))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var placement = Records(admission, "placement", item.ExamId)
|
||||||
|
.FirstOrDefault(entry => entry.Id == AdmissionText(item.Payload["placementId"]));
|
||||||
|
var schoolId = placement?.SchoolId ?? item.SchoolId;
|
||||||
|
var school = schoolId is not null ? admission.Schools.GetValueOrDefault(schoolId) : null;
|
||||||
|
var exam = snapshot.Exams.FirstOrDefault(entry => entry.Id == item.ExamId);
|
||||||
|
var output = AdmissionRecordJson(item);
|
||||||
|
output["examName"] = exam?.Name ?? string.Empty;
|
||||||
|
output["schoolName"] = school?.Name ?? string.Empty;
|
||||||
|
output["schoolCode"] = school?.Code ?? string.Empty;
|
||||||
|
output["categoryName"] = AdmissionText(placement?.Payload["categoryName"]);
|
||||||
|
output["noticeNumber"] = AdmissionText(placement?.Payload["noticeNumber"]);
|
||||||
|
output["placementStatus"] = placement?.Status ?? string.Empty;
|
||||||
|
return output;
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["admissions"] = new JsonArray(settings.Select(item => (JsonNode)item).ToArray()),
|
||||||
|
["notifications"] = new JsonArray(notifications.Select(item => (JsonNode)item).ToArray())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CandidateEndpointResult> UpdateAdmissionPreferencesAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string examId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return context.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = context.User!;
|
||||||
|
var profile = context.Profile!;
|
||||||
|
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||||
|
var admission = await admissionRepository.LoadAsync(cancellationToken);
|
||||||
|
var setting = Records(admission, "setting", examId).FirstOrDefault();
|
||||||
|
if (setting is null || !AdmissionBoolean(setting.Payload["enabled"]))
|
||||||
|
{
|
||||||
|
return Error(404, "该考试未开放志愿填报");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.Status is not ("filling" or "supplementary"))
|
||||||
|
{
|
||||||
|
return Error(409, "当前不在志愿填报阶段");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.Status == "supplementary")
|
||||||
|
{
|
||||||
|
var blocking = Records(admission, "placement", setting.ExamId).FirstOrDefault(item =>
|
||||||
|
item.UserId == user.Id && BlockingPlacementStatuses.Contains(item.Status, StringComparer.Ordinal));
|
||||||
|
if (blocking?.Status == "forfeited")
|
||||||
|
{
|
||||||
|
return Error(403, "因未按规定完成报到,本轮不能再次参加补录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocking is not null)
|
||||||
|
{
|
||||||
|
return Error(403, "你已被录取,本轮补录不能再次填报");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceStart"]), out var start) && now < start)
|
||||||
|
{
|
||||||
|
return Error(409, "志愿填报尚未开始");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryAdmissionDate(AdmissionText(setting.Payload["preferenceEnd"]), out var end) && now > end)
|
||||||
|
{
|
||||||
|
return Error(409, "志愿填报已经截止");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CandidateTotalScore(snapshot, setting.ExamId) is null)
|
||||||
|
{
|
||||||
|
return Error(403, "本场考试成绩全部发布后才能填报志愿");
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxChoices = Math.Max(1, AdmissionInteger(setting.Payload["maxChoices"], 5));
|
||||||
|
var round = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1));
|
||||||
|
var current = ActivePreference(admission, setting.ExamId, user.Id, round);
|
||||||
|
var maxSubmissions = Math.Max(1, AdmissionInteger(setting.Payload["maxSubmissions"], 3));
|
||||||
|
var submissionCount = AdmissionInteger(current?.Payload["submissionCount"]);
|
||||||
|
if (submissionCount >= maxSubmissions)
|
||||||
|
{
|
||||||
|
return Error(409, $"志愿已达到 {maxSubmissions} 次提交上限,现已自动锁定");
|
||||||
|
}
|
||||||
|
|
||||||
|
var choices = body["choices"] is JsonArray requested
|
||||||
|
? requested.Take(maxChoices + 1).OfType<JsonObject>().Select(item => new AdmissionChoice(
|
||||||
|
Clean(item, "schoolId", 64),
|
||||||
|
Clean(item, "categoryCode", 40),
|
||||||
|
Text(item, "preferenceType") == "indicator" ? "indicator" : "general")).ToArray()
|
||||||
|
: [];
|
||||||
|
if (choices.Length == 0)
|
||||||
|
{
|
||||||
|
return Error(400, "请至少选择一个志愿");
|
||||||
|
}
|
||||||
|
|
||||||
|
var indicatorChoices = choices.Count(item => item.PreferenceType == "indicator");
|
||||||
|
var generalChoices = choices.Count(item => item.PreferenceType == "general");
|
||||||
|
if (indicatorChoices > 1 || generalChoices > maxChoices)
|
||||||
|
{
|
||||||
|
return Error(400, $"本轮最多填报 1 个指标分配志愿和 {maxChoices} 个普通志愿");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indicatorChoices > 0 && choices[0].PreferenceType != "indicator")
|
||||||
|
{
|
||||||
|
return Error(400, "指标分配志愿必须位于专用第一栏");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choices.Select(item => $"{item.PreferenceType}|{item.SchoolId}|{item.CategoryCode}")
|
||||||
|
.Distinct(StringComparer.Ordinal).Count() != choices.Length)
|
||||||
|
{
|
||||||
|
return Error(400, "同类志愿中同一学校和招生类别不能重复填报");
|
||||||
|
}
|
||||||
|
|
||||||
|
var supplementarySchools = SupplementarySchoolIds(admission, setting);
|
||||||
|
var plans = Records(admission, "plan", setting.ExamId)
|
||||||
|
.Where(item => item.Status == "approved" &&
|
||||||
|
(supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId)))
|
||||||
|
.ToArray();
|
||||||
|
var qualification = Records(admission, "indicator_qualification", setting.ExamId)
|
||||||
|
.FirstOrDefault(item => item.UserId == user.Id);
|
||||||
|
if (choices.Any(choice => !ValidAdmissionChoice(admission, profile, plans, qualification, choice)))
|
||||||
|
{
|
||||||
|
return Error(400, "志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowValue = NowIso();
|
||||||
|
var storedChoices = choices.Select(choice =>
|
||||||
|
{
|
||||||
|
var school = admission.Schools.GetValueOrDefault(choice.SchoolId);
|
||||||
|
var category = PlanCategories(plans.First(item => item.SchoolId == choice.SchoolId))
|
||||||
|
.First(item => AdmissionText(item["code"]) == choice.CategoryCode);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["schoolId"] = choice.SchoolId,
|
||||||
|
["categoryCode"] = choice.CategoryCode,
|
||||||
|
["preferenceType"] = choice.PreferenceType,
|
||||||
|
["schoolCode"] = school?.Code ?? string.Empty,
|
||||||
|
["schoolName"] = school?.Name ?? string.Empty,
|
||||||
|
["categoryName"] = AdmissionText(category["name"])
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
var preference = new CandidateAdmissionRecord(
|
||||||
|
current?.Id ?? Uid("preference"),
|
||||||
|
"preference",
|
||||||
|
setting.ExamId,
|
||||||
|
user.Id,
|
||||||
|
null,
|
||||||
|
"submitted",
|
||||||
|
new JsonObject
|
||||||
|
{
|
||||||
|
["round"] = round,
|
||||||
|
["choices"] = new JsonArray(storedChoices.Select(item => (JsonNode)item).ToArray()),
|
||||||
|
["submittedAt"] = nowValue,
|
||||||
|
["submissionCount"] = submissionCount + 1
|
||||||
|
},
|
||||||
|
current?.CreatedAt ?? nowValue,
|
||||||
|
nowValue);
|
||||||
|
await admissionRepository.SavePreferenceAsync(preference, cancellationToken);
|
||||||
|
|
||||||
|
var locked = submissionCount + 1 >= maxSubmissions;
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["preference"] = AdmissionRecordJson(preference),
|
||||||
|
["remainingSubmissions"] = Math.Max(0, maxSubmissions - submissionCount - 1),
|
||||||
|
["locked"] = locked,
|
||||||
|
["message"] = locked ? "志愿已保存并达到提交上限,现已自动锁定" : "志愿已由本人保存"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] BlockingPlacementStatuses =
|
||||||
|
["school_review", "admitted", "final", "withdrawal_pending", "forfeited"];
|
||||||
|
|
||||||
|
private static IEnumerable<CandidateAdmissionRecord> Records(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
string kind,
|
||||||
|
string? examId = null) => snapshot.Records.Where(item =>
|
||||||
|
item.Kind == kind && (examId is null || item.ExamId == examId));
|
||||||
|
|
||||||
|
private static CandidateAdmissionRecord? ActivePreference(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
string examId,
|
||||||
|
string userId,
|
||||||
|
int round) => Records(snapshot, "preference", examId).FirstOrDefault(item =>
|
||||||
|
item.UserId == userId && AdmissionInteger(item.Payload["round"], 1) == round);
|
||||||
|
|
||||||
|
private static JsonObject PreferenceView(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
CandidateAdmissionRecord preference)
|
||||||
|
{
|
||||||
|
var output = AdmissionRecordJson(preference);
|
||||||
|
var payload = preference.Payload.DeepClone().AsObject();
|
||||||
|
var choices = preference.Payload["choices"]?.AsArray().OfType<JsonObject>().Select(choice =>
|
||||||
|
{
|
||||||
|
var item = choice.DeepClone().AsObject();
|
||||||
|
var schoolId = AdmissionText(choice["schoolId"]);
|
||||||
|
var categoryCode = AdmissionText(choice["categoryCode"]);
|
||||||
|
var school = snapshot.Schools.GetValueOrDefault(schoolId);
|
||||||
|
var categories = Records(snapshot, "plan", preference.ExamId)
|
||||||
|
.Where(plan => plan.SchoolId == schoolId)
|
||||||
|
.SelectMany(PlanCategories)
|
||||||
|
.ToArray();
|
||||||
|
var category = categories.FirstOrDefault(entry => AdmissionText(entry["code"]) == categoryCode)
|
||||||
|
?? (categoryCode == "general" ? categories.FirstOrDefault(entry =>
|
||||||
|
AdmissionText(entry["specialtyCategory"]).Length == 0 && AdmissionText(entry["specialtyType"]).Length == 0) : null)
|
||||||
|
?? (categoryCode is "sport" or "sports" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "sports") : null)
|
||||||
|
?? (categoryCode is "art" or "arts" ? categories.FirstOrDefault(entry => AdmissionText(entry["specialtyCategory"]) == "arts") : null);
|
||||||
|
item["schoolCode"] = AdmissionText(choice["schoolCode"]).Length > 0 ? AdmissionText(choice["schoolCode"]) : school?.Code ?? string.Empty;
|
||||||
|
item["schoolName"] = AdmissionText(choice["schoolName"]).Length > 0 ? AdmissionText(choice["schoolName"]) : school?.Name ?? string.Empty;
|
||||||
|
item["categoryName"] = AdmissionText(choice["categoryName"]).Length > 0 ? AdmissionText(choice["categoryName"]) : AdmissionText(category?["name"]);
|
||||||
|
return item;
|
||||||
|
}).ToArray() ?? [];
|
||||||
|
payload["choices"] = new JsonArray(choices.Select(item => (JsonNode)item).ToArray());
|
||||||
|
output["payload"] = payload;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<JsonObject> BuildAvailablePlans(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
JsonObject profile,
|
||||||
|
string examId,
|
||||||
|
CandidateAdmissionRecord? qualification,
|
||||||
|
IReadOnlySet<string>? supplementarySchools)
|
||||||
|
{
|
||||||
|
var output = new List<JsonObject>();
|
||||||
|
foreach (var plan in Records(snapshot, "plan", examId).Where(item => item.Status == "approved" &&
|
||||||
|
(supplementarySchools is null || item.SchoolId is not null && supplementarySchools.Contains(item.SchoolId))))
|
||||||
|
{
|
||||||
|
var school = plan.SchoolId is not null ? snapshot.Schools.GetValueOrDefault(plan.SchoolId) : null;
|
||||||
|
var placements = Records(snapshot, "placement", examId).Where(item =>
|
||||||
|
item.SchoolId == plan.SchoolId && item.Status is not ("withdrawn" or "forfeited")).ToArray();
|
||||||
|
var categories = new List<JsonObject>();
|
||||||
|
foreach (var source in PlanCategories(plan))
|
||||||
|
{
|
||||||
|
if (!CandidateEligibleForCategory(profile, source))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = source.DeepClone().AsObject();
|
||||||
|
var categoryCode = AdmissionText(source["code"]);
|
||||||
|
var quota = AdmissionNumber(source["quota"]);
|
||||||
|
var allocations = source["indicatorAllocations"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
|
||||||
|
var used = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode);
|
||||||
|
category["used"] = used;
|
||||||
|
category["remaining"] = Math.Max(0, quota - used);
|
||||||
|
var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == Text(profile, "schoolId"));
|
||||||
|
var indicatorUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode &&
|
||||||
|
AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{Text(profile, "schoolId")}");
|
||||||
|
var generalQuota = Math.Max(0, quota - allocations.Sum(item => AdmissionNumber(item["quota"])));
|
||||||
|
var generalUsed = placements.Count(item => AdmissionText(item.Payload["categoryCode"]) == categoryCode &&
|
||||||
|
AdmissionText(item.Payload["quotaBucket"]) == "general");
|
||||||
|
var indicatorRemaining = Math.Max(0, AdmissionNumber(allocation?["quota"]) - indicatorUsed);
|
||||||
|
var generalRemaining = Math.Max(0, generalQuota - generalUsed);
|
||||||
|
var preferenceTypes = new List<JsonNode>();
|
||||||
|
if (generalRemaining > 0) preferenceTypes.Add(JsonValue.Create("general")!);
|
||||||
|
if (AdmissionBoolean(qualification?.Payload["eligible"]) && indicatorRemaining > 0) preferenceTypes.Add(JsonValue.Create("indicator")!);
|
||||||
|
category["generalRemaining"] = generalRemaining;
|
||||||
|
category["indicatorRemaining"] = indicatorRemaining;
|
||||||
|
category["preferenceTypes"] = new JsonArray(preferenceTypes.ToArray());
|
||||||
|
if (preferenceTypes.Count > 0)
|
||||||
|
{
|
||||||
|
categories.Add(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.Count > 0)
|
||||||
|
{
|
||||||
|
output.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = plan.Id,
|
||||||
|
["schoolId"] = plan.SchoolId,
|
||||||
|
["schoolCode"] = school?.Code ?? string.Empty,
|
||||||
|
["schoolName"] = school?.Name ?? string.Empty,
|
||||||
|
["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidAdmissionChoice(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
JsonObject profile,
|
||||||
|
IReadOnlyList<CandidateAdmissionRecord> plans,
|
||||||
|
CandidateAdmissionRecord? qualification,
|
||||||
|
AdmissionChoice choice)
|
||||||
|
{
|
||||||
|
var plan = plans.FirstOrDefault(item => item.SchoolId == choice.SchoolId);
|
||||||
|
var category = plan is null
|
||||||
|
? null
|
||||||
|
: PlanCategories(plan).FirstOrDefault(item => AdmissionText(item["code"]) == choice.CategoryCode);
|
||||||
|
if (plan is null || category is null || !CandidateEligibleForCategory(profile, category))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placements = Records(snapshot, "placement", plan.ExamId).Where(item =>
|
||||||
|
item.SchoolId == plan.SchoolId && AdmissionText(item.Payload["categoryCode"]) == choice.CategoryCode &&
|
||||||
|
item.Status is not ("withdrawn" or "forfeited")).ToArray();
|
||||||
|
var allocations = category["indicatorAllocations"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
|
||||||
|
if (choice.PreferenceType == "indicator")
|
||||||
|
{
|
||||||
|
var schoolId = Text(profile, "schoolId");
|
||||||
|
var allocation = allocations.FirstOrDefault(item => AdmissionText(item["sourceSchoolId"]) == schoolId);
|
||||||
|
var used = placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == $"indicator:{schoolId}");
|
||||||
|
return AdmissionBoolean(qualification?.Payload["eligible"]) && AdmissionNumber(allocation?["quota"]) > used;
|
||||||
|
}
|
||||||
|
|
||||||
|
var quota = AdmissionNumber(category["quota"]) - allocations.Sum(item => AdmissionNumber(item["quota"]));
|
||||||
|
return quota > placements.Count(item => AdmissionText(item.Payload["quotaBucket"]) == "general");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlySet<string>? SupplementarySchoolIds(
|
||||||
|
CandidateAdmissionSnapshot snapshot,
|
||||||
|
CandidateAdmissionRecord setting)
|
||||||
|
{
|
||||||
|
if (setting.Status != "supplementary")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceRound = Math.Max(1, AdmissionInteger(setting.Payload["round"], 1) - 1);
|
||||||
|
var ids = Records(snapshot, "notification", setting.ExamId)
|
||||||
|
.Where(item => item.UserId is null && item.Status == "approved" &&
|
||||||
|
AdmissionText(item.Payload["type"]) == "admission_reporting" &&
|
||||||
|
AdmissionInteger(item.Payload["round"], 1) == sourceRound &&
|
||||||
|
AdmissionText(item.Payload["supplementDecision"]) == "supplement" && item.SchoolId is not null)
|
||||||
|
.Select(item => item.SchoolId!)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
return ids.Count == 0 ? null : ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? CandidateTotalScore(CandidateReadSnapshot snapshot, string examId)
|
||||||
|
{
|
||||||
|
var registration = snapshot.Registrations.FirstOrDefault(item => item.ExamId == examId && item.Status == "approved");
|
||||||
|
if (registration is null || registration.SubjectIds.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = snapshot.Results.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
|
||||||
|
if (registration.SubjectIds.Any(id => results.All(result => result.SubjectId != id)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Round(results.Sum(item => item.Score), 2, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CandidateEligibleForCategory(JsonObject profile, JsonObject category)
|
||||||
|
{
|
||||||
|
var requiredCategory = AdmissionText(category["specialtyCategory"]);
|
||||||
|
var requiredType = AdmissionText(category["specialtyType"]);
|
||||||
|
if (requiredCategory.Length == 0 && LegacySpecialties.TryGetValue(requiredType, out var legacy))
|
||||||
|
{
|
||||||
|
(requiredCategory, requiredType) = legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredCategory.Length == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qualification = ResolveSpecialty(profile);
|
||||||
|
return qualification.Category == requiredCategory &&
|
||||||
|
(requiredType.Length == 0 || qualification.Type == requiredType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject SpecialtyQualification(JsonObject profile)
|
||||||
|
{
|
||||||
|
var specialty = ResolveSpecialty(profile);
|
||||||
|
return new JsonObject { ["category"] = specialty.Category, ["type"] = specialty.Type };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Category, string Type) ResolveSpecialty(JsonObject profile)
|
||||||
|
{
|
||||||
|
var category = Text(profile, "specialtyCategory");
|
||||||
|
var type = Text(profile, "specialtyType");
|
||||||
|
if (ValidSpecialty(category, type) && category.Length > 0)
|
||||||
|
{
|
||||||
|
return (category, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var value in profile["specialtyTypes"]?.AsArray() ?? [])
|
||||||
|
{
|
||||||
|
if (LegacySpecialties.TryGetValue(value?.ToString() ?? string.Empty, out var legacy))
|
||||||
|
{
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string.Empty, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject PublicExamJson(CandidateExam exam)
|
||||||
|
{
|
||||||
|
var output = ExamJson(exam);
|
||||||
|
output["totalScore"] = exam.Subjects.Sum(item => item.FullScore);
|
||||||
|
output["registrationState"] = exam.ArchivedAt is not null ? "archived" : RegistrationState(exam);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject AdmissionRecordJson(CandidateAdmissionRecord item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["kind"] = item.Kind,
|
||||||
|
["examId"] = item.ExamId,
|
||||||
|
["userId"] = JsonValue.Create(item.UserId),
|
||||||
|
["schoolId"] = JsonValue.Create(item.SchoolId),
|
||||||
|
["status"] = item.Status,
|
||||||
|
["payload"] = item.Payload.DeepClone(),
|
||||||
|
["createdAt"] = item.CreatedAt,
|
||||||
|
["updatedAt"] = item.UpdatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<JsonObject> PlanCategories(CandidateAdmissionRecord plan) =>
|
||||||
|
plan.Payload["categories"]?.AsArray().OfType<JsonObject>().ToArray() ?? [];
|
||||||
|
|
||||||
|
private static string AdmissionText(JsonNode? value) => value?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
private static double AdmissionNumber(JsonNode? value, double fallback = 0) =>
|
||||||
|
double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||||
|
|
||||||
|
private static int AdmissionInteger(JsonNode? value, int fallback = 0) =>
|
||||||
|
double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? (int)Math.Truncate(parsed)
|
||||||
|
: fallback;
|
||||||
|
|
||||||
|
private static bool AdmissionBoolean(JsonNode? value) =>
|
||||||
|
bool.TryParse(value?.ToString(), out var parsed) && parsed;
|
||||||
|
|
||||||
|
private static bool TryAdmissionDate(string value, out DateTimeOffset parsed) =>
|
||||||
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsed);
|
||||||
|
|
||||||
|
private sealed record AdmissionChoice(string SchoolId, string CategoryCode, string PreferenceType);
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ internal sealed partial class CandidateService(
|
|||||||
IPublicQueryService publicQueries,
|
IPublicQueryService publicQueries,
|
||||||
CandidateWriteRepository writeRepository,
|
CandidateWriteRepository writeRepository,
|
||||||
RegionCatalog regionCatalog,
|
RegionCatalog regionCatalog,
|
||||||
DocumentVerificationCodeService documentCodes) : ICandidateService
|
DocumentVerificationCodeService documentCodes,
|
||||||
|
CandidateAdmissionRepository admissionRepository) : ICandidateService
|
||||||
{
|
{
|
||||||
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<RegionCatalog>();
|
services.AddSingleton<RegionCatalog>();
|
||||||
services.AddScoped<CandidateReadSnapshotLoader>();
|
services.AddScoped<CandidateReadSnapshotLoader>();
|
||||||
services.AddScoped<CandidateWriteRepository>();
|
services.AddScoped<CandidateWriteRepository>();
|
||||||
|
services.AddScoped<CandidateAdmissionRepository>();
|
||||||
services.AddScoped<ICandidateService, CandidateService>();
|
services.AddScoped<ICandidateService, CandidateService>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ namespace Eis.Infrastructure.Migration;
|
|||||||
|
|
||||||
public static class MigrationFeatureCatalog
|
public static class MigrationFeatureCatalog
|
||||||
{
|
{
|
||||||
public static IReadOnlyList<MigrationFeature> Current(bool authenticationNative) =>
|
public static IReadOnlyList<MigrationFeature> Current(bool authenticationNative, bool candidateNative) =>
|
||||||
[
|
[
|
||||||
new(FeatureArea.Public, true, "/api/public"),
|
new(FeatureArea.Public, true, "/api/public"),
|
||||||
new(FeatureArea.Authentication, authenticationNative, "/api/auth"),
|
new(FeatureArea.Authentication, authenticationNative, "/api/auth"),
|
||||||
new(FeatureArea.Candidate, false, "/api/candidate"),
|
new(FeatureArea.Candidate, candidateNative, "/api/candidate"),
|
||||||
new(FeatureArea.Administration, false, "/api/admin"),
|
new(FeatureArea.Administration, false, "/api/admin"),
|
||||||
new(FeatureArea.Admission, false, "/api/admission"),
|
new(FeatureArea.Admission, false, "/api/admission"),
|
||||||
new(FeatureArea.Documents, false, "/api"),
|
new(FeatureArea.Documents, false, "/api"),
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ public static class NativeCandidateEndpoints
|
|||||||
SessionToken(context),
|
SessionToken(context),
|
||||||
VerificationBaseUrl(context),
|
VerificationBaseUrl(context),
|
||||||
cancellationToken)));
|
cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/candidate/admissions", async (
|
||||||
|
HttpContext context,
|
||||||
|
ICandidateService service,
|
||||||
|
CancellationToken cancellationToken) => ToResult(
|
||||||
|
context,
|
||||||
|
await service.GetAdmissionsAsync(
|
||||||
|
SessionToken(context),
|
||||||
|
VerificationBaseUrl(context),
|
||||||
|
cancellationToken)));
|
||||||
|
endpoints.MapPut("/api/candidate/admissions/{examId}/preferences", async (
|
||||||
|
HttpContext context,
|
||||||
|
string examId,
|
||||||
|
System.Text.Json.Nodes.JsonObject request,
|
||||||
|
ICandidateService service,
|
||||||
|
CancellationToken cancellationToken) => ToResult(
|
||||||
|
context,
|
||||||
|
await service.UpdateAdmissionPreferencesAsync(
|
||||||
|
SessionToken(context),
|
||||||
|
examId,
|
||||||
|
request,
|
||||||
|
cancellationToken)));
|
||||||
endpoints.MapPost("/api/candidate/results/{resultId}/appeals", async (
|
endpoints.MapPost("/api/candidate/results/{resultId}/appeals", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string resultId,
|
string resultId,
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
? new[]
|
? new[]
|
||||||
{
|
{
|
||||||
"GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations",
|
"GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations",
|
||||||
"GET results", "POST result appeals", "GET admit cards"
|
"GET results", "POST result appeals", "GET admit cards", "GET admissions", "PUT admission preferences"
|
||||||
}
|
}
|
||||||
: []
|
: []
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
}, statusCode: statusCode);
|
}, statusCode: statusCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Eis.Domain.Migration;
|
||||||
|
using Eis.Infrastructure.Migration;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Tests.Migration;
|
||||||
|
|
||||||
|
public sealed class MigrationFeatureCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ReportsCandidateAreaFromItsIndependentMigrationFlag()
|
||||||
|
{
|
||||||
|
var features = MigrationFeatureCatalog.Current(authenticationNative: true, candidateNative: true);
|
||||||
|
|
||||||
|
Assert.True(features.Single(item => item.Area == FeatureArea.Authentication).Native);
|
||||||
|
Assert.True(features.Single(item => item.Area == FeatureArea.Candidate).Native);
|
||||||
|
Assert.False(features.Single(item => item.Area == FeatureArea.Administration).Native);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
const databasePath = process.argv[2];
|
||||||
|
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
|
||||||
|
|
||||||
|
const database = new DatabaseSync(databasePath);
|
||||||
|
const setting = database.prepare("SELECT id, payload_json FROM admission_records WHERE kind = 'setting' ORDER BY created_at, id LIMIT 1").get();
|
||||||
|
if (!setting) throw new Error('测试数据库缺少招生设置');
|
||||||
|
const payload = {
|
||||||
|
...JSON.parse(setting.payload_json || '{}'),
|
||||||
|
enabled: true,
|
||||||
|
preferenceStart: '2000-01-01T00:00:00.000Z',
|
||||||
|
preferenceEnd: '2099-12-31T23:59:59.999Z',
|
||||||
|
maxChoices: 3,
|
||||||
|
maxSubmissions: 2,
|
||||||
|
round: 2,
|
||||||
|
progress: 'ASP.NET Core 志愿提交冒烟验证'
|
||||||
|
};
|
||||||
|
database.prepare("UPDATE admission_records SET status = 'filling', payload_json = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.run(JSON.stringify(payload), '2026-07-22T00:00:00.000Z', setting.id);
|
||||||
|
database.close();
|
||||||
Reference in New Issue
Block a user