本批“工作流调度”迁移完成:
GET /api/admin/workflow-instances
PATCH /api/admin/workflow-instances/{id}/transfer
PATCH /api/admin/workflow-instances/{id}/supervise
覆盖资料变更、报名审核、考点变更、批量报名号和成绩复议五类流程,并实现同级转交、范围校验、超级管理员监督回退及业务状态同步重置。
This commit is contained in:
@@ -43,6 +43,8 @@ ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false
|
|||||||
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false
|
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false
|
||||||
# 报名审核与缴费状态更新;必须同时启用考生/报名/缴费读取。
|
# 报名审核与缴费状态更新;必须同时启用考生/报名/缴费读取。
|
||||||
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false
|
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false
|
||||||
|
# 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。
|
||||||
|
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+4
-3
@@ -12,7 +12,7 @@
|
|||||||
- [x] 招生公示与 HMAC 文书验真公开接口
|
- [x] 招生公示与 HMAC 文书验真公开接口
|
||||||
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
||||||
- [x] 考生业务
|
- [x] 考生业务
|
||||||
- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护及报名缴费写入已原生化)
|
- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入及工作流调度已原生化)
|
||||||
- [x] 考生志愿填报与招生录取查询
|
- [x] 考生志愿填报与招生录取查询
|
||||||
- [ ] Excel、文书和缓存
|
- [ ] Excel、文书和缓存
|
||||||
- [ ] 容器入口切换及 Node.js 后端移除
|
- [ ] 容器入口切换及 Node.js 后端移除
|
||||||
@@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_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;各写入子功能还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
@@ -69,9 +69,10 @@ $env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
|||||||
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
$env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
|
$env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
|
||||||
|
$env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_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.nativeRegistrationPaymentWritesEnabled` 和 `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.nativeWorkflowOperationsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ try {
|
|||||||
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
|
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
|
||||||
|
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -821,6 +822,34 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($workflowSession in $operationalSessions) {
|
||||||
|
$legacyWorkflowRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/workflow-instances" -WebSession $workflowSession.Legacy
|
||||||
|
$nativeWorkflowRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances" -WebSession $workflowSession.Native
|
||||||
|
if ($nativeWorkflowRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw "Workflow inbox for $($workflowSession.Level) admin did not use ASP.NET Core"
|
||||||
|
}
|
||||||
|
Assert-JsonEquivalent -Expected $legacyWorkflowRead.Content -Actual $nativeWorkflowRead.Content -Label "Workflow inbox for $($workflowSession.Level) admin"
|
||||||
|
}
|
||||||
|
$workflowState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances" -WebSession $nativeSession
|
||||||
|
$transferInstance = @($workflowState.instances | Where-Object { $_.status -eq 'pending' -and $_.assigneeId } | Select-Object -First 1)[0]
|
||||||
|
if ($null -eq $transferInstance) {
|
||||||
|
throw 'Seed data did not provide a pending workflow for transfer testing'
|
||||||
|
}
|
||||||
|
$invalidTransferBody = @{ assigneeId = 'missing-admin'; note = '无效转交测试' } | ConvertTo-Json -Compress
|
||||||
|
$invalidTransfer = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances/$($transferInstance.id)/transfer" -Method Patch -ContentType 'application/json' -Body $invalidTransferBody -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidTransfer.StatusCode -ne 400 -or $invalidTransfer.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native workflow transfer accepted an invalid target administrator'
|
||||||
|
}
|
||||||
|
$transferBody = @{ assigneeId = $transferInstance.assigneeId; note = '原生同级转交验证' } | ConvertTo-Json -Compress
|
||||||
|
$transferResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances/$($transferInstance.id)/transfer" -Method Patch -ContentType 'application/json' -Body $transferBody -WebSession $nativeSession
|
||||||
|
$transferredWorkflow = ($transferResponse.Content | ConvertFrom-Json).workflow
|
||||||
|
if ($transferResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $transferredWorkflow.assigneeId -ne $transferInstance.assigneeId -or @($transferredWorkflow.actions | Select-Object -Last 1)[0].action -ne 'transfer') {
|
||||||
|
throw 'Native workflow transfer did not persist its action and assignee'
|
||||||
|
}
|
||||||
|
$legacyWorkflowsAfterTransfer = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/workflow-instances" -WebSession $session
|
||||||
|
$nativeWorkflowsAfterTransfer = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances" -WebSession $nativeSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyWorkflowsAfterTransfer.Content -Actual $nativeWorkflowsAfterTransfer.Content -Label 'Workflow transfer follow-up'
|
||||||
|
|
||||||
$archiveBody = @{
|
$archiveBody = @{
|
||||||
scopeType = 'class'
|
scopeType = 'class'
|
||||||
scopeValue = $profileUpdate.profile.classId
|
scopeValue = $profileUpdate.profile.classId
|
||||||
@@ -965,6 +994,27 @@ try {
|
|||||||
$nativePaymentsAfterReversal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments" -WebSession $nativeSession
|
$nativePaymentsAfterReversal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments" -WebSession $nativeSession
|
||||||
Assert-JsonEquivalent -Expected $legacyPaymentsAfterReversal.Content -Actual $nativePaymentsAfterReversal.Content -Label 'Payment reversal follow-up'
|
Assert-JsonEquivalent -Expected $legacyPaymentsAfterReversal.Content -Actual $nativePaymentsAfterReversal.Content -Label 'Payment reversal follow-up'
|
||||||
|
|
||||||
|
$schoolSuperviseForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances/$($registrationReview.workflow.id)/supervise" -Method Patch -ContentType 'application/json' -Body '{}' -WebSession $nativeSchoolSession -SkipHttpErrorCheck
|
||||||
|
if ($schoolSuperviseForbidden.StatusCode -ne 403 -or $schoolSuperviseForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native workflow supervision did not preserve the super-admin boundary'
|
||||||
|
}
|
||||||
|
$superviseBody = @{ currentStep = 1; note = '原生监督重新打开报名流程' } | ConvertTo-Json -Compress
|
||||||
|
$superviseResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances/$($registrationReview.workflow.id)/supervise" -Method Patch -ContentType 'application/json' -Body $superviseBody -WebSession $nativeSession
|
||||||
|
$supervisedWorkflow = ($superviseResponse.Content | ConvertFrom-Json).workflow
|
||||||
|
if ($superviseResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $supervisedWorkflow.status -ne 'pending' -or $supervisedWorkflow.currentStep -ne 1 -or @($supervisedWorkflow.actions | Select-Object -Last 1)[0].action -ne 'return') {
|
||||||
|
throw 'Native workflow supervision did not reopen the completed registration at step 1'
|
||||||
|
}
|
||||||
|
$registrationStateAfterSupervision = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/registrations" -WebSession $nativeSession
|
||||||
|
$supervisedRegistration = @($registrationStateAfterSupervision.registrations | Where-Object id -eq $nativeRegistration.registration.id)[0]
|
||||||
|
if ($null -eq $supervisedRegistration -or $supervisedRegistration.status -ne 'pending' -or $supervisedRegistration.reviewedAt) {
|
||||||
|
throw 'Native workflow supervision did not reset the registration business state'
|
||||||
|
}
|
||||||
|
foreach ($workflowSession in $operationalSessions) {
|
||||||
|
$legacyWorkflowAfterSupervision = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/workflow-instances" -WebSession $workflowSession.Legacy
|
||||||
|
$nativeWorkflowAfterSupervision = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflow-instances" -WebSession $workflowSession.Native
|
||||||
|
Assert-JsonEquivalent -Expected $legacyWorkflowAfterSupervision.Content -Actual $nativeWorkflowAfterSupervision.Content -Label "Workflow supervision follow-up for $($workflowSession.Level) admin"
|
||||||
|
}
|
||||||
|
|
||||||
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
||||||
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
||||||
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
@@ -1436,6 +1486,7 @@ try {
|
|||||||
NativeAdminOperationalReads = 'passed'
|
NativeAdminOperationalReads = 'passed'
|
||||||
NativeAdminCandidateManagement = 'passed'
|
NativeAdminCandidateManagement = 'passed'
|
||||||
NativeAdminRegistrationPaymentWrites = 'passed'
|
NativeAdminRegistrationPaymentWrites = 'passed'
|
||||||
|
NativeAdminWorkflowOperations = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Eis.Application.Administration;
|
||||||
|
|
||||||
|
public interface IAdminWorkflowService
|
||||||
|
{
|
||||||
|
Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> TransferAsync(string sessionToken, string instanceId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> SuperviseAsync(string sessionToken, string instanceId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@ internal sealed class AdminAccountBatchService(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonObject BatchView(AdminAccountBatchSnapshot snapshot, AccountBatch batch, IReadOnlyList<AccountBatchItem>? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList<AccountWorkflowAction>? extraActions = null)
|
internal static JsonObject BatchView(AdminAccountBatchSnapshot snapshot, AccountBatch batch, IReadOnlyList<AccountBatchItem>? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList<AccountWorkflowAction>? extraActions = null)
|
||||||
{
|
{
|
||||||
var items = (itemOverride ?? snapshot.Items.Where(item => item.BatchId == batch.Id).ToArray()).OrderBy(item => item.Position).ToArray();
|
var items = (itemOverride ?? snapshot.Items.Where(item => item.BatchId == batch.Id).ToArray()).OrderBy(item => item.Position).ToArray();
|
||||||
var quotas = items.GroupBy(item => item.ClassId).Select(group => { var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == group.Key); return new JsonObject { ["classId"] = group.Key, ["className"] = schoolClass?.Name ?? "未知班级", ["grade"] = schoolClass?.Grade ?? "", ["count"] = group.Count() }; }).ToArray();
|
var quotas = items.GroupBy(item => item.ClassId).Select(group => { var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == group.Key); return new JsonObject { ["classId"] = group.Key, ["className"] = schoolClass?.Name ?? "未知班级", ["grade"] = schoolClass?.Grade ?? "", ["count"] = group.Count() }; }).ToArray();
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ internal sealed class AdminCenterService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonObject ChangeJson(
|
internal static JsonObject ChangeJson(
|
||||||
AdminAccountBatchSnapshot workflow,
|
AdminAccountBatchSnapshot workflow,
|
||||||
AdminCenterSnapshot centers,
|
AdminCenterSnapshot centers,
|
||||||
AdminCenterChange item,
|
AdminCenterChange item,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool NativeCentersEnabled = false,
|
bool NativeCentersEnabled = false,
|
||||||
bool NativeOperationalReadsEnabled = false,
|
bool NativeOperationalReadsEnabled = false,
|
||||||
bool NativeCandidateManagementEnabled = false,
|
bool NativeCandidateManagementEnabled = false,
|
||||||
bool NativeRegistrationPaymentWritesEnabled = false)
|
bool NativeRegistrationPaymentWritesEnabled = false,
|
||||||
|
bool NativeWorkflowOperationsEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -22,7 +23,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool configuredNativeCentersEnabled = false,
|
bool configuredNativeCentersEnabled = false,
|
||||||
bool configuredNativeOperationalReadsEnabled = false,
|
bool configuredNativeOperationalReadsEnabled = false,
|
||||||
bool configuredNativeCandidateManagementEnabled = false,
|
bool configuredNativeCandidateManagementEnabled = false,
|
||||||
bool configuredNativeRegistrationPaymentWritesEnabled = false)
|
bool configuredNativeRegistrationPaymentWritesEnabled = false,
|
||||||
|
bool configuredNativeWorkflowOperationsEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -52,7 +54,10 @@ public sealed record AdminMigrationOptions(
|
|||||||
var registrationPaymentWritesEnabled = ParseBoolean(
|
var registrationPaymentWritesEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"),
|
||||||
configuredNativeRegistrationPaymentWritesEnabled);
|
configuredNativeRegistrationPaymentWritesEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled) && !readsEnabled)
|
var workflowOperationsEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
|
||||||
|
configuredNativeWorkflowOperationsEnabled);
|
||||||
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
@@ -62,7 +67,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
throw new InvalidOperationException(
|
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 || registrationPaymentWritesEnabled;
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -78,7 +83,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled);
|
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed class AdminWorkflowRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public Task TransferAsync(
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => ExecuteAsync(
|
||||||
|
instance,
|
||||||
|
action,
|
||||||
|
null,
|
||||||
|
audit,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task SuperviseAsync(
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action,
|
||||||
|
WorkflowBusinessReset? business,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => ExecuteAsync(
|
||||||
|
instance,
|
||||||
|
action,
|
||||||
|
business,
|
||||||
|
audit,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action,
|
||||||
|
WorkflowBusinessReset? business,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, transaction, 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)
|
||||||
|
]), cancellationToken);
|
||||||
|
await ExecuteAsync(connection, transaction, 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)
|
||||||
|
]), cancellationToken);
|
||||||
|
if (business is not null)
|
||||||
|
{
|
||||||
|
await ExecuteBusinessResetAsync(connection, transaction, business, cancellationToken);
|
||||||
|
}
|
||||||
|
await ExecuteAsync(connection, transaction, new SqlOperation(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
|
||||||
|
VALUES (@id, @actorId, @action, @detail, @createdAt)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action),
|
||||||
|
new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt)
|
||||||
|
]), cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task ExecuteBusinessResetAsync(
|
||||||
|
DbConnection connection,
|
||||||
|
DbTransaction transaction,
|
||||||
|
WorkflowBusinessReset business,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var operation = business.BusinessType switch
|
||||||
|
{
|
||||||
|
"profile_change" => new SqlOperation(
|
||||||
|
"""
|
||||||
|
UPDATE candidate_profiles SET status = 'pending', review_note = @note,
|
||||||
|
reviewed_at = NULL, reviewer_id = NULL WHERE id = @id
|
||||||
|
""",
|
||||||
|
[new("@note", Optional(business.Note)), new("@id", business.BusinessId)]),
|
||||||
|
"registration_review" => new SqlOperation(
|
||||||
|
"UPDATE registrations SET status = 'pending', review_note = @note, reviewed_at = NULL WHERE id = @id",
|
||||||
|
[new("@note", Optional(business.Note)), new("@id", business.BusinessId)]),
|
||||||
|
"center_change" => new SqlOperation(
|
||||||
|
"UPDATE center_change_requests SET status = 'pending', review_note = @note, reviewed_at = NULL WHERE id = @id",
|
||||||
|
[new("@note", Optional(business.Note)), new("@id", business.BusinessId)]),
|
||||||
|
"candidate_account_batch" => new SqlOperation(
|
||||||
|
"UPDATE candidate_account_batches SET status = 'pending', review_note = @note, reviewed_at = NULL WHERE id = @id",
|
||||||
|
[new("@note", Optional(business.Note)), new("@id", business.BusinessId)]),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported workflow business type: {business.BusinessType}")
|
||||||
|
};
|
||||||
|
return ExecuteAsync(connection, transaction, operation, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExecuteAsync(
|
||||||
|
DbConnection connection,
|
||||||
|
DbTransaction transaction,
|
||||||
|
SqlOperation operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = operation.Sql;
|
||||||
|
foreach (var item in operation.Parameters)
|
||||||
|
{
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = item.Name;
|
||||||
|
parameter.Value = item.Value ?? DBNull.Value;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
}
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Optional(string value) => value.Length == 0 ? null : value;
|
||||||
|
private sealed record SqlOperation(string Sql, IReadOnlyList<SqlParameterValue> Parameters);
|
||||||
|
private sealed record SqlParameterValue(string Name, object? Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record WorkflowBusinessReset(string BusinessType, string BusinessId, string Note);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminWorkflowResult(
|
||||||
|
string Id,
|
||||||
|
string RegistrationId,
|
||||||
|
string SubjectId,
|
||||||
|
double Score,
|
||||||
|
string Grade,
|
||||||
|
bool Published);
|
||||||
|
|
||||||
|
internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<AdminWorkflowResult>> LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published FROM results ORDER BY id";
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
var output = new List<AdminWorkflowResult>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
output.Add(new AdminWorkflowResult(
|
||||||
|
Text(reader, "id"),
|
||||||
|
Text(reader, "registration_id"),
|
||||||
|
Text(reader, "subject_id"),
|
||||||
|
Number(reader, "score"),
|
||||||
|
Text(reader, "grade"),
|
||||||
|
Boolean(reader, "published")));
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
|
||||||
|
|
||||||
|
private static double Number(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToDouble(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static bool Boolean(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
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 AdminWorkflowService(
|
||||||
|
IAuthenticationStateStore authenticationState,
|
||||||
|
AuthenticationRepository authenticationRepository,
|
||||||
|
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
|
||||||
|
AdminOperationalSnapshotLoader operationalSnapshotLoader,
|
||||||
|
AdminCenterSnapshotLoader centerSnapshotLoader,
|
||||||
|
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
|
||||||
|
AdminWorkflowRepository repository) : IAdminWorkflowService
|
||||||
|
{
|
||||||
|
public async Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var snapshots = await LoadAsync(cancellationToken);
|
||||||
|
var instances = snapshots.Workflow.Instances
|
||||||
|
.Where(instance => Level(user) == "super" || InScope(user, Scope(snapshots, instance)))
|
||||||
|
.Select(instance => InstanceView(snapshots, instance))
|
||||||
|
.ToArray();
|
||||||
|
var availableAdmins = snapshots.Workflow.Users
|
||||||
|
.Where(item => item.Role == "admin" && item.Active)
|
||||||
|
.Select(SafeUser)
|
||||||
|
.ToArray();
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["instances"] = new JsonArray(instances),
|
||||||
|
["availableAdmins"] = new JsonArray(availableAdmins),
|
||||||
|
["canSupervise"] = Level(user) == "super"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> TransferAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string instanceId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var snapshots = await LoadAsync(cancellationToken);
|
||||||
|
var instance = snapshots.Workflow.Instances.FirstOrDefault(item => item.Id == instanceId && item.Status == "pending");
|
||||||
|
if (instance is null) return Error(404, "待处理流程不存在");
|
||||||
|
var workflow = snapshots.Workflow.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep);
|
||||||
|
if (Level(user) != "super" && instance.AssigneeId != user.Id)
|
||||||
|
return Error(403, "只有当前处理人可以转交该流程");
|
||||||
|
var assigneeId = Text(body["assigneeId"]);
|
||||||
|
var target = snapshots.Workflow.Users.FirstOrDefault(item =>
|
||||||
|
item.Id == assigneeId && item.Role == "admin" && item.Active && item.AdminLevel == step?.AdminLevel);
|
||||||
|
if (target is null) return Error(400, "只能转交给当前步骤同级管理员");
|
||||||
|
var scope = Scope(snapshots, instance);
|
||||||
|
if (step?.AdminLevel == "school" && target.SchoolId != scope?.SchoolId)
|
||||||
|
return Error(400, "校级流程只能转交给本校同级管理员");
|
||||||
|
if (step?.AdminLevel == "class" && (target.SchoolId != scope?.SchoolId || target.ClassId != scope?.ClassId))
|
||||||
|
return Error(400, "班级流程只能转交给本班同级管理员");
|
||||||
|
|
||||||
|
var updated = instance with { AssigneeId = target.Id };
|
||||||
|
var action = new AccountWorkflowAction(
|
||||||
|
Uid("flow_action"), instance.Id, user.Id, "transfer", Clean(Text(body["note"]), 300),
|
||||||
|
instance.AssigneeId, target.Id, NowIso());
|
||||||
|
await repository.TransferAsync(
|
||||||
|
updated,
|
||||||
|
action,
|
||||||
|
Audit(user, "转交审批流程", $"{workflow?.Name ?? "未命名流程"} → {target.DisplayName}"),
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["workflow"] = WorkflowJson(snapshots.Workflow, updated, action)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> SuperviseAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string instanceId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
if (Level(user) != "super") return Error(403, "当前管理员层级无权执行此操作");
|
||||||
|
var snapshots = await LoadAsync(cancellationToken);
|
||||||
|
var instance = snapshots.Workflow.Instances.FirstOrDefault(item => item.Id == instanceId);
|
||||||
|
if (instance is null) return Error(404, "流程不存在");
|
||||||
|
if (instance.BusinessType == "candidate_account_batch" && snapshots.Workflow.Items.Any(item => item.BatchId == instance.BusinessId && item.UserId is not null))
|
||||||
|
return Error(409, "已生成账号的批次不可重新打开,避免重复建号");
|
||||||
|
var workflow = snapshots.Workflow.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
if (workflow is null || workflow.Steps.Count == 0) return Error(409, "流程定义不存在或没有审批步骤");
|
||||||
|
var requestedNumber = JsBoolean(body["currentStep"])
|
||||||
|
? Number(body["currentStep"])
|
||||||
|
: instance.CurrentStep;
|
||||||
|
if (!double.IsFinite(requestedNumber)) return Error(400, "目标步骤无效");
|
||||||
|
var requestedStep = (int)Math.Min(workflow.Steps.Count, Math.Max(1, requestedNumber));
|
||||||
|
var step = workflow.Steps.FirstOrDefault(item => item.Position == requestedStep);
|
||||||
|
if (step is null) return Error(400, "目标步骤无效");
|
||||||
|
var scope = Scope(snapshots, instance);
|
||||||
|
var eligible = EligibleAdmins(snapshots.Workflow, step.AdminLevel, scope).ToArray();
|
||||||
|
var requestedAssigneeId = JsBoolean(body["assigneeId"]) ? Text(body["assigneeId"]) : "";
|
||||||
|
var requestedAssignee = requestedAssigneeId.Length == 0
|
||||||
|
? null
|
||||||
|
: eligible.FirstOrDefault(item => item.Id == requestedAssigneeId);
|
||||||
|
if (requestedAssigneeId.Length > 0 && requestedAssignee is null)
|
||||||
|
return Error(400, "指定管理员不在该学校或班级的目标步骤范围内");
|
||||||
|
var assignee = requestedAssignee ?? SelectAssignee(snapshots.Workflow, eligible);
|
||||||
|
if (assignee is null) return Error(409, "目标步骤没有可用管理员");
|
||||||
|
|
||||||
|
var note = Clean(Text(body["note"]), 300);
|
||||||
|
if (note.Length == 0) note = $"超级管理员将流程调整到第 {requestedStep} 步";
|
||||||
|
var updated = instance with
|
||||||
|
{
|
||||||
|
Status = "pending",
|
||||||
|
CompletedAt = null,
|
||||||
|
CurrentStep = requestedStep,
|
||||||
|
AssigneeId = assignee.Id
|
||||||
|
};
|
||||||
|
var action = new AccountWorkflowAction(
|
||||||
|
Uid("flow_action"), instance.Id, user.Id,
|
||||||
|
requestedStep < instance.CurrentStep ? "return" : "supervise",
|
||||||
|
note, instance.AssigneeId, assignee.Id, NowIso());
|
||||||
|
var business = instance.BusinessType is "profile_change" or "registration_review" or "center_change" or "candidate_account_batch"
|
||||||
|
? new WorkflowBusinessReset(instance.BusinessType, instance.BusinessId, note)
|
||||||
|
: null;
|
||||||
|
await repository.SuperviseAsync(
|
||||||
|
updated,
|
||||||
|
action,
|
||||||
|
business,
|
||||||
|
Audit(user, "监督调整审批流程", $"{workflow.Name} · 第 {requestedStep} 步 · {assignee.DisplayName}"),
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["workflow"] = WorkflowJson(snapshots.Workflow, updated, action)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkflowSnapshots> LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var centers = await centerSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var results = await resultSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
return new(workflow, operational, centers, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject InstanceView(WorkflowSnapshots snapshots, AccountWorkflowInstance instance)
|
||||||
|
{
|
||||||
|
var scope = Scope(snapshots, instance);
|
||||||
|
var output = WorkflowJson(snapshots.Workflow, instance);
|
||||||
|
output["candidateName"] = scope?.Name ?? "";
|
||||||
|
output["schoolName"] = scope?.SchoolName ?? "";
|
||||||
|
output["className"] = scope?.ClassName ?? "";
|
||||||
|
var registration = instance.BusinessType == "registration_review"
|
||||||
|
? snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == instance.BusinessId)
|
||||||
|
: null;
|
||||||
|
output["examName"] = registration is null
|
||||||
|
? ""
|
||||||
|
: snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId)?.Data["name"]?.GetValue<string>() ?? "";
|
||||||
|
var centerChange = instance.BusinessType == "center_change"
|
||||||
|
? snapshots.Centers.Changes.FirstOrDefault(item => item.Id == instance.BusinessId)
|
||||||
|
: null;
|
||||||
|
output["centerName"] = centerChange?.Name ?? "";
|
||||||
|
output["requestType"] = centerChange?.RequestType ?? "";
|
||||||
|
output["centerChange"] = centerChange is null
|
||||||
|
? null
|
||||||
|
: AdminCenterService.ChangeJson(snapshots.Workflow, snapshots.Centers, centerChange);
|
||||||
|
var batch = instance.BusinessType == "candidate_account_batch"
|
||||||
|
? snapshots.Workflow.Batches.FirstOrDefault(item => item.Id == instance.BusinessId)
|
||||||
|
: null;
|
||||||
|
output["accountBatch"] = batch is null ? null : AdminAccountBatchService.BatchView(snapshots.Workflow, batch);
|
||||||
|
output["batchTotalCount"] = batch is null ? 0 : snapshots.Workflow.Items.Count(item => item.BatchId == batch.Id);
|
||||||
|
var appealResult = instance.BusinessType == "score_appeal"
|
||||||
|
? snapshots.Results.FirstOrDefault(item => item.Id == instance.BusinessId)
|
||||||
|
: null;
|
||||||
|
output["appealResult"] = appealResult is null ? null : AppealResultJson(snapshots, appealResult);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowScope? Scope(WorkflowSnapshots snapshots, AccountWorkflowInstance instance)
|
||||||
|
{
|
||||||
|
OperationalProfile? profile = null;
|
||||||
|
if (instance.BusinessType == "profile_change")
|
||||||
|
profile = snapshots.Operational.Profiles.FirstOrDefault(item => item.Id == instance.BusinessId);
|
||||||
|
else if (instance.BusinessType == "registration_review")
|
||||||
|
{
|
||||||
|
var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == instance.BusinessId);
|
||||||
|
profile = snapshots.Operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
|
||||||
|
}
|
||||||
|
else if (instance.BusinessType == "score_appeal")
|
||||||
|
{
|
||||||
|
var result = snapshots.Results.FirstOrDefault(item => item.Id == instance.BusinessId);
|
||||||
|
var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
|
||||||
|
profile = snapshots.Operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
|
||||||
|
}
|
||||||
|
if (profile is not null)
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
profile.SchoolId,
|
||||||
|
profile.ClassId,
|
||||||
|
profile.Data["name"]?.GetValue<string>() ?? "",
|
||||||
|
profile.Data["school"]?.GetValue<string>() ?? "",
|
||||||
|
profile.Data["grade"]?.GetValue<string>() ?? "");
|
||||||
|
}
|
||||||
|
var schoolId = instance.BusinessType switch
|
||||||
|
{
|
||||||
|
"center_change" => snapshots.Centers.Changes.FirstOrDefault(item => item.Id == instance.BusinessId)?.SchoolId,
|
||||||
|
"candidate_account_batch" => snapshots.Workflow.Batches.FirstOrDefault(item => item.Id == instance.BusinessId)?.SchoolId,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (schoolId is null) return null;
|
||||||
|
return new(
|
||||||
|
schoolId,
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
snapshots.Workflow.Schools.FirstOrDefault(item => item.Id == schoolId)?.Name ?? "",
|
||||||
|
"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject AppealResultJson(WorkflowSnapshots snapshots, AdminWorkflowResult result)
|
||||||
|
{
|
||||||
|
var registration = snapshots.Operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
|
||||||
|
var exam = snapshots.Operational.Exams.FirstOrDefault(item => item.Id == registration?.ExamId);
|
||||||
|
var subject = exam?.Subjects.FirstOrDefault(item => item["id"]?.GetValue<string>() == result.SubjectId);
|
||||||
|
var rank = Rank(snapshots.Results, result);
|
||||||
|
var pass = Pass(snapshots.Results, result, subject);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["score"] = result.Score,
|
||||||
|
["grade"] = rank.Grade.Length > 0 ? rank.Grade : result.Grade,
|
||||||
|
["rank"] = JsonValue.Create(rank.Rank),
|
||||||
|
["cohortSize"] = rank.CohortSize,
|
||||||
|
["rankPercent"] = JsonValue.Create(rank.RankPercent),
|
||||||
|
["examName"] = exam?.Data["name"]?.GetValue<string>() ?? "",
|
||||||
|
["examCode"] = exam?.Data["code"]?.GetValue<string>() ?? "",
|
||||||
|
["subjectName"] = subject?["name"]?.GetValue<string>() ?? "",
|
||||||
|
["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])),
|
||||||
|
["passRule"] = subject?["passRule"]?.GetValue<string>() ?? "fixed_score",
|
||||||
|
["passValue"] = JsonValue.Create(NullableNumber(subject?["passValue"] ?? subject?["passScore"])),
|
||||||
|
["passScore"] = JsonValue.Create(pass.PassScore),
|
||||||
|
["cutoffRank"] = JsonValue.Create(pass.CutoffRank),
|
||||||
|
["passText"] = PassText(subject),
|
||||||
|
["qualified"] = JsonValue.Create(pass.Qualified)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RankInfo Rank(IReadOnlyList<AdminWorkflowResult> results, AdminWorkflowResult result)
|
||||||
|
{
|
||||||
|
var scores = results.Where(item => item.SubjectId == result.SubjectId && item.Published).Select(item => item.Score).Order().ToArray();
|
||||||
|
var cohortSize = scores.Length + (result.Published ? 0 : 1);
|
||||||
|
if (cohortSize == 0) return new(null, 0, null, "");
|
||||||
|
var rank = 1 + scores.Count(score => score > result.Score);
|
||||||
|
var percent = Math.Round((double)rank / cohortSize * 100, 2, MidpointRounding.AwayFromZero);
|
||||||
|
return new(rank, cohortSize, percent, Grade(rank, cohortSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Grade(int rank, int cohortSize)
|
||||||
|
{
|
||||||
|
int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio));
|
||||||
|
if (rank <= Cutoff(0.1)) return "A+";
|
||||||
|
if (rank <= Cutoff(0.25)) return "A";
|
||||||
|
if (rank <= Cutoff(0.5)) return "B+";
|
||||||
|
if (rank <= Cutoff(0.7)) return "B";
|
||||||
|
return rank <= Cutoff(0.9) ? "C" : "D";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PassInfo Pass(IReadOnlyList<AdminWorkflowResult> results, AdminWorkflowResult result, JsonObject? subject)
|
||||||
|
{
|
||||||
|
var rule = subject?["passRule"]?.GetValue<string>() ?? "fixed_score";
|
||||||
|
var rank = Rank(results, result);
|
||||||
|
if (rule == "none") return new(null, null, null);
|
||||||
|
if (rule == "rank_percent")
|
||||||
|
{
|
||||||
|
var value = NullableNumber(subject?["passValue"]) ?? 60;
|
||||||
|
var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * value / 100));
|
||||||
|
return new(rank.Rank <= cutoff, null, cutoff);
|
||||||
|
}
|
||||||
|
var passScore = Math.Round(NullableNumber(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero);
|
||||||
|
return new(result.Score >= passScore, passScore, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PassText(JsonObject? subject)
|
||||||
|
{
|
||||||
|
var rule = subject?["passRule"]?.GetValue<string>() ?? "fixed_score";
|
||||||
|
if (rule == "none") return "不设单科线";
|
||||||
|
if (rule == "rank_percent") return $"本科排名前 {Format(NullableNumber(subject?["passValue"]) ?? 60)}% 达线";
|
||||||
|
return $"固定 {Format(Math.Round(NullableNumber(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero))} 分";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<AdminUser> EligibleAdmins(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
string level,
|
||||||
|
WorkflowScope? scope) => snapshot.Users.Where(item =>
|
||||||
|
item.Role == "admin" && item.Active && item.AdminLevel == level && level switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => scope?.SchoolId is not null && item.SchoolId == scope.SchoolId,
|
||||||
|
"class" => scope?.SchoolId is not null && scope.ClassId is not null &&
|
||||||
|
item.SchoolId == scope.SchoolId && item.ClassId == scope.ClassId,
|
||||||
|
_ => false
|
||||||
|
});
|
||||||
|
|
||||||
|
private static AdminUser? SelectAssignee(AdminAccountBatchSnapshot snapshot, IReadOnlyList<AdminUser> eligible)
|
||||||
|
{
|
||||||
|
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 eligible.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 bool InScope(AuthenticationUser user, WorkflowScope? scope) => scope is not null && Level(user) switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => user.SchoolId is not null && scope.SchoolId == user.SchoolId,
|
||||||
|
_ => user.ClassId is not null && scope.ClassId == user.ClassId
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject WorkflowJson(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction? extraAction = null)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
.Concat(extraAction is null ? [] : [extraAction])
|
||||||
|
.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 { } step ? StepJson(step) : 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 double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN;
|
||||||
|
private static double? NullableNumber(JsonNode? node) => node is not null && double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : null;
|
||||||
|
private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
|
||||||
|
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
|
||||||
|
private static bool JsBoolean(JsonNode? node) { if (node is null) return false; if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text)) return text.Length > 0; return true; }
|
||||||
|
private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
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 WorkflowSnapshots(AdminAccountBatchSnapshot Workflow, AdminOperationalSnapshot Operational, AdminCenterSnapshot Centers, IReadOnlyList<AdminWorkflowResult> Results);
|
||||||
|
private sealed record WorkflowScope(string? SchoolId, string? ClassId, string Name, string SchoolName, string ClassName);
|
||||||
|
private sealed record RankInfo(int? Rank, int CohortSize, double? RankPercent, string Grade);
|
||||||
|
private sealed record PassInfo(bool? Qualified, double? PassScore, int? CutoffRank);
|
||||||
|
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<AdminCenterSnapshotLoader>();
|
services.AddScoped<AdminCenterSnapshotLoader>();
|
||||||
services.AddScoped<AdminCenterRepository>();
|
services.AddScoped<AdminCenterRepository>();
|
||||||
services.AddScoped<AdminOperationalSnapshotLoader>();
|
services.AddScoped<AdminOperationalSnapshotLoader>();
|
||||||
|
services.AddScoped<AdminWorkflowResultSnapshotLoader>();
|
||||||
|
services.AddScoped<AdminWorkflowRepository>();
|
||||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||||
@@ -70,6 +72,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
|
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
|
||||||
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
|
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
|
||||||
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
|
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
|
||||||
|
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
|
||||||
services.AddSingleton<NoticeContentFormatter>();
|
services.AddSingleton<NoticeContentFormatter>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ public static class NativeAdminReadEndpoints
|
|||||||
endpoints.MapPatch("/api/admin/payments/{registrationId}", (HttpContext context, string registrationId, JsonObject body, IAdminRegistrationManagementService service, CancellationToken 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)));
|
Execute(context, service.UpdatePaymentAsync(Token(context), registrationId, body, cancellationToken)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.NativeWorkflowOperationsEnabled)
|
||||||
|
{
|
||||||
|
endpoints.MapGet("/api/admin/workflow-instances", (HttpContext context, IAdminWorkflowService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/workflow-instances/{instanceId}/transfer", (HttpContext context, string instanceId, JsonObject body, IAdminWorkflowService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.TransferAsync(Token(context), instanceId, body, cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/workflow-instances/{instanceId}/supervise", (HttpContext context, string instanceId, JsonObject body, IAdminWorkflowService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SuperviseAsync(Token(context), instanceId, body, cancellationToken)));
|
||||||
|
}
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"),
|
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.Configuration.GetValue<bool>("AdminMigration:NativeRegistrationPaymentWritesEnabled"),
|
||||||
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled"));
|
||||||
builder.Services.AddEisInfrastructure(
|
builder.Services.AddEisInfrastructure(
|
||||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
||||||
@@ -116,6 +117,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled,
|
nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled,
|
||||||
nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled,
|
nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled,
|
||||||
nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled,
|
nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled,
|
||||||
|
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
|
||||||
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
||||||
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
||||||
: [])
|
: [])
|
||||||
@@ -147,6 +149,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
.Concat(adminMigrationOptions.NativeRegistrationPaymentWritesEnabled
|
.Concat(adminMigrationOptions.NativeRegistrationPaymentWritesEnabled
|
||||||
? new[] { "PATCH registrations", "PATCH payments" }
|
? new[] { "PATCH registrations", "PATCH payments" }
|
||||||
: [])
|
: [])
|
||||||
|
.Concat(adminMigrationOptions.NativeWorkflowOperationsEnabled
|
||||||
|
? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" }
|
||||||
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"NativeCentersEnabled": false,
|
"NativeCentersEnabled": false,
|
||||||
"NativeOperationalReadsEnabled": false,
|
"NativeOperationalReadsEnabled": false,
|
||||||
"NativeCandidateManagementEnabled": false,
|
"NativeCandidateManagementEnabled": false,
|
||||||
"NativeRegistrationPaymentWritesEnabled": false
|
"NativeRegistrationPaymentWritesEnabled": false,
|
||||||
|
"NativeWorkflowOperationsEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -241,6 +241,21 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}, operationalReadsEnabled: "true", registrationPaymentWritesEnabled: "true");
|
}, operationalReadsEnabled: "true", registrationPaymentWritesEnabled: "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnablesWorkflowOperationsWithBaseReadsAndSharedSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeReadsEnabled);
|
||||||
|
Assert.True(options.NativeWorkflowOperationsEnabled);
|
||||||
|
}, workflowOperationsEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
private static void WithEnvironment(
|
private static void WithEnvironment(
|
||||||
string? nativeReadsEnabled,
|
string? nativeReadsEnabled,
|
||||||
string? nativeOrganizationWritesEnabled,
|
string? nativeOrganizationWritesEnabled,
|
||||||
@@ -253,7 +268,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
string? centersEnabled = null,
|
string? centersEnabled = null,
|
||||||
string? operationalReadsEnabled = null,
|
string? operationalReadsEnabled = null,
|
||||||
string? candidateManagementEnabled = null,
|
string? candidateManagementEnabled = null,
|
||||||
string? registrationPaymentWritesEnabled = null)
|
string? registrationPaymentWritesEnabled = null,
|
||||||
|
string? workflowOperationsEnabled = null)
|
||||||
{
|
{
|
||||||
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
||||||
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
||||||
@@ -265,6 +281,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED");
|
var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED");
|
||||||
var previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED");
|
var previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED");
|
||||||
var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED");
|
var previousRegistrationPaymentWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED");
|
||||||
|
var previousWorkflowOperationsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED");
|
||||||
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -278,6 +295,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", registrationPaymentWritesEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -293,6 +311,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED", previousRegistrationPaymentWritesEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user