已完成管理后台第一批只读功能的 ASP.NET Core 10 迁移:
原生接口:context、dashboard、schools、school-organization、admins、exams 保持超级、校级、班级管理员的数据范围及权限隔离 其余管理写入、审批流和考务编排仍由 Node 转发 新增 ADMIN_NATIVE_READS_ENABLED=true 开关,并强制要求原生认证和共享 Redis
This commit is contained in:
@@ -25,6 +25,8 @@ PORT=4173
|
|||||||
AUTH_NATIVE_ENABLED=false
|
AUTH_NATIVE_ENABLED=false
|
||||||
# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。
|
# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。
|
||||||
CANDIDATE_NATIVE_ENABLED=false
|
CANDIDATE_NATIVE_ENABLED=false
|
||||||
|
# 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。
|
||||||
|
ADMIN_NATIVE_READS_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+12
-3
@@ -11,9 +11,9 @@
|
|||||||
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
|
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
|
||||||
- [x] 招生公示与 HMAC 文书验真公开接口
|
- [x] 招生公示与 HMAC 文书验真公开接口
|
||||||
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
||||||
- [ ] 考生业务
|
- [x] 考生业务
|
||||||
- [ ] 管理后台、审批流和考务编排
|
- [ ] 管理后台、审批流和考务编排(第一批管理端只读接口已原生化)
|
||||||
- [ ] 招生录取
|
- [x] 考生志愿填报与招生录取查询
|
||||||
- [ ] Excel、文书和缓存
|
- [ ] Excel、文书和缓存
|
||||||
- [ ] 容器入口切换及 Node.js 后端移除
|
- [ ] 容器入口切换及 Node.js 后端移除
|
||||||
|
|
||||||
@@ -54,6 +54,15 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。其余管理端写入、审批流和考务编排接口仍转发给 Node,因此该开关同样要求原生认证和共享 Redis:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AUTH_NATIVE_ENABLED = 'true'
|
||||||
|
$env:ADMIN_NATIVE_READS_ENABLED = 'true'
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /health/migration` 的 `administration.nativeReadsEnabled` 和 `administration.routes` 会报告这一批端点是否已切换。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -422,6 +422,8 @@ try {
|
|||||||
AUTH_NATIVE_ENABLED = 'true'
|
AUTH_NATIVE_ENABLED = 'true'
|
||||||
CANDIDATE_NATIVE_ENABLED = 'true'
|
CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
CANDIDATE_NATIVE_ALLOW_MEMORY = 'true'
|
CANDIDATE_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
|
ADMIN_NATIVE_READS_ENABLED = 'true'
|
||||||
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
DATABASE_CLIENT = 'sqlite'
|
DATABASE_CLIENT = 'sqlite'
|
||||||
@@ -692,6 +694,48 @@ try {
|
|||||||
if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
|
if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
|
||||||
throw 'Native authentication could not verify the existing Node PBKDF2 account'
|
throw 'Native authentication could not verify the existing Node PBKDF2 account'
|
||||||
}
|
}
|
||||||
|
foreach ($adminReadRoute in @('context', 'dashboard', 'schools', 'admins', 'exams')) {
|
||||||
|
$legacyAdminRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$adminReadRoute" -WebSession $session
|
||||||
|
$nativeAdminRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$adminReadRoute" -WebSession $nativeSession
|
||||||
|
if ($nativeAdminRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw "Admin read route '$adminReadRoute' did not use the native ASP.NET Core endpoint"
|
||||||
|
}
|
||||||
|
Assert-JsonEquivalent -Expected $legacyAdminRead.Content -Actual $nativeAdminRead.Content -Label "Admin read route '$adminReadRoute'"
|
||||||
|
}
|
||||||
|
$superSchoolOrganization = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/school-organization" -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($superSchoolOrganization.StatusCode -ne 403 -or $superSchoolOrganization.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native school-organization route did not preserve the school-level authorization boundary'
|
||||||
|
}
|
||||||
|
$schoolLoginBody = @{ username = 'school_admin'; password = '12345678' } | ConvertTo-Json -Compress
|
||||||
|
$legacySchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
$nativeSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $legacySchoolSession | Out-Null
|
||||||
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $schoolLoginBody -WebSession $nativeSchoolSession | Out-Null
|
||||||
|
foreach ($schoolAdminRoute in @('context', 'dashboard', 'school-organization', 'admins')) {
|
||||||
|
$legacySchoolRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$schoolAdminRoute" -WebSession $legacySchoolSession
|
||||||
|
$nativeSchoolRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$schoolAdminRoute" -WebSession $nativeSchoolSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacySchoolRead.Content -Actual $nativeSchoolRead.Content -Label "School admin read route '$schoolAdminRoute'"
|
||||||
|
}
|
||||||
|
foreach ($superOnlyRoute in @('schools', 'exams')) {
|
||||||
|
$schoolForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$superOnlyRoute" -WebSession $nativeSchoolSession -SkipHttpErrorCheck
|
||||||
|
if ($schoolForbidden.StatusCode -ne 403 -or $schoolForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw "Native admin route '$superOnlyRoute' did not preserve the super-admin boundary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$classLoginBody = @{ username = 'class_admin'; password = '12345678' } | ConvertTo-Json -Compress
|
||||||
|
$legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
$nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $legacyClassSession | Out-Null
|
||||||
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $classLoginBody -WebSession $nativeClassSession | Out-Null
|
||||||
|
foreach ($classAdminRoute in @('context', 'dashboard')) {
|
||||||
|
$legacyClassRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$classAdminRoute" -WebSession $legacyClassSession
|
||||||
|
$nativeClassRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$classAdminRoute" -WebSession $nativeClassSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyClassRead.Content -Actual $nativeClassRead.Content -Label "Class admin read route '$classAdminRoute'"
|
||||||
|
}
|
||||||
|
$classAdminsForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins" -WebSession $nativeClassSession -SkipHttpErrorCheck
|
||||||
|
if ($classAdminsForbidden.StatusCode -ne 403) {
|
||||||
|
throw 'Native admins route did not preserve the class-admin boundary'
|
||||||
|
}
|
||||||
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
|
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
if ($adminCandidateRoute.StatusCode -ne 403) {
|
if ($adminCandidateRoute.StatusCode -ne 403) {
|
||||||
throw 'Native candidate API did not enforce the candidate role boundary'
|
throw 'Native candidate API did not enforce the candidate role boundary'
|
||||||
@@ -774,6 +818,7 @@ try {
|
|||||||
NativeCandidateWrites = 'passed'
|
NativeCandidateWrites = 'passed'
|
||||||
NativeCandidateDocuments = 'passed'
|
NativeCandidateDocuments = 'passed'
|
||||||
NativeCandidateAdmissions = 'passed'
|
NativeCandidateAdmissions = 'passed'
|
||||||
|
NativeAdminReads = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminReadSnapshot(
|
||||||
|
IReadOnlyList<AdminSchool> Schools,
|
||||||
|
IReadOnlyList<AdminClass> Classes,
|
||||||
|
IReadOnlyList<AdminUser> Users,
|
||||||
|
IReadOnlyList<AdminProfile> Profiles,
|
||||||
|
IReadOnlyList<AdminRegistration> Registrations,
|
||||||
|
IReadOnlyList<AdminExam> Exams,
|
||||||
|
IReadOnlyList<AdminWorkflowInstance> WorkflowInstances,
|
||||||
|
IReadOnlyList<AdminScopeBusiness> CenterChanges,
|
||||||
|
IReadOnlyList<AdminScopeBusiness> AccountBatches,
|
||||||
|
IReadOnlyList<AdminResultScope> Results,
|
||||||
|
IReadOnlyList<AdminAuditLog> AuditLogs,
|
||||||
|
IReadOnlyList<string> CenterSchoolIds,
|
||||||
|
int PublishedNoticeCount,
|
||||||
|
bool SelfRegistrationEnabled);
|
||||||
|
|
||||||
|
internal sealed record AdminSchool(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Code,
|
||||||
|
string Address,
|
||||||
|
bool IsSourceSchool,
|
||||||
|
bool IsAdmissionSchool,
|
||||||
|
bool Active);
|
||||||
|
|
||||||
|
internal sealed record AdminClass(string Id, string SchoolId, string Name, string Grade, bool Active);
|
||||||
|
|
||||||
|
internal sealed record AdminUser(
|
||||||
|
string Id,
|
||||||
|
string Username,
|
||||||
|
string Role,
|
||||||
|
string? AdminLevel,
|
||||||
|
string? SchoolId,
|
||||||
|
string? ClassId,
|
||||||
|
string DisplayName,
|
||||||
|
string? CandidateNumber,
|
||||||
|
bool Active,
|
||||||
|
bool MustChangePassword,
|
||||||
|
bool TotpEnabled,
|
||||||
|
string? ArchivedAt,
|
||||||
|
string CreatedAt);
|
||||||
|
|
||||||
|
internal sealed record AdminProfile(string Id, string UserId, string? SchoolId, string? ClassId, string Status);
|
||||||
|
|
||||||
|
internal sealed record AdminRegistration(
|
||||||
|
string Id,
|
||||||
|
string UserId,
|
||||||
|
string ExamId,
|
||||||
|
string Status,
|
||||||
|
string PaymentStatus);
|
||||||
|
|
||||||
|
internal sealed record AdminExam(
|
||||||
|
string Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string RegistrationStart,
|
||||||
|
string RegistrationEnd,
|
||||||
|
string ExamStart,
|
||||||
|
string ExamEnd,
|
||||||
|
string AdmitDownloadStart,
|
||||||
|
string AdmitDownloadEnd,
|
||||||
|
string Location,
|
||||||
|
string PassPolicy,
|
||||||
|
double PassValue,
|
||||||
|
string Status,
|
||||||
|
string? ArchivedAt,
|
||||||
|
string? ArchivedBy,
|
||||||
|
string CreatedAt,
|
||||||
|
IReadOnlyList<AdminSubject> Subjects);
|
||||||
|
|
||||||
|
internal sealed record AdminSubject(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Date,
|
||||||
|
string Start,
|
||||||
|
string End,
|
||||||
|
double Fee,
|
||||||
|
double FullScore,
|
||||||
|
string PassRule,
|
||||||
|
double PassValue,
|
||||||
|
double? PassScore,
|
||||||
|
int Order);
|
||||||
|
|
||||||
|
internal sealed record AdminWorkflowInstance(
|
||||||
|
string Id,
|
||||||
|
string BusinessType,
|
||||||
|
string BusinessId,
|
||||||
|
string Status,
|
||||||
|
string? AssigneeId);
|
||||||
|
|
||||||
|
internal sealed record AdminScopeBusiness(string Id, string SchoolId);
|
||||||
|
|
||||||
|
internal sealed record AdminResultScope(string Id, string RegistrationId);
|
||||||
|
|
||||||
|
internal sealed record AdminAuditLog(string Id, string? ActorId, string Action, string Detail, string CreatedAt);
|
||||||
|
|
||||||
|
internal sealed class AdminReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<AdminReadSnapshot> LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
var schools = await QueryAsync(connection,
|
||||||
|
"SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id",
|
||||||
|
reader => new AdminSchool(
|
||||||
|
Text(reader, "id"), Text(reader, "name"), Text(reader, "code"), Optional(reader, "address") ?? string.Empty,
|
||||||
|
Boolean(reader, "is_source_school"), Boolean(reader, "is_admission_school"), Boolean(reader, "active")),
|
||||||
|
cancellationToken);
|
||||||
|
var classes = await QueryAsync(connection,
|
||||||
|
"SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, grade, name, id",
|
||||||
|
reader => new AdminClass(Text(reader, "id"), Text(reader, "school_id"), Text(reader, "name"), Text(reader, "grade"), Boolean(reader, "active")),
|
||||||
|
cancellationToken);
|
||||||
|
var users = await QueryAsync(connection,
|
||||||
|
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
|
||||||
|
reader => new AdminUser(
|
||||||
|
Text(reader, "id"), Text(reader, "username"), Text(reader, "role"), Optional(reader, "admin_level"),
|
||||||
|
Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "display_name"), Optional(reader, "candidate_number"),
|
||||||
|
Boolean(reader, "active"), Boolean(reader, "must_change_password"), Boolean(reader, "totp_enabled"),
|
||||||
|
Optional(reader, "archived_at"), Text(reader, "created_at")),
|
||||||
|
cancellationToken);
|
||||||
|
var profiles = await QueryAsync(connection,
|
||||||
|
"SELECT id, user_id, school_id, class_id, status FROM candidate_profiles ORDER BY updated_at, id",
|
||||||
|
reader => new AdminProfile(Text(reader, "id"), Text(reader, "user_id"), Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "status")),
|
||||||
|
cancellationToken);
|
||||||
|
var registrations = await QueryAsync(connection,
|
||||||
|
"SELECT id, user_id, exam_id, status, payment_status FROM registrations ORDER BY created_at, id",
|
||||||
|
reader => new AdminRegistration(Text(reader, "id"), Text(reader, "user_id"), Text(reader, "exam_id"), Text(reader, "status"), Text(reader, "payment_status")),
|
||||||
|
cancellationToken);
|
||||||
|
var subjects = await QueryAsync(connection,
|
||||||
|
"SELECT id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position FROM exam_subjects ORDER BY exam_id, position, id",
|
||||||
|
ReadSubject,
|
||||||
|
cancellationToken);
|
||||||
|
var subjectsByExam = subjects.GroupBy(item => item.ExamId)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<AdminSubject>)group.Select(item => item.Subject).ToArray(), StringComparer.Ordinal);
|
||||||
|
var exams = await QueryAsync(connection,
|
||||||
|
"SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at FROM exams ORDER BY created_at, id",
|
||||||
|
reader => ReadExam(reader, subjectsByExam),
|
||||||
|
cancellationToken);
|
||||||
|
var instances = await QueryAsync(connection,
|
||||||
|
"SELECT id, business_type, business_id, status, assignee_id FROM workflow_instances ORDER BY created_at DESC, id",
|
||||||
|
reader => new AdminWorkflowInstance(
|
||||||
|
Text(reader, "id"), Text(reader, "business_type"), Text(reader, "business_id"), Text(reader, "status"), Optional(reader, "assignee_id")),
|
||||||
|
cancellationToken);
|
||||||
|
var centerChanges = await QueryAsync(connection,
|
||||||
|
"SELECT id, school_id FROM center_change_requests ORDER BY created_at DESC, id",
|
||||||
|
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
|
||||||
|
cancellationToken);
|
||||||
|
var batches = await QueryAsync(connection,
|
||||||
|
"SELECT id, school_id FROM candidate_account_batches ORDER BY created_at DESC, id",
|
||||||
|
reader => new AdminScopeBusiness(Text(reader, "id"), Text(reader, "school_id")),
|
||||||
|
cancellationToken);
|
||||||
|
var results = await QueryAsync(connection,
|
||||||
|
"SELECT id, registration_id FROM results ORDER BY id",
|
||||||
|
reader => new AdminResultScope(Text(reader, "id"), Text(reader, "registration_id")),
|
||||||
|
cancellationToken);
|
||||||
|
var logs = await QueryAsync(connection,
|
||||||
|
"SELECT id, actor_id, action, detail, created_at FROM audit_logs ORDER BY created_at DESC, id DESC",
|
||||||
|
reader => new AdminAuditLog(Text(reader, "id"), Optional(reader, "actor_id"), Text(reader, "action"), Text(reader, "detail"), Text(reader, "created_at")),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var centerSchoolIds = await QueryAsync(connection,
|
||||||
|
"SELECT school_id FROM test_centers ORDER BY school_id, name, id",
|
||||||
|
reader => Text(reader, "school_id"),
|
||||||
|
cancellationToken);
|
||||||
|
var noticeCount = await ScalarIntAsync(connection, "SELECT COUNT(*) FROM notices WHERE status = 'published'", cancellationToken);
|
||||||
|
var selfRegistration = await ScalarIntAsync(connection, "SELECT self_registration_enabled FROM schema_metadata WHERE id = 1", cancellationToken) != 0;
|
||||||
|
return new AdminReadSnapshot(
|
||||||
|
schools, classes, users, profiles, registrations, exams, instances, centerChanges, batches, results, logs,
|
||||||
|
centerSchoolIds, noticeCount, selfRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubjectRow ReadSubject(DbDataReader reader)
|
||||||
|
{
|
||||||
|
var fullScore = Number(reader, "full_score", 150);
|
||||||
|
var rawRule = Optional(reader, "pass_rule") ?? "fixed_score";
|
||||||
|
var passRule = rawRule == "score_ratio" ? "rank_percent" : rawRule;
|
||||||
|
var passValue = NullableNumber(reader, "pass_value") ?? NullableNumber(reader, "pass_score") ?? fullScore * 0.6;
|
||||||
|
return new SubjectRow(
|
||||||
|
Text(reader, "exam_id"),
|
||||||
|
new AdminSubject(
|
||||||
|
Text(reader, "id"), Text(reader, "name"), Text(reader, "subject_date"), Text(reader, "start_time"), Text(reader, "end_time"),
|
||||||
|
Number(reader, "fee"), fullScore, passRule, passValue,
|
||||||
|
passRule == "fixed_score" ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) : null,
|
||||||
|
Integer(reader, "position")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminExam ReadExam(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<AdminSubject>> subjects)
|
||||||
|
{
|
||||||
|
var id = Text(reader, "id");
|
||||||
|
var rawPolicy = Optional(reader, "pass_policy") ?? "rank_percent";
|
||||||
|
return new AdminExam(
|
||||||
|
id, Text(reader, "code"), Text(reader, "name"), Text(reader, "description"),
|
||||||
|
Text(reader, "registration_start"), Text(reader, "registration_end"), Text(reader, "exam_start"), Text(reader, "exam_end"),
|
||||||
|
Text(reader, "admit_download_start"), Text(reader, "admit_download_end"), Text(reader, "location"),
|
||||||
|
rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy, Number(reader, "pass_value", 60), Text(reader, "status"),
|
||||||
|
Optional(reader, "archived_at"), Optional(reader, "archived_by"), Text(reader, "created_at"),
|
||||||
|
subjects.GetValueOrDefault(id) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async Task<int> ScalarIntAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
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 bool Boolean(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
||||||
|
|
||||||
|
private static int Integer(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static double Number(DbDataReader reader, string name, double fallback = 0)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(name);
|
||||||
|
return reader.IsDBNull(ordinal) ? fallback : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? NullableNumber(DbDataReader reader, string name)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(name);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SubjectRow(string ExamId, AdminSubject Subject);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using Eis.Application.Authentication;
|
using Eis.Application.Authentication;
|
||||||
|
using Eis.Application.Administration;
|
||||||
using Eis.Application.Candidate;
|
using Eis.Application.Candidate;
|
||||||
using Eis.Application.Public;
|
using Eis.Application.Public;
|
||||||
using Eis.Infrastructure.Authentication;
|
using Eis.Infrastructure.Authentication;
|
||||||
|
using Eis.Infrastructure.Administration;
|
||||||
using Eis.Infrastructure.Candidate;
|
using Eis.Infrastructure.Candidate;
|
||||||
using Eis.Infrastructure.Data;
|
using Eis.Infrastructure.Data;
|
||||||
using Eis.Infrastructure.Public;
|
using Eis.Infrastructure.Public;
|
||||||
@@ -27,7 +29,8 @@ public static class DependencyInjection
|
|||||||
DatabaseOptions databaseOptions,
|
DatabaseOptions databaseOptions,
|
||||||
DocumentVerificationOptions documentVerificationOptions,
|
DocumentVerificationOptions documentVerificationOptions,
|
||||||
AuthenticationOptions authenticationOptions,
|
AuthenticationOptions authenticationOptions,
|
||||||
CandidateMigrationOptions candidateMigrationOptions)
|
CandidateMigrationOptions candidateMigrationOptions,
|
||||||
|
AdminMigrationOptions adminMigrationOptions)
|
||||||
{
|
{
|
||||||
services.AddSingleton(databaseOptions);
|
services.AddSingleton(databaseOptions);
|
||||||
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
||||||
@@ -48,6 +51,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<CandidateWriteRepository>();
|
services.AddScoped<CandidateWriteRepository>();
|
||||||
services.AddScoped<CandidateAdmissionRepository>();
|
services.AddScoped<CandidateAdmissionRepository>();
|
||||||
services.AddScoped<ICandidateService, CandidateService>();
|
services.AddScoped<ICandidateService, CandidateService>();
|
||||||
|
services.AddSingleton(adminMigrationOptions);
|
||||||
|
services.AddScoped<AdminReadSnapshotLoader>();
|
||||||
|
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Eis.Application.Administration;
|
||||||
|
using Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
namespace Eis.Web.Administration;
|
||||||
|
|
||||||
|
public static class NativeAdminReadEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNativeAdminReadEndpoints(
|
||||||
|
this IEndpointRouteBuilder endpoints,
|
||||||
|
AdminMigrationOptions options)
|
||||||
|
{
|
||||||
|
if (!options.NativeReadsEnabled) return endpoints;
|
||||||
|
|
||||||
|
endpoints.MapGet("/api/admin/context", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetContextAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/dashboard", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetDashboardAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/school-organization", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetSchoolOrganizationAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/admins", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetAdminsAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetExamsAsync(Token(context), cancellationToken)));
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Execute(HttpContext context, Task<AdminEndpointResult> operation)
|
||||||
|
{
|
||||||
|
var result = await operation;
|
||||||
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
|
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
|
||||||
|
return Results.Json(result.Body, statusCode: result.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Token(HttpContext context) =>
|
||||||
|
context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty;
|
||||||
|
}
|
||||||
+16
-1
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using Eis.Infrastructure.Authentication;
|
using Eis.Infrastructure.Authentication;
|
||||||
|
using Eis.Infrastructure.Administration;
|
||||||
using Eis.Infrastructure.Candidate;
|
using Eis.Infrastructure.Candidate;
|
||||||
using Eis.Application.Public;
|
using Eis.Application.Public;
|
||||||
using Eis.Infrastructure;
|
using Eis.Infrastructure;
|
||||||
@@ -8,6 +9,7 @@ using Eis.Infrastructure.Migration;
|
|||||||
using Eis.Infrastructure.Security;
|
using Eis.Infrastructure.Security;
|
||||||
using Eis.Web.Configuration;
|
using Eis.Web.Configuration;
|
||||||
using Eis.Web.Authentication;
|
using Eis.Web.Authentication;
|
||||||
|
using Eis.Web.Administration;
|
||||||
using Eis.Web.Candidate;
|
using Eis.Web.Candidate;
|
||||||
using Eis.Web.Frontend;
|
using Eis.Web.Frontend;
|
||||||
using Eis.Web.Legacy;
|
using Eis.Web.Legacy;
|
||||||
@@ -40,11 +42,16 @@ var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
|
|||||||
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
|
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
|
||||||
authenticationOptions.NativeEnabled,
|
authenticationOptions.NativeEnabled,
|
||||||
authenticationOptions.SharesLegacySessions);
|
authenticationOptions.SharesLegacySessions);
|
||||||
|
var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
||||||
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeReadsEnabled"),
|
||||||
|
authenticationOptions.NativeEnabled,
|
||||||
|
authenticationOptions.SharesLegacySessions);
|
||||||
builder.Services.AddEisInfrastructure(
|
builder.Services.AddEisInfrastructure(
|
||||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
||||||
authenticationOptions,
|
authenticationOptions,
|
||||||
candidateMigrationOptions);
|
candidateMigrationOptions,
|
||||||
|
adminMigrationOptions);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
|
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
|
||||||
@@ -90,6 +97,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
}
|
}
|
||||||
: []
|
: []
|
||||||
},
|
},
|
||||||
|
administration = new
|
||||||
|
{
|
||||||
|
nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled,
|
||||||
|
nativeRoutes = adminMigrationOptions.NativeReadsEnabled
|
||||||
|
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
||||||
|
: []
|
||||||
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
}, statusCode: statusCode);
|
}, statusCode: statusCode);
|
||||||
});
|
});
|
||||||
@@ -97,6 +111,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
app.MapNativePublicEndpoints();
|
app.MapNativePublicEndpoints();
|
||||||
app.MapNativeAuthenticationEndpoints(authenticationOptions);
|
app.MapNativeAuthenticationEndpoints(authenticationOptions);
|
||||||
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
|
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
|
||||||
|
app.MapNativeAdminReadEndpoints(adminMigrationOptions);
|
||||||
|
|
||||||
string[] methods =
|
string[] methods =
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"CandidateMigration": {
|
"CandidateMigration": {
|
||||||
"NativeEnabled": false
|
"NativeEnabled": false
|
||||||
},
|
},
|
||||||
|
"AdminMigration": {
|
||||||
|
"NativeReadsEnabled": false
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Tests.Administration;
|
||||||
|
|
||||||
|
public sealed class AdminMigrationOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DisabledByDefault()
|
||||||
|
{
|
||||||
|
WithEnvironment(null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: false,
|
||||||
|
sharesLegacySessions: false);
|
||||||
|
|
||||||
|
Assert.False(options.NativeReadsEnabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RequiresNativeAuthentication()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", "true", () =>
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: false,
|
||||||
|
sharesLegacySessions: false));
|
||||||
|
|
||||||
|
Assert.Contains("AUTH_NATIVE_ENABLED=true", exception.Message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RequiresSharedSessionsOutsideIsolatedTests()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, () =>
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: false));
|
||||||
|
|
||||||
|
Assert.Contains("Redis", exception.Message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowsSharedRedisSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeReadsEnabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WithEnvironment(
|
||||||
|
string? nativeReadsEnabled,
|
||||||
|
string? allowMemory,
|
||||||
|
Action test)
|
||||||
|
{
|
||||||
|
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
||||||
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
|
test();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user