本批迁移已完成:
原生化后台只读接口:GET /api/admin/candidates GET /api/admin/registrations GET /api/admin/payments 保持超级、校级、班级管理员的数据范围一致。 保持证件号脱敏、审批流、考试科目、缴费信息及座位号前导零兼容。 新增灰度开关:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true 归档、审核、缴费更新等写操作暂时继续转发 Node。
This commit is contained in:
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
Reference in new issue
Block a user