diff --git a/.env.example b/.env.example index 65fa821..d1e5087 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,8 @@ ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false ADMIN_NATIVE_CONFIGURATION_ENABLED=false # 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。 ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false +# 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。 +ADMIN_NATIVE_CENTERS_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 8170612..ffc4ca2 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 原生处理。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -65,9 +65,10 @@ $env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' $env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' $env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' +$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true' ``` -旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 024359e..a5e4ebb 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -441,6 +441,7 @@ try { ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' + ADMIN_NATIVE_CENTERS_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -801,6 +802,131 @@ try { throw 'Native admins route did not preserve the class-admin boundary' } + $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') { + throw 'Native center management list did not use ASP.NET Core' + } + Assert-JsonEquivalent -Expected $legacyCenters.Content -Actual $nativeCenters.Content -Label 'Super-admin center management' + $legacySchoolCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $legacySchoolSession + $nativeSchoolCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSchoolSession + Assert-JsonEquivalent -Expected $legacySchoolCenters.Content -Actual $nativeSchoolCenters.Content -Label 'School-admin center management' + $classCentersForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeClassSession -SkipHttpErrorCheck + if ($classCentersForbidden.StatusCode -ne 403 -or $classCentersForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native center management did not preserve the class-admin boundary' + } + + $schoolCenterState = $nativeSchoolCenters.Content | ConvertFrom-Json + $templateCenter = @($schoolCenterState.centers)[0] + if ($null -eq $templateCenter) { + throw 'Seed data did not provide a school-scoped center for center-management smoke testing' + } + $invalidCenterBody = @{ + code = 'NATIVE_INVALID_CENTER' + name = '无考场测试考点' + provinceCode = $templateCenter.provinceCode + cityCode = $templateCenter.cityCode + districtCode = $templateCenter.districtCode + address = '迁移测试路 10 号' + rooms = @() + } | ConvertTo-Json -Depth 5 -Compress + $invalidCenter = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -Method Post -ContentType 'application/json' -Body $invalidCenterBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($invalidCenter.StatusCode -ne 400) { + throw 'Native center submission accepted a center without structured rooms' + } + $centerCreateBody = @{ + code = 'NATIVE_CENTER' + name = '原生迁移考点' + provinceCode = $templateCenter.provinceCode + cityCode = $templateCenter.cityCode + districtCode = $templateCenter.districtCode + address = '迁移测试路 11 号' + contact = '迁移联系人' + managerName = '考务负责人' + managerPhone = '13800001111' + emergencyPhone = '13800002222' + gateOpenTime = '07:00' + transport = '地铁迁移测试站' + status = 'active' + notes = 'ASP.NET Core 原生考点' + rooms = @(@{ + code = 'NATIVE_ROOM_01' + name = '迁移第一考场' + building = '迁移楼' + floor = '1层' + capacity = 32 + seatPlan = '4x8' + roomType = 'standard' + status = 'active' + notes = '原生创建' + }) + } | ConvertTo-Json -Depth 6 -Compress + $centerCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -Method Post -ContentType 'application/json' -Body $centerCreateBody -WebSession $nativeSchoolSession + $createdCenterChange = ($centerCreateResponse.Content | ConvertFrom-Json).changeRequest + if ($centerCreateResponse.StatusCode -ne 202 -or $centerCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $createdCenterChange.status -ne 'pending' -or $createdCenterChange.workflow.assignee.adminLevel -ne 'super') { + throw 'Native center submission did not create the expected super-admin workflow' + } + $duplicateCenterSubmission = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -Method Post -ContentType 'application/json' -Body $centerCreateBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($duplicateCenterSubmission.StatusCode -ne 409) { + throw 'Native center submission did not reserve pending center codes' + } + $centerApprovalBody = @{ status = 'approved'; reviewNote = '原生考点终审通过' } | ConvertTo-Json -Compress + $centerApprovalResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/center-change-requests/$($createdCenterChange.id)" -Method Patch -ContentType 'application/json' -Body $centerApprovalBody -WebSession $nativeSession + if ($centerApprovalResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or ($centerApprovalResponse.Content | ConvertFrom-Json).changeRequest.status -ne 'approved') { + throw 'Native center approval did not complete the workflow' + } + $centersAfterCreate = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSchoolSession + $createdCenter = @($centersAfterCreate.centers | Where-Object { $_.code -eq 'NATIVE_CENTER' })[0] + if ($null -eq $createdCenter -or $createdCenter.totalCapacity -ne 32 -or $createdCenter.rooms.Count -ne 1) { + throw 'Native center approval did not materialize the center and room atomically' + } + + $centerUpdateBody = @{ + code = $createdCenter.code + name = '原生迁移考点(更新)' + provinceCode = $createdCenter.provinceCode + cityCode = $createdCenter.cityCode + districtCode = $createdCenter.districtCode + address = '迁移测试路 12 号' + contact = $createdCenter.contact + managerName = $createdCenter.managerName + managerPhone = $createdCenter.managerPhone + emergencyPhone = $createdCenter.emergencyPhone + gateOpenTime = $createdCenter.gateOpenTime + transport = $createdCenter.transport + status = 'active' + notes = 'ASP.NET Core 原生考点已更新' + rooms = @($createdCenter.rooms | ForEach-Object { + @{ + id = $_.id + code = $_.code + name = '迁移第一考场(更新)' + building = $_.building + floor = $_.floor + capacity = 36 + seatPlan = $_.seatPlan + roomType = $_.roomType + status = $_.status + notes = '原生更新' + } + }) + } | ConvertTo-Json -Depth 6 -Compress + $centerUpdateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers/$($createdCenter.id)" -Method Patch -ContentType 'application/json' -Body $centerUpdateBody -WebSession $nativeSchoolSession + $updatedCenterChange = ($centerUpdateResponse.Content | ConvertFrom-Json).changeRequest + if ($centerUpdateResponse.StatusCode -ne 202 -or $updatedCenterChange.requestType -ne 'update') { + throw 'Native center update did not create a change workflow' + } + $duplicateCenterUpdate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers/$($createdCenter.id)" -Method Patch -ContentType 'application/json' -Body $centerUpdateBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($duplicateCenterUpdate.StatusCode -ne 409) { + throw 'Native center update allowed a second pending change for the same center' + } + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/center-change-requests/$($updatedCenterChange.id)" -Method Patch -ContentType 'application/json' -Body $centerApprovalBody -WebSession $nativeSession | Out-Null + $centersAfterUpdate = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSchoolSession + $updatedCenter = @($centersAfterUpdate.centers | Where-Object { $_.id -eq $createdCenter.id })[0] + if ($updatedCenter.name -ne '原生迁移考点(更新)' -or $updatedCenter.address -ne '迁移测试路 12 号' -or $updatedCenter.totalCapacity -ne 36 -or $updatedCenter.pendingChange -ne $false) { + throw 'Native center update approval did not replace the formal center archive' + } + foreach ($configurationRoute in @('number-rules', 'workflows')) { $legacyConfiguration = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$configurationRoute" -WebSession $session $nativeConfiguration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$configurationRoute" -WebSession $nativeSession @@ -1143,6 +1269,7 @@ try { NativeAdminAccountBatches = 'passed' NativeAdminConfiguration = 'passed' NativeAdminNoticeManagement = 'passed' + NativeAdminCenters = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminCenterService.cs b/src/Eis.Application/Administration/IAdminCenterService.cs new file mode 100644 index 0000000..ed182ad --- /dev/null +++ b/src/Eis.Application/Administration/IAdminCenterService.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminCenterService +{ + Task GetAsync(string sessionToken, CancellationToken cancellationToken); + Task CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task UpdateAsync(string sessionToken, string centerId, JsonObject body, CancellationToken cancellationToken); + Task ReviewAsync(string sessionToken, string changeRequestId, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminCenterRepository.cs b/src/Eis.Infrastructure/Administration/AdminCenterRepository.cs new file mode 100644 index 0000000..29adfc9 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminCenterRepository.cs @@ -0,0 +1,194 @@ +using System.Data.Common; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminCenterRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task CreateRequestAsync( + AdminCenterChange change, + IReadOnlyList rooms, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operations = new List { InsertChange(change), InsertInstance(instance), InsertAction(action) }; + operations.AddRange(rooms.Select(InsertChangeRoom)); + operations.Add(InsertAudit(audit)); + return ExecuteTransactionAsync(operations, cancellationToken); + } + + public Task ProcessAsync( + AdminCenterChange change, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminAuditEntry audit, + CancellationToken cancellationToken) => ExecuteTransactionAsync( + [UpdateInstance(instance), InsertAction(action), UpdateChange(change), InsertAudit(audit)], cancellationToken); + + public Task ApplyAsync( + AdminCenterChange change, + AccountWorkflowInstance instance, + AccountWorkflowAction action, + AdminCenter center, + IReadOnlyList rooms, + AdminAuditEntry audit, + CancellationToken cancellationToken) + { + var operations = new List + { + UpdateInstance(instance), + InsertAction(action), + UpdateChange(change), + change.RequestType == "create" ? InsertCenter(center) : UpdateCenter(center), + new("DELETE FROM test_rooms WHERE center_id = @centerId", [new("@centerId", center.Id)]) + }; + operations.AddRange(rooms.Select(InsertRoom)); + 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 InsertChange(AdminCenterChange item) => new(""" + INSERT INTO center_change_requests ( + id, center_id, school_id, request_type, code, name, province_code, province_name, city_code, city_name, + district_code, district_name, address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, center_status, notes, status, review_note, requested_by, created_at, reviewed_at + ) VALUES ( + @id, @centerId, @schoolId, @requestType, @code, @name, @provinceCode, @provinceName, @cityCode, @cityName, + @districtCode, @districtName, @address, @contact, @managerName, @managerPhone, @emergencyPhone, + @gateOpenTime, @transport, @centerStatus, @notes, @status, @reviewNote, @requestedBy, @createdAt, @reviewedAt + ) + """, ChangeParameters(item)); + + private static SqlOperation UpdateChange(AdminCenterChange item) => new(""" + UPDATE center_change_requests 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 IReadOnlyList ChangeParameters(AdminCenterChange item) => + [ + new("@id", item.Id), new("@centerId", item.CenterId), new("@schoolId", item.SchoolId), new("@requestType", item.RequestType), + new("@code", item.Code), new("@name", item.Name), new("@provinceCode", item.ProvinceCode), new("@provinceName", item.ProvinceName), + new("@cityCode", item.CityCode), new("@cityName", item.CityName), new("@districtCode", item.DistrictCode), new("@districtName", item.DistrictName), + new("@address", item.Address), new("@contact", Optional(item.Contact)), new("@managerName", Optional(item.ManagerName)), + new("@managerPhone", Optional(item.ManagerPhone)), new("@emergencyPhone", Optional(item.EmergencyPhone)), + new("@gateOpenTime", Optional(item.GateOpenTime)), new("@transport", Optional(item.Transport)), new("@centerStatus", item.CenterStatus), + new("@notes", Optional(item.Notes)), new("@status", item.Status), new("@reviewNote", Optional(item.ReviewNote)), + new("@requestedBy", item.RequestedBy), new("@createdAt", item.CreatedAt), new("@reviewedAt", item.ReviewedAt) + ]; + + private static SqlOperation InsertChangeRoom(AdminCenterChangeRoom item) => new(""" + INSERT INTO center_change_rooms ( + id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES ( + @id, @requestId, @roomId, @code, @name, @building, @floor, @capacity, @seatPlan, @seatStart, @seatEnd, @roomType, @status, @notes + ) + """, + [ + new("@id", item.Id), new("@requestId", item.RequestId), new("@roomId", item.RoomId), new("@code", item.Code), + new("@name", item.Name), new("@building", item.Building), new("@floor", Optional(item.Floor)), new("@capacity", item.Capacity), + new("@seatPlan", Optional(item.SeatPlan)), new("@seatStart", item.SeatStart), new("@seatEnd", item.SeatEnd), + new("@roomType", item.RoomType), new("@status", item.Status), new("@notes", Optional(item.Notes)) + ]); + + private static SqlOperation InsertCenter(AdminCenter item) => new(""" + INSERT INTO test_centers ( + id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, manager_name, manager_phone, emergency_phone, gate_open_time, transport, status, notes, rooms, updated_at + ) VALUES ( + @id, @schoolId, @code, @name, @provinceCode, @provinceName, @cityCode, @cityName, @districtCode, @districtName, + @address, @contact, @managerName, @managerPhone, @emergencyPhone, @gateOpenTime, @transport, @status, @notes, @rooms, @updatedAt + ) + """, CenterParameters(item)); + + private static SqlOperation UpdateCenter(AdminCenter item) => new(""" + UPDATE test_centers SET code = @code, name = @name, province_code = @provinceCode, province_name = @provinceName, + city_code = @cityCode, city_name = @cityName, district_code = @districtCode, district_name = @districtName, + address = @address, contact = @contact, manager_name = @managerName, manager_phone = @managerPhone, + emergency_phone = @emergencyPhone, gate_open_time = @gateOpenTime, transport = @transport, status = @status, + notes = @notes, rooms = @rooms, updated_at = @updatedAt WHERE id = @id + """, CenterParameters(item)); + + private static IReadOnlyList CenterParameters(AdminCenter item) => + [ + new("@id", item.Id), new("@schoolId", item.SchoolId), new("@code", item.Code), new("@name", item.Name), + new("@provinceCode", item.ProvinceCode), new("@provinceName", item.ProvinceName), new("@cityCode", item.CityCode), + new("@cityName", item.CityName), new("@districtCode", item.DistrictCode), new("@districtName", item.DistrictName), + new("@address", item.Address), new("@contact", Optional(item.Contact)), new("@managerName", Optional(item.ManagerName)), + new("@managerPhone", Optional(item.ManagerPhone)), new("@emergencyPhone", Optional(item.EmergencyPhone)), + new("@gateOpenTime", Optional(item.GateOpenTime)), new("@transport", Optional(item.Transport)), new("@status", item.Status), + new("@notes", Optional(item.Notes)), new("@rooms", item.Rooms), new("@updatedAt", item.UpdatedAt) + ]; + + private static SqlOperation InsertRoom(AdminCenterRoom item) => new(""" + INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES ( + @id, @ownerId, @code, @name, @building, @floor, @capacity, @seatPlan, @seatStart, @seatEnd, @roomType, @status, @notes + ) + """, + [ + new("@id", item.Id), new("@ownerId", item.CenterId), new("@code", item.Code), new("@name", item.Name), + new("@building", item.Building), new("@floor", Optional(item.Floor)), new("@capacity", item.Capacity), + new("@seatPlan", Optional(item.SeatPlan)), new("@seatStart", item.SeatStart), new("@seatEnd", item.SeatEnd), + new("@roomType", item.RoomType), new("@status", item.Status), new("@notes", Optional(item.Notes)) + ]); + + 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 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/AdminCenterService.cs b/src/Eis.Infrastructure/Administration/AdminCenterService.cs new file mode 100644 index 0000000..893e456 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminCenterService.cs @@ -0,0 +1,532 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Candidate; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminCenterService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + RegionCatalog regionCatalog, + AdminAccountBatchSnapshotLoader workflowSnapshotLoader, + AdminCenterSnapshotLoader centerSnapshotLoader, + AdminCenterRepository repository) : IAdminCenterService +{ + 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 (!CanManageCenters(user)) return Error(403, "当前管理员层级无权执行此操作"); + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var centers = await centerSnapshotLoader.LoadAsync(cancellationToken); + var visibleCenters = centers.Centers.Where(item => Level(user) == "super" || item.SchoolId == user.SchoolId) + .Select(item => CenterJson(workflow, centers, item)).ToArray(); + var changes = centers.Changes.Where(item => Level(user) == "super" || item.SchoolId == user.SchoolId) + .Select(item => ChangeJson(workflow, centers, item)).ToArray(); + var schools = workflow.Schools.Where(item => Level(user) == "super" || item.Id == user.SchoolId) + .Select(SchoolJson).ToArray(); + return Success(new JsonObject + { + ["ok"] = true, + ["centers"] = new JsonArray(visibleCenters), + ["changeRequests"] = new JsonArray(changes), + ["schools"] = new JsonArray(schools) + }); + } + + public async Task CreateAsync( + 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 (!CanManageCenters(user)) return Error(403, "当前管理员层级无权执行此操作"); + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var centers = await centerSnapshotLoader.LoadAsync(cancellationToken); + var schoolId = Level(user) == "super" ? Clean(Text(body["schoolId"]), 64) : user.SchoolId ?? ""; + if (!workflow.Schools.Any(item => item.Id == schoolId)) return Error(400, "考点必须归属有效学校"); + var parsed = Parse(body, schoolId, null, centers); + if (parsed.Error is not null) return parsed.Error; + return await SubmitAsync(user, workflow, centers, parsed.Value!, null, "create", cancellationToken); + } + + public async Task UpdateAsync( + string sessionToken, + string centerId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (!CanManageCenters(user)) return Error(403, "当前管理员层级无权执行此操作"); + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var centers = await centerSnapshotLoader.LoadAsync(cancellationToken); + var center = centers.Centers.FirstOrDefault(item => item.Id == centerId); + if (center is null) return Error(404, "考点不存在"); + if (Level(user) != "super" && center.SchoolId != user.SchoolId) return Error(403, "只能维护本校考点"); + if (centers.Changes.Any(item => item.CenterId == center.Id && item.Status == "pending")) + { + return Error(409, "该考点已有待审批变更,请处理完成后再提交"); + } + var parsed = Parse(body, center.SchoolId, center, centers); + if (parsed.Error is not null) return parsed.Error; + return await SubmitAsync(user, workflow, centers, parsed.Value!, center.Id, "update", cancellationToken); + } + + public async Task ReviewAsync( + string sessionToken, + string changeRequestId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (!CanManageCenters(user)) return Error(403, "当前管理员层级无权执行此操作"); + var status = Text(body["status"]); + if (status is not ("approved" or "rejected")) return Error(400, "审批状态无效"); + var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken); + var centers = await centerSnapshotLoader.LoadAsync(cancellationToken); + var change = centers.Changes.FirstOrDefault(item => item.Id == changeRequestId && item.Status == "pending"); + if (change is null) return Error(404, "待审批的考点变更不存在"); + if (Level(user) != "super" && change.SchoolId != user.SchoolId) return Error(403, "该变更不在你的学校范围内"); + var instance = workflow.Instances.FirstOrDefault(item => + item.BusinessType == "center_change" && item.BusinessId == change.Id && item.Status == "pending"); + var definition = workflow.Workflows.FirstOrDefault(item => item.Id == instance?.WorkflowId); + var step = definition?.Steps.FirstOrDefault(item => item.Position == instance?.CurrentStep); + if (instance is null || definition 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 audit = Audit( + user, + status == "approved" ? "审批考点变更" : "退回考点变更", + $"{change.Name} · {(note.Length == 0 ? "无备注" : note)}"); + + if (status == "rejected") + { + var rejectedAt = NowIso(); + var rejectedChange = change with { Status = "rejected", ReviewNote = note, ReviewedAt = rejectedAt }; + var rejectedInstance = instance with { Status = "rejected", AssigneeId = null, CompletedAt = rejectedAt }; + await repository.ProcessAsync(rejectedChange, rejectedInstance, action, audit, cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["changeRequest"] = ChangeJson(workflow, centers, rejectedChange, rejectedInstance, [action]) + }); + } + + if (instance.CurrentStep < definition.Steps.Count) + { + var nextStep = definition.Steps.First(item => item.Position == instance.CurrentStep + 1); + var nextAssignee = SelectAssignee(workflow, nextStep.AdminLevel, change.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 advancedChange = change with { ReviewNote = note }; + await repository.ProcessAsync(advancedChange, advancedInstance, advancedAction, audit, cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["changeRequest"] = ChangeJson(workflow, centers, advancedChange, advancedInstance, [advancedAction]) + }); + } + + var completedAt = NowIso(); + var completedChange = change with { Status = "approved", ReviewNote = note, ReviewedAt = completedAt }; + var completedInstance = instance with { Status = "approved", AssigneeId = null, CompletedAt = completedAt }; + var centerId = change.CenterId ?? Uid("center"); + var proposedRooms = centers.ChangeRooms.Where(item => item.RequestId == change.Id).ToArray(); + var rooms = proposedRooms.Select(item => new AdminCenterRoom( + item.RoomId ?? Uid("room"), centerId, item.Code, item.Name, item.Building, item.Floor, item.Capacity, + item.SeatPlan, 1, item.Capacity, item.RoomType, item.Status, item.Notes)).ToArray(); + var center = new AdminCenter( + centerId, change.SchoolId, change.Code, change.Name, change.ProvinceCode, change.ProvinceName, + change.CityCode, change.CityName, change.DistrictCode, change.DistrictName, change.Address, change.Contact, + change.ManagerName, change.ManagerPhone, change.EmergencyPhone, change.GateOpenTime, change.Transport, + change.CenterStatus, change.Notes, string.Join(";", rooms.Select(item => $"{item.Building} {item.Name}")), NowIso()); + await repository.ApplyAsync(completedChange, completedInstance, action, center, rooms, audit, cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["changeRequest"] = ChangeJson(workflow, centers, completedChange, completedInstance, [action]) + }); + } + + private async Task SubmitAsync( + AuthenticationUser user, + AdminAccountBatchSnapshot workflow, + AdminCenterSnapshot centers, + ParsedCenter parsed, + string? centerId, + string requestType, + CancellationToken cancellationToken) + { + var definition = workflow.Workflows.FirstOrDefault(item => item.BusinessType == "center_change" && item.Active); + if (definition is null || definition.Steps.Count == 0) return Error(409, "该业务尚未配置审批流程"); + var firstStep = definition.Steps[0]; + var assignee = SelectAssignee(workflow, firstStep.AdminLevel, parsed.SchoolId); + if (assignee is null) return Error(409, $"没有可承接“{firstStep.Name}”的{LevelNames[firstStep.AdminLevel]}"); + var now = NowIso(); + var changeId = Uid("center_change"); + var change = new AdminCenterChange( + changeId, centerId, parsed.SchoolId, requestType, parsed.Code, parsed.Name, + parsed.ProvinceCode, parsed.ProvinceName, parsed.CityCode, parsed.CityName, parsed.DistrictCode, parsed.DistrictName, + parsed.Address, parsed.Contact, parsed.ManagerName, parsed.ManagerPhone, parsed.EmergencyPhone, + parsed.GateOpenTime, parsed.Transport, parsed.Status, parsed.Notes, "pending", "", user.Id, now, null); + var rooms = parsed.Rooms.Select(item => item with { RequestId = changeId }).ToArray(); + var instance = new AccountWorkflowInstance( + Uid("flow"), definition.Id, "center_change", change.Id, "pending", 1, assignee.Id, NowIso(), null); + var action = new AccountWorkflowAction( + Uid("flow_action"), instance.Id, user.Id, "submit", "提交审批", null, assignee.Id, NowIso()); + var auditAction = requestType == "create" ? "提交新增考点审批" : "提交考点变更审批"; + await repository.CreateRequestAsync( + change, rooms, instance, action, + Audit(user, auditAction, $"{change.Name} · {rooms.Length} 个考场"), cancellationToken); + return Result(202, new JsonObject + { + ["ok"] = true, + ["changeRequest"] = ChangeJson(workflow, centers, change, instance, [action], rooms) + }); + } + + private ParsedResult Parse(JsonObject body, string schoolId, AdminCenter? existing, AdminCenterSnapshot snapshot) + { + var code = Clean(Text(body["code"]), 30).ToUpperInvariant(); + var name = Clean(Text(body["name"]), 100); + var address = Clean(Text(body["address"]), 200); + var region = regionCatalog.Resolve(Text(body["provinceCode"]), Text(body["cityCode"]), Text(body["districtCode"])); + if (code.Length == 0 || name.Length == 0 || address.Length == 0 || region is null) + { + return ParsedResult.Failed(Error(400, "请填写考点代码、名称、省市区县和详细地址")); + } + if (body["rooms"] is not JsonArray requestedRooms || requestedRooms.Count == 0) + { + return ParsedResult.Failed(Error(400, "请至少配置一个结构化考场")); + } + var duplicate = snapshot.Centers.Any(item => item.Code.Equals(code, StringComparison.OrdinalIgnoreCase) && item.Id != existing?.Id) || + snapshot.Changes.Any(item => item.Status == "pending" && item.Code.Equals(code, StringComparison.OrdinalIgnoreCase) && + (existing is null || item.CenterId != existing.Id)); + if (duplicate) return ParsedResult.Failed(Error(409, "考点代码已被正式档案或待审批申请占用")); + var roomCodes = new HashSet(StringComparer.Ordinal); + var rooms = new List(); + for (var index = 0; index < requestedRooms.Count; index++) + { + if (requestedRooms[index] is not JsonObject room) + { + return ParsedResult.Failed(Error(400, $"第 {index + 1} 个考场的代码、名称、楼栋或容量无效")); + } + var roomCode = Clean(Text(room["code"]), 30).ToUpperInvariant(); + var roomName = Clean(Text(room["name"]), 80); + var building = Clean(Text(room["building"]), 80); + var capacity = Number(room["capacity"]); + if (roomCode.Length == 0 || roomName.Length == 0 || building.Length == 0 || !double.IsInteger(capacity) || capacity < 1 || capacity > int.MaxValue) + { + return ParsedResult.Failed(Error(400, $"第 {index + 1} 个考场的代码、名称、楼栋或容量无效")); + } + if (!roomCodes.Add(roomCode)) return ParsedResult.Failed(Error(400, $"考场代码 {roomCode} 重复")); + var integerCapacity = (int)capacity; + var roomType = Text(room["roomType"]); + rooms.Add(new AdminCenterChangeRoom( + Uid("change_room"), "", Optional(Clean(Text(room["id"]), 64)), roomCode, roomName, building, + Clean(Text(room["floor"]), 30), integerCapacity, Clean(Text(room["seatPlan"]), 500), 1, integerCapacity, + roomType is "standard" or "computer" or "accessible" or "spare" ? roomType : "standard", + Text(room["status"]) == "inactive" ? "inactive" : "active", Clean(Text(room["notes"]), 300))); + } + return ParsedResult.Succeeded(new ParsedCenter( + schoolId, code, name, region.ProvinceCode, region.ProvinceName, region.CityCode, region.CityName, + region.DistrictCode, region.DistrictName, address, Clean(Text(body["contact"]), 80), + Clean(Text(body["managerName"]), 50), Clean(Text(body["managerPhone"]), 30), + Clean(Text(body["emergencyPhone"]), 30), Clean(Text(body["gateOpenTime"]), 20), + Clean(Text(body["transport"]), 500), Text(body["status"]) == "inactive" ? "inactive" : "active", + Clean(Text(body["notes"]), 1000), rooms)); + } + + 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 JsonObject CenterJson(AdminAccountBatchSnapshot workflow, AdminCenterSnapshot snapshot, AdminCenter item) + { + var rooms = snapshot.Rooms.Where(room => room.CenterId == item.Id).Select(RoomJson).ToArray(); + return new JsonObject + { + ["id"] = item.Id, + ["schoolId"] = item.SchoolId, + ["code"] = item.Code, + ["name"] = item.Name, + ["provinceCode"] = item.ProvinceCode, + ["provinceName"] = item.ProvinceName, + ["cityCode"] = item.CityCode, + ["cityName"] = item.CityName, + ["districtCode"] = item.DistrictCode, + ["districtName"] = item.DistrictName, + ["address"] = item.Address, + ["contact"] = item.Contact, + ["managerName"] = item.ManagerName, + ["managerPhone"] = item.ManagerPhone, + ["emergencyPhone"] = item.EmergencyPhone, + ["gateOpenTime"] = item.GateOpenTime, + ["transport"] = item.Transport, + ["status"] = item.Status, + ["notes"] = item.Notes, + ["rooms"] = new JsonArray(rooms), + ["updatedAt"] = item.UpdatedAt, + ["schoolName"] = workflow.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Name ?? "", + ["totalCapacity"] = snapshot.Rooms.Where(room => room.CenterId == item.Id && room.Status == "active").Sum(room => room.Capacity), + ["pendingChange"] = snapshot.Changes.Any(change => change.CenterId == item.Id && change.Status == "pending") + }; + } + + private static JsonObject ChangeJson( + AdminAccountBatchSnapshot workflow, + AdminCenterSnapshot centers, + AdminCenterChange item, + AccountWorkflowInstance? instanceOverride = null, + IReadOnlyList? extraActions = null, + IReadOnlyList? roomOverride = null) + { + var rooms = (roomOverride ?? centers.ChangeRooms.Where(room => room.RequestId == item.Id).ToArray()) + .Select(ChangeRoomJson).ToArray(); + var instance = instanceOverride ?? workflow.Instances.FirstOrDefault(entry => entry.BusinessType == "center_change" && entry.BusinessId == item.Id); + return new JsonObject + { + ["id"] = item.Id, + ["centerId"] = JsonValue.Create(item.CenterId), + ["schoolId"] = item.SchoolId, + ["requestType"] = item.RequestType, + ["code"] = item.Code, + ["name"] = item.Name, + ["provinceCode"] = item.ProvinceCode, + ["provinceName"] = item.ProvinceName, + ["cityCode"] = item.CityCode, + ["cityName"] = item.CityName, + ["districtCode"] = item.DistrictCode, + ["districtName"] = item.DistrictName, + ["address"] = item.Address, + ["contact"] = item.Contact, + ["managerName"] = item.ManagerName, + ["managerPhone"] = item.ManagerPhone, + ["emergencyPhone"] = item.EmergencyPhone, + ["gateOpenTime"] = item.GateOpenTime, + ["transport"] = item.Transport, + ["centerStatus"] = item.CenterStatus, + ["notes"] = item.Notes, + ["status"] = item.Status, + ["reviewNote"] = item.ReviewNote, + ["requestedBy"] = JsonValue.Create(item.RequestedBy), + ["createdAt"] = item.CreatedAt, + ["reviewedAt"] = JsonValue.Create(item.ReviewedAt), + ["schoolName"] = workflow.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Name ?? "", + ["rooms"] = new JsonArray(rooms), + ["workflow"] = instance is null ? null : WorkflowJson(workflow, instance, extraActions) + }; + } + + 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 JsonObject RoomJson(AdminCenterRoom item) => new() + { + ["id"] = item.Id, + ["centerId"] = item.CenterId, + ["code"] = item.Code, + ["name"] = item.Name, + ["building"] = item.Building, + ["floor"] = item.Floor, + ["capacity"] = item.Capacity, + ["seatPlan"] = item.SeatPlan, + ["seatStart"] = item.SeatStart, + ["seatEnd"] = item.SeatEnd, + ["roomType"] = item.RoomType, + ["status"] = item.Status, + ["notes"] = item.Notes + }; + + private static JsonObject ChangeRoomJson(AdminCenterChangeRoom item) => new() + { + ["id"] = item.Id, + ["requestId"] = item.RequestId, + ["roomId"] = JsonValue.Create(item.RoomId), + ["code"] = item.Code, + ["name"] = item.Name, + ["building"] = item.Building, + ["floor"] = item.Floor, + ["capacity"] = item.Capacity, + ["seatPlan"] = item.SeatPlan, + ["seatStart"] = item.SeatStart, + ["seatEnd"] = item.SeatEnd, + ["roomType"] = item.RoomType, + ["status"] = item.Status, + ["notes"] = item.Notes + }; + + 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 StepJson(AccountWorkflowStep item) => new() + { + ["id"] = item.Id, + ["position"] = item.Position, + ["name"] = item.Name, + ["adminLevel"] = item.AdminLevel + }; + + 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 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 string? Optional(string value) => value.Length == 0 ? null : value; + private static bool CanManageCenters(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 AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso()); + 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 ParsedCenter( + string SchoolId, + string Code, + string Name, + string ProvinceCode, + string ProvinceName, + string CityCode, + string CityName, + string DistrictCode, + string DistrictName, + string Address, + string Contact, + string ManagerName, + string ManagerPhone, + string EmergencyPhone, + string GateOpenTime, + string Transport, + string Status, + string Notes, + IReadOnlyList Rooms); + + private sealed record ParsedResult(ParsedCenter? Value, AdminEndpointResult? Error) + { + public static ParsedResult Succeeded(ParsedCenter value) => new(value, null); + public static ParsedResult Failed(AdminEndpointResult error) => new(null, error); + } + + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) + { + public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminCenterSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminCenterSnapshot.cs new file mode 100644 index 0000000..189a0e5 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminCenterSnapshot.cs @@ -0,0 +1,188 @@ +using System.Data.Common; +using System.Globalization; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminCenterSnapshot( + IReadOnlyList Centers, + IReadOnlyList Rooms, + IReadOnlyList Changes, + IReadOnlyList ChangeRooms); + +internal sealed record AdminCenter( + string Id, + string SchoolId, + string Code, + string Name, + string ProvinceCode, + string ProvinceName, + string CityCode, + string CityName, + string DistrictCode, + string DistrictName, + string Address, + string Contact, + string ManagerName, + string ManagerPhone, + string EmergencyPhone, + string GateOpenTime, + string Transport, + string Status, + string Notes, + string Rooms, + string UpdatedAt); + +internal sealed record AdminCenterRoom( + string Id, + string CenterId, + string Code, + string Name, + string Building, + string Floor, + int Capacity, + string SeatPlan, + int SeatStart, + int SeatEnd, + string RoomType, + string Status, + string Notes); + +internal sealed record AdminCenterChange( + string Id, + string? CenterId, + string SchoolId, + string RequestType, + string Code, + string Name, + string ProvinceCode, + string ProvinceName, + string CityCode, + string CityName, + string DistrictCode, + string DistrictName, + string Address, + string Contact, + string ManagerName, + string ManagerPhone, + string EmergencyPhone, + string GateOpenTime, + string Transport, + string CenterStatus, + string Notes, + string Status, + string ReviewNote, + string? RequestedBy, + string CreatedAt, + string? ReviewedAt); + +internal sealed record AdminCenterChangeRoom( + string Id, + string RequestId, + string? RoomId, + string Code, + string Name, + string Building, + string Floor, + int Capacity, + string SeatPlan, + int SeatStart, + int SeatEnd, + string RoomType, + string Status, + string Notes); + +internal sealed class AdminCenterSnapshotLoader(IRelationalConnectionFactory connectionFactory) +{ + public async Task LoadAsync(CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var centers = await QueryAsync(connection, + """ + SELECT id, school_id, code, name, province_code, province_name, city_code, city_name, + district_code, district_name, address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, status, notes, rooms, updated_at + FROM test_centers ORDER BY school_id, name, id + """, + ReadCenter, + cancellationToken); + var rooms = await QueryAsync(connection, + """ + SELECT id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, + room_type, status, notes FROM test_rooms ORDER BY center_id, code, id + """, + ReadRoom, + cancellationToken); + var changes = await QueryAsync(connection, + """ + SELECT id, center_id, school_id, request_type, code, name, province_code, province_name, + city_code, city_name, district_code, district_name, address, contact, manager_name, + manager_phone, emergency_phone, gate_open_time, transport, center_status, notes, status, + review_note, requested_by, created_at, reviewed_at + FROM center_change_requests ORDER BY created_at DESC, id + """, + ReadChange, + cancellationToken); + var changeRooms = await QueryAsync(connection, + """ + SELECT id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, + seat_end, room_type, status, notes FROM center_change_rooms ORDER BY request_id, code, id + """, + ReadChangeRoom, + cancellationToken); + return new(centers, rooms, changes, changeRooms); + } + + private static AdminCenter ReadCenter(DbDataReader reader) => new( + Text(reader, "id"), Text(reader, "school_id"), Text(reader, "code"), Text(reader, "name"), + Text(reader, "province_code"), Text(reader, "province_name"), Text(reader, "city_code"), Text(reader, "city_name"), + Text(reader, "district_code"), Text(reader, "district_name"), Text(reader, "address"), Optional(reader, "contact") ?? "", + Optional(reader, "manager_name") ?? "", Optional(reader, "manager_phone") ?? "", Optional(reader, "emergency_phone") ?? "", + Optional(reader, "gate_open_time") ?? "", Optional(reader, "transport") ?? "", Text(reader, "status"), + Optional(reader, "notes") ?? "", Text(reader, "rooms"), Text(reader, "updated_at")); + + private static AdminCenterRoom ReadRoom(DbDataReader reader) => new( + Text(reader, "id"), Text(reader, "center_id"), Text(reader, "code"), Text(reader, "name"), Text(reader, "building"), + Optional(reader, "floor") ?? "", Integer(reader, "capacity"), Optional(reader, "seat_plan") ?? "", + Integer(reader, "seat_start"), Integer(reader, "seat_end"), Text(reader, "room_type"), Text(reader, "status"), Optional(reader, "notes") ?? ""); + + private static AdminCenterChange ReadChange(DbDataReader reader) => new( + Text(reader, "id"), Optional(reader, "center_id"), Text(reader, "school_id"), Text(reader, "request_type"), + Text(reader, "code"), Text(reader, "name"), Text(reader, "province_code"), Text(reader, "province_name"), + Text(reader, "city_code"), Text(reader, "city_name"), Text(reader, "district_code"), Text(reader, "district_name"), + Text(reader, "address"), Optional(reader, "contact") ?? "", Optional(reader, "manager_name") ?? "", + Optional(reader, "manager_phone") ?? "", Optional(reader, "emergency_phone") ?? "", Optional(reader, "gate_open_time") ?? "", + Optional(reader, "transport") ?? "", Text(reader, "center_status"), Optional(reader, "notes") ?? "", Text(reader, "status"), + Optional(reader, "review_note") ?? "", Optional(reader, "requested_by"), Text(reader, "created_at"), Optional(reader, "reviewed_at")); + + private static AdminCenterChangeRoom ReadChangeRoom(DbDataReader reader) => new( + Text(reader, "id"), Text(reader, "request_id"), Optional(reader, "room_id"), Text(reader, "code"), Text(reader, "name"), + Text(reader, "building"), Optional(reader, "floor") ?? "", Integer(reader, "capacity"), Optional(reader, "seat_plan") ?? "", + Integer(reader, "seat_start"), Integer(reader, "seat_end"), Text(reader, "room_type"), Text(reader, "status"), Optional(reader, "notes") ?? ""); + + 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 int Integer(DbDataReader reader, string name) => + Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 124ab1a..3c92b84 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -5,7 +5,8 @@ public sealed record AdminMigrationOptions( bool NativeOrganizationWritesEnabled = false, bool NativeAccountBatchesEnabled = false, bool NativeConfigurationEnabled = false, - bool NativeNoticeManagementEnabled = false) + bool NativeNoticeManagementEnabled = false, + bool NativeCentersEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -14,7 +15,8 @@ public sealed record AdminMigrationOptions( bool configuredNativeOrganizationWritesEnabled = false, bool configuredNativeAccountBatchesEnabled = false, bool configuredNativeConfigurationEnabled = false, - bool configuredNativeNoticeManagementEnabled = false) + bool configuredNativeNoticeManagementEnabled = false, + bool configuredNativeCentersEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -32,12 +34,15 @@ public sealed record AdminMigrationOptions( Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED") ?? Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"), configuredNativeNoticeManagementEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled) && !readsEnabled) + var centersEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"), + configuredNativeCentersEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -53,7 +58,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled); } 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 370bb7e..0f47deb 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -58,11 +58,14 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 2dec735..3ce1570 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -67,16 +67,30 @@ public static class NativeAdminReadEndpoints Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken))); } - if (!options.NativeNoticeManagementEnabled) return endpoints; - endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) => - Execute(context, service.ListAsync(Token(context), cancellationToken))); - endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => - Execute(context, service.CreateAsync(Token(context), body, cancellationToken))); - endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => - Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken))); - endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}", - (HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => - Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, cancellationToken))); + if (options.NativeNoticeManagementEnabled) + { + endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) => + Execute(context, service.ListAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken))); + endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}", + (HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => + Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, cancellationToken))); + } + + if (options.NativeCentersEnabled) + { + endpoints.MapGet("/api/admin/centers", (HttpContext context, IAdminCenterService service, CancellationToken cancellationToken) => + Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/centers", (HttpContext context, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) => + Execute(context, service.CreateAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/centers/{centerId}", (HttpContext context, string centerId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) => + Execute(context, service.UpdateAsync(Token(context), centerId, body, cancellationToken))); + endpoints.MapPatch("/api/admin/center-change-requests/{changeRequestId}", (HttpContext context, string changeRequestId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewAsync(Token(context), changeRequestId, body, cancellationToken))); + } return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index e827803..5893a8b 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -49,7 +49,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeNoticeManagementEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeNoticeManagementEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeCentersEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -108,6 +109,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled, nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled, nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled, + nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -127,6 +129,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeNoticeManagementEnabled ? new[] { "GET/POST/PATCH notices", "PATCH system publications" } : []) + .Concat(adminMigrationOptions.NativeCentersEnabled + ? new[] { "GET/POST/PATCH centers", "PATCH center-change-requests" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 3d37d86..e079fcf 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -14,7 +14,8 @@ "NativeOrganizationWritesEnabled": false, "NativeAccountBatchesEnabled": false, "NativeConfigurationEnabled": false, - "NativeNoticeManagementEnabled": false + "NativeNoticeManagementEnabled": false, + "NativeCentersEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index fc7a1f7..bca982a 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -151,6 +151,21 @@ public sealed class AdminMigrationOptionsTests }, legacyNoticeWritesEnabled: "true"); } + [Fact] + public void EnablesCentersWithReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeCentersEnabled); + }, centersEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, @@ -159,7 +174,8 @@ public sealed class AdminMigrationOptionsTests string? accountBatchesEnabled = null, string? configurationEnabled = null, string? noticeManagementEnabled = null, - string? legacyNoticeWritesEnabled = null) + string? legacyNoticeWritesEnabled = null, + string? centersEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); @@ -167,6 +183,7 @@ public sealed class AdminMigrationOptionsTests var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"); var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED"); var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"); + var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -176,6 +193,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -187,6 +205,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }