本批迁移已完成:

原生化后台只读接口:GET /api/admin/candidates
GET /api/admin/registrations
GET /api/admin/payments

保持超级、校级、班级管理员的数据范围一致。
保持证件号脱敏、审批流、考试科目、缴费信息及座位号前导零兼容。
新增灰度开关:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true
归档、审核、缴费更新等写操作暂时继续转发 Node。
This commit is contained in:
biss committed 2026-07-23 09:39:47 +08:00
1 parent 518c922773
commit 95a87f0276
12 files changed
+620 -11

No files matched your search

@@ -0,0 +1,8 @@
namespace Eis.Application.Administration;
public interface IAdminOperationalReadService
{
Task<AdminEndpointResult> GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -6,7 +6,8 @@ public sealed record AdminMigrationOptions(
bool NativeAccountBatchesEnabled = false,
bool NativeConfigurationEnabled = false,
bool NativeNoticeManagementEnabled = false,
bool NativeCentersEnabled = false)
bool NativeCentersEnabled = false,
bool NativeOperationalReadsEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -16,7 +17,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeAccountBatchesEnabled = false,
bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeManagementEnabled = false,
bool configuredNativeCentersEnabled = false)
bool configuredNativeCentersEnabled = false,
bool configuredNativeOperationalReadsEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -37,12 +39,15 @@ public sealed record AdminMigrationOptions(
var centersEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
configuredNativeCentersEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled) && !readsEnabled)
var operationalReadsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
configuredNativeOperationalReadsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -58,7 +63,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -0,0 +1,221 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminOperationalReadService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
AdminOperationalSnapshotLoader operationalSnapshotLoader) : IAdminOperationalReadService
{
public Task<AdminEndpointResult> GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "candidates", cancellationToken);
public Task<AdminEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "registrations", cancellationToken);
public Task<AdminEndpointResult> GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken) =>
ExecuteAsync(sessionToken, "payments", cancellationToken);
private async Task<AdminEndpointResult> ExecuteAsync(
string sessionToken,
string resource,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
return resource switch
{
"candidates" => Candidates(user, workflow, operational),
"registrations" => Registrations(user, workflow, operational),
_ => Payments(user, workflow, operational)
};
}
private static AdminEndpointResult Candidates(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var candidates = operational.Profiles.Where(profile => InScope(user, profile)).Select(profile =>
{
var output = profile.Data.DeepClone().AsObject();
var account = operational.Users.FirstOrDefault(item => item.Id == profile.UserId);
var idNumber = output["idNumber"]?.GetValue<string>() ?? "";
output["idNumberMasked"] = idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ? "待考生补充" : MaskId(idNumber);
if (account is not null) output["username"] = account.Username;
output["candidateNumber"] = account?.CandidateNumber ?? "";
output["mustChangePassword"] = account?.MustChangePassword ?? false;
output["accountArchived"] = account?.ArchivedAt is not null;
output["archivedAt"] = JsonValue.Create(account?.ArchivedAt);
output["archivedByName"] = operational.Users.FirstOrDefault(item => item.Id == account?.ArchivedBy)?.DisplayName ?? "";
var registrations = operational.Registrations.Where(item => item.UserId == profile.UserId)
.OrderByDescending(item => ParseDate(item.CreatedAt))
.Select(item => RegistrationJson(workflow, operational, item)).ToArray();
output["registrations"] = new JsonArray(registrations);
var instance = WorkflowInstance(workflow, "profile_change", profile.Id);
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
return output;
}).ToArray();
var schools = workflow.Schools.Where(item => item.Active && (Level(user) == "super" || item.Id == user.SchoolId))
.Select(SchoolJson).ToArray();
var classes = workflow.Classes.Where(item => item.Active && (Level(user) == "super" || item.SchoolId == user.SchoolId))
.Select(ClassJson).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["candidates"] = new JsonArray(candidates),
["schools"] = new JsonArray(schools),
["classes"] = new JsonArray(classes)
});
}
private static AdminEndpointResult Registrations(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var registrations = operational.Registrations.Select(registration =>
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
if (profile is null || !InScope(user, profile)) return null;
var output = RegistrationJson(workflow, operational, registration);
var candidate = profile.Data.DeepClone().AsObject();
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
output["candidate"] = candidate;
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
profile.Data["school"]?.GetValue<string>() ?? "";
output["gradeName"] = schoolClass?.Grade ?? "";
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
return output;
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
return Success(new JsonObject { ["ok"] = true, ["registrations"] = new JsonArray(registrations) });
}
private static AdminEndpointResult Payments(
AuthenticationUser user,
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational)
{
var registrations = operational.Registrations.Where(item => item.Status == "approved").Select(registration =>
{
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
if (profile is null || !InScope(user, profile)) return null;
var output = RegistrationJson(workflow, operational, registration);
var candidate = profile.Data.DeepClone().AsObject();
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
output["candidate"] = candidate;
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
profile.Data["school"]?.GetValue<string>() ?? "";
output["gradeName"] = schoolClass?.Grade ?? "";
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
return output;
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
return Success(new JsonObject
{
["ok"] = true,
["scopeLabel"] = ScopeLabel(user, workflow),
["canConfirmPayment"] = true,
["canUpdatePayment"] = true,
["registrations"] = new JsonArray(registrations)
});
}
private static JsonObject RegistrationJson(
AdminAccountBatchSnapshot workflow,
AdminOperationalSnapshot operational,
OperationalRegistration registration)
{
var output = registration.Data.DeepClone().AsObject();
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var subjectIds = output["subjectIds"]?.AsArray().Select(item => item?.GetValue<string>() ?? "").ToHashSet(StringComparer.Ordinal) ?? [];
var subjects = (exam?.Subjects ?? []).Where(item => subjectIds.Contains(item["id"]?.GetValue<string>() ?? ""))
.Select(item => item.DeepClone()).ToArray();
output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray(subjects);
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = WorkflowInstance(workflow, "registration_review", registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
return output;
}
private static AccountWorkflowInstance? WorkflowInstance(AdminAccountBatchSnapshot snapshot, string businessType, string businessId) =>
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId && item.Status == "pending") ??
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId);
private static JsonObject WorkflowJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance)
{
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).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 bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
_ => user.ClassId is not null && profile.ClassId == user.ClassId
};
private static string ScopeLabel(AuthenticationUser user, AdminAccountBatchSnapshot snapshot)
{
if (Level(user) == "super") return "全部学校与班级";
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
if (Level(user) == "school") return school;
var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
return $"{school} · {schoolClass}";
}
private static double AmountDue(JsonArray? subjects) =>
Math.Round(subjects?.OfType<JsonObject>().Sum(item => item["fee"]?.GetValue<double>() ?? 0) ?? 0, 2, MidpointRounding.AwayFromZero);
private static string MaskId(string value) => value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value;
private static DateTimeOffset ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue;
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static JsonObject SchoolJson(AdminSchool item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["code"] = item.Code, ["address"] = item.Address, ["isSourceSchool"] = item.IsSourceSchool, ["isAdmissionSchool"] = item.IsAdmissionSchool, ["active"] = item.Active };
private static JsonObject ClassJson(AdminClass item) => new() { ["id"] = item.Id, ["schoolId"] = item.SchoolId, ["name"] = item.Name, ["grade"] = item.Grade, ["active"] = item.Active };
private static JsonObject StepJson(AccountWorkflowStep item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["adminLevel"] = item.AdminLevel, ["position"] = item.Position };
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new() { ["id"] = item.Id, ["instanceId"] = item.InstanceId, ["actorId"] = JsonValue.Create(item.ActorId), ["action"] = item.Action, ["note"] = item.Note, ["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId), ["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId), ["createdAt"] = item.CreatedAt, ["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统", ["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "", ["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? "" };
private static JsonObject SafeUser(AdminUser item) => new() { ["id"] = item.Id, ["username"] = item.Username, ["role"] = item.Role, ["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null, ["schoolId"] = JsonValue.Create(item.SchoolId), ["classId"] = JsonValue.Create(item.ClassId), ["displayName"] = item.DisplayName, ["candidateNumber"] = JsonValue.Create(item.CandidateNumber), ["mustChangePassword"] = item.MustChangePassword, ["totpEnabled"] = item.TotpEnabled, ["archived"] = item.ArchivedAt is not null };
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message });
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
}
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff. Show more