From f7c34247fd8773dec751babf7f55237a88b65b73 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 08:19:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=AC=AC=E4=B8=89=E6=89=B9=20ASP.NET=20Core?= =?UTF-8?q?=2010=20=E8=BF=81=E7=A7=BB=EF=BC=9A=E6=89=B9=E9=87=8F=E6=8A=A5?= =?UTF-8?q?=E5=90=8D=E5=8F=B7=E7=94=B3=E9=A2=86=E4=B8=8E=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E3=80=82=20=E5=8E=9F=E7=94=9F=E6=8E=A5=E5=8F=A3=EF=BC=9A=20GET?= =?UTF-8?q?=20/api/admin/candidate-account-batches=20POST=20/api/admin/can?= =?UTF-8?q?didate-account-batches=20PATCH=20/api/admin/candidate-account-b?= =?UTF-8?q?atches/{batchId}=20=E5=AE=9E=E7=8E=B0=E5=8C=85=E5=90=AB?= =?UTF-8?q?=EF=BC=9A=20=E6=A0=A1=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=8C=89=E7=8F=AD=E7=BA=A7=E6=8F=90=E4=BA=A4=E5=90=8D=E9=A2=9D?= =?UTF-8?q?=20=E5=8F=AF=E9=85=8D=E7=BD=AE=E5=A4=9A=E7=BA=A7=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E6=B5=81=E4=B8=8E=E5=BE=85=E5=8A=9E=E8=B4=9F=E8=BD=BD?= =?UTF-8?q?=E5=88=86=E9=85=8D=20=E5=BD=93=E5=89=8D=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=BA=BA=E5=8F=8A=E7=AE=A1=E7=90=86=E5=91=98=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20=E5=AE=A1=E6=89=B9=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=81=E9=80=80=E5=9B=9E=E5=92=8C=E9=87=8D=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E4=BF=9D=E6=8A=A4=20=E7=BB=88=E5=AE=A1=E6=97=B6?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E7=94=9F=E6=88=90=E8=80=83=E7=94=9F=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E3=80=81=E6=8A=A5=E5=90=8D=E5=8F=B7=E3=80=81=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=AF=86=E7=A0=81=E5=92=8C=E5=BE=85=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=E8=B5=84=E6=96=99=20=E7=94=9F=E6=88=90=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E7=8E=B0=E6=9C=89=20PBKDF2=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=8F=8A=E9=A6=96=E6=AC=A1=E5=BC=BA=E5=88=B6=E6=94=B9?= =?UTF-8?q?=E5=AF=86?= 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 | 54 +++++ .../IAdminAccountBatchService.cs | 19 ++ .../AdminAccountBatchRepository.cs | 146 ++++++++++++ .../AdminAccountBatchService.cs | 214 ++++++++++++++++++ .../AdminAccountBatchSnapshot.cs | 60 +++++ .../Administration/AdminMigrationOptions.cs | 15 +- src/Eis.Infrastructure/DependencyInjection.cs | 3 + .../NativeAdminReadEndpoints.cs | 44 ++-- src/Eis.Web/Program.cs | 7 +- src/Eis.Web/appsettings.json | 3 +- .../AdminMigrationOptionsTests.cs | 21 +- 13 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminAccountBatchService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminAccountBatchRepository.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs diff --git a/.env.example b/.env.example index 95efd5b..63b3cd7 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ CANDIDATE_NATIVE_ENABLED=false ADMIN_NATIVE_READS_ENABLED=false # 学校、班级、管理员维护及自主注册开关切换;同样要求原生认证和共享 Redis。 ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false +# 批量报名号申领、审批与账号生成;必须同时启用管理端只读接口。 +ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 09186a9..0bebd23 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' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。 其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用: @@ -62,9 +62,10 @@ $env:CANDIDATE_NATIVE_ENABLED = 'true' $env:AUTH_NATIVE_ENABLED = 'true' $env:ADMIN_NATIVE_READS_ENABLED = 'true' $env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' +$env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' ``` -`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index d1defe0..12e4c13 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -424,6 +424,7 @@ try { CANDIDATE_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_READS_ENABLED = 'true' ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' + ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -738,6 +739,58 @@ try { throw 'Native admins route did not preserve the class-admin boundary' } + $legacySuperBatches = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/candidate-account-batches" -WebSession $session + $nativeSuperBatches = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches" -WebSession $nativeSession + if ($nativeSuperBatches.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native candidate-account batch read did not use ASP.NET Core' + } + Assert-JsonEquivalent -Expected $legacySuperBatches.Content -Actual $nativeSuperBatches.Content -Label 'Super-admin candidate-account batches' + $legacySchoolBatches = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/candidate-account-batches" -WebSession $legacySchoolSession + $nativeSchoolBatches = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches" -WebSession $nativeSchoolSession + Assert-JsonEquivalent -Expected $legacySchoolBatches.Content -Actual $nativeSchoolBatches.Content -Label 'School-admin candidate-account batches' + $classBatchesForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches" -WebSession $nativeClassSession -SkipHttpErrorCheck + if ($classBatchesForbidden.StatusCode -ne 403 -or $classBatchesForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native candidate-account batches did not preserve the candidates.write boundary' + } + + $schoolBatchState = $nativeSchoolBatches.Content | ConvertFrom-Json + $batchClass = @($schoolBatchState.classes)[0] + if ($null -eq $batchClass) { + throw 'Seed data did not provide an active class for account-batch smoke testing' + } + $batchSubmitBody = @{ quotas = @(@{ classId = $batchClass.id; count = 2 }) } | ConvertTo-Json -Depth 4 -Compress + $batchSubmitResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches" -Method Post -ContentType 'application/json' -Body $batchSubmitBody -WebSession $nativeSchoolSession + if ($batchSubmitResponse.StatusCode -ne 202 -or $batchSubmitResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native candidate-account batch submission did not use ASP.NET Core' + } + $submittedBatch = ($batchSubmitResponse.Content | ConvertFrom-Json).batch + if ($submittedBatch.status -ne 'pending' -or $submittedBatch.totalCount -ne 2 -or $submittedBatch.workflow.status -ne 'pending') { + throw 'Native candidate-account batch submission did not create its workflow atomically' + } + $schoolBatchReview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches/$($submittedBatch.id)" -Method Patch -ContentType 'application/json' -Body (@{ status = 'approved'; reviewNote = '越权审批' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolBatchReview.StatusCode -ne 403) { + throw 'Native account-batch review did not enforce the current workflow assignee' + } + $batchApproveBody = @{ status = 'approved'; reviewNote = '原生终审通过' } | ConvertTo-Json -Compress + $batchApproveResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches/$($submittedBatch.id)" -Method Patch -ContentType 'application/json' -Body $batchApproveBody -WebSession $nativeSession + if ($batchApproveResponse.StatusCode -ne 200 -or $batchApproveResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native candidate-account batch approval did not use ASP.NET Core' + } + $approvedBatch = ($batchApproveResponse.Content | ConvertFrom-Json).batch + if ($approvedBatch.status -ne 'approved' -or @($approvedBatch.items).Count -ne 2 -or @($approvedBatch.items | Where-Object { -not $_.candidateNumber -or $_.initialPassword -notmatch '^Init-' }).Count -ne 0) { + throw 'Native candidate-account batch approval did not generate all candidate credentials' + } + $generatedAccount = @($approvedBatch.items)[0] + $generatedLoginBody = @{ username = $generatedAccount.candidateNumber; password = $generatedAccount.initialPassword } | ConvertTo-Json -Compress + $generatedLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $generatedLoginBody + if ($generatedLogin.user.candidateNumber -ne $generatedAccount.candidateNumber -or $generatedLogin.user.mustChangePassword -ne $true) { + throw 'Generated candidate credentials are not compatible with native authentication' + } + $duplicateBatchApproval = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches/$($submittedBatch.id)" -Method Patch -ContentType 'application/json' -Body $batchApproveBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($duplicateBatchApproval.StatusCode -ne 404) { + throw 'Native candidate-account batch approval was not idempotently protected' + } + $schoolCreateBody = @{ name = '原生迁移测试学校' code = 'NATIVE_SMOKE' @@ -935,6 +988,7 @@ try { NativeCandidateAdmissions = 'passed' NativeAdminReads = 'passed' NativeAdminOrganizationWrites = 'passed' + NativeAdminAccountBatches = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminAccountBatchService.cs b/src/Eis.Application/Administration/IAdminAccountBatchService.cs new file mode 100644 index 0000000..eb09760 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminAccountBatchService.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminAccountBatchService +{ + Task GetAsync(string sessionToken, CancellationToken cancellationToken); + + Task SubmitAsync( + string sessionToken, + JsonObject body, + CancellationToken cancellationToken); + + Task ReviewAsync( + string sessionToken, + string batchId, + JsonObject body, + CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAccountBatchRepository.cs b/src/Eis.Infrastructure/Administration/AdminAccountBatchRepository.cs new file mode 100644 index 0000000..7376171 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAccountBatchRepository.cs @@ -0,0 +1,146 @@ +using System.Data.Common; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record GeneratedBatchCandidate( + string UserId, + string CandidateNumber, + string PasswordHash, + string DisplayName, + string SchoolId, + string ClassId, + string CreatedAt, + string ProfileId, + string SchoolName, + string ClassName, + string UpdatedAt); + +internal sealed class AdminAccountBatchRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task CreateAsync( + AccountBatch batch, + IReadOnlyList items, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operations = new List + { + new(""" + INSERT INTO candidate_account_batches (id, school_id, requested_by, status, review_note, created_at, reviewed_at) + VALUES (@id, @schoolId, @requestedBy, @status, @reviewNote, @createdAt, @reviewedAt) + """, [new("@id", batch.Id), new("@schoolId", batch.SchoolId), new("@requestedBy", batch.RequestedBy), new("@status", batch.Status), new("@reviewNote", Optional(batch.ReviewNote)), new("@createdAt", batch.CreatedAt), new("@reviewedAt", batch.ReviewedAt)]) + }; + operations.AddRange(items.Select(item => new SqlOperation(""" + INSERT INTO candidate_account_batch_items (id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at) + VALUES (@id, @batchId, @classId, @position, @candidateNumber, @initialPassword, @userId, @createdAt) + """, [new("@id", item.Id), new("@batchId", item.BatchId), new("@classId", item.ClassId), new("@position", item.Position), new("@candidateNumber", Optional(item.CandidateNumber)), new("@initialPassword", Optional(item.InitialPassword)), new("@userId", item.UserId), new("@createdAt", item.CreatedAt)]))); + operations.Add(InsertInstance(instance)); + operations.Add(InsertAction(action)); + operations.Add(InsertAudit(audit)); + return ExecuteTransactionAsync(operations, cancellationToken); + } + + public Task ProcessAsync( + AccountBatch batch, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteTransactionAsync( + [UpdateInstance(instance), InsertAction(action), UpdateBatch(batch), InsertAudit(audit)], cancellationToken); + + public Task CompleteAsync( + AccountBatch batch, + IReadOnlyList items, + IReadOnlyList candidates, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operations = new List { UpdateInstance(instance), InsertAction(action), UpdateBatch(batch) }; + for (var index = 0; index < candidates.Count; index++) + { + var candidate = candidates[index]; + var item = items[index]; + operations.Add(new SqlOperation(""" + INSERT INTO users ( + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, archived_at, archived_by, display_name, created_at + ) VALUES ( + @id, @username, @candidateNumber, @passwordHash, 'candidate', NULL, @schoolId, @classId, 1, + 1, NULL, NULL, @displayName, @createdAt + ) + """, [new("@id", candidate.UserId), new("@username", candidate.CandidateNumber), new("@candidateNumber", candidate.CandidateNumber), new("@passwordHash", candidate.PasswordHash), new("@schoolId", candidate.SchoolId), new("@classId", candidate.ClassId), new("@displayName", candidate.DisplayName), new("@createdAt", candidate.CreatedAt)])); + operations.Add(new SqlOperation(""" + INSERT INTO candidate_profiles ( + id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, + province_code, province_name, city_code, city_name, district_code, district_name, address, + emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, + guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at + ) VALUES ( + @id, @userId, @name, NULL, @idNumber, '', NULL, @school, @grade, @schoolId, @classId, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, 0, 'pending', NULL, NULL, NULL, @updatedAt + ) + """, [new("@id", candidate.ProfileId), new("@userId", candidate.UserId), new("@name", candidate.DisplayName), new("@idNumber", $"PENDING-{candidate.UserId}"), new("@school", candidate.SchoolName), new("@grade", candidate.ClassName), new("@schoolId", candidate.SchoolId), new("@classId", candidate.ClassId), new("@updatedAt", candidate.UpdatedAt)])); + operations.Add(new SqlOperation(""" + UPDATE candidate_account_batch_items SET candidate_number = @candidateNumber, + initial_password = @initialPassword, user_id = @userId, created_at = @createdAt WHERE id = @id + """, [new("@candidateNumber", item.CandidateNumber), new("@initialPassword", item.InitialPassword), new("@userId", item.UserId), new("@createdAt", item.CreatedAt), new("@id", item.Id)])); + } + operations.Add(InsertAudit(audit)); + return ExecuteTransactionAsync(operations, cancellationToken); + } + + private async Task ExecuteTransactionAsync(IReadOnlyList operations, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + foreach (var operation in operations) await ExecuteAsync(connection, transaction, operation, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static SqlOperation InsertInstance(AccountWorkflowInstance item) => new(""" + INSERT INTO workflow_instances (id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at) + VALUES (@id, @workflowId, @businessType, @businessId, @status, @currentStep, @assigneeId, @createdAt, @completedAt) + """, [new("@id", item.Id), new("@workflowId", item.WorkflowId), new("@businessType", item.BusinessType), new("@businessId", item.BusinessId), new("@status", item.Status), new("@currentStep", item.CurrentStep), new("@assigneeId", item.AssigneeId), new("@createdAt", item.CreatedAt), new("@completedAt", item.CompletedAt)]); + + private static SqlOperation UpdateInstance(AccountWorkflowInstance item) => new(""" + UPDATE workflow_instances SET status = @status, current_step = @currentStep, + assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id + """, [new("@status", item.Status), new("@currentStep", item.CurrentStep), new("@assigneeId", item.AssigneeId), new("@completedAt", item.CompletedAt), new("@id", item.Id)]); + + private static SqlOperation InsertAction(AccountWorkflowAction item) => new(""" + 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", item.Id), new("@instanceId", item.InstanceId), new("@actorId", item.ActorId), new("@action", item.Action), new("@note", Optional(item.Note)), new("@fromAssigneeId", item.FromAssigneeId), new("@toAssigneeId", item.ToAssigneeId), new("@createdAt", item.CreatedAt)]); + + private static SqlOperation UpdateBatch(AccountBatch item) => new(""" + UPDATE candidate_account_batches SET status = @status, review_note = @reviewNote, reviewed_at = @reviewedAt WHERE id = @id + """, [new("@status", item.Status), new("@reviewNote", Optional(item.ReviewNote)), new("@reviewedAt", item.ReviewedAt), new("@id", item.Id)]); + + private static SqlOperation InsertAudit(AdminAuditEntry item) => new(""" + INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt) + """, [new("@id", item.Id), new("@actorId", item.ActorId), new("@action", item.Action), new("@detail", item.Detail), new("@createdAt", item.CreatedAt)]); + + 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); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs b/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs new file mode 100644 index 0000000..765cfc8 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs @@ -0,0 +1,214 @@ +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 AdminAccountBatchService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + PasswordCompatibilityService passwords, + AdminAccountBatchSnapshotLoader snapshotLoader, + AdminAccountBatchRepository repository) : IAdminAccountBatchService +{ + private static readonly IReadOnlyDictionary LevelNames = new Dictionary(StringComparer.Ordinal) + { + ["super"] = "超级管理员", + ["school"] = "校级管理员", + ["class"] = "班级管理员" + }; + + 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!; + if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作"); + if (Level(user) is not ("school" or "super")) return Error(403, "只有校级管理员可以申领批量报名号"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var batches = snapshot.Batches.Where(item => Level(user) == "super" || item.SchoolId == user.SchoolId) + .Select(item => BatchView(snapshot, item)).ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["batches"] = new JsonArray(batches), + ["classes"] = new JsonArray(snapshot.Classes.Where(item => item.Active && (Level(user) == "super" || item.SchoolId == user.SchoolId)).Select(ClassJson).ToArray()), + ["schools"] = new JsonArray(snapshot.Schools.Where(item => item.Active && item.IsSourceSchool).Select(SchoolJson).ToArray()) + }); + } + + public async Task SubmitAsync(string sessionToken, 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) != "school") return Error(403, "批量报名号由校级管理员发起申领"); + if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作"); + var quotas = ParseQuotas(body["quotas"] as JsonArray).Where(item => item.Count > 0).ToArray(); + if (quotas.Length == 0) return Error(400, "请至少为一个班级填写申领数量"); + if (quotas.Select(item => item.ClassId).Distinct(StringComparer.Ordinal).Count() != quotas.Length) return Error(400, "同一班级只能填写一次申领数量"); + if (quotas.Any(item => item.Count != Math.Truncate(item.Count) || item.Count < 1 || item.Count > 200)) return Error(400, "每个班级一次可申领 1—200 个报名号"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + if (quotas.Any(item => !snapshot.Classes.Any(entry => entry.Id == item.ClassId && entry.SchoolId == user.SchoolId && entry.Active))) return Error(400, "只能为本校有效班级申领报名号"); + var totalCount = quotas.Sum(item => (int)item.Count); + if (totalCount > 500) return Error(400, "单个批次最多申领 500 个报名号"); + var workflow = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.Active); + if (workflow is null || workflow.Steps.Count == 0) return Error(409, "该业务尚未配置审批流程"); + var firstStep = workflow.Steps[0]; + var assignee = SelectAssignee(snapshot, firstStep.AdminLevel, user.SchoolId); + if (assignee is null) return Error(409, $"没有可承接“{firstStep.Name}”的{LevelNames[firstStep.AdminLevel]}"); + var batch = new AccountBatch(Uid("account_batch"), user.SchoolId!, user.Id, "pending", "", NowIso(), null); + var items = new List(); + var position = 1; + foreach (var quota in quotas) + for (var index = 0; index < quota.Count; index++) + items.Add(new(Uid("account_batch_item"), batch.Id, quota.ClassId, position++, "", "", null, null)); + var instance = new AccountWorkflowInstance(Uid("flow"), workflow.Id, "candidate_account_batch", batch.Id, "pending", 1, assignee.Id, NowIso(), null); + var action = new AccountWorkflowAction(Uid("flow_action"), instance.Id, user.Id, "submit", "提交审批", null, assignee.Id, NowIso()); + var summary = string.Join(";", quotas.Select(item => $"{snapshot.Classes.First(entry => entry.Id == item.ClassId).Name} {(int)item.Count} 人")); + await repository.CreateAsync(batch, items, instance, action, Audit(user, "提交批量报名号申领", $"{totalCount} 个账户 · {summary}"), cancellationToken); + return Result(202, new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, batch, items, instance, [action]) }); + } + + public async Task ReviewAsync(string sessionToken, string batchId, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作"); + var status = Text(body["status"]); + if (status is not ("approved" or "rejected")) return Error(400, "审批状态无效"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var batch = snapshot.Batches.FirstOrDefault(item => item.Id == batchId && item.Status == "pending"); + if (batch is null) return Error(404, "待审批的批量报名号申请不存在"); + var instance = snapshot.Instances.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.BusinessId == batch.Id && item.Status == "pending"); + var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance?.WorkflowId); + var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance?.CurrentStep); + if (instance is null || workflow is null || step is null) return Error(409, "批量报名号审批流程状态异常"); + if (Level(user) != "super" && (instance.AssigneeId != user.Id || step.AdminLevel != Level(user))) return Error(403, "该流程当前未分配给你,可由当前处理人转交"); + var note = Clean(Text(body["reviewNote"]), 300); + var action = new AccountWorkflowAction(Uid("flow_action"), instance.Id, user.Id, status == "approved" ? "approve" : "reject", note, instance.AssigneeId, null, NowIso()); + var schoolName = snapshot.Schools.FirstOrDefault(item => item.Id == batch.SchoolId)?.Name ?? ""; + var audit = Audit(user, status == "approved" ? "审批批量报名号申领" : "退回批量报名号申领", $"{schoolName} · {(note.Length == 0 ? "无备注" : note)}"); + if (status == "rejected") + { + var rejectedInstance = instance with { Status = "rejected", CompletedAt = NowIso(), AssigneeId = null }; + var rejectedBatch = batch with { Status = "rejected", ReviewNote = note, ReviewedAt = NowIso() }; + await repository.ProcessAsync(rejectedBatch, rejectedInstance, action, audit, cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, rejectedBatch, null, rejectedInstance, [action]) }); + } + if (instance.CurrentStep < workflow.Steps.Count) + { + var nextStep = workflow.Steps.First(item => item.Position == instance.CurrentStep + 1); + var nextAssignee = SelectAssignee(snapshot, nextStep.AdminLevel, batch.SchoolId); + if (nextAssignee is null) return Error(409, $"没有可承接“{nextStep.Name}”的管理员"); + var advancedInstance = instance with { CurrentStep = instance.CurrentStep + 1, AssigneeId = nextAssignee.Id }; + var advancedAction = action with { ToAssigneeId = nextAssignee.Id }; + var advancedBatch = batch with { ReviewNote = note }; + await repository.ProcessAsync(advancedBatch, advancedInstance, advancedAction, audit, cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, advancedBatch, null, advancedInstance, [advancedAction]) }); + } + return await CompleteAsync(snapshot, user, batch, instance, action, note, audit, cancellationToken); + } + + private async Task CompleteAsync(AdminAccountBatchSnapshot snapshot, AuthenticationUser reviewer, AccountBatch batch, AccountWorkflowInstance instance, AccountWorkflowAction action, string note, AdminAuditEntry audit, CancellationToken cancellationToken) + { + var items = snapshot.Items.Where(item => item.BatchId == batch.Id).OrderBy(item => item.Position).ToArray(); + if (items.Length == 0 || items.Any(item => item.UserId is not null || item.CandidateNumber.Length > 0)) return Error(409, "批次明细异常或已经生成过账号"); + var rule = snapshot.NumberRules.FirstOrDefault(item => item.Active); + if (rule is null || rule.Segments.Count == 0) return Error(409, "尚未配置可用的报名号生成规则"); + var users = snapshot.Users.ToList(); + var generatedItems = new List(); + var candidates = new List(); + for (var index = 0; index < items.Length; index++) + { + var item = items[index]; + var schoolClass = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId && entry.SchoolId == batch.SchoolId); + if (schoolClass is null) return Error(409, "批次包含无效班级,无法生成账号"); + var number = GenerateCandidateNumber(snapshot, users, rule, batch.SchoolId); + var userId = Uid("usr"); + var initialPassword = $"Init-{Base64Url(RandomNumberGenerator.GetBytes(6))}"; + var displayName = $"待补录考生 {index + 1:000}"; + var now = NowIso(); + candidates.Add(new(userId, number, passwords.Hash(initialPassword), displayName, batch.SchoolId, item.ClassId, now, Uid("profile"), snapshot.Schools.FirstOrDefault(entry => entry.Id == batch.SchoolId)?.Name ?? "", schoolClass.Name, NowIso())); + generatedItems.Add(item with { CandidateNumber = number, InitialPassword = initialPassword, UserId = userId, CreatedAt = NowIso() }); + users.Add(new AdminUser(userId, number, "candidate", null, batch.SchoolId, item.ClassId, displayName, number, true, true, false, null, now)); + } + var completedInstance = instance with { Status = "approved", CompletedAt = NowIso(), AssigneeId = null }; + var completedBatch = batch with { Status = "approved", ReviewNote = note, ReviewedAt = NowIso() }; + await repository.CompleteAsync(completedBatch, generatedItems, candidates, completedInstance, action, audit, cancellationToken); + var outputSnapshot = snapshot with { Users = users }; + return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(outputSnapshot, completedBatch, generatedItems, completedInstance, [action]) }); + } + + private static AdminUser? SelectAssignee(AdminAccountBatchSnapshot snapshot, string level, string? schoolId) + { + var pending = snapshot.Instances.Where(item => item.Status == "pending" && item.AssigneeId is not null).GroupBy(item => item.AssigneeId!).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var assigned = snapshot.Actions.Where(item => item.ToAssigneeId is not null).GroupBy(item => item.ToAssigneeId!).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + return snapshot.Users.Where(item => item.Role == "admin" && item.Active && item.AdminLevel == level && (level == "super" || level == "school" && item.SchoolId == schoolId)) + .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 string GenerateCandidateNumber(AdminAccountBatchSnapshot snapshot, IReadOnlyList users, AccountNumberRule rule, string schoolId) + { + var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture); + var schoolCode = snapshot.Schools.FirstOrDefault(item => item.Id == schoolId)?.Code ?? ""; + var prefix = string.Join(rule.Separator, rule.Segments.Where(item => item.Type != "sequence").Select(item => item.Type == "year" ? year : item.Type == "school_code" ? schoolCode : "").Where(item => item.Length > 0)); + var sequence = users.Count(item => item.Role == "candidate" && item.CandidateNumber is not null && (prefix.Length == 0 || item.CandidateNumber.StartsWith(prefix, StringComparison.Ordinal))) + 1; + return string.Join(rule.Separator, rule.Segments.Select(segment => segment.Type switch + { + "year" => year[^Math.Min(year.Length, Math.Max(2, segment.Width == 0 ? 4 : segment.Width))..], + "school_code" => schoolCode.Length > 0 ? schoolCode : "NOSCHOOL", + "gender" => "X", + "sequence" => sequence.ToString(CultureInfo.InvariantCulture).PadLeft(Math.Max(1, segment.Width == 0 ? 4 : segment.Width), '0'), + _ => Clean(segment.Value, 20).ToUpperInvariant() + })); + } + + private 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(); + var output = new JsonObject { ["id"] = batch.Id, ["schoolId"] = batch.SchoolId, ["requestedBy"] = JsonValue.Create(batch.RequestedBy), ["status"] = batch.Status, ["reviewNote"] = batch.ReviewNote, ["createdAt"] = batch.CreatedAt, ["reviewedAt"] = JsonValue.Create(batch.ReviewedAt), ["schoolName"] = snapshot.Schools.FirstOrDefault(item => item.Id == batch.SchoolId)?.Name ?? "", ["requesterName"] = snapshot.Users.FirstOrDefault(item => item.Id == batch.RequestedBy)?.DisplayName ?? "原提交人", ["totalCount"] = items.Length, ["quotas"] = new JsonArray(quotas), ["items"] = new JsonArray(items.Select(item => ItemJson(snapshot, item)).ToArray()) }; + var instance = instanceOverride ?? snapshot.Instances.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.BusinessId == batch.Id); + output["workflow"] = instance is null ? null : WorkflowJson(snapshot, instance, extraActions); + return output; + } + + private static JsonObject WorkflowJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance, IReadOnlyList? extraActions) + { + 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(extraActions ?? []).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 IEnumerable ParseQuotas(JsonArray? array) => array?.OfType().Select(item => new Quota(Clean(Text(item["classId"]), 64), Number(item["count"]))) ?? []; + private static double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN; + 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 CanWriteCandidates(AuthenticationUser user) => Level(user) is "super" or "school"; + 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 string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso()); + private static JsonObject SchoolJson(AdminSchool item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["code"] = item.Code, ["address"] = item.Address, ["isSourceSchool"] = item.IsSourceSchool, ["isAdmissionSchool"] = item.IsAdmissionSchool, ["active"] = item.Active }; + private static JsonObject ClassJson(AdminClass item) => new() { ["id"] = item.Id, ["schoolId"] = item.SchoolId, ["name"] = item.Name, ["grade"] = item.Grade, ["active"] = item.Active }; + private static JsonObject ItemJson(AdminAccountBatchSnapshot snapshot, AccountBatchItem item) => new() { ["id"] = item.Id, ["batchId"] = item.BatchId, ["classId"] = item.ClassId, ["position"] = item.Position, ["candidateNumber"] = item.CandidateNumber, ["initialPassword"] = item.InitialPassword, ["userId"] = JsonValue.Create(item.UserId), ["createdAt"] = JsonValue.Create(item.CreatedAt), ["className"] = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Name ?? "未知班级", ["grade"] = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Grade ?? "" }; + 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) => Result(200, body); + private static AdminEndpointResult Result(int status, JsonObject body) => new(status, body); + private static AdminEndpointResult Error(int status, string message) => Result(status, new JsonObject { ["ok"] = false, ["message"] = message }); + private sealed record Quota(string ClassId, double Count); + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); } +} diff --git a/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs new file mode 100644 index 0000000..51930f0 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs @@ -0,0 +1,60 @@ +using System.Data.Common; +using System.Globalization; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminAccountBatchSnapshot( + IReadOnlyList Schools, + IReadOnlyList Classes, + IReadOnlyList Users, + IReadOnlyList Batches, + IReadOnlyList Items, + IReadOnlyList Workflows, + IReadOnlyList Instances, + IReadOnlyList Actions, + IReadOnlyList NumberRules); + +internal sealed record AccountBatch(string Id, string SchoolId, string? RequestedBy, string Status, string ReviewNote, string CreatedAt, string? ReviewedAt); +internal sealed record AccountBatchItem(string Id, string BatchId, string ClassId, int Position, string CandidateNumber, string InitialPassword, string? UserId, string? CreatedAt); +internal sealed record AccountWorkflow(string Id, string BusinessType, string Name, bool Active, IReadOnlyList Steps); +internal sealed record AccountWorkflowStep(string Id, string Name, string AdminLevel, int Position); +internal sealed record AccountWorkflowInstance(string Id, string WorkflowId, string BusinessType, string BusinessId, string Status, int CurrentStep, string? AssigneeId, string CreatedAt, string? CompletedAt); +internal sealed record AccountWorkflowAction(string Id, string InstanceId, string? ActorId, string Action, string Note, string? FromAssigneeId, string? ToAssigneeId, string CreatedAt); +internal sealed record AccountNumberRule(string Id, string Name, string Separator, bool Active, IReadOnlyList Segments); +internal sealed record AccountNumberSegment(string Id, string Type, string Value, int Width, int Position); + +internal sealed class AdminAccountBatchSnapshotLoader(IRelationalConnectionFactory connectionFactory) +{ + public async Task LoadAsync(CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var schools = await QueryAsync(connection, "SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id", reader => new AdminSchool(Text(reader, "id"), Text(reader, "name"), Text(reader, "code"), Optional(reader, "address") ?? "", Boolean(reader, "is_source_school"), Boolean(reader, "is_admission_school"), Boolean(reader, "active")), cancellationToken); + var classes = await QueryAsync(connection, "SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, grade, name, id", reader => new AdminClass(Text(reader, "id"), Text(reader, "school_id"), Text(reader, "name"), Text(reader, "grade"), Boolean(reader, "active")), cancellationToken); + var users = await QueryAsync(connection, "SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id", reader => new AdminUser(Text(reader, "id"), Text(reader, "username"), Text(reader, "role"), Optional(reader, "admin_level"), Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "display_name"), Optional(reader, "candidate_number"), Boolean(reader, "active"), Boolean(reader, "must_change_password"), Boolean(reader, "totp_enabled"), Optional(reader, "archived_at"), Text(reader, "created_at")), cancellationToken); + var batches = await QueryAsync(connection, "SELECT id, school_id, requested_by, status, review_note, created_at, reviewed_at FROM candidate_account_batches ORDER BY created_at DESC, id", reader => new AccountBatch(Text(reader, "id"), Text(reader, "school_id"), Optional(reader, "requested_by"), Text(reader, "status"), Optional(reader, "review_note") ?? "", Text(reader, "created_at"), Optional(reader, "reviewed_at")), cancellationToken); + var items = await QueryAsync(connection, "SELECT id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at FROM candidate_account_batch_items ORDER BY batch_id, position, id", reader => new AccountBatchItem(Text(reader, "id"), Text(reader, "batch_id"), Text(reader, "class_id"), Integer(reader, "position"), Optional(reader, "candidate_number") ?? "", Optional(reader, "initial_password") ?? "", Optional(reader, "user_id"), Optional(reader, "created_at")), cancellationToken); + var steps = await QueryAsync(connection, "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", reader => new StepRow(Text(reader, "workflow_id"), new AccountWorkflowStep(Text(reader, "id"), Text(reader, "name"), Text(reader, "admin_level"), Integer(reader, "position"))), cancellationToken); + var byWorkflow = steps.GroupBy(item => item.WorkflowId).ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.Step).ToArray(), StringComparer.Ordinal); + var workflows = await QueryAsync(connection, "SELECT id, business_type, name, active FROM workflow_definitions ORDER BY business_type, id", reader => { var id = Text(reader, "id"); return new AccountWorkflow(id, Text(reader, "business_type"), Text(reader, "name"), Boolean(reader, "active"), byWorkflow.GetValueOrDefault(id) ?? []); }, cancellationToken); + var instances = await QueryAsync(connection, "SELECT id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at FROM workflow_instances ORDER BY created_at DESC, id", reader => new AccountWorkflowInstance(Text(reader, "id"), Text(reader, "workflow_id"), Text(reader, "business_type"), Text(reader, "business_id"), Text(reader, "status"), Integer(reader, "current_step"), Optional(reader, "assignee_id"), Text(reader, "created_at"), Optional(reader, "completed_at")), cancellationToken); + var actions = await QueryAsync(connection, "SELECT id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at FROM workflow_actions ORDER BY created_at, id", reader => new AccountWorkflowAction(Text(reader, "id"), Text(reader, "instance_id"), Optional(reader, "actor_id"), Text(reader, "action"), Optional(reader, "note") ?? "", Optional(reader, "from_assignee_id"), Optional(reader, "to_assignee_id"), Text(reader, "created_at")), cancellationToken); + var segments = await QueryAsync(connection, "SELECT id, rule_id, type, value, width, position FROM number_rule_segments ORDER BY rule_id, position, id", reader => new SegmentRow(Text(reader, "rule_id"), new AccountNumberSegment(Text(reader, "id"), Text(reader, "type"), Optional(reader, "value") ?? "", Integer(reader, "width"), Integer(reader, "position"))), cancellationToken); + var byRule = segments.GroupBy(item => item.RuleId).ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(item => item.Segment).ToArray(), StringComparer.Ordinal); + var rules = await QueryAsync(connection, "SELECT id, name, `separator`, active FROM number_rules ORDER BY updated_at DESC, id", reader => { var id = Text(reader, "id"); return new AccountNumberRule(id, Text(reader, "name"), Text(reader, "separator"), Boolean(reader, "active"), byRule.GetValueOrDefault(id) ?? []); }, cancellationToken); + return new(schools, classes, users, batches, items, workflows, instances, actions, rules); + } + + private static async Task> QueryAsync(DbConnection connection, string sql, Func map, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); command.CommandText = sql; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var output = new List(); while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader)); return output; + } + private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? ""; + private static string? Optional(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); } + private static bool Boolean(DbDataReader reader, string name) => Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0; + private static int Integer(DbDataReader reader, string name) => Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); + private sealed record StepRow(string WorkflowId, AccountWorkflowStep Step); + private sealed record SegmentRow(string RuleId, AccountNumberSegment Segment); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index e76177f..1d55d44 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -2,13 +2,15 @@ namespace Eis.Infrastructure.Administration; public sealed record AdminMigrationOptions( bool NativeReadsEnabled, - bool NativeOrganizationWritesEnabled = false) + bool NativeOrganizationWritesEnabled = false, + bool NativeAccountBatchesEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, bool authenticationNativeEnabled, bool sharesLegacySessions, - bool configuredNativeOrganizationWritesEnabled = false) + bool configuredNativeOrganizationWritesEnabled = false, + bool configuredNativeAccountBatchesEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -16,12 +18,15 @@ public sealed record AdminMigrationOptions( var organizationWritesEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"), configuredNativeOrganizationWritesEnabled); - if (organizationWritesEnabled && !readsEnabled) + var accountBatchesEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"), + configuredNativeAccountBatchesEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -37,7 +42,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 3ee94e9..9cc5636 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -54,8 +54,11 @@ public static class DependencyInjection services.AddSingleton(adminMigrationOptions); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index a531e99..36ab0fd 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -25,23 +25,33 @@ public static class NativeAdminReadEndpoints endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => Execute(context, service.GetExamsAsync(Token(context), cancellationToken))); - if (!options.NativeOrganizationWritesEnabled) return endpoints; - endpoints.MapPost("/api/admin/schools", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.CreateSchoolAsync(Token(context), body, cancellationToken))); - endpoints.MapPatch("/api/admin/schools/{schoolId}", (HttpContext context, string schoolId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.UpdateSchoolAsync(Token(context), schoolId, body, cancellationToken))); - endpoints.MapPost("/api/admin/classes", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.CreateClassAsync(Token(context), body, cancellationToken))); - endpoints.MapPatch("/api/admin/classes/{classId}", (HttpContext context, string classId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.UpdateClassAsync(Token(context), classId, body, cancellationToken))); - endpoints.MapPost("/api/admin/admins", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.CreateAdminAsync(Token(context), body, cancellationToken))); - endpoints.MapPatch("/api/admin/admins/{adminId}", (HttpContext context, string adminId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.UpdateAdminAsync(Token(context), adminId, body, cancellationToken))); - endpoints.MapPost("/api/admin/admins/{adminId}/reset-password", (HttpContext context, string adminId, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.ResetAdminPasswordAsync(Token(context), adminId, cancellationToken))); - endpoints.MapPut("/api/admin/settings/self-registration", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => - Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken))); + if (options.NativeOrganizationWritesEnabled) + { + endpoints.MapPost("/api/admin/schools", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateSchoolAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/schools/{schoolId}", (HttpContext context, string schoolId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateSchoolAsync(Token(context), schoolId, body, cancellationToken))); + endpoints.MapPost("/api/admin/classes", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateClassAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/classes/{classId}", (HttpContext context, string classId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateClassAsync(Token(context), classId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admins", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAdminAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/admins/{adminId}", (HttpContext context, string adminId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAdminAsync(Token(context), adminId, body, cancellationToken))); + endpoints.MapPost("/api/admin/admins/{adminId}/reset-password", (HttpContext context, string adminId, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.ResetAdminPasswordAsync(Token(context), adminId, cancellationToken))); + endpoints.MapPut("/api/admin/settings/self-registration", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken))); + } + + if (!options.NativeAccountBatchesEnabled) return endpoints; + endpoints.MapGet("/api/admin/candidate-account-batches", (HttpContext context, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/candidate-account-batches", (HttpContext context, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.SubmitAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/candidate-account-batches/{batchId}", (HttpContext context, string batchId, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken))); return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index ff1bfd1..104bfe6 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -46,7 +46,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeReadsEnabled"), authenticationOptions.NativeEnabled, authenticationOptions.SharesLegacySessions, - builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -102,6 +103,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c { nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled, nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled, + nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -112,6 +114,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c "POST admin password reset", "PUT self-registration setting" } : []) + .Concat(adminMigrationOptions.NativeAccountBatchesEnabled + ? new[] { "GET/POST/PATCH candidate-account-batches" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 5c8551c..34e5d82 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -11,7 +11,8 @@ }, "AdminMigration": { "NativeReadsEnabled": false, - "NativeOrganizationWritesEnabled": false + "NativeOrganizationWritesEnabled": false, + "NativeAccountBatchesEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 82000e2..cf68d30 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -92,19 +92,37 @@ public sealed class AdminMigrationOptionsTests }); } + [Fact] + public void EnablesAccountBatchesWithReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeAccountBatchesEnabled); + }, accountBatchesEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, string? allowMemory, - Action test) + Action test, + string? accountBatchesEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); + var previousAccountBatchesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", accountBatchesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -112,6 +130,7 @@ public sealed class AdminMigrationOptionsTests { Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", previousAccountBatchesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }