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

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
@@ -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,16 +67,30 @@ public static class NativeAdminReadEndpoints
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
}
if (!options.NativeNoticeManagementEnabled) return endpoints;
endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.ListAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken)));
endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}",
(HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, cancellationToken)));
if (options.NativeNoticeManagementEnabled)
{
endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.ListAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken)));
endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}",
(HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, cancellationToken)));
}
if (options.NativeCentersEnabled)
{
endpoints.MapGet("/api/admin/centers", (HttpContext context, IAdminCenterService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/centers", (HttpContext context, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/centers/{centerId}", (HttpContext context, string centerId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAsync(Token(context), centerId, body, cancellationToken)));
endpoints.MapPatch("/api/admin/center-change-requests/{changeRequestId}", (HttpContext context, string changeRequestId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAsync(Token(context), changeRequestId, body, cancellationToken)));
}
return endpoints;
}
+6 -1
View File
@@ -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)
+2 -1
View File
@@ -14,7 +14,8 @@
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false,
"NativeNoticeManagementEnabled": false
"NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false
},
"Logging": {
"LogLevel": {