PUT /api/candidate/profile

行政区划、学校班级、特长类别校验
证件号码唯一性
自动创建资料审核工作流
资料和用户显示名称原子更新

POST /api/candidate/registrations
资料审核状态、报名时间和科目校验
重复报名防护
自动选择负载最低的审批管理员
报名、科目和审批流原子写入
This commit is contained in:
2026-07-22 19:55:19 +08:00 Unverified
parent 07efa6813b
commit aecb0a04e7
16 changed files with 995 additions and 36 deletions
+1 -1
View File
@@ -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 同时启用。成绩、准考证、复议和志愿填报仍会继续转发给 Node:
```powershell
$env:AUTH_NATIVE_ENABLED = 'true'
+103
View File
@@ -440,6 +440,108 @@ try {
throw 'Native candidate profile route was not available during onboarding'
}
$invalidSpecialtyBody = @{
name = '原生迁移注册考生'
gender = '女'
idNumber = 'SMOKE-INVALID-SPECIALTY'
phone = '13800000000'
email = 'candidate@example.test'
nativePlace = '江苏连云港'
address = '迁移测试路 1 号'
schoolId = $registrationSchool.id
classId = $registrationClass.id
provinceCode = '320000'
cityCode = '320700'
districtCode = '320706'
specialtyCategory = 'arts'
specialtyType = 'track_field'
} | ConvertTo-Json -Compress
$invalidSpecialty = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $invalidSpecialtyBody -WebSession $registeredSession -SkipHttpErrorCheck
if ($invalidSpecialty.StatusCode -ne 400) {
throw 'Native candidate profile API accepted a mismatched specialty category and type'
}
$profileIdNumber = "SMOKE-NATIVE-$([guid]::NewGuid().ToString('N'))"
$profileBody = @{
name = '原生迁移注册考生'
gender = '女'
idNumber = $profileIdNumber
phone = '13800000000'
email = 'candidate@example.test'
address = '迁移测试路 1 号'
emergencyContact = '测试联系人'
emergencyPhone = '13900000000'
nativePlace = '江苏连云港'
birthDate = '2010-01-02'
ethnicity = '汉族'
postalCode = '222000'
guardianName = '测试监护人'
guardianPhone = '13700000000'
schoolId = $registrationSchool.id
classId = $registrationClass.id
provinceCode = '320000'
cityCode = '320700'
districtCode = '320706'
specialtyCategory = ''
specialtyType = ''
specialtyCertificate = ''
policyEligibility = ''
} | ConvertTo-Json -Compress
$profileUpdateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $profileBody -WebSession $registeredSession
if ($profileUpdateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Candidate profile update did not use the native ASP.NET Core endpoint'
}
$profileUpdate = $profileUpdateResponse.Content | ConvertFrom-Json
if ($profileUpdate.profile.profileCompleted -ne $true -or $profileUpdate.profile.districtName -ne '海州区' -or $profileUpdate.profile.status -ne 'pending') {
throw 'Native candidate profile update did not resolve the region or persist onboarding state'
}
$prematureRegistrationBody = @{ examId = 'not-approved-yet'; subjectIds = @('none') } | ConvertTo-Json -Compress
$prematureRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $prematureRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
if ($prematureRegistration.StatusCode -ne 403) {
throw 'Native registration API did not require an approved candidate profile'
}
$candidateWriteSetup = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
'tests/helpers/prepare-candidate-write-smoke.mjs', $smokeDatabasePath, $registration.registrationNumber
) -Environment $nodeEnvironment
if (-not $candidateWriteSetup.WaitForExit(10000)) {
$candidateWriteSetup.Kill($true)
throw 'Timed out while preparing the native candidate write smoke test'
}
$candidateWriteSetupOutput = $candidateWriteSetup.StandardOutput.ReadToEnd().Trim()
$candidateWriteSetupError = $candidateWriteSetup.StandardError.ReadToEnd()
if ($candidateWriteSetup.ExitCode -ne 0) {
throw "Could not prepare the native candidate write smoke test`n$candidateWriteSetupError"
}
$candidateWriteSetup.Dispose()
$candidateWriteTarget = $candidateWriteSetupOutput | ConvertFrom-Json
$nativeRegistrationBody = @{
examId = $candidateWriteTarget.examId
subjectIds = @($candidateWriteTarget.subjectId)
} | ConvertTo-Json -Compress
$nativeRegistrationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession
if ($nativeRegistrationResponse.StatusCode -ne 201 -or $nativeRegistrationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Exam registration submission did not use the native ASP.NET Core endpoint'
}
$nativeRegistration = $nativeRegistrationResponse.Content | ConvertFrom-Json
if ($nativeRegistration.registration.status -ne 'pending' -or $nativeRegistration.registration.subjectIds.Count -ne 1) {
throw 'Native exam registration did not persist the selected subject and pending state'
}
$duplicateRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
if ($duplicateRegistration.StatusCode -ne 409) {
throw 'Native exam registration did not reject a duplicate submission'
}
$legacyRegisteredSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $legacyRegisteredSession | Out-Null
foreach ($candidateWriteReadRoute in @('profile', 'registrations')) {
$legacyWriteRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $legacyRegisteredSession
$nativeWriteRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $registeredSession
Assert-JsonEquivalent -Expected $legacyWriteRead.Content -Actual $nativeWriteRead.Content -Label "Candidate write follow-up '$candidateWriteReadRoute'"
}
$nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession
if ($nativeLoginResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
@@ -528,6 +630,7 @@ try {
DocumentCodes = 'passed'
NativeAuthentication = 'passed'
NativeCandidateReads = 'passed'
NativeCandidateWrites = 'passed'
} | Format-List
}
finally {
@@ -4,7 +4,7 @@ namespace Eis.Application.Candidate;
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
public interface ICandidateQueryService
public interface ICandidateService
{
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
@@ -15,4 +15,14 @@ public interface ICandidateQueryService
Task<CandidateEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
Task<CandidateEndpointResult> UpdateProfileAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
Task<CandidateEndpointResult> CreateRegistrationAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
}
@@ -1,15 +1,15 @@
namespace Eis.Infrastructure.Candidate;
public sealed record CandidateMigrationOptions(bool NativeReadEnabled)
public sealed record CandidateMigrationOptions(bool NativeEnabled)
{
public static CandidateMigrationOptions FromEnvironment(
bool configuredNativeReadEnabled,
bool configuredNativeEnabled,
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
configuredNativeReadEnabled);
configuredNativeEnabled);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -141,9 +141,11 @@ internal sealed record CandidateUserSummary(
string? ClassId,
string DisplayName,
string? CandidateNumber,
bool Active,
bool MustChangePassword,
bool TotpEnabled,
string? ArchivedAt);
string? ArchivedAt,
string CreatedAt);
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
@@ -262,7 +264,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
ReadWorkflowAction,
cancellationToken);
var users = await QueryAsync(connection,
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, must_change_password, totp_enabled, archived_at FROM users ORDER BY created_at, id",
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
ReadUserSummary,
cancellationToken);
@@ -435,18 +437,24 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
ReadOptionalString(reader, "to_assignee_id"),
ReadString(reader, "created_at"));
private static CandidateUserSummary ReadUserSummary(DbDataReader reader) => new(
ReadString(reader, "id"),
ReadString(reader, "username"),
ReadString(reader, "role"),
ReadOptionalString(reader, "admin_level"),
ReadOptionalString(reader, "school_id"),
ReadOptionalString(reader, "class_id"),
ReadString(reader, "display_name"),
ReadOptionalString(reader, "candidate_number"),
ReadBoolean(reader, "must_change_password"),
ReadBoolean(reader, "totp_enabled"),
ReadOptionalString(reader, "archived_at"));
private static CandidateUserSummary ReadUserSummary(DbDataReader reader)
{
var role = ReadString(reader, "role");
return new CandidateUserSummary(
ReadString(reader, "id"),
ReadString(reader, "username"),
role,
ReadOptionalString(reader, "admin_level") ?? (role == "admin" ? "super" : null),
ReadOptionalString(reader, "school_id"),
ReadOptionalString(reader, "class_id"),
ReadString(reader, "display_name"),
ReadOptionalString(reader, "candidate_number"),
ReadBoolean(reader, "active"),
ReadBoolean(reader, "must_change_password"),
ReadBoolean(reader, "totp_enabled"),
ReadOptionalString(reader, "archived_at"),
ReadString(reader, "created_at"));
}
private static string ReadString(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
@@ -0,0 +1,373 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Candidate;
namespace Eis.Infrastructure.Candidate;
internal sealed partial class CandidateService
{
private static readonly IReadOnlyDictionary<string, string> AdminLevelNames =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
private static readonly IReadOnlyDictionary<string, string[]> SpecialtyTypes =
new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["sports"] =
[
"track_field", "basketball", "football", "volleyball", "table_tennis", "badminton",
"swimming", "martial_arts", "aerobics_cheer"
],
["arts"] =
[
"vocal_music", "instrumental_music", "dance", "fine_arts", "calligraphy", "drama_broadcasting"
]
};
public async Task<CandidateEndpointResult> UpdateProfileAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, profileRoute: true, cancellationToken);
if (context.Error is not null)
{
return context.Error;
}
var user = context.User!;
var profile = context.Profile!;
foreach (var field in new[]
{
"name", "gender", "idNumber", "phone", "email", "address", "emergencyContact",
"emergencyPhone", "nativePlace", "birthDate", "ethnicity", "postalCode", "guardianName",
"guardianPhone", "specialtyCertificate", "policyEligibility"
})
{
profile[field] = Clean(body, field, field == "address" ? 160 : 80);
}
var specialtyCategory = Clean(body, "specialtyCategory", 30);
var specialtyType = Clean(body, "specialtyType", 40);
if (!ValidSpecialty(specialtyCategory, specialtyType))
{
return Error(400, "请选择对应的特长生大类和小类");
}
profile["specialtyCategory"] = specialtyCategory;
profile["specialtyType"] = specialtyType;
profile["specialtyTypes"] = specialtyType.Length == 0
? new JsonArray()
: new JsonArray(JsonValue.Create(specialtyType));
var region = regionCatalog.Resolve(
Text(body, "provinceCode"),
Text(body, "cityCode"),
Text(body, "districtCode"));
if (region is null)
{
return Error(400, "请选择有效的省、市和区县");
}
profile["provinceCode"] = region.ProvinceCode;
profile["provinceName"] = region.ProvinceName;
profile["cityCode"] = region.CityCode;
profile["cityName"] = region.CityName;
profile["districtCode"] = region.DistrictCode;
profile["districtName"] = region.DistrictName;
var school = await writeRepository.FindSchoolClassAsync(
Clean(body, "schoolId", 64),
Clean(body, "classId", 64),
cancellationToken);
if (school is null)
{
return Error(400, "请选择有效的学校和班级");
}
profile["schoolId"] = school.SchoolId;
profile["classId"] = school.ClassId;
profile["school"] = school.SchoolName;
profile["grade"] = school.ClassName;
var name = Text(profile, "name");
var gender = Text(profile, "gender");
var idNumber = Text(profile, "idNumber");
if (name.Length == 0 || gender is not ("男" or "女") || idNumber.Length == 0 ||
idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ||
Text(profile, "nativePlace").Length == 0 || Text(profile, "address").Length == 0 ||
Text(profile, "phone").Length == 0 || Text(profile, "email").Length == 0 ||
Text(profile, "school").Length == 0 || Text(profile, "classId").Length == 0)
{
return Error(400, "请完整填写姓名、性别、证件号码、籍贯、省市区县、家庭住址、手机号、邮箱、学校和班级");
}
if (await writeRepository.IdNumberExistsAsync(idNumber, ProfileId(profile), cancellationToken))
{
return Error(409, "证件号码已被其他考生使用");
}
profile["status"] = "pending";
profile["profileCompleted"] = true;
profile["reviewNote"] = string.Empty;
profile["updatedAt"] = NowIso();
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
WorkflowSubmission? submission = null;
if (FindInstance(snapshot, "profile_change", ProfileId(profile)) is not { Status: "pending" })
{
var workflowResult = CreateWorkflowSubmission(
snapshot,
"profile_change",
ProfileId(profile),
profile,
user.Id);
if (workflowResult.Error is not null)
{
return workflowResult.Error;
}
submission = workflowResult.Submission;
}
await writeRepository.UpdateProfileAsync(
profile,
name,
submission?.Instance,
submission?.Action,
cancellationToken);
return Success(new JsonObject
{
["ok"] = true,
["profile"] = profile,
["message"] = "资料已提交,等待管理员复核"
});
}
public async Task<CandidateEndpointResult> CreateRegistrationAsync(
string sessionToken,
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!;
if (Text(profile, "status") != "approved")
{
return Error(403, "个人资料审核通过后才能报名考试");
}
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
var examId = Text(body, "examId");
var exam = snapshot.Exams.FirstOrDefault(item =>
item.Id == examId && item.Status == "published" && item.ArchivedAt is null);
if (exam is null)
{
return Error(404, "考试不存在或尚未发布");
}
var registrationState = RegistrationState(exam);
if (registrationState != "open")
{
return Error(400, registrationState == "upcoming" ? "报名尚未开始" : "报名已经截止");
}
if (snapshot.Registrations.Any(item => item.ExamId == exam.Id) ||
await writeRepository.RegistrationExistsAsync(user.Id, exam.Id, cancellationToken))
{
return Error(409, "你已经报名该考试");
}
var subjectIds = body["subjectIds"] is JsonArray array
? array.Select(item => item?.ToString() ?? string.Empty)
.Distinct(StringComparer.Ordinal)
.ToArray()
: [];
if (subjectIds.Length == 0 || subjectIds.Any(id => exam.Subjects.All(subject => subject.Id != id)))
{
return Error(400, "请选择有效的报考科目");
}
var now = NowIso();
var registration = new CandidateRegistration(
Uid("reg"),
user.Id,
exam.Id,
subjectIds,
"pending",
"unpaid",
null,
null,
now,
null,
string.Empty,
user.CandidateNumber ?? string.Empty,
await writeRepository.GetActiveNumberRuleIdAsync(cancellationToken),
0,
null);
var workflowResult = CreateWorkflowSubmission(
snapshot,
"registration_review",
registration.Id,
profile,
user.Id);
if (workflowResult.Error is not null)
{
return workflowResult.Error;
}
var submission = workflowResult.Submission!;
await writeRepository.CreateRegistrationAsync(
registration,
submission.Instance,
submission.Action,
cancellationToken);
return new CandidateEndpointResult(
201,
new JsonObject
{
["ok"] = true,
["registration"] = RegistrationView(snapshot, registration),
["message"] = "考试报名已提交"
});
}
private static WorkflowSubmissionResult CreateWorkflowSubmission(
CandidateReadSnapshot snapshot,
string businessType,
string businessId,
JsonObject profile,
string actorId)
{
var workflow = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == businessType && item.Active);
if (workflow is null || workflow.Steps.Count == 0)
{
return WorkflowSubmissionResult.Failed(Error(409, "该业务尚未配置审批流程"));
}
var firstStep = workflow.Steps[0];
var schoolId = Text(profile, "schoolId");
var classId = Text(profile, "classId");
var pendingByAdmin = snapshot.WorkflowInstances
.Where(item => item.Status == "pending" && item.AssigneeId is not null)
.GroupBy(item => item.AssigneeId!, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
var assignedByAdmin = snapshot.WorkflowActions
.Where(item => item.ToAssigneeId is not null)
.GroupBy(item => item.ToAssigneeId!, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
var assignee = snapshot.Users.Values
.Where(item => item.Role == "admin" && item.Active && (item.AdminLevel ?? "super") == firstStep.AdminLevel)
.Where(item => firstStep.AdminLevel switch
{
"super" => true,
"school" => schoolId.Length > 0 && item.SchoolId == schoolId,
"class" => schoolId.Length > 0 && classId.Length > 0 && item.SchoolId == schoolId && item.ClassId == classId,
_ => false
})
.OrderBy(item => pendingByAdmin.GetValueOrDefault(item.Id))
.ThenBy(item => assignedByAdmin.GetValueOrDefault(item.Id))
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
.ThenBy(item => item.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (assignee is null)
{
var levelName = AdminLevelNames.GetValueOrDefault(firstStep.AdminLevel) ?? firstStep.AdminLevel;
return WorkflowSubmissionResult.Failed(Error(409, $"没有可承接“{firstStep.Name}”的{levelName}"));
}
var instance = new CandidateWorkflowInstance(
Uid("flow"),
workflow.Id,
businessType,
businessId,
"pending",
1,
assignee.Id,
NowIso(),
null);
var action = new CandidateWorkflowAction(
Uid("flow_action"),
instance.Id,
actorId,
"submit",
"提交审批",
null,
assignee.Id,
NowIso());
return new WorkflowSubmissionResult(new WorkflowSubmission(instance, action), null);
}
private static bool ValidSpecialty(string category, string type)
{
if (category.Length == 0 && type.Length == 0)
{
return true;
}
return SpecialtyTypes.TryGetValue(category, out var types) && types.Contains(type, StringComparer.Ordinal);
}
private static string RegistrationState(CandidateExam exam)
{
var now = DateTimeOffset.UtcNow;
if (DateTimeOffset.TryParse(exam.RegistrationStart, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var start) && now < start)
{
return "upcoming";
}
if (DateTimeOffset.TryParse(exam.RegistrationEnd, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var end) && now > end)
{
return "closed";
}
return "open";
}
private static string Clean(JsonObject value, string property, int maximum)
{
var normalized = Text(value, property).Trim();
return normalized[..Math.Min(normalized.Length, maximum)];
}
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
private static string NowIso() => DateTimeOffset.UtcNow.ToString(
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture);
private static string Uid(string prefix) =>
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
private static string ToBase36(long value)
{
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
Span<char> buffer = stackalloc char[16];
var position = buffer.Length;
do
{
buffer[--position] = alphabet[(int)(value % 36)];
value /= 36;
}
while (value > 0);
return new string(buffer[position..]);
}
private sealed record WorkflowSubmission(
CandidateWorkflowInstance Instance,
CandidateWorkflowAction Action);
private sealed record WorkflowSubmissionResult(
WorkflowSubmission? Submission,
CandidateEndpointResult? Error)
{
public static WorkflowSubmissionResult Failed(CandidateEndpointResult error) => new(null, error);
}
}
@@ -6,11 +6,13 @@ using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Candidate;
internal sealed class CandidateQueryService(
internal sealed partial class CandidateService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
CandidateReadSnapshotLoader snapshotLoader,
IPublicQueryService publicQueries) : ICandidateQueryService
IPublicQueryService publicQueries,
CandidateWriteRepository writeRepository,
RegionCatalog regionCatalog) : ICandidateService
{
public async Task<CandidateEndpointResult> GetDashboardAsync(
string sessionToken,
@@ -0,0 +1,312 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Candidate;
internal sealed record SchoolClassSelection(
string SchoolId,
string SchoolName,
string ClassId,
string ClassName);
internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory)
{
public async Task<SchoolClassSelection?> FindSchoolClassAsync(
string schoolId,
string classId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT school.id AS school_id, school.name AS school_name,
class.id AS class_id, class.name AS class_name
FROM schools school
JOIN school_classes class ON class.school_id = school.id
WHERE school.id = @schoolId AND class.id = @classId
AND school.active = 1 AND school.is_source_school = 1 AND class.active = 1
LIMIT 1
""";
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = CreateCommand(connection, sql,
[
new("@schoolId", schoolId),
new("@classId", classId)
]);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? new SchoolClassSelection(
ReadString(reader, "school_id"),
ReadString(reader, "school_name"),
ReadString(reader, "class_id"),
ReadString(reader, "class_name"))
: null;
}
public async Task<bool> IdNumberExistsAsync(
string idNumber,
string excludedProfileId,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = CreateCommand(connection,
"SELECT COUNT(*) FROM candidate_profiles WHERE id <> @profileId AND id_number = @idNumber",
[new("@profileId", excludedProfileId), new("@idNumber", idNumber)]);
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
}
public async Task<bool> RegistrationExistsAsync(
string userId,
string examId,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = CreateCommand(connection,
"SELECT COUNT(*) FROM registrations WHERE user_id = @userId AND exam_id = @examId",
[new("@userId", userId), new("@examId", examId)]);
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
}
public async Task<string?> GetActiveNumberRuleIdAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = CreateCommand(connection,
"SELECT id FROM number_rules WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1",
[]);
var value = await command.ExecuteScalarAsync(cancellationToken);
return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
}
public async Task UpdateProfileAsync(
JsonObject profile,
string displayName,
CandidateWorkflowInstance? instance,
CandidateWorkflowAction? action,
CancellationToken cancellationToken)
{
const string sql = """
UPDATE candidate_profiles SET
name = @name, gender = @gender, id_number = @idNumber, phone = @phone, email = @email,
school = @school, grade = @grade, province_code = @provinceCode, province_name = @provinceName,
city_code = @cityCode, city_name = @cityName, district_code = @districtCode,
district_name = @districtName, address = @address, school_id = @schoolId, class_id = @classId,
emergency_contact = @emergencyContact, emergency_phone = @emergencyPhone, status = @status,
review_note = @reviewNote, native_place = @nativePlace, birth_date = @birthDate,
ethnicity = @ethnicity, postal_code = @postalCode, guardian_name = @guardianName,
guardian_phone = @guardianPhone, specialty_category = @specialtyCategory,
specialty_type = @specialtyType, specialty_types = @specialtyTypes,
specialty_certificate = @specialtyCertificate, policy_eligibility = @policyEligibility,
profile_completed = @profileCompleted, reviewed_at = @reviewedAt, reviewer_id = @reviewerId,
updated_at = @updatedAt
WHERE id = @id
""";
var parameters = new QueryParameter[]
{
new("@name", Text(profile, "name")),
new("@gender", Optional(Text(profile, "gender"))),
new("@idNumber", Text(profile, "idNumber")),
new("@phone", Text(profile, "phone")),
new("@email", Optional(Text(profile, "email"))),
new("@school", Optional(Text(profile, "school"))),
new("@grade", Optional(Text(profile, "grade"))),
new("@provinceCode", Optional(Text(profile, "provinceCode"))),
new("@provinceName", Optional(Text(profile, "provinceName"))),
new("@cityCode", Optional(Text(profile, "cityCode"))),
new("@cityName", Optional(Text(profile, "cityName"))),
new("@districtCode", Optional(Text(profile, "districtCode"))),
new("@districtName", Optional(Text(profile, "districtName"))),
new("@address", Optional(Text(profile, "address"))),
new("@schoolId", Optional(Text(profile, "schoolId"))),
new("@classId", Optional(Text(profile, "classId"))),
new("@emergencyContact", Optional(Text(profile, "emergencyContact"))),
new("@emergencyPhone", Optional(Text(profile, "emergencyPhone"))),
new("@status", Text(profile, "status")),
new("@reviewNote", Optional(Text(profile, "reviewNote"))),
new("@nativePlace", Optional(Text(profile, "nativePlace"))),
new("@birthDate", Optional(Text(profile, "birthDate"))),
new("@ethnicity", Optional(Text(profile, "ethnicity"))),
new("@postalCode", Optional(Text(profile, "postalCode"))),
new("@guardianName", Optional(Text(profile, "guardianName"))),
new("@guardianPhone", Optional(Text(profile, "guardianPhone"))),
new("@specialtyCategory", Optional(Text(profile, "specialtyCategory"))),
new("@specialtyType", Optional(Text(profile, "specialtyType"))),
new("@specialtyTypes", profile["specialtyTypes"]?.ToJsonString() ?? "[]"),
new("@specialtyCertificate", Optional(Text(profile, "specialtyCertificate"))),
new("@policyEligibility", Optional(Text(profile, "policyEligibility"))),
new("@profileCompleted", Boolean(profile, "profileCompleted") ? 1 : 0),
new("@reviewedAt", Optional(Text(profile, "reviewedAt"))),
new("@reviewerId", Optional(Text(profile, "reviewerId"))),
new("@updatedAt", Text(profile, "updatedAt")),
new("@id", Text(profile, "id"))
};
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ExecuteAsync(connection, transaction, sql, parameters, cancellationToken);
await ExecuteAsync(connection, transaction,
"UPDATE users SET display_name = @displayName WHERE id = @userId",
[new("@displayName", displayName), new("@userId", Text(profile, "userId"))],
cancellationToken);
if (instance is not null && action is not null)
{
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task CreateRegistrationAsync(
CandidateRegistration registration,
CandidateWorkflowInstance? instance,
CandidateWorkflowAction? action,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO registrations (
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at,
reviewed_at, review_note, registration_number, number_rule_id, feature_score
) VALUES (
@id, @userId, @examId, @status, @paymentStatus, @paidAt, @paidBy, @createdAt,
@reviewedAt, @reviewNote, @registrationNumber, @numberRuleId, @featureScore
)
""";
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ExecuteAsync(connection, transaction, sql,
[
new("@id", registration.Id),
new("@userId", registration.UserId),
new("@examId", registration.ExamId),
new("@status", registration.Status),
new("@paymentStatus", registration.PaymentStatus),
new("@paidAt", registration.PaidAt),
new("@paidBy", registration.PaidBy),
new("@createdAt", registration.CreatedAt),
new("@reviewedAt", registration.ReviewedAt),
new("@reviewNote", Optional(registration.ReviewNote)),
new("@registrationNumber", Optional(registration.RegistrationNumber)),
new("@numberRuleId", registration.NumberRuleId),
new("@featureScore", registration.FeatureScore)
], cancellationToken);
foreach (var subjectId in registration.SubjectIds)
{
await ExecuteAsync(connection, transaction,
"INSERT INTO registration_subjects (registration_id, subject_id) VALUES (@registrationId, @subjectId)",
[new("@registrationId", registration.Id), new("@subjectId", subjectId)],
cancellationToken);
}
if (instance is not null && action is not null)
{
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static async Task InsertWorkflowAsync(
DbConnection connection,
DbTransaction transaction,
CandidateWorkflowInstance instance,
CandidateWorkflowAction action,
CancellationToken cancellationToken)
{
const string instanceSql = """
INSERT INTO workflow_instances (
id, workflow_id, business_type, business_id, status, current_step,
assignee_id, created_at, completed_at
) VALUES (
@id, @workflowId, @businessType, @businessId, @status, @currentStep,
@assigneeId, @createdAt, @completedAt
)
""";
await ExecuteAsync(connection, transaction, instanceSql,
[
new("@id", instance.Id),
new("@workflowId", instance.WorkflowId),
new("@businessType", instance.BusinessType),
new("@businessId", instance.BusinessId),
new("@status", instance.Status),
new("@currentStep", instance.CurrentStep),
new("@assigneeId", instance.AssigneeId),
new("@createdAt", instance.CreatedAt),
new("@completedAt", instance.CompletedAt)
], cancellationToken);
const string actionSql = """
INSERT INTO workflow_actions (
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
) VALUES (
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
)
""";
await ExecuteAsync(connection, transaction, actionSql,
[
new("@id", action.Id),
new("@instanceId", action.InstanceId),
new("@actorId", action.ActorId),
new("@action", action.Action),
new("@note", Optional(action.Note)),
new("@fromAssigneeId", action.FromAssigneeId),
new("@toAssigneeId", action.ToAssigneeId),
new("@createdAt", action.CreatedAt)
], cancellationToken);
}
private static async Task ExecuteAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
IReadOnlyList<QueryParameter> parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters, transaction);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static DbCommand CreateCommand(
DbConnection connection,
string sql,
IReadOnlyList<QueryParameter> parameters,
DbTransaction? transaction = null)
{
var command = connection.CreateCommand();
command.CommandText = sql;
command.Transaction = transaction;
foreach (var item in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = item.Name;
parameter.Value = item.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
return command;
}
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
private static bool Boolean(JsonObject value, string property) => value[property]?.GetValue<bool>() == true;
private static object? Optional(string? value) => string.IsNullOrEmpty(value) ? null : value;
private static string ReadString(DbDataReader reader, string name) =>
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
private sealed record QueryParameter(string Name, object? Value);
}
@@ -0,0 +1,88 @@
using System.Reflection;
using System.Text.Json;
namespace Eis.Infrastructure.Candidate;
internal sealed record ResolvedRegion(
string ProvinceCode,
string ProvinceName,
string CityCode,
string CityName,
string DistrictCode,
string DistrictName);
internal sealed class RegionCatalog
{
private const string Marker = "export const chinaRegions = ";
private readonly IReadOnlyDictionary<string, Province> _provinces;
public RegionCatalog()
{
var assembly = typeof(RegionCatalog).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.Single(name => name.EndsWith("china-regions.mjs", StringComparison.Ordinal));
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException("无法读取内嵌行政区划数据");
using var reader = new StreamReader(stream);
var source = reader.ReadToEnd();
var markerIndex = source.IndexOf(Marker, StringComparison.Ordinal);
if (markerIndex < 0)
{
throw new InvalidOperationException("行政区划数据格式无效");
}
var json = source[(markerIndex + Marker.Length)..].Trim();
if (json.EndsWith(';'))
{
json = json[..^1];
}
var provinces = JsonSerializer.Deserialize<Province[]>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("行政区划数据为空");
_provinces = provinces.ToDictionary(item => item.Code, StringComparer.Ordinal);
}
public ResolvedRegion? Resolve(string? provinceCode, string? cityCode, string? districtCode)
{
var provinceKey = (provinceCode ?? string.Empty).Trim();
var cityKey = (cityCode ?? string.Empty).Trim();
var districtKey = (districtCode ?? string.Empty).Trim();
if (!_provinces.TryGetValue(provinceKey, out var province))
{
return null;
}
var city = province.Cities.FirstOrDefault(item => item.Code == cityKey);
var district = city?.Districts.FirstOrDefault(item => item.Code == districtKey);
return city is null || district is null
? null
: new ResolvedRegion(province.Code, province.Name, city.Code, city.Name, district.Code, district.Name);
}
private sealed class Province
{
public required string Code { get; init; }
public required string Name { get; init; }
public required City[] Cities { get; init; }
}
private sealed class City
{
public required string Code { get; init; }
public required string Name { get; init; }
public required District[] Districts { get; init; }
}
private sealed class District
{
public required string Code { get; init; }
public required string Name { get; init; }
}
}
@@ -43,8 +43,10 @@ public static class DependencyInjection
services.AddScoped<AuthenticationRepository>();
services.AddScoped<IAuthenticationService, AuthenticationService>();
services.AddSingleton(candidateMigrationOptions);
services.AddSingleton<RegionCatalog>();
services.AddScoped<CandidateReadSnapshotLoader>();
services.AddScoped<ICandidateQueryService, CandidateQueryService>();
services.AddScoped<CandidateWriteRepository>();
services.AddScoped<ICandidateService, CandidateService>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
}
@@ -15,4 +15,7 @@
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
</ItemGroup>
</Project>
@@ -3,47 +3,61 @@ using Eis.Infrastructure.Candidate;
namespace Eis.Web.Candidate;
public static class NativeCandidateReadEndpoints
public static class NativeCandidateEndpoints
{
public static IEndpointRouteBuilder MapNativeCandidateReadEndpoints(
public static IEndpointRouteBuilder MapNativeCandidateEndpoints(
this IEndpointRouteBuilder endpoints,
CandidateMigrationOptions options)
{
if (!options.NativeReadEnabled)
if (!options.NativeEnabled)
{
return endpoints;
}
endpoints.MapGet("/api/candidate/dashboard", async (
HttpContext context,
ICandidateQueryService service,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.GetDashboardAsync(SessionToken(context), cancellationToken)));
endpoints.MapGet("/api/candidate/notices", async (
HttpContext context,
ICandidateQueryService service,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.GetNoticesAsync(SessionToken(context), cancellationToken)));
endpoints.MapGet("/api/candidate/profile", async (
HttpContext context,
ICandidateQueryService service,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.GetProfileAsync(SessionToken(context), cancellationToken)));
endpoints.MapPut("/api/candidate/profile", async (
HttpContext context,
System.Text.Json.Nodes.JsonObject request,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.UpdateProfileAsync(SessionToken(context), request, cancellationToken)));
endpoints.MapGet("/api/candidate/exams", async (
HttpContext context,
ICandidateQueryService service,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.GetExamsAsync(SessionToken(context), cancellationToken)));
endpoints.MapGet("/api/candidate/registrations", async (
HttpContext context,
ICandidateQueryService service,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.GetRegistrationsAsync(SessionToken(context), cancellationToken)));
endpoints.MapPost("/api/candidate/registrations", async (
HttpContext context,
System.Text.Json.Nodes.JsonObject request,
ICandidateService service,
CancellationToken cancellationToken) => ToResult(
context,
await service.CreateRegistrationAsync(SessionToken(context), request, cancellationToken)));
return endpoints;
}
+5 -5
View File
@@ -37,7 +37,7 @@ var authenticationOptions = AuthenticationOptions.FromEnvironment(
builder.Environment.IsProduction(),
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("CandidateMigration:NativeReadEnabled"),
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions);
builder.Services.AddEisInfrastructure(
@@ -81,9 +81,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
},
candidate = new
{
nativeReadEnabled = candidateMigrationOptions.NativeReadEnabled,
nativeRoutes = candidateMigrationOptions.NativeReadEnabled
? new[] { "dashboard", "notices", "profile", "exams", "registrations" }
nativeEnabled = candidateMigrationOptions.NativeEnabled,
nativeRoutes = candidateMigrationOptions.NativeEnabled
? new[] { "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations" }
: []
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
@@ -92,7 +92,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
app.MapNativePublicEndpoints();
app.MapNativeAuthenticationEndpoints(authenticationOptions);
app.MapNativeCandidateReadEndpoints(candidateMigrationOptions);
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
string[] methods =
[
+1 -1
View File
@@ -7,7 +7,7 @@
"NativeEnabled": false
},
"CandidateMigration": {
"NativeReadEnabled": false
"NativeEnabled": false
},
"Logging": {
"LogLevel": {
@@ -0,0 +1,20 @@
using Eis.Infrastructure.Candidate;
namespace Eis.Infrastructure.Tests.Candidate;
public sealed class RegionCatalogTests
{
[Fact]
public void ResolvesCodesFromEmbeddedAuthoritativeSnapshot()
{
var catalog = new RegionCatalog();
var region = catalog.Resolve("320000", "320700", "320706");
Assert.NotNull(region);
Assert.Equal("江苏省", region.ProvinceName);
Assert.Equal("连云港市", region.CityName);
Assert.Equal("海州区", region.DistrictName);
Assert.Null(catalog.Resolve("320000", "320700", "invalid"));
}
}
@@ -0,0 +1,24 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
const candidateNumber = process.argv[3];
if (!databasePath || !candidateNumber) throw new Error('缺少 SQLite 路径或报名号');
const database = new DatabaseSync(databasePath);
const user = database.prepare("SELECT id FROM users WHERE candidate_number = ? AND role = 'candidate'").get(candidateNumber);
if (!user) throw new Error('未找到冒烟测试考生');
const reviewer = database.prepare("SELECT id FROM users WHERE role = 'admin' AND active = 1 ORDER BY created_at, id LIMIT 1").get();
database.prepare(
"UPDATE candidate_profiles SET status = 'approved', review_note = '', reviewed_at = ?, reviewer_id = ? WHERE user_id = ?"
).run('2026-07-22T00:00:00.000Z', reviewer?.id || null, user.id);
const exam = database.prepare("SELECT id FROM exams WHERE status = 'published' AND archived_at IS NULL ORDER BY created_at, id LIMIT 1").get();
if (!exam) throw new Error('未找到已发布考试');
database.prepare(
'UPDATE exams SET registration_start = ?, registration_end = ? WHERE id = ?'
).run('2000-01-01T00:00:00.000Z', '2099-12-31T23:59:59.999Z', exam.id);
const subject = database.prepare('SELECT id FROM exam_subjects WHERE exam_id = ? ORDER BY position, id LIMIT 1').get(exam.id);
if (!subject) throw new Error('考试缺少科目');
database.close();
console.log(JSON.stringify({ examId: exam.id, subjectId: subject.id }));