本轮完成“考点与考场变更审批”原生迁移:
GET/POST/PATCH /api/admin/centers
PATCH /api/admin/center-change-requests/{id}
校级数据范围、班级管理员禁用
省市区县及结构化考场校验
正式档案和待审申请代码去重
多级工作流分配、通过与拒绝
终审时原子更新流程、审计、考点及考场
新增开关 ADMIN_NATIVE_CENTERS_ENABLED=false
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-3
@@ -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 冒烟测试:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -58,11 +58,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<AdminAccountBatchRepository>();
|
||||
services.AddScoped<AdminConfigurationRepository>();
|
||||
services.AddScoped<AdminNoticeRepository>();
|
||||
services.AddScoped<AdminCenterSnapshotLoader>();
|
||||
services.AddScoped<AdminCenterRepository>();
|
||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
|
||||
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
||||
services.AddScoped<IAdminCenterService, AdminCenterService>();
|
||||
services.AddSingleton<NoticeContentFormatter>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
|
||||
@@ -67,7 +67,8 @@ public static class NativeAdminReadEndpoints
|
||||
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) =>
|
||||
Execute(context, service.ListAsync(Token(context), 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}",
|
||||
(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
|
||||
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(
|
||||
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)
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"NativeOrganizationWritesEnabled": false,
|
||||
"NativeAccountBatchesEnabled": false,
|
||||
"NativeConfigurationEnabled": false,
|
||||
"NativeNoticeManagementEnabled": false
|
||||
"NativeNoticeManagementEnabled": false,
|
||||
"NativeCentersEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user