From aecb0a04e7e9094a3ebdcdc861c92d1086f86c30 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 19:55:19 +0800 Subject: [PATCH] =?UTF-8?q?PUT=20/api/candidate/profile=20=E8=A1=8C?= =?UTF-8?q?=E6=94=BF=E5=8C=BA=E5=88=92=E3=80=81=E5=AD=A6=E6=A0=A1=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E3=80=81=E7=89=B9=E9=95=BF=E7=B1=BB=E5=88=AB=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20=E8=AF=81=E4=BB=B6=E5=8F=B7=E7=A0=81=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=80=A7=20=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=B5=84=E6=96=99=E5=AE=A1=E6=A0=B8=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=20=E8=B5=84=E6=96=99=E5=92=8C=E7=94=A8=E6=88=B7=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E7=A7=B0=E5=8E=9F=E5=AD=90=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/candidate/registrations 资料审核状态、报名时间和科目校验 重复报名防护 自动选择负载最低的审批管理员 报名、科目和审批流原子写入 --- MIGRATION.md | 2 +- scripts/smoke-dotnet-migration.ps1 | 103 +++++ ...teQueryService.cs => ICandidateService.cs} | 12 +- .../Candidate/CandidateMigrationOptions.cs | 6 +- .../Candidate/CandidateReadSnapshot.cs | 36 +- .../Candidate/CandidateService.Commands.cs | 373 ++++++++++++++++++ ...ateQueryService.cs => CandidateService.cs} | 6 +- .../Candidate/CandidateWriteRepository.cs | 312 +++++++++++++++ .../Candidate/RegionCatalog.cs | 88 +++++ src/Eis.Infrastructure/DependencyInjection.cs | 4 +- .../Eis.Infrastructure.csproj | 3 + ...dpoints.cs => NativeCandidateEndpoints.cs} | 30 +- src/Eis.Web/Program.cs | 10 +- src/Eis.Web/appsettings.json | 2 +- .../Candidate/RegionCatalogTests.cs | 20 + .../helpers/prepare-candidate-write-smoke.mjs | 24 ++ 16 files changed, 995 insertions(+), 36 deletions(-) rename src/Eis.Application/Candidate/{ICandidateQueryService.cs => ICandidateService.cs} (66%) create mode 100644 src/Eis.Infrastructure/Candidate/CandidateService.Commands.cs rename src/Eis.Infrastructure/Candidate/{CandidateQueryService.cs => CandidateService.cs} (98%) create mode 100644 src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs create mode 100644 src/Eis.Infrastructure/Candidate/RegionCatalog.cs rename src/Eis.Web/Candidate/{NativeCandidateReadEndpoints.cs => NativeCandidateEndpoints.cs} (65%) create mode 100644 tests/Eis.Infrastructure.Tests/Candidate/RegionCatalogTests.cs create mode 100644 tests/helpers/prepare-candidate-write-smoke.mjs diff --git a/MIGRATION.md b/MIGRATION.md index a5c264d..c3baf86 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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' diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 9e1c5a5..01357cd 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -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 { diff --git a/src/Eis.Application/Candidate/ICandidateQueryService.cs b/src/Eis.Application/Candidate/ICandidateService.cs similarity index 66% rename from src/Eis.Application/Candidate/ICandidateQueryService.cs rename to src/Eis.Application/Candidate/ICandidateService.cs index aedb822..56f6f48 100644 --- a/src/Eis.Application/Candidate/ICandidateQueryService.cs +++ b/src/Eis.Application/Candidate/ICandidateService.cs @@ -4,7 +4,7 @@ namespace Eis.Application.Candidate; public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body); -public interface ICandidateQueryService +public interface ICandidateService { Task GetDashboardAsync(string sessionToken, CancellationToken cancellationToken); @@ -15,4 +15,14 @@ public interface ICandidateQueryService Task GetExamsAsync(string sessionToken, CancellationToken cancellationToken); Task GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken); + + Task UpdateProfileAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); + + Task CreateRegistrationAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); } diff --git a/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs index 783aaa2..2ca0df7 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateMigrationOptions.cs @@ -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( diff --git a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs index cbe6958..5e74633 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateReadSnapshot.cs @@ -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; diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Commands.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Commands.cs new file mode 100644 index 0000000..bf9a819 --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Commands.cs @@ -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 AdminLevelNames = + new Dictionary(StringComparer.Ordinal) + { + ["super"] = "超级管理员", + ["school"] = "校级管理员", + ["class"] = "班级管理员" + }; + + private static readonly IReadOnlyDictionary SpecialtyTypes = + new Dictionary(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 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 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 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); + } +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateQueryService.cs b/src/Eis.Infrastructure/Candidate/CandidateService.cs similarity index 98% rename from src/Eis.Infrastructure/Candidate/CandidateQueryService.cs rename to src/Eis.Infrastructure/Candidate/CandidateService.cs index 54c3977..b941d6c 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateQueryService.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.cs @@ -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 GetDashboardAsync( string sessionToken, diff --git a/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs b/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs new file mode 100644 index 0000000..a7daa8c --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/CandidateWriteRepository.cs @@ -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 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 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 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 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 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 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() == 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); +} diff --git a/src/Eis.Infrastructure/Candidate/RegionCatalog.cs b/src/Eis.Infrastructure/Candidate/RegionCatalog.cs new file mode 100644 index 0000000..bac6e5e --- /dev/null +++ b/src/Eis.Infrastructure/Candidate/RegionCatalog.cs @@ -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 _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(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; } + } +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index c7becab..40aeb38 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -43,8 +43,10 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddSingleton(candidateMigrationOptions); + services.AddSingleton(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Infrastructure/Eis.Infrastructure.csproj b/src/Eis.Infrastructure/Eis.Infrastructure.csproj index 8149b61..f36838e 100644 --- a/src/Eis.Infrastructure/Eis.Infrastructure.csproj +++ b/src/Eis.Infrastructure/Eis.Infrastructure.csproj @@ -15,4 +15,7 @@ + + + diff --git a/src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs similarity index 65% rename from src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs rename to src/Eis.Web/Candidate/NativeCandidateEndpoints.cs index 0a6b297..0edee1c 100644 --- a/src/Eis.Web/Candidate/NativeCandidateReadEndpoints.cs +++ b/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs @@ -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; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index fac9c06..8837ea5 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -37,7 +37,7 @@ var authenticationOptions = AuthenticationOptions.FromEnvironment( builder.Environment.IsProduction(), builder.Configuration.GetValue("AuthenticationMigration:NativeEnabled")); var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment( - builder.Configuration.GetValue("CandidateMigration:NativeReadEnabled"), + builder.Configuration.GetValue("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 = [ diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 7949e83..aeba1f4 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -7,7 +7,7 @@ "NativeEnabled": false }, "CandidateMigration": { - "NativeReadEnabled": false + "NativeEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Candidate/RegionCatalogTests.cs b/tests/Eis.Infrastructure.Tests/Candidate/RegionCatalogTests.cs new file mode 100644 index 0000000..8b67c87 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Candidate/RegionCatalogTests.cs @@ -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")); + } +} diff --git a/tests/helpers/prepare-candidate-write-smoke.mjs b/tests/helpers/prepare-candidate-write-smoke.mjs new file mode 100644 index 0000000..9e5cc30 --- /dev/null +++ b/tests/helpers/prepare-candidate-write-smoke.mjs @@ -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 }));