From 7fd20caf3e7d51fc653d3ce7a93c532e92519ecc Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 10:00:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E6=89=B9=E2=80=9C=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E4=B8=8E=E7=BC=B4=E8=B4=B9=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E2=80=9D=E8=BF=81=E7=A7=BB=E5=AE=8C=E6=88=90=EF=BC=9A=20PATCH?= =?UTF-8?q?=20/api/admin/registrations/{registrationId}=20PATCH=20/api/adm?= =?UTF-8?q?in/payments/{registrationId}=20=E5=AE=9E=E7=8E=B0=E4=BA=86?= =?UTF-8?q?=E5=A4=9A=E7=BA=A7=E6=8A=A5=E5=90=8D=E5=AE=A1=E6=A0=B8=E3=80=81?= =?UTF-8?q?=E7=BB=88=E5=AE=A1=E7=BB=91=E5=AE=9A=E6=8A=A5=E5=90=8D=E5=8F=B7?= =?UTF-8?q?=E4=B8=8E=E5=8F=B7=E7=A0=81=E8=A7=84=E5=88=99=E3=80=81=E7=BC=B4?= =?UTF-8?q?=E8=B4=B9=E7=A1=AE=E8=AE=A4/=E6=92=A4=E9=94=80=E3=80=81?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=98=B2=E9=87=8D=E3=80=81=E8=80=83=E8=AF=95?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E9=94=81=E5=AE=9A=E5=8F=8A=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + MIGRATION.md | 7 +- scripts/smoke-dotnet-migration.ps1 | 60 ++++ .../IAdminRegistrationManagementService.cs | 9 + .../Administration/AdminMigrationOptions.cs | 19 +- .../AdminRegistrationManagementService.cs | 329 ++++++++++++++++++ .../Administration/AdminWriteRepository.cs | 74 ++++ src/Eis.Infrastructure/DependencyInjection.cs | 1 + .../NativeAdminReadEndpoints.cs | 8 + src/Eis.Web/Program.cs | 7 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 36 +- 12 files changed, 542 insertions(+), 13 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminRegistrationManagementService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminRegistrationManagementService.cs diff --git a/.env.example b/.env.example index 6d67166..9f7139f 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ ADMIN_NATIVE_CENTERS_ENABLED=false ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false # 考生账户归档、密码重置及资料流程审核;必须同时启用考生/报名/缴费读取。 ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false +# 报名审核与缴费状态更新;必须同时启用考生/报名/缴费读取。 +ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 29cc077..ccff3c7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取及考生账号维护已原生化) +- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护及报名缴费写入已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -68,9 +68,10 @@ $env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' $env:ADMIN_NATIVE_CENTERS_ENABLED = 'true' $env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' $env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' +$env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled`、`administration.nativeRegistrationPaymentWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 8e07367..a1fb14f 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -444,6 +444,7 @@ try { ADMIN_NATIVE_CENTERS_ENABLED = 'true' ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' + ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -906,6 +907,64 @@ try { $nativeCandidatesAfterManagement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates" -WebSession $nativeSession Assert-JsonEquivalent -Expected $legacyCandidatesAfterManagement.Content -Actual $nativeCandidatesAfterManagement.Content -Label 'Candidate management follow-up' + $prematurePayment = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body '{}' -WebSession $nativeSession -SkipHttpErrorCheck + if ($prematurePayment.StatusCode -ne 409 -or $prematurePayment.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native payment update did not require an approved registration' + } + $invalidRegistrationReview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body '{"status":"invalid"}' -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidRegistrationReview.StatusCode -ne 400 -or $invalidRegistrationReview.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native registration review accepted an invalid status' + } + $registrationReviewIterations = 0 + do { + $registrationReviewIterations += 1 + if ($registrationReviewIterations -gt 10) { + throw 'Native registration workflow did not reach a terminal state' + } + $registrationReviewBody = @{ status = 'approved'; reviewNote = "原生报名审核第 $registrationReviewIterations 步" } | ConvertTo-Json -Compress + $registrationReviewResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body $registrationReviewBody -WebSession $nativeSession + if ($registrationReviewResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Registration review did not use ASP.NET Core' + } + $registrationReview = $registrationReviewResponse.Content | ConvertFrom-Json + } while ($registrationReview.workflow.status -eq 'pending') + if ($registrationReview.registration.status -ne 'approved' -or $registrationReview.workflow.status -ne 'approved' -or $registrationReview.registration.registrationNumber -ne $registration.registrationNumber) { + throw 'Native registration review did not complete the configured workflow or bind the candidate number' + } + $duplicateRegistrationReview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body $registrationReviewBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($duplicateRegistrationReview.StatusCode -ne 409) { + throw 'Native registration review did not protect a completed workflow' + } + $legacyRegistrationsAfterReview = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/registrations" -WebSession $session + $nativeRegistrationsAfterReview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/registrations" -WebSession $nativeSession + Assert-JsonEquivalent -Expected $legacyRegistrationsAfterReview.Content -Actual $nativeRegistrationsAfterReview.Content -Label 'Registration review follow-up' + + $invalidPayment = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body '{"status":"settled"}' -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidPayment.StatusCode -ne 400 -or $invalidPayment.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native payment update accepted an invalid status' + } + $paidResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body '{}' -WebSession $nativeSession + $paidResult = ($paidResponse.Content | ConvertFrom-Json).payment + if ($paidResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $paidResult.status -ne 'paid' -or -not $paidResult.paidAt -or $paidResult.paidByName -ne $nativeLogin.user.displayName) { + throw 'Native payment update did not record the payment actor and timestamp' + } + $duplicatePayment = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body '{}' -WebSession $nativeSession -SkipHttpErrorCheck + if ($duplicatePayment.StatusCode -ne 409) { + throw 'Native payment update did not reject an unchanged status' + } + $legacyPaymentsAfterConfirmation = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/payments" -WebSession $session + $nativePaymentsAfterConfirmation = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments" -WebSession $nativeSession + Assert-JsonEquivalent -Expected $legacyPaymentsAfterConfirmation.Content -Actual $nativePaymentsAfterConfirmation.Content -Label 'Payment confirmation follow-up' + $unpaidBody = @{ status = 'unpaid' } | ConvertTo-Json -Compress + $unpaidResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments/$($nativeRegistration.registration.id)" -Method Patch -ContentType 'application/json' -Body $unpaidBody -WebSession $nativeSession + $unpaidResult = ($unpaidResponse.Content | ConvertFrom-Json).payment + if ($unpaidResult.status -ne 'unpaid' -or $null -ne $unpaidResult.paidAt -or $null -ne $unpaidResult.paidBy -or $unpaidResult.paidByName -ne '') { + throw 'Native payment update did not clear payment metadata when reverting to unpaid' + } + $legacyPaymentsAfterReversal = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/payments" -WebSession $session + $nativePaymentsAfterReversal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments" -WebSession $nativeSession + Assert-JsonEquivalent -Expected $legacyPaymentsAfterReversal.Content -Actual $nativePaymentsAfterReversal.Content -Label 'Payment reversal follow-up' + $legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session $nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { @@ -1376,6 +1435,7 @@ try { NativeAdminCenters = 'passed' NativeAdminOperationalReads = 'passed' NativeAdminCandidateManagement = 'passed' + NativeAdminRegistrationPaymentWrites = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminRegistrationManagementService.cs b/src/Eis.Application/Administration/IAdminRegistrationManagementService.cs new file mode 100644 index 0000000..4184f0f --- /dev/null +++ b/src/Eis.Application/Administration/IAdminRegistrationManagementService.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminRegistrationManagementService +{ + Task ReviewAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken); + Task UpdatePaymentAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index e549c87..2c00d1e 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -8,7 +8,8 @@ public sealed record AdminMigrationOptions( bool NativeNoticeManagementEnabled = false, bool NativeCentersEnabled = false, bool NativeOperationalReadsEnabled = false, - bool NativeCandidateManagementEnabled = false) + bool NativeCandidateManagementEnabled = false, + bool NativeRegistrationPaymentWritesEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -20,7 +21,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeNoticeManagementEnabled = false, bool configuredNativeCentersEnabled = false, bool configuredNativeOperationalReadsEnabled = false, - bool configuredNativeCandidateManagementEnabled = false) + bool configuredNativeCandidateManagementEnabled = false, + bool configuredNativeRegistrationPaymentWritesEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -47,17 +49,20 @@ public sealed record AdminMigrationOptions( var candidateManagementEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"), configuredNativeCandidateManagementEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled) && !readsEnabled) + var registrationPaymentWritesEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"), + configuredNativeRegistrationPaymentWritesEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - if (candidateManagementEnabled && !operationalReadsEnabled) + if ((candidateManagementEnabled || registrationPaymentWritesEnabled) && !operationalReadsEnabled) { throw new InvalidOperationException( - "启用原生考生账号维护接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true"); + "启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -73,7 +78,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/Administration/AdminRegistrationManagementService.cs b/src/Eis.Infrastructure/Administration/AdminRegistrationManagementService.cs new file mode 100644 index 0000000..1bbb64a --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminRegistrationManagementService.cs @@ -0,0 +1,329 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminRegistrationManagementService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + AdminAccountBatchSnapshotLoader workflowSnapshotLoader, + AdminOperationalSnapshotLoader operationalSnapshotLoader, + AdminWriteRepository repository) : IAdminRegistrationManagementService +{ + public async Task UpdatePaymentAsync( + string sessionToken, + string registrationId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var registration = operational.Registrations.FirstOrDefault(item => item.Id == registrationId); + var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId); + if (registration is null || profile is null || !InScope(user, profile)) + return Error(404, "缴费记录不存在或不在当前管理范围内"); + if (registration.Status != "approved") return Error(409, "报名审核通过后才能确认缴费"); + var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + if (OptionalString(exam?.Data, "archivedAt") is not null) return Error(409, "该考试已归档,缴费记录已冻结"); + + var nextStatus = JsBoolean(body["status"]) ? Text(body["status"]) : "paid"; + if (nextStatus is not ("paid" or "unpaid")) return Error(400, "缴费状态无效"); + if (registration.PaymentStatus == nextStatus) + return Error(409, $"该考生已经是{(nextStatus == "paid" ? "已缴费" : "待缴费")}状态"); + + var paidAt = nextStatus == "paid" ? NowIso() : null; + var paidBy = nextStatus == "paid" ? user.Id : null; + var name = profile.Data["name"]?.GetValue(); + var registrationNumber = registration.Data["registrationNumber"]?.GetValue() ?? ""; + var examName = exam?.Data["name"]?.GetValue() ?? registration.ExamId; + await repository.UpdateRegistrationPaymentAsync( + registration.Id, + nextStatus, + paidAt, + paidBy, + Audit( + user, + nextStatus == "paid" ? "标记考生已缴费" : "撤销考生缴费确认", + $"{(string.IsNullOrEmpty(name) ? registrationNumber : name)} · {examName}"), + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["payment"] = new JsonObject + { + ["registrationId"] = registration.Id, + ["status"] = nextStatus, + ["paidAt"] = JsonValue.Create(paidAt), + ["paidBy"] = JsonValue.Create(paidBy), + ["paidByName"] = nextStatus == "paid" ? user.DisplayName : "" + } + }); + } + + public async Task ReviewAsync( + string sessionToken, + string registrationId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var workflowSnapshot = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken); + var registration = operational.Registrations.FirstOrDefault(item => item.Id == registrationId); + if (registration is null) return Error(404, "报名记录不存在"); + var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId); + if (OptionalString(exam?.Data, "archivedAt") is not null) return Error(409, "该考试已归档,报名流程已冻结"); + var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId); + if (Level(user) != "super" && (profile is null || !InScope(user, profile))) + return Error(403, "该报名不在你的数据范围内"); + var requestedStatus = Text(body["status"]); + if (requestedStatus is not ("approved" or "rejected")) return Error(400, "审核状态无效"); + + var instance = workflowSnapshot.Instances.FirstOrDefault(item => + item.BusinessType == "registration_review" && item.BusinessId == registration.Id && item.Status == "pending"); + if (instance is null) return Error(409, "当前没有待处理的报名审核流程"); + var workflow = workflowSnapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId); + var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep); + if (workflow is null || step is null) return Error(409, "报名审核流程状态异常"); + if (Level(user) != "super" && (instance.AssigneeId != user.Id || step.AdminLevel != Level(user))) + return Error(403, "该流程当前未分配给你,可由当前处理人转交"); + + var note = Clean(Text(body["reviewNote"]), 300); + var action = new AccountWorkflowAction( + Uid("flow_action"), instance.Id, user.Id, requestedStatus == "approved" ? "approve" : "reject", + note, instance.AssigneeId, null, NowIso()); + var updatedInstance = instance; + var output = registration.Data.DeepClone().AsObject(); + if (requestedStatus == "rejected") + { + updatedInstance = instance with { Status = "rejected", CompletedAt = NowIso(), AssigneeId = null }; + output["status"] = "rejected"; + output["reviewNote"] = note; + output["reviewedAt"] = NowIso(); + } + else if (instance.CurrentStep < workflow.Steps.Count) + { + var nextStep = workflow.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep + 1); + var nextAssignee = nextStep is null ? null : SelectAssignee(workflowSnapshot, nextStep.AdminLevel, profile); + if (nextAssignee is null) return Error(409, $"没有可承接“{nextStep?.Name ?? "下一审批步骤"}”的管理员"); + updatedInstance = instance with { CurrentStep = instance.CurrentStep + 1, AssigneeId = nextAssignee.Id }; + action = action with { ToAssigneeId = nextAssignee.Id }; + output["status"] = "pending"; + output["reviewNote"] = note; + } + else + { + var account = workflowSnapshot.Users.FirstOrDefault(item => item.Id == registration.UserId); + if (string.IsNullOrEmpty(account?.CandidateNumber)) + return Error(409, "考生账户尚未分配报名号,请先在报名号管理中完成分配"); + updatedInstance = instance with { Status = "approved", CompletedAt = NowIso(), AssigneeId = null }; + output["status"] = "approved"; + output["reviewNote"] = note; + output["reviewedAt"] = NowIso(); + output["registrationNumber"] = account.CandidateNumber; + output["numberRuleId"] = JsonValue.Create( + workflowSnapshot.NumberRules.FirstOrDefault(item => item.Active)?.Id ?? OptionalString(output, "numberRuleId")); + } + + var profileName = profile?.Data["name"]?.GetValue() ?? registration.UserId; + var examName = exam?.Data["name"]?.GetValue() ?? ""; + await repository.ProcessRegistrationWorkflowAsync( + updatedInstance, + action, + registration.Id, + output["status"]?.GetValue() ?? registration.Status, + output["paymentStatus"]?.GetValue() ?? registration.PaymentStatus, + OptionalString(output, "paidAt"), + OptionalString(output, "paidBy"), + OptionalString(output, "reviewedAt"), + output["reviewNote"]?.GetValue() ?? "", + output["registrationNumber"]?.GetValue() ?? "", + OptionalString(output, "numberRuleId"), + Audit( + user, + requestedStatus == "approved" ? "处理报名审核流程" : "退回考试报名", + $"{profileName} · {examName}"), + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["registration"] = output, + ["workflow"] = WorkflowJson(workflowSnapshot, updatedInstance, action) + }); + } + + private static AdminUser? SelectAssignee( + AdminAccountBatchSnapshot snapshot, + string level, + OperationalProfile? profile) + { + var pending = snapshot.Instances.Where(item => item.Status == "pending" && item.AssigneeId is not null) + .GroupBy(item => item.AssigneeId!) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var assigned = snapshot.Actions.Where(item => item.ToAssigneeId is not null) + .GroupBy(item => item.ToAssigneeId!) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + return snapshot.Users + .Where(item => item.Role == "admin" && item.Active && item.AdminLevel == level && level switch + { + "super" => true, + "school" => profile?.SchoolId is not null && item.SchoolId == profile.SchoolId, + "class" => profile?.SchoolId is not null && profile.ClassId is not null && + item.SchoolId == profile.SchoolId && item.ClassId == profile.ClassId, + _ => false + }) + .OrderBy(item => pending.GetValueOrDefault(item.Id)) + .ThenBy(item => assigned.GetValueOrDefault(item.Id)) + .ThenBy(item => item.CreatedAt, StringComparer.Ordinal) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static JsonObject WorkflowJson( + AdminAccountBatchSnapshot snapshot, + AccountWorkflowInstance instance, + AccountWorkflowAction action) + { + var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId); + var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId); + var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id).Append(action) + .Select(item => ActionJson(snapshot, item)).ToArray(); + return new JsonObject + { + ["id"] = instance.Id, + ["workflowId"] = instance.WorkflowId, + ["businessType"] = instance.BusinessType, + ["businessId"] = instance.BusinessId, + ["status"] = instance.Status, + ["currentStep"] = instance.CurrentStep, + ["assigneeId"] = JsonValue.Create(instance.AssigneeId), + ["createdAt"] = instance.CreatedAt, + ["completedAt"] = JsonValue.Create(instance.CompletedAt), + ["workflowName"] = workflow?.Name ?? "未命名流程", + ["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()), + ["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } currentStep ? StepJson(currentStep) : null, + ["assignee"] = assignee is null ? null : SafeUser(assignee), + ["actions"] = new JsonArray(actions) + }; + } + + private async Task ResolveAsync(string token, CancellationToken cancellationToken) + { + if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var userId = await authenticationState.GetSessionUserIdAsync(token); + if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录")); + var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken); + if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录")); + return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作")); + } + + private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch + { + "super" => true, + "school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId, + _ => user.ClassId is not null && profile.ClassId == user.ClassId + }; + + private static bool JsBoolean(JsonNode? node) + { + if (node is null) return false; + if (node is not JsonValue value) return true; + if (value.TryGetValue(out var boolean)) return boolean; + if (value.TryGetValue(out var text)) return text.Length > 0; + if (value.TryGetValue(out var number)) return number != 0 && !double.IsNaN(number); + return true; + } + + private static string? OptionalString(JsonObject? value, string property) + { + if (value?[property] is not JsonValue node) return null; + return node.TryGetValue(out var text) ? text : null; + } + + private static string Text(JsonNode? node) + { + if (node is null) return ""; + if (node is JsonValue value && value.TryGetValue(out var text)) return text; + return node.ToString(); + } + + private static string Clean(string value, int maximum) + { + var cleaned = value.Trim(); + return cleaned[..Math.Min(cleaned.Length, maximum)]; + } + + private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super"; + 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(buffer[position..]); + } + + private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => + new(Uid("log"), user.Id, action, detail, NowIso()); + + private static JsonObject StepJson(AccountWorkflowStep item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["adminLevel"] = item.AdminLevel, + ["position"] = item.Position + }; + + private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new() + { + ["id"] = item.Id, + ["instanceId"] = item.InstanceId, + ["actorId"] = JsonValue.Create(item.ActorId), + ["action"] = item.Action, + ["note"] = item.Note, + ["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId), + ["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId), + ["createdAt"] = item.CreatedAt, + ["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统", + ["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "", + ["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? "" + }; + + private static JsonObject SafeUser(AdminUser item) => new() + { + ["id"] = item.Id, + ["username"] = item.Username, + ["role"] = item.Role, + ["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null, + ["schoolId"] = JsonValue.Create(item.SchoolId), + ["classId"] = JsonValue.Create(item.ClassId), + ["displayName"] = item.DisplayName, + ["candidateNumber"] = JsonValue.Create(item.CandidateNumber), + ["mustChangePassword"] = item.MustChangePassword, + ["totpEnabled"] = item.TotpEnabled, + ["archived"] = item.ArchivedAt is not null + }; + + private static AdminEndpointResult Success(JsonObject body) => new(200, body); + private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message }); + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) + { + public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs index 1008346..04ae51d 100644 --- a/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs @@ -197,6 +197,80 @@ internal sealed class AdminWriteRepository(IRelationalConnectionFactory connecti audit, cancellationToken); + public Task UpdateRegistrationPaymentAsync( + string registrationId, + string paymentStatus, + string? paidAt, + string? paidBy, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteWithAuditAsync( + new SqlOperation( + """ + UPDATE registrations SET payment_status = @paymentStatus, paid_at = @paidAt, paid_by = @paidBy + WHERE id = @id + """, + [ + new("@paymentStatus", paymentStatus), new("@paidAt", paidAt), + new("@paidBy", paidBy), new("@id", registrationId) + ]), + audit, + cancellationToken); + + public Task ProcessRegistrationWorkflowAsync( + AccountWorkflowInstance instance, + AccountWorkflowAction action, + string registrationId, + string status, + string paymentStatus, + string? paidAt, + string? paidBy, + string? reviewedAt, + string reviewNote, + string registrationNumber, + string? numberRuleId, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteWithAuditAsync( + [ + new SqlOperation( + """ + UPDATE workflow_instances SET status = @status, current_step = @currentStep, + assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id + """, + [ + new("@status", instance.Status), new("@currentStep", instance.CurrentStep), + new("@assigneeId", instance.AssigneeId), new("@completedAt", instance.CompletedAt), + new("@id", instance.Id) + ]), + new SqlOperation( + """ + 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 + ) + """, + [ + new("@id", action.Id), new("@instanceId", action.InstanceId), new("@actorId", action.ActorId), + new("@action", action.Action), new("@note", action.Note), new("@fromAssigneeId", action.FromAssigneeId), + new("@toAssigneeId", action.ToAssigneeId), new("@createdAt", action.CreatedAt) + ]), + new SqlOperation( + """ + UPDATE registrations SET status = @status, payment_status = @paymentStatus, + paid_at = @paidAt, paid_by = @paidBy, reviewed_at = @reviewedAt, + review_note = @reviewNote, registration_number = @registrationNumber, + number_rule_id = @numberRuleId WHERE id = @id + """, + [ + new("@status", status), new("@paymentStatus", paymentStatus), new("@paidAt", paidAt), + new("@paidBy", paidBy), new("@reviewedAt", reviewedAt), new("@reviewNote", Optional(reviewNote)), + new("@registrationNumber", Optional(registrationNumber)), new("@numberRuleId", numberRuleId), + new("@id", registrationId) + ]) + ], + audit, + cancellationToken); + private async Task ExecuteWithAuditAsync( SqlOperation operation, AdminAuditEntry audit, diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 9ecbf05..94f8504 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -69,6 +69,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 56e87b3..668aaf3 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -111,6 +111,14 @@ public static class NativeAdminReadEndpoints endpoints.MapPatch("/api/admin/candidates/{profileId}", (HttpContext context, string profileId, JsonObject body, IAdminCandidateManagementService service, CancellationToken cancellationToken) => Execute(context, service.ReviewProfileAsync(Token(context), profileId, body, cancellationToken))); } + + if (options.NativeRegistrationPaymentWritesEnabled) + { + endpoints.MapPatch("/api/admin/registrations/{registrationId}", (HttpContext context, string registrationId, JsonObject body, IAdminRegistrationManagementService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewAsync(Token(context), registrationId, body, cancellationToken))); + endpoints.MapPatch("/api/admin/payments/{registrationId}", (HttpContext context, string registrationId, JsonObject body, IAdminRegistrationManagementService service, CancellationToken cancellationToken) => + Execute(context, service.UpdatePaymentAsync(Token(context), registrationId, body, cancellationToken))); + } return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index d99904c..57e68c5 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -52,7 +52,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeNoticeManagementEnabled"), builder.Configuration.GetValue("AdminMigration:NativeCentersEnabled"), builder.Configuration.GetValue("AdminMigration:NativeOperationalReadsEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeCandidateManagementEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeCandidateManagementEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -114,6 +115,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled, nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled, nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled, + nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -142,6 +144,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeCandidateManagementEnabled ? new[] { "POST candidate-accounts/archive", "POST candidate reset-password", "PATCH candidates" } : []) + .Concat(adminMigrationOptions.NativeRegistrationPaymentWritesEnabled + ? new[] { "PATCH registrations", "PATCH payments" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index f88cc05..20fcc1a 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -17,7 +17,8 @@ "NativeNoticeManagementEnabled": false, "NativeCentersEnabled": false, "NativeOperationalReadsEnabled": false, - "NativeCandidateManagementEnabled": false + "NativeCandidateManagementEnabled": false, + "NativeRegistrationPaymentWritesEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index e4c9865..11ed404 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -211,6 +211,36 @@ public sealed class AdminMigrationOptionsTests }, operationalReadsEnabled: "true", candidateManagementEnabled: "true"); } + [Fact] + public void RegistrationPaymentWritesRequireOperationalReads() + { + WithEnvironment("true", null, null, () => + { + var exception = Assert.Throws(() => + AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true)); + + Assert.Contains("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true", exception.Message); + }, registrationPaymentWritesEnabled: "true"); + } + + [Fact] + public void EnablesRegistrationPaymentWritesWithOperationalReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeOperationalReadsEnabled); + Assert.True(options.NativeRegistrationPaymentWritesEnabled); + }, operationalReadsEnabled: "true", registrationPaymentWritesEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -222,7 +252,8 @@ public sealed class AdminMigrationOptionsTests string? legacyNoticeWritesEnabled = null, string? centersEnabled = null, string? operationalReadsEnabled = null, - string? candidateManagementEnabled = null) + string? candidateManagementEnabled = null, + string? registrationPaymentWritesEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -233,6 +264,7 @@ public sealed class AdminMigrationOptionsTests var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"); var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"); var previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"); + var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -245,6 +277,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -259,6 +292,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }