本批“报名审核与缴费更新”迁移完成:

PATCH /api/admin/registrations/{registrationId}
PATCH /api/admin/payments/{registrationId}
实现了多级报名审核、终审绑定报名号与号码规则、缴费确认/撤销、状态防重、考试归档锁定及事务审计
This commit is contained in:
2026-07-23 10:00:26 +08:00 Unverified
parent 3f555929fd
commit 7fd20caf3e
12 changed files with 542 additions and 13 deletions
+2
View File
@@ -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
+4 -3
View File
@@ -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 冒烟测试:
+60
View File
@@ -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 {
@@ -0,0 +1,9 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminRegistrationManagementService
{
Task<AdminEndpointResult> ReviewAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdatePaymentAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken);
}
@@ -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
@@ -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<AdminEndpointResult> 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<string>();
var registrationNumber = registration.Data["registrationNumber"]?.GetValue<string>() ?? "";
var examName = exam?.Data["name"]?.GetValue<string>() ?? 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<AdminEndpointResult> 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<string>() ?? registration.UserId;
var examName = exam?.Data["name"]?.GetValue<string>() ?? "";
await repository.ProcessRegistrationWorkflowAsync(
updatedInstance,
action,
registration.Id,
output["status"]?.GetValue<string>() ?? registration.Status,
output["paymentStatus"]?.GetValue<string>() ?? registration.PaymentStatus,
OptionalString(output, "paidAt"),
OptionalString(output, "paidBy"),
OptionalString(output, "reviewedAt"),
output["reviewNote"]?.GetValue<string>() ?? "",
output["registrationNumber"]?.GetValue<string>() ?? "",
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<ResolvedAdmin> 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<bool>(out var boolean)) return boolean;
if (value.TryGetValue<string>(out var text)) return text.Length > 0;
if (value.TryGetValue<double>(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<string>(out var text) ? text : null;
}
private static string Text(JsonNode? node)
{
if (node is null) return "";
if (node is JsonValue value && value.TryGetValue<string>(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<char> 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);
}
}
@@ -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,
@@ -69,6 +69,7 @@ public static class DependencyInjection
services.AddScoped<IAdminCenterService, AdminCenterService>();
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
@@ -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;
}
+6 -1
View File
@@ -52,7 +52,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"));
builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"),
builder.Configuration.GetValue<bool>("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)
+2 -1
View File
@@ -17,7 +17,8 @@
"NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false,
"NativeOperationalReadsEnabled": false,
"NativeCandidateManagementEnabled": false
"NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false
},
"Logging": {
"LogLevel": {
@@ -211,6 +211,36 @@ public sealed class AdminMigrationOptionsTests
}, operationalReadsEnabled: "true", candidateManagementEnabled: "true");
}
[Fact]
public void RegistrationPaymentWritesRequireOperationalReads()
{
WithEnvironment("true", null, null, () =>
{
var exception = Assert.Throws<InvalidOperationException>(() =>
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);
}
}