From 2589305f3c30732c18112cbc760dd90f1c2c5b52 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 10:21:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E6=89=B9=E2=80=9C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=B0=83=E5=BA=A6=E2=80=9D=E8=BF=81=E7=A7=BB=E5=AE=8C?= =?UTF-8?q?=E6=88=90=EF=BC=9A=20GET=20/api/admin/workflow-instances=20PATC?= =?UTF-8?q?H=20/api/admin/workflow-instances/{id}/transfer=20PATCH=20/api/?= =?UTF-8?q?admin/workflow-instances/{id}/supervise=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E8=B5=84=E6=96=99=E5=8F=98=E6=9B=B4=E3=80=81=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E3=80=81=E8=80=83=E7=82=B9=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E3=80=81=E6=89=B9=E9=87=8F=E6=8A=A5=E5=90=8D=E5=8F=B7=E5=92=8C?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E5=A4=8D=E8=AE=AE=E4=BA=94=E7=B1=BB=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=8C=E5=B9=B6=E5=AE=9E=E7=8E=B0=E5=90=8C=E7=BA=A7?= =?UTF-8?q?=E8=BD=AC=E4=BA=A4=E3=80=81=E8=8C=83=E5=9B=B4=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98=E7=9B=91?= =?UTF-8?q?=E7=9D=A3=E5=9B=9E=E9=80=80=E5=8F=8A=E4=B8=9A=E5=8A=A1=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=90=8C=E6=AD=A5=E9=87=8D=E7=BD=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + MIGRATION.md | 7 +- scripts/smoke-dotnet-migration.ps1 | 51 +++ .../Administration/IAdminWorkflowService.cs | 10 + .../AdminAccountBatchService.cs | 2 +- .../Administration/AdminCenterService.cs | 2 +- .../Administration/AdminMigrationOptions.cs | 15 +- .../Administration/AdminWorkflowRepository.cs | 139 +++++++ .../AdminWorkflowResultSnapshot.cs | 45 ++ .../Administration/AdminWorkflowService.cs | 393 ++++++++++++++++++ src/Eis.Infrastructure/DependencyInjection.cs | 3 + .../NativeAdminReadEndpoints.cs | 10 + src/Eis.Web/Program.cs | 7 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 21 +- 15 files changed, 697 insertions(+), 13 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminWorkflowService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminWorkflowRepository.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminWorkflowService.cs diff --git a/.env.example b/.env.example index 9f7139f..aea4801 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false # 报名审核与缴费状态更新;必须同时启用考生/报名/缴费读取。 ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false +# 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。 +ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index ccff3c7..dfa5268 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护及报名缴费写入已原生化) +- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批、考生/报名/缴费读取、考生账号维护、报名缴费写入及工作流调度已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。第九批覆盖报名审核流程及缴费状态更新:多步报名终审会绑定考生报名号和当前号码规则,缴费确认及撤销会维护确认时间和操作人,所有流程、业务状态与审计日志均在同一事务中提交。第十批覆盖五类业务的工作流收件箱、当前处理人同级转交以及超级管理员指定步骤和处理人的监督调整;重新打开业务时会同步重置业务状态,并阻止已生成账号的批次重复开启。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -69,9 +69,10 @@ $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' +$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 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index a1fb14f..46c2892 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -445,6 +445,7 @@ try { ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true' ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true' + ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' 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 = @{ scopeType = 'class' scopeValue = $profileUpdate.profile.classId @@ -965,6 +994,27 @@ try { $nativePaymentsAfterReversal = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/payments" -WebSession $nativeSession 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 $nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { @@ -1436,6 +1486,7 @@ try { NativeAdminOperationalReads = 'passed' NativeAdminCandidateManagement = 'passed' NativeAdminRegistrationPaymentWrites = 'passed' + NativeAdminWorkflowOperations = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminWorkflowService.cs b/src/Eis.Application/Administration/IAdminWorkflowService.cs new file mode 100644 index 0000000..b677bf7 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminWorkflowService.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminWorkflowService +{ + Task GetAsync(string sessionToken, CancellationToken cancellationToken); + Task TransferAsync(string sessionToken, string instanceId, JsonObject body, CancellationToken cancellationToken); + Task SuperviseAsync(string sessionToken, string instanceId, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs b/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs index 765cfc8..d5a4890 100644 --- a/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs +++ b/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs @@ -167,7 +167,7 @@ internal sealed class AdminAccountBatchService( })); } - private static JsonObject BatchView(AdminAccountBatchSnapshot snapshot, AccountBatch batch, IReadOnlyList? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList? extraActions = null) + internal static JsonObject BatchView(AdminAccountBatchSnapshot snapshot, AccountBatch batch, IReadOnlyList? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList? extraActions = null) { 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(); diff --git a/src/Eis.Infrastructure/Administration/AdminCenterService.cs b/src/Eis.Infrastructure/Administration/AdminCenterService.cs index 893e456..8d8fc97 100644 --- a/src/Eis.Infrastructure/Administration/AdminCenterService.cs +++ b/src/Eis.Infrastructure/Administration/AdminCenterService.cs @@ -308,7 +308,7 @@ internal sealed class AdminCenterService( }; } - private static JsonObject ChangeJson( + internal static JsonObject ChangeJson( AdminAccountBatchSnapshot workflow, AdminCenterSnapshot centers, AdminCenterChange item, diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 2c00d1e..05bdd55 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -9,7 +9,8 @@ public sealed record AdminMigrationOptions( bool NativeCentersEnabled = false, bool NativeOperationalReadsEnabled = false, bool NativeCandidateManagementEnabled = false, - bool NativeRegistrationPaymentWritesEnabled = false) + bool NativeRegistrationPaymentWritesEnabled = false, + bool NativeWorkflowOperationsEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -22,7 +23,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeCentersEnabled = false, bool configuredNativeOperationalReadsEnabled = false, bool configuredNativeCandidateManagementEnabled = false, - bool configuredNativeRegistrationPaymentWritesEnabled = false) + bool configuredNativeRegistrationPaymentWritesEnabled = false, + bool configuredNativeWorkflowOperationsEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -52,7 +54,10 @@ public sealed record AdminMigrationOptions( var registrationPaymentWritesEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"), 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( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); @@ -62,7 +67,7 @@ public sealed record AdminMigrationOptions( throw new InvalidOperationException( "启用原生考生、报名或缴费写接口前必须同时设置 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) { throw new InvalidOperationException( @@ -78,7 +83,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 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 diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowRepository.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowRepository.cs new file mode 100644 index 0000000..7ce25ea --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowRepository.cs @@ -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 Parameters); + private sealed record SqlParameterValue(string Name, object? Value); +} + +internal sealed record WorkflowBusinessReset(string BusinessType, string BusinessId, string Note); diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs new file mode 100644 index 0000000..f1c100a --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowResultSnapshot.cs @@ -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> 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(); + 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; +} diff --git a/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs b/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs new file mode 100644 index 0000000..972e5ac --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminWorkflowService.cs @@ -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 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 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 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 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() ?? ""; + 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() ?? "", + profile.Data["school"]?.GetValue() ?? "", + profile.Data["grade"]?.GetValue() ?? ""); + } + 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() == 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() ?? "", + ["examCode"] = exam?.Data["code"]?.GetValue() ?? "", + ["subjectName"] = subject?["name"]?.GetValue() ?? "", + ["fullScore"] = JsonValue.Create(NullableNumber(subject?["fullScore"])), + ["passRule"] = subject?["passRule"]?.GetValue() ?? "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 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 results, AdminWorkflowResult result, JsonObject? subject) + { + var rule = subject?["passRule"]?.GetValue() ?? "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() ?? "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 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 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 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(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(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue(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 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 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); } +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 94f8504..65ae813 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -61,6 +61,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -70,6 +72,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 668aaf3..6ebd3eb 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -119,6 +119,16 @@ public static class NativeAdminReadEndpoints 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))); } + + 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; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 57e68c5..fc6c045 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -53,7 +53,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeCentersEnabled"), builder.Configuration.GetValue("AdminMigration:NativeOperationalReadsEnabled"), builder.Configuration.GetValue("AdminMigration:NativeCandidateManagementEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeRegistrationPaymentWritesEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeWorkflowOperationsEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -116,6 +117,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled, nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled, nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled, + nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? 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 ? new[] { "PATCH registrations", "PATCH payments" } : []) + .Concat(adminMigrationOptions.NativeWorkflowOperationsEnabled + ? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 20fcc1a..a30efd3 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -18,7 +18,8 @@ "NativeCentersEnabled": false, "NativeOperationalReadsEnabled": false, "NativeCandidateManagementEnabled": false, - "NativeRegistrationPaymentWritesEnabled": false + "NativeRegistrationPaymentWritesEnabled": false, + "NativeWorkflowOperationsEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 11ed404..2aab9be 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -241,6 +241,21 @@ public sealed class AdminMigrationOptionsTests }, 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( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -253,7 +268,8 @@ public sealed class AdminMigrationOptionsTests string? centersEnabled = null, string? operationalReadsEnabled = null, string? candidateManagementEnabled = null, - string? registrationPaymentWritesEnabled = null) + string? registrationPaymentWritesEnabled = null, + string? workflowOperationsEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_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 previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_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"); try { @@ -278,6 +295,7 @@ public sealed class AdminMigrationOptionsTests 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_WORKFLOW_OPERATIONS_ENABLED", workflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -293,6 +311,7 @@ public sealed class AdminMigrationOptionsTests 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_WORKFLOW_OPERATIONS_ENABLED", previousWorkflowOperationsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }