已完成管理后台第一批只读功能的 ASP.NET Core 10 迁移:

原生接口:context、dashboard、schools、school-organization、admins、exams
保持超级、校级、班级管理员的数据范围及权限隔离
其余管理写入、审批流和考务编排仍由 Node 转发
新增 ADMIN_NATIVE_READS_ENABLED=true 开关,并强制要求原生认证和共享 Redis
This commit is contained in:
biss committed 2026-07-22 20:34:13 +08:00
1 parent 4b0dae6d71
commit 712bd1a3a2
12 files changed
+894 -5

No files matched your search

@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public sealed record AdminEndpointResult(int StatusCode, JsonObject Body);
public interface IAdminReadService
{
Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,37 @@
namespace Eis.Infrastructure.Administration;
public sealed record AdminMigrationOptions(bool NativeReadsEnabled)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生管理端读取接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"管理端仍有写入接口需要转发给 Node;启用原生管理端读取接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(enabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
{
"1" or "true" or "yes" or "on" => true,
"0" or "false" or "no" or "off" => false,
_ => fallback
};
}
@@ -0,0 +1,374 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminReadService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminReadSnapshotLoader snapshotLoader) : IAdminReadService
{
private static readonly IReadOnlyDictionary<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
private static readonly IReadOnlyDictionary<string, string[]> Permissions = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["super"] = ["*"],
["school"] =
[
"dashboard.read", "candidates.read", "candidates.write", "candidates.review", "registrations.read",
"registrations.review", "payments.read", "payments.write", "results.read", "centers.read", "centers.write",
"workflows.inbox"
],
["class"] =
[
"dashboard.read", "candidates.read", "candidates.review", "registrations.read", "registrations.review",
"payments.read", "payments.write", "results.read", "workflows.inbox"
]
};
public async Task<AdminEndpointResult> GetContextAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var level = Level(context.User!);
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(context.User!),
["adminLevelName"] = LevelNames[level],
["permissions"] = StringArray(Permissions[level]),
["scopeLabel"] = ScopeLabel(snapshot, context.User!),
["schools"] = new JsonArray(snapshot.Schools.Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray())
});
}
public async Task<AdminEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var profiles = snapshot.Profiles.Where(item => InScope(user, item.SchoolId, item.ClassId)).ToArray();
var userIds = profiles.Select(item => item.UserId).ToHashSet(StringComparer.Ordinal);
var registrations = snapshot.Registrations.Where(item => userIds.Contains(item.UserId)).ToArray();
var visibleFlows = snapshot.WorkflowInstances.Where(instance =>
{
if (Level(user) == "super") return true;
var scope = WorkflowScope(snapshot, instance);
return scope is not null && InScope(user, scope.Value.SchoolId, scope.Value.ClassId) &&
(instance.AssigneeId == user.Id || instance.Status != "pending");
}).ToArray();
var logs = snapshot.AuditLogs
.Where(item => Level(user) == "super" || item.ActorId == user.Id)
.Take(8)
.Select(LogJson)
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admin"] = SafeUser(user),
["scopeLabel"] = ScopeLabel(snapshot, user),
["permissions"] = StringArray(Permissions[Level(user)]),
["metrics"] = new JsonObject
{
["candidates"] = profiles.Length,
["pendingCandidates"] = profiles.Count(item => item.Status == "pending"),
["registrations"] = registrations.Length,
["pendingRegistrations"] = registrations.Count(item => item.Status == "pending"),
["pendingPayments"] = registrations.Count(item => item.Status == "approved" && item.PaymentStatus == "unpaid"),
["pendingFlows"] = visibleFlows.Count(item => item.Status == "pending"),
["publishedExams"] = snapshot.Exams.Count(item => item.Status == "published"),
["notices"] = snapshot.PublishedNoticeCount
},
["logs"] = new JsonArray(logs)
});
}
public async Task<AdminEndpointResult> GetSchoolsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "只有超级管理员可以管理学校");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var schools = snapshot.Schools.Select(school =>
{
var item = SchoolJson(school);
item["classCount"] = snapshot.Classes.Count(entry => entry.SchoolId == school.Id);
item["adminCount"] = snapshot.Users.Count(entry => entry.Role == "admin" && entry.SchoolId == school.Id);
item["candidateCount"] = snapshot.Profiles.Count(entry => entry.SchoolId == school.Id);
item["centerCount"] = snapshot.CenterSchoolIds.Count(id => id == school.Id);
return item;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["schools"] = new JsonArray(schools) });
}
public async Task<AdminEndpointResult> GetSchoolOrganizationAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "school") return Error(403, "只有校级管理员可以维护本校组织");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId);
var classes = snapshot.Classes.Where(item => item.SchoolId == user.SchoolId).Select(item =>
{
var output = ClassJson(item);
output["candidateCount"] = snapshot.Profiles.Count(profile => profile.ClassId == item.Id);
output["admins"] = new JsonArray(snapshot.Users
.Where(admin => admin.Role == "admin" && admin.AdminLevel == "class" && admin.ClassId == item.Id)
.Select(admin =>
{
var json = SafeUser(admin);
json["active"] = admin.Active;
return json;
}).ToArray());
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = school is null ? null : SchoolJson(school),
["classes"] = new JsonArray(classes)
});
}
public async Task<AdminEndpointResult> GetAdminsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能管理管理员");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var admins = snapshot.Users.Where(item => item.Role == "admin" &&
(Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId)).Select(item =>
{
var output = SafeUser(item);
output["active"] = item.Active;
output["levelName"] = LevelNames[item.AdminLevel ?? "super"];
output["schoolName"] = snapshot.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Name ?? string.Empty;
output["className"] = snapshot.Classes.FirstOrDefault(schoolClass => schoolClass.Id == item.ClassId)?.Name ?? string.Empty;
return output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["admins"] = new JsonArray(admins),
["schools"] = new JsonArray(snapshot.Schools.Where(item => item.IsSourceSchool).Select(SchoolJson).ToArray()),
["classes"] = new JsonArray(snapshot.Classes.Select(ClassJson).ToArray()),
["selfRegistrationEnabled"] = snapshot.SelfRegistrationEnabled
});
}
public async Task<AdminEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (Level(context.User!) != "super") return Error(403, "当前管理员层级无权执行此操作");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var exams = snapshot.Exams.Select(exam =>
{
var output = PublicExamJson(exam);
output["registrationCount"] = snapshot.Registrations.Count(item => item.ExamId == exam.Id);
return output;
}).ToArray();
return Success(new JsonObject { ["ok"] = true, ["exams"] = new JsonArray(exams) });
}
private async Task<ResolvedAdmin> ResolveAsync(string sessionToken, CancellationToken cancellationToken)
{
if (sessionToken.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(sessionToken);
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 ResolvedAdmin(user, null)
: ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
}
private static (string? SchoolId, string? ClassId)? WorkflowScope(AdminReadSnapshot snapshot, AdminWorkflowInstance instance)
{
if (instance.BusinessType == "profile_change")
{
var profile = snapshot.Profiles.FirstOrDefault(item => item.Id == instance.BusinessId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "registration_review")
{
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == instance.BusinessId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
if (instance.BusinessType == "center_change")
{
var change = snapshot.CenterChanges.FirstOrDefault(item => item.Id == instance.BusinessId);
return change is null ? null : (change.SchoolId, null);
}
if (instance.BusinessType == "candidate_account_batch")
{
var batch = snapshot.AccountBatches.FirstOrDefault(item => item.Id == instance.BusinessId);
return batch is null ? null : (batch.SchoolId, null);
}
if (instance.BusinessType == "score_appeal")
{
var result = snapshot.Results.FirstOrDefault(item => item.Id == instance.BusinessId);
var registration = snapshot.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
var profile = snapshot.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
return profile is null ? null : (profile.SchoolId, profile.ClassId);
}
return null;
}
private static bool InScope(AuthenticationUser user, string? schoolId, string? classId) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && schoolId == user.SchoolId,
_ => user.ClassId is not null && classId == user.ClassId
};
private static string ScopeLabel(AdminReadSnapshot snapshot, AuthenticationUser user)
{
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 JsonObject PublicExamJson(AdminExam item)
{
var json = ExamJson(item);
json["totalScore"] = item.Subjects.Sum(subject => subject.FullScore);
json["registrationState"] = item.ArchivedAt is not null
? "archived"
: DateTimeOffset.UtcNow < ParseDate(item.RegistrationStart) ? "upcoming"
: DateTimeOffset.UtcNow > ParseDate(item.RegistrationEnd) ? "closed"
: "open";
return json;
}
private static JsonObject ExamJson(AdminExam item) => new()
{
["id"] = item.Id,
["code"] = item.Code,
["name"] = item.Name,
["description"] = item.Description,
["registrationStart"] = item.RegistrationStart,
["registrationEnd"] = item.RegistrationEnd,
["examStart"] = item.ExamStart,
["examEnd"] = item.ExamEnd,
["admitDownloadStart"] = item.AdmitDownloadStart,
["admitDownloadEnd"] = item.AdmitDownloadEnd,
["location"] = item.Location,
["passPolicy"] = item.PassPolicy,
["passValue"] = item.PassValue,
["status"] = item.Status,
["archivedAt"] = JsonValue.Create(item.ArchivedAt),
["archivedBy"] = JsonValue.Create(item.ArchivedBy),
["createdAt"] = item.CreatedAt,
["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray())
};
private static JsonObject SubjectJson(AdminSubject item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["date"] = item.Date,
["start"] = item.Start,
["end"] = item.End,
["fee"] = item.Fee,
["fullScore"] = item.FullScore,
["passRule"] = item.PassRule,
["passValue"] = item.PassValue,
["passScore"] = JsonValue.Create(item.PassScore),
["order"] = item.Order
};
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 SafeUser(AuthenticationUser 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 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 JsonObject LogJson(AdminAuditLog item) => new()
{
["id"] = item.Id,
["actorId"] = JsonValue.Create(item.ActorId),
["action"] = item.Action,
["detail"] = item.Detail,
["createdAt"] = item.CreatedAt
};
private static JsonArray StringArray(IEnumerable<string> values) =>
new(values.Select(value => JsonValue.Create(value)).ToArray());
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static DateTimeOffset ParseDate(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: DateTimeOffset.MinValue;
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