本轮完成“考点与考场变更审批”原生迁移:

GET/POST/PATCH /api/admin/centers
PATCH /api/admin/center-change-requests/{id}
校级数据范围、班级管理员禁用
省市区县及结构化考场校验
正式档案和待审申请代码去重
多级工作流分配、通过与拒绝
终审时原子更新流程、审计、考点及考场
新增开关 ADMIN_NATIVE_CENTERS_ENABLED=false
This commit is contained in:
2026-07-23 09:17:04 +08:00 Unverified
parent e845ba8d3a
commit 518c922773
13 changed files with 1123 additions and 21 deletions
+2
View File
@@ -35,6 +35,8 @@ ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false
ADMIN_NATIVE_CONFIGURATION_ENABLED=false ADMIN_NATIVE_CONFIGURATION_ENABLED=false
# 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。 # 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
# 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。
ADMIN_NATIVE_CENTERS_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+4 -3
View File
@@ -12,7 +12,7 @@
- [x] 招生公示与 HMAC 文书验真公开接口 - [x] 招生公示与 HMAC 文书验真公开接口
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [x] 考生业务 - [x] 考生业务
- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置通知公告管理已原生化) - [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置通知公告及考点变更审批已原生化)
- [x] 考生志愿填报与招生录取查询 - [x] 考生志愿填报与招生录取查询
- [ ] Excel、文书和缓存 - [ ] Excel、文书和缓存
- [ ] 容器入口切换及 Node.js 后端移除 - [ ] 容器入口切换及 Node.js 后端移除
@@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
$env:CANDIDATE_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true'
``` ```
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。 管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
@@ -65,9 +65,10 @@ $env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
$env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
$env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' $env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_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 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
+127
View File
@@ -441,6 +441,7 @@ try {
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
ADMIN_NATIVE_CENTERS_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
@@ -801,6 +802,131 @@ try {
throw 'Native admins route did not preserve the class-admin boundary' 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')) { foreach ($configurationRoute in @('number-rules', 'workflows')) {
$legacyConfiguration = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$configurationRoute" -WebSession $session $legacyConfiguration = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$configurationRoute" -WebSession $session
$nativeConfiguration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$configurationRoute" -WebSession $nativeSession $nativeConfiguration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$configurationRoute" -WebSession $nativeSession
@@ -1143,6 +1269,7 @@ try {
NativeAdminAccountBatches = 'passed' NativeAdminAccountBatches = 'passed'
NativeAdminConfiguration = 'passed' NativeAdminConfiguration = 'passed'
NativeAdminNoticeManagement = 'passed' NativeAdminNoticeManagement = 'passed'
NativeAdminCenters = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -0,0 +1,11 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminCenterService
{
Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string centerId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAsync(string sessionToken, string changeRequestId, JsonObject body, CancellationToken cancellationToken);
}
@@ -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<AdminCenterChangeRoom> rooms,
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operations = new List<SqlOperation> { 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<AdminCenterRoom> rooms,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operations = new List<SqlOperation>
{
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<SqlOperation> 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<SqlParameterValue> 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<SqlParameterValue> 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<SqlParameterValue> Parameters);
private sealed record SqlParameterValue(string Name, object? Value);
}
@@ -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<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
public async Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
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<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> 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<string>(StringComparer.Ordinal);
var rooms = new List<AdminCenterChangeRoom>();
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<AccountWorkflowAction>? extraActions = null,
IReadOnlyList<AdminCenterChangeRoom>? 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<AccountWorkflowAction>? 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<ResolvedAdmin> ResolveAsync(string token, CancellationToken cancellationToken)
{
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(token);
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
}
private static 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<string>(out var text) ? text : node?.ToString() ?? "";
private static string Clean(string value, int maximum)
{
var cleaned = value.Trim();
return cleaned[..Math.Min(cleaned.Length, maximum)];
}
private static 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<char> buffer = stackalloc char[16]; var position = buffer.Length; do { buffer[--position] = alphabet[(int)(value % 36)]; value /= 36; } while (value > 0); return new(buffer[position..]); }
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso());
private static 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<AdminCenterChangeRoom> 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);
}
}
@@ -0,0 +1,188 @@
using System.Data.Common;
using System.Globalization;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminCenterSnapshot(
IReadOnlyList<AdminCenter> Centers,
IReadOnlyList<AdminCenterRoom> Rooms,
IReadOnlyList<AdminCenterChange> Changes,
IReadOnlyList<AdminCenterChangeRoom> 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<AdminCenterSnapshot> 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<IReadOnlyList<T>> QueryAsync<T>(
DbConnection connection,
string sql,
Func<DbDataReader, T> map,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<T>();
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);
}
@@ -5,7 +5,8 @@ public sealed record AdminMigrationOptions(
bool NativeOrganizationWritesEnabled = false, bool NativeOrganizationWritesEnabled = false,
bool NativeAccountBatchesEnabled = false, bool NativeAccountBatchesEnabled = false,
bool NativeConfigurationEnabled = false, bool NativeConfigurationEnabled = false,
bool NativeNoticeManagementEnabled = false) bool NativeNoticeManagementEnabled = false,
bool NativeCentersEnabled = false)
{ {
public static AdminMigrationOptions FromEnvironment( public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled, bool configuredNativeReadsEnabled,
@@ -14,7 +15,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeOrganizationWritesEnabled = false, bool configuredNativeOrganizationWritesEnabled = false,
bool configuredNativeAccountBatchesEnabled = false, bool configuredNativeAccountBatchesEnabled = false,
bool configuredNativeConfigurationEnabled = false, bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeManagementEnabled = false) bool configuredNativeNoticeManagementEnabled = false,
bool configuredNativeCentersEnabled = false)
{ {
var readsEnabled = ParseBoolean( var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), 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_MANAGEMENT_ENABLED") ??
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
configuredNativeNoticeManagementEnabled); 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( throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
} }
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled; var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -53,7 +58,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); "管理端仍有接口需要转发给 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 private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -58,11 +58,14 @@ public static class DependencyInjection
services.AddScoped<AdminAccountBatchRepository>(); services.AddScoped<AdminAccountBatchRepository>();
services.AddScoped<AdminConfigurationRepository>(); services.AddScoped<AdminConfigurationRepository>();
services.AddScoped<AdminNoticeRepository>(); services.AddScoped<AdminNoticeRepository>();
services.AddScoped<AdminCenterSnapshotLoader>();
services.AddScoped<AdminCenterRepository>();
services.AddScoped<IAdminReadService, AdminReadService>(); services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>(); services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>(); services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>(); services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
services.AddScoped<IAdminNoticeService, AdminNoticeService>(); services.AddScoped<IAdminNoticeService, AdminNoticeService>();
services.AddScoped<IAdminCenterService, AdminCenterService>();
services.AddSingleton<NoticeContentFormatter>(); services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
@@ -67,7 +67,8 @@ public static class NativeAdminReadEndpoints
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken))); Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
} }
if (!options.NativeNoticeManagementEnabled) return endpoints; if (options.NativeNoticeManagementEnabled)
{
endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) => endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.ListAsync(Token(context), cancellationToken))); Execute(context, service.ListAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) => endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
@@ -77,6 +78,19 @@ public static class NativeAdminReadEndpoints
endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}", 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) => (HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, 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; return endpoints;
} }
+6 -1
View File
@@ -49,7 +49,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled")); builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"));
builder.Services.AddEisInfrastructure( builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
@@ -108,6 +109,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled, nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled,
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled, nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled, nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled,
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: []) : [])
@@ -127,6 +129,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
.Concat(adminMigrationOptions.NativeNoticeManagementEnabled .Concat(adminMigrationOptions.NativeNoticeManagementEnabled
? new[] { "GET/POST/PATCH notices", "PATCH system publications" } ? new[] { "GET/POST/PATCH notices", "PATCH system publications" }
: []) : [])
.Concat(adminMigrationOptions.NativeCentersEnabled
? new[] { "GET/POST/PATCH centers", "PATCH center-change-requests" }
: [])
.ToArray() .ToArray()
}, },
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
+2 -1
View File
@@ -14,7 +14,8 @@
"NativeOrganizationWritesEnabled": false, "NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false, "NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false, "NativeConfigurationEnabled": false,
"NativeNoticeManagementEnabled": false "NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -151,6 +151,21 @@ public sealed class AdminMigrationOptionsTests
}, legacyNoticeWritesEnabled: "true"); }, 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( private static void WithEnvironment(
string? nativeReadsEnabled, string? nativeReadsEnabled,
string? nativeOrganizationWritesEnabled, string? nativeOrganizationWritesEnabled,
@@ -159,7 +174,8 @@ public sealed class AdminMigrationOptionsTests
string? accountBatchesEnabled = null, string? accountBatchesEnabled = null,
string? configurationEnabled = null, string? configurationEnabled = null,
string? noticeManagementEnabled = null, string? noticeManagementEnabled = null,
string? legacyNoticeWritesEnabled = null) string? legacyNoticeWritesEnabled = null,
string? centersEnabled = null)
{ {
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
@@ -167,6 +183,7 @@ public sealed class AdminMigrationOptionsTests
var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"); var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED");
var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED"); var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED");
var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_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"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
try try
{ {
@@ -176,6 +193,7 @@ public sealed class AdminMigrationOptionsTests
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
test(); test();
} }
@@ -187,6 +205,7 @@ public sealed class AdminMigrationOptionsTests
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
} }
} }