已完成管理后台第二批 ASP.NET Core 10 迁移:

学校创建、修改
班级创建、修改
管理员创建、修改
管理员密码重置
自主注册开关
写入与审计日志保持同一事务
停用或重置管理员后自动清除其会话
This commit is contained in:
2026-07-23 08:08:26 +08:00 Unverified
parent 712bd1a3a2
commit 875e59b6ce
12 changed files with 885 additions and 20 deletions
+2
View File
@@ -27,6 +27,8 @@ AUTH_NATIVE_ENABLED=false
CANDIDATE_NATIVE_ENABLED=false CANDIDATE_NATIVE_ENABLED=false
# 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。 # 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。
ADMIN_NATIVE_READS_ENABLED=false ADMIN_NATIVE_READS_ENABLED=false
# 学校、班级、管理员维护及自主注册开关切换;同样要求原生认证和共享 Redis。
ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。 # 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
+6 -3
View File
@@ -12,7 +12,7 @@
- [x] 招生公示与 HMAC 文书验真公开接口 - [x] 招生公示与 HMAC 文书验真公开接口
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [x] 考生业务 - [x] 考生业务
- [ ] 管理后台、审批流和考务编排(第一批管理端只读接口已原生化) - [ ] 管理后台、审批流和考务编排(管理端读取与组织维护已部分原生化)
- [x] 考生志愿填报与招生录取查询 - [x] 考生志愿填报与招生录取查询
- [ ] Excel、文书和缓存 - [ ] Excel、文书和缓存
- [ ] 容器入口切换及 Node.js 后端移除 - [ ] 容器入口切换及 Node.js 后端移除
@@ -54,14 +54,17 @@ $env:AUTH_NATIVE_ENABLED = 'true'
$env:CANDIDATE_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true'
``` ```
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。其余管理端写入、审批流和考务编排接口仍转发给 Node,因此该开关同样要求原生认证和共享 Redis: 管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。
其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用:
```powershell ```powershell
$env:AUTH_NATIVE_ENABLED = 'true' $env:AUTH_NATIVE_ENABLED = 'true'
$env:ADMIN_NATIVE_READS_ENABLED = 'true' $env:ADMIN_NATIVE_READS_ENABLED = 'true'
$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
``` ```
`GET /health/migration``administration.nativeReadsEnabled``administration.routes` 会报告这一批端点是否已切换。 `GET /health/migration``administration.nativeReadsEnabled``administration.nativeOrganizationWritesEnabled``administration.nativeRoutes` 会报告这端点是否已切换。
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
+116
View File
@@ -423,6 +423,7 @@ try {
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_READS_ENABLED = 'true'
ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
ADMIN_NATIVE_ALLOW_MEMORY = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true'
LegacyNode__Enabled = 'true' LegacyNode__Enabled = 'true'
LegacyNode__BaseUrl = $legacyBaseUrl LegacyNode__BaseUrl = $legacyBaseUrl
@@ -736,6 +737,120 @@ try {
if ($classAdminsForbidden.StatusCode -ne 403) { if ($classAdminsForbidden.StatusCode -ne 403) {
throw 'Native admins route did not preserve the class-admin boundary' throw 'Native admins route did not preserve the class-admin boundary'
} }
$schoolCreateBody = @{
name = '原生迁移测试学校'
code = 'NATIVE_SMOKE'
address = '迁移测试路 1 号'
isSourceSchool = $true
isAdmissionSchool = $true
} | ConvertTo-Json -Compress
$schoolCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSession
if ($schoolCreateResponse.StatusCode -ne 201 -or $schoolCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native school creation did not use the ASP.NET Core endpoint'
}
$createdSchool = ($schoolCreateResponse.Content | ConvertFrom-Json).school
if ($createdSchool.code -ne 'NATIVE_SMOKE' -or $createdSchool.active -ne $true) {
throw 'Native school creation returned an unexpected projection'
}
$duplicateSchool = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($duplicateSchool.StatusCode -ne 409) {
throw 'Native school creation did not reject a duplicate school code'
}
$schoolCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/schools" -Method Post -ContentType 'application/json' -Body $schoolCreateBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($schoolCreateForbidden.StatusCode -ne 403 -or $schoolCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native school creation did not preserve the super-admin boundary'
}
$schoolPatchBody = @{ address = '迁移测试路 2 号'; isAdmissionSchool = $false } | ConvertTo-Json -Compress
$schoolPatch = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/schools/$($createdSchool.id)" -Method Patch -ContentType 'application/json' -Body $schoolPatchBody -WebSession $nativeSession
if ($schoolPatch.school.address -ne '迁移测试路 2 号' -or $schoolPatch.school.isAdmissionSchool -ne $false) {
throw 'Native school update did not persist the requested fields'
}
$newSchoolAdminBody = @{
username = 'native_school_writer'
password = '12345678'
displayName = '原生校级管理员'
adminLevel = 'school'
schoolId = $createdSchool.id
} | ConvertTo-Json -Compress
$newSchoolAdminResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admins" -Method Post -ContentType 'application/json' -Body $newSchoolAdminBody -WebSession $nativeSession
if ($newSchoolAdminResponse.StatusCode -ne 201 -or $newSchoolAdminResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native administrator creation did not use the ASP.NET Core endpoint'
}
$createdSchoolAdmin = ($newSchoolAdminResponse.Content | ConvertFrom-Json).admin
$newSchoolAdminSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$newSchoolAdminLoginBody = @{ username = 'native_school_writer'; password = '12345678' } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $newSchoolAdminLoginBody -WebSession $newSchoolAdminSession | Out-Null
$classCreateBody = @{ name = '迁移测试班'; grade = '2026级' } | ConvertTo-Json -Compress
$classCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/classes" -Method Post -ContentType 'application/json' -Body $classCreateBody -WebSession $newSchoolAdminSession
if ($classCreateResponse.StatusCode -ne 201 -or $classCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native class creation did not use the ASP.NET Core endpoint'
}
$createdClass = ($classCreateResponse.Content | ConvertFrom-Json).schoolClass
$superClassForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/classes" -Method Post -ContentType 'application/json' -Body $classCreateBody -WebSession $nativeSession -SkipHttpErrorCheck
if ($superClassForbidden.StatusCode -ne 403) {
throw 'Native class creation did not preserve the school-admin boundary'
}
$classPatchBody = @{ name = '迁移测试一班'; active = $true } | ConvertTo-Json -Compress
$classPatch = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/classes/$($createdClass.id)" -Method Patch -ContentType 'application/json' -Body $classPatchBody -WebSession $newSchoolAdminSession
if ($classPatch.schoolClass.name -ne '迁移测试一班') {
throw 'Native class update did not persist the requested name'
}
$newClassAdminBody = @{
username = 'native_class_writer'
password = '12345678'
displayName = '原生班级管理员'
adminLevel = 'super'
classId = $createdClass.id
} | ConvertTo-Json -Compress
$newClassAdminResponse = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins" -Method Post -ContentType 'application/json' -Body $newClassAdminBody -WebSession $newSchoolAdminSession
if ($newClassAdminResponse.admin.adminLevel -ne 'class' -or $newClassAdminResponse.admin.schoolId -ne $createdSchool.id) {
throw 'School admin did not create a class-scoped administrator'
}
$createdClassAdmin = $newClassAdminResponse.admin
$newClassAdminSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$newClassAdminLoginBody = @{ username = 'native_class_writer'; password = '12345678' } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $newClassAdminLoginBody -WebSession $newClassAdminSession | Out-Null
$disableClassAdminBody = @{ active = $false; displayName = '原生班级管理员(停用)' } | ConvertTo-Json -Compress
$disabledClassAdmin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins/$($createdClassAdmin.id)" -Method Patch -ContentType 'application/json' -Body $disableClassAdminBody -WebSession $newSchoolAdminSession
if ($disabledClassAdmin.admin.displayName -ne '原生班级管理员(停用)') {
throw 'Native administrator update did not persist the display name'
}
$invalidatedClassSession = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $newClassAdminSession -SkipHttpErrorCheck
$invalidatedClassIdentity = $invalidatedClassSession.Content | ConvertFrom-Json
if ($invalidatedClassSession.StatusCode -ne 200 -or $null -ne $invalidatedClassIdentity.user) {
throw 'Disabling an administrator did not invalidate existing sessions'
}
$resetClassAdmin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins/$($createdClassAdmin.id)/reset-password" -Method Post -WebSession $newSchoolAdminSession
if ($resetClassAdmin.username -ne 'native_class_writer' -or $resetClassAdmin.temporaryPassword -notmatch '^Reset-[A-Za-z0-9_-]+$') {
throw 'Native administrator password reset returned an invalid temporary password'
}
$resetLoginBody = @{ username = 'native_class_writer'; password = $resetClassAdmin.temporaryPassword } | ConvertTo-Json -Compress
$resetLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $resetLoginBody
if ($resetLogin.user.username -ne 'native_class_writer') {
throw 'The temporary administrator password is not compatible with native authentication'
}
$adminStateBeforeSetting = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/admins" -WebSession $nativeSession
$originalSelfRegistration = [bool]$adminStateBeforeSetting.selfRegistrationEnabled
$settingBody = @{ enabled = -not $originalSelfRegistration } | ConvertTo-Json -Compress
$settingUpdate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $settingBody -WebSession $nativeSession
if ($settingUpdate.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or ($settingUpdate.Content | ConvertFrom-Json).enabled -eq $originalSelfRegistration) {
throw 'Native self-registration setting did not persist the requested value'
}
$settingForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $settingBody -WebSession $nativeSchoolSession -SkipHttpErrorCheck
if ($settingForbidden.StatusCode -ne 403) {
throw 'Native self-registration setting did not preserve the super-admin boundary'
}
$restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null
$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'
@@ -819,6 +934,7 @@ try {
NativeCandidateDocuments = 'passed' NativeCandidateDocuments = 'passed'
NativeCandidateAdmissions = 'passed' NativeCandidateAdmissions = 'passed'
NativeAdminReads = 'passed' NativeAdminReads = 'passed'
NativeAdminOrganizationWrites = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
@@ -0,0 +1,49 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminOrganizationService
{
Task<AdminEndpointResult> CreateSchoolAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateSchoolAsync(
string sessionToken,
string schoolId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> CreateClassAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateClassAsync(
string sessionToken,
string classId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> CreateAdminAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateAdminAsync(
string sessionToken,
string adminId,
JsonObject body,
CancellationToken cancellationToken);
Task<AdminEndpointResult> ResetAdminPasswordAsync(
string sessionToken,
string adminId,
CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateSelfRegistrationAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken);
}
@@ -1,31 +1,43 @@
namespace Eis.Infrastructure.Administration; namespace Eis.Infrastructure.Administration;
public sealed record AdminMigrationOptions(bool NativeReadsEnabled) public sealed record AdminMigrationOptions(
bool NativeReadsEnabled,
bool NativeOrganizationWritesEnabled = false)
{ {
public static AdminMigrationOptions FromEnvironment( public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled, bool configuredNativeReadsEnabled,
bool authenticationNativeEnabled, bool authenticationNativeEnabled,
bool sharesLegacySessions) bool sharesLegacySessions,
bool configuredNativeOrganizationWritesEnabled = false)
{ {
var enabled = ParseBoolean( var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled); configuredNativeReadsEnabled);
if (enabled && !authenticationNativeEnabled) var organizationWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"),
configuredNativeOrganizationWritesEnabled);
if (organizationWritesEnabled && !readsEnabled)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"启用原生管理端读取接口前必须同时设置 AUTH_NATIVE_ENABLED=true"); "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生管理端接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
} }
var allowMemoryForIsolatedTesting = ParseBoolean( var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"), Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false); fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting) if (anyNativeAdminEndpointEnabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"管理端仍有写入接口需要转发给 Node;启用原生管理端读取接口必须配置共享 Redis 会话"); "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
} }
return new AdminMigrationOptions(enabled); return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled);
} }
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -0,0 +1,433 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed partial class AdminOrganizationService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
PasswordCompatibilityService passwords,
AdminReadSnapshotLoader snapshotLoader,
AdminWriteRepository repository) : IAdminOrganizationService
{
private static readonly IReadOnlyDictionary<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
public async Task<AdminEndpointResult> CreateSchoolAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "super") return Error(403, "只有超级管理员可以创建学校");
var name = Clean(Text(body["name"]), 100);
var code = Clean(Text(body["code"]), 40).ToUpperInvariant();
var address = Clean(Text(body["address"]), 200);
var isSourceSchool = !ExactlyFalse(body["isSourceSchool"]);
var isAdmissionSchool = !ExactlyFalse(body["isAdmissionSchool"]);
if (name.Length == 0 || code.Length == 0) return Error(400, "学校名称和学校代码不能为空");
if (!isSourceSchool && !isAdmissionSchool) return Error(400, "学校至少应设置为生源校或招生校");
if (!SchoolCodePattern().IsMatch(code)) return Error(400, "学校代码只能包含字母、数字、下划线和连字符");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
if (snapshot.Schools.Any(item => string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase)))
return Error(409, "学校代码已存在");
if (snapshot.Schools.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)))
return Error(409, "学校名称已存在");
var school = new AdminSchool(
Uid("school"), name, code, address, isSourceSchool, isAdmissionSchool, !ExactlyFalse(body["active"]));
await repository.SaveSchoolAsync(
school,
isNew: true,
Audit(user, "创建学校", $"{name} · {code}"),
cancellationToken);
return Result(201, new JsonObject { ["ok"] = true, ["school"] = SchoolJson(school) });
}
public async Task<AdminEndpointResult> UpdateSchoolAsync(
string sessionToken,
string schoolId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "super") return Error(403, "只有超级管理员可以维护学校");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var existing = snapshot.Schools.FirstOrDefault(item => item.Id == schoolId);
if (existing is null) return Error(404, "学校不存在");
var name = Clean(NullishText(body, "name", existing.Name), 100);
var code = Clean(NullishText(body, "code", existing.Code), 40).ToUpperInvariant();
var address = Clean(NullishText(body, "address", existing.Address), 200);
var isSourceSchool = NullishBoolean(body, "isSourceSchool", existing.IsSourceSchool);
var isAdmissionSchool = NullishBoolean(body, "isAdmissionSchool", existing.IsAdmissionSchool);
var active = NullishBoolean(body, "active", existing.Active);
if (name.Length == 0 || code.Length == 0) return Error(400, "学校名称和学校代码不能为空");
if (!isSourceSchool && !isAdmissionSchool) return Error(400, "学校至少应设置为生源校或招生校");
if (!SchoolCodePattern().IsMatch(code)) return Error(400, "学校代码只能包含字母、数字、下划线和连字符");
if (snapshot.Schools.Any(item => item.Id != existing.Id && string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase)))
return Error(409, "学校代码已存在");
if (snapshot.Schools.Any(item => item.Id != existing.Id && string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)))
return Error(409, "学校名称已存在");
var school = existing with
{
Name = name,
Code = code,
Address = address,
IsSourceSchool = isSourceSchool,
IsAdmissionSchool = isAdmissionSchool,
Active = active
};
await repository.SaveSchoolAsync(
school,
isNew: false,
Audit(user, "维护学校", $"{name} · {code} · {(active ? "" : "")}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["school"] = SchoolJson(school) });
}
public async Task<AdminEndpointResult> CreateClassAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "school") return Error(403, "只有校级管理员可以新增本校班级");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
if (!snapshot.Schools.Any(item => item.Id == user.SchoolId && item.Active && item.IsSourceSchool))
return Error(409, "当前学校未设置为已启用的生源校");
var name = Clean(Text(body["name"]), 100);
var grade = Clean(Text(body["grade"]), 60);
if (name.Length == 0 || grade.Length == 0) return Error(400, "年级和班级名称不能为空");
if (snapshot.Classes.Any(item => item.SchoolId == user.SchoolId && item.Name == name))
return Error(409, "本校已存在同名班级");
var schoolClass = new AdminClass(Uid("class"), user.SchoolId!, name, grade, !ExactlyFalse(body["active"]));
await repository.SaveClassAsync(
schoolClass,
isNew: true,
Audit(user, "新增本校班级", $"{grade} · {name}"),
cancellationToken);
return Result(201, new JsonObject { ["ok"] = true, ["schoolClass"] = ClassJson(schoolClass) });
}
public async Task<AdminEndpointResult> UpdateClassAsync(
string sessionToken,
string classId,
JsonObject body,
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 existing = snapshot.Classes.FirstOrDefault(item => item.Id == classId && item.SchoolId == user.SchoolId);
if (existing is null) return Error(404, "班级不存在");
var name = Clean(NullishText(body, "name", existing.Name), 100);
var grade = Clean(NullishText(body, "grade", existing.Grade), 60);
if (name.Length == 0 || grade.Length == 0) return Error(400, "年级和班级名称不能为空");
if (snapshot.Classes.Any(item => item.Id != existing.Id && item.SchoolId == user.SchoolId && item.Name == name))
return Error(409, "本校已存在同名班级");
var schoolClass = existing with
{
Name = name,
Grade = grade,
Active = NullishBoolean(body, "active", existing.Active)
};
await repository.SaveClassAsync(
schoolClass,
isNew: false,
Audit(user, "更新本校班级", $"{grade} · {name} · {(schoolClass.Active ? "" : "")}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["schoolClass"] = ClassJson(schoolClass) });
}
public async Task<AdminEndpointResult> CreateAdminAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var username = Clean(Text(body["username"]), 50);
var password = TruthyText(body["password"]);
var displayName = Clean(Text(body["displayName"]), 50);
var adminLevel = Level(user) == "school" ? "class" : Clean(Text(body["adminLevel"]), 20);
if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能创建管理员");
if (username.Length == 0 || displayName.Length == 0 || password.Length < 8 ||
adminLevel is not ("super" or "school" or "class"))
return Error(400, "请完整填写管理员账号、姓名、层级和至少 8 位密码");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
if (snapshot.Users.Any(item => string.Equals(item.Username, username, StringComparison.OrdinalIgnoreCase)))
return Error(409, "该登录账号已存在");
var schoolId = adminLevel == "super"
? null
: Level(user) == "school" ? user.SchoolId : Clean(Text(body["schoolId"]), 64);
var classId = adminLevel == "class" ? Clean(Text(body["classId"]), 64) : null;
if (adminLevel != "super" && !snapshot.Schools.Any(item => item.Id == schoolId && item.Active && item.IsSourceSchool))
return Error(400, "校级和班级管理员必须绑定已启用的生源校");
if (adminLevel == "class" && !snapshot.Classes.Any(item => item.Id == classId && item.SchoolId == schoolId))
return Error(400, "请选择该学校下的有效班级");
var created = new AdminUser(
Uid("usr"), username, "admin", adminLevel, schoolId, classId, displayName, null,
true, false, false, null, NowIso());
await repository.CreateAdminAsync(
created,
passwords.Hash(password),
Audit(user, "创建管理员", $"{displayName} · {LevelNames[adminLevel]}"),
cancellationToken);
return Result(201, new JsonObject { ["ok"] = true, ["admin"] = SafeUser(created) });
}
public async Task<AdminEndpointResult> UpdateAdminAsync(
string sessionToken,
string adminId,
JsonObject body,
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 target = snapshot.Users.FirstOrDefault(item => item.Id == adminId && item.Role == "admin" &&
(Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId));
if (target is null) return Error(404, "管理员账户不存在或不在当前管理范围");
if (target.Id == user.Id && ExactlyFalse(body["active"]))
return Error(409, "不能停用当前正在使用的管理员账户");
var requestedClassId = Clean(TruthyText(body["classId"], target.ClassId ?? string.Empty), 64);
var schoolClass = target.AdminLevel == "class"
? snapshot.Classes.FirstOrDefault(item => item.Id == requestedClassId && item.SchoolId == target.SchoolId)
: null;
if (target.AdminLevel == "class" && schoolClass is null)
return Error(400, "请选择该管理员所属学校的有效班级");
var password = TruthyText(body["password"]);
if (password.Length is > 0 and < 8) return Error(400, "重置密码至少 8 位");
var displayName = Clean(TruthyText(body["displayName"], target.DisplayName), 50);
var active = NullishBoolean(body, "active", target.Active);
var updated = target with
{
DisplayName = displayName,
ClassId = schoolClass?.Id ?? target.ClassId,
Active = active
};
await repository.UpdateAdminAsync(
updated.Id,
updated.DisplayName,
updated.ClassId,
updated.Active,
password.Length == 0 ? null : passwords.Hash(password),
Audit(user, "维护管理员账户", $"{updated.DisplayName} · {LevelNames[updated.AdminLevel ?? "super"]} · {(active ? "" : "")}"),
cancellationToken);
if (!active || password.Length > 0) await authenticationState.DeleteUserSessionsAsync(updated.Id);
return Success(new JsonObject { ["ok"] = true, ["admin"] = SafeUser(updated) });
}
public async Task<AdminEndpointResult> ResetAdminPasswordAsync(
string sessionToken,
string adminId,
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 target = snapshot.Users.FirstOrDefault(item => item.Id == adminId && item.Role == "admin" &&
(Level(user) == "super" || item.AdminLevel == "class" && item.SchoolId == user.SchoolId));
if (target is null) return Error(404, "管理员账户不存在或不在当前管理范围");
if (target.Id == user.Id) return Error(409, "当前账号请在“账户安全”中修改自己的密码");
var temporaryPassword = $"Reset-{Base64Url(RandomNumberGenerator.GetBytes(7))}";
await repository.UpdateAdminAsync(
target.Id,
target.DisplayName,
target.ClassId,
active: true,
passwords.Hash(temporaryPassword),
Audit(user, "重置管理员密码", $"{target.DisplayName} · {target.Username}"),
cancellationToken);
await authenticationState.DeleteUserSessionsAsync(target.Id);
return Success(new JsonObject
{
["ok"] = true,
["username"] = target.Username,
["temporaryPassword"] = temporaryPassword
});
}
public async Task<AdminEndpointResult> UpdateSelfRegistrationAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (Level(user) != "super") return Error(403, "当前管理员层级无权执行此操作");
var enabled = JsBoolean(body["enabled"]);
await repository.UpdateSelfRegistrationAsync(
enabled,
Audit(
user,
enabled ? "开启自主注册" : "关闭自主注册",
enabled ? "考生可从公开入口申请报名号" : "仅允许使用学校下发的报名号登录"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["enabled"] = enabled });
}
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 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(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 AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) =>
new(Uid("log"), user.Id, action, detail, NowIso());
private static string NullishText(JsonObject body, string property, string fallback) =>
body[property] is null ? fallback : Text(body[property]);
private static bool NullishBoolean(JsonObject body, string property, bool fallback) =>
body[property] is null ? fallback : JsBoolean(body[property]);
private static bool ExactlyFalse(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<bool>(out var boolean) && !boolean;
private static bool JsBoolean(JsonNode? node)
{
if (node is null) return false;
if (node is not JsonValue value) return true;
if (value.TryGetValue<bool>(out var boolean)) return boolean;
if (value.TryGetValue<string>(out var text)) return text.Length > 0;
if (value.TryGetValue<double>(out var number)) return number != 0 && !double.IsNaN(number);
return true;
}
private static string TruthyText(JsonNode? node, string fallback = "") =>
JsBoolean(node) ? Text(node) : fallback;
private static string Text(JsonNode? node)
{
if (node is null) return string.Empty;
if (node is JsonValue value)
{
if (value.TryGetValue<string>(out var text)) return text;
if (value.TryGetValue<bool>(out var boolean)) return boolean ? "true" : "false";
if (value.TryGetValue<double>(out var number)) return number.ToString(CultureInfo.InvariantCulture);
}
return node.ToJsonString();
}
private static string Clean(string value, int maximum)
{
var cleaned = value.Trim();
return cleaned[..Math.Min(cleaned.Length, maximum)];
}
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static string Uid(string prefix) =>
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
private static string ToBase36(long value)
{
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
Span<char> buffer = stackalloc char[16];
var position = buffer.Length;
do
{
buffer[--position] = alphabet[(int)(value % 36)];
value /= 36;
}
while (value > 0);
return new string(buffer[position..]);
}
private static string NowIso() =>
DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
private static string Base64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static AdminEndpointResult Success(JsonObject body) => Result(200, body);
private static AdminEndpointResult Result(int statusCode, JsonObject body) => new(statusCode, body);
private static AdminEndpointResult Error(int status, string message) =>
Result(status, new JsonObject { ["ok"] = false, ["message"] = message });
[GeneratedRegex("^[A-Z0-9_-]+$", RegexOptions.CultureInvariant)]
private static partial Regex SchoolCodePattern();
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
}
}
@@ -0,0 +1,184 @@
using System.Data.Common;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminAuditEntry(
string Id,
string ActorId,
string Action,
string Detail,
string CreatedAt);
internal sealed class AdminWriteRepository(IRelationalConnectionFactory connectionFactory)
{
public Task SaveSchoolAsync(
AdminSchool school,
bool isNew,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operation = isNew
? new SqlOperation(
"""
INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active)
VALUES (@id, @name, @code, @address, @isSourceSchool, @isAdmissionSchool, @active)
""",
[
new("@id", school.Id), new("@name", school.Name), new("@code", school.Code),
new("@address", Optional(school.Address)), new("@isSourceSchool", Number(school.IsSourceSchool)),
new("@isAdmissionSchool", Number(school.IsAdmissionSchool)), new("@active", Number(school.Active))
])
: new SqlOperation(
"""
UPDATE schools SET name = @name, code = @code, address = @address,
is_source_school = @isSourceSchool, is_admission_school = @isAdmissionSchool, active = @active
WHERE id = @id
""",
[
new("@name", school.Name), new("@code", school.Code), new("@address", Optional(school.Address)),
new("@isSourceSchool", Number(school.IsSourceSchool)), new("@isAdmissionSchool", Number(school.IsAdmissionSchool)),
new("@active", Number(school.Active)), new("@id", school.Id)
]);
return ExecuteWithAuditAsync(operation, audit, cancellationToken);
}
public Task SaveClassAsync(
AdminClass schoolClass,
bool isNew,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operation = isNew
? new SqlOperation(
"""
INSERT INTO school_classes (id, school_id, name, grade, active)
VALUES (@id, @schoolId, @name, @grade, @active)
""",
[
new("@id", schoolClass.Id), new("@schoolId", schoolClass.SchoolId), new("@name", schoolClass.Name),
new("@grade", schoolClass.Grade), new("@active", Number(schoolClass.Active))
])
: new SqlOperation(
"UPDATE school_classes SET name = @name, grade = @grade, active = @active WHERE id = @id",
[
new("@name", schoolClass.Name), new("@grade", schoolClass.Grade),
new("@active", Number(schoolClass.Active)), new("@id", schoolClass.Id)
]);
return ExecuteWithAuditAsync(operation, audit, cancellationToken);
}
public Task CreateAdminAsync(
AdminUser user,
string passwordHash,
AdminAuditEntry audit,
CancellationToken cancellationToken) => ExecuteWithAuditAsync(
new SqlOperation(
"""
INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (
@id, @username, @passwordHash, 'admin', @adminLevel, @schoolId, @classId, @active, @displayName, @createdAt
)
""",
[
new("@id", user.Id), new("@username", user.Username), new("@passwordHash", passwordHash),
new("@adminLevel", user.AdminLevel), new("@schoolId", user.SchoolId), new("@classId", user.ClassId),
new("@active", Number(user.Active)), new("@displayName", user.DisplayName), new("@createdAt", user.CreatedAt)
]),
audit,
cancellationToken);
public Task UpdateAdminAsync(
string userId,
string displayName,
string? classId,
bool active,
string? passwordHash,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operation = passwordHash is null
? new SqlOperation(
"UPDATE users SET display_name = @displayName, class_id = @classId, active = @active WHERE id = @id",
[
new("@displayName", displayName), new("@classId", classId),
new("@active", Number(active)), new("@id", userId)
])
: new SqlOperation(
"""
UPDATE users SET display_name = @displayName, class_id = @classId, active = @active,
password_hash = @passwordHash WHERE id = @id
""",
[
new("@displayName", displayName), new("@classId", classId), new("@active", Number(active)),
new("@passwordHash", passwordHash), new("@id", userId)
]);
return ExecuteWithAuditAsync(operation, audit, cancellationToken);
}
public Task UpdateSelfRegistrationAsync(
bool enabled,
AdminAuditEntry audit,
CancellationToken cancellationToken) => ExecuteWithAuditAsync(
new SqlOperation(
"UPDATE schema_metadata SET self_registration_enabled = @enabled WHERE id = 1",
[new("@enabled", Number(enabled))]),
audit,
cancellationToken);
private async Task ExecuteWithAuditAsync(
SqlOperation operation,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ExecuteAsync(connection, transaction, operation, cancellationToken);
await ExecuteAsync(connection, transaction, new SqlOperation(
"""
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
VALUES (@id, @actorId, @action, @detail, @createdAt)
""",
[
new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action),
new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt)
]), cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static async Task ExecuteAsync(
DbConnection connection,
DbTransaction transaction,
SqlOperation operation,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = operation.Sql;
foreach (var item in operation.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = item.Name;
parameter.Value = item.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static int Number(bool value) => value ? 1 : 0;
private static string? Optional(string value) => value.Length == 0 ? null : value;
private sealed record SqlOperation(string Sql, IReadOnlyList<SqlParameterValue> Parameters);
private sealed record SqlParameterValue(string Name, object? Value);
}
@@ -53,7 +53,9 @@ public static class DependencyInjection
services.AddScoped<ICandidateService, CandidateService>(); services.AddScoped<ICandidateService, CandidateService>();
services.AddSingleton(adminMigrationOptions); services.AddSingleton(adminMigrationOptions);
services.AddScoped<AdminReadSnapshotLoader>(); services.AddScoped<AdminReadSnapshotLoader>();
services.AddScoped<AdminWriteRepository>();
services.AddScoped<IAdminReadService, AdminReadService>(); services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
} }
@@ -1,5 +1,6 @@
using Eis.Application.Administration; using Eis.Application.Administration;
using Eis.Infrastructure.Administration; using Eis.Infrastructure.Administration;
using System.Text.Json.Nodes;
namespace Eis.Web.Administration; namespace Eis.Web.Administration;
@@ -23,6 +24,24 @@ public static class NativeAdminReadEndpoints
Execute(context, service.GetAdminsAsync(Token(context), cancellationToken))); Execute(context, service.GetAdminsAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => endpoints.MapGet("/api/admin/exams", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetExamsAsync(Token(context), cancellationToken))); Execute(context, service.GetExamsAsync(Token(context), cancellationToken)));
if (!options.NativeOrganizationWritesEnabled) return endpoints;
endpoints.MapPost("/api/admin/schools", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateSchoolAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/schools/{schoolId}", (HttpContext context, string schoolId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateSchoolAsync(Token(context), schoolId, body, cancellationToken)));
endpoints.MapPost("/api/admin/classes", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateClassAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/classes/{classId}", (HttpContext context, string classId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateClassAsync(Token(context), classId, body, cancellationToken)));
endpoints.MapPost("/api/admin/admins", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAdminAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/admins/{adminId}", (HttpContext context, string adminId, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAdminAsync(Token(context), adminId, body, cancellationToken)));
endpoints.MapPost("/api/admin/admins/{adminId}/reset-password", (HttpContext context, string adminId, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.ResetAdminPasswordAsync(Token(context), adminId, cancellationToken)));
endpoints.MapPut("/api/admin/settings/self-registration", (HttpContext context, JsonObject body, IAdminOrganizationService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken)));
return endpoints; return endpoints;
} }
+14 -4
View File
@@ -45,7 +45,8 @@ var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeReadsEnabled"), builder.Configuration.GetValue<bool>("AdminMigration:NativeReadsEnabled"),
authenticationOptions.NativeEnabled, authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions); authenticationOptions.SharesLegacySessions,
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"));
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()),
@@ -100,9 +101,18 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
administration = new administration = new
{ {
nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled, nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled,
nativeRoutes = adminMigrationOptions.NativeReadsEnabled nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled,
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
: [] ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: [])
.Concat(adminMigrationOptions.NativeOrganizationWritesEnabled
? new[]
{
"POST/PATCH schools", "POST/PATCH classes", "POST/PATCH admins",
"POST admin password reset", "PUT self-registration setting"
}
: [])
.ToArray()
}, },
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode); }, statusCode: statusCode);
+2 -1
View File
@@ -10,7 +10,8 @@
"NativeEnabled": false "NativeEnabled": false
}, },
"AdminMigration": { "AdminMigration": {
"NativeReadsEnabled": false "NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -7,7 +7,7 @@ public sealed class AdminMigrationOptionsTests
[Fact] [Fact]
public void DisabledByDefault() public void DisabledByDefault()
{ {
WithEnvironment(null, null, () => WithEnvironment(null, null, null, () =>
{ {
var options = AdminMigrationOptions.FromEnvironment( var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false, configuredNativeReadsEnabled: false,
@@ -21,7 +21,7 @@ public sealed class AdminMigrationOptionsTests
[Fact] [Fact]
public void RequiresNativeAuthentication() public void RequiresNativeAuthentication()
{ {
WithEnvironment("true", "true", () => WithEnvironment("true", null, "true", () =>
{ {
var exception = Assert.Throws<InvalidOperationException>(() => var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment( AdminMigrationOptions.FromEnvironment(
@@ -36,7 +36,7 @@ public sealed class AdminMigrationOptionsTests
[Fact] [Fact]
public void RequiresSharedSessionsOutsideIsolatedTests() public void RequiresSharedSessionsOutsideIsolatedTests()
{ {
WithEnvironment("true", null, () => WithEnvironment("true", null, null, () =>
{ {
var exception = Assert.Throws<InvalidOperationException>(() => var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment( AdminMigrationOptions.FromEnvironment(
@@ -51,7 +51,7 @@ public sealed class AdminMigrationOptionsTests
[Fact] [Fact]
public void AllowsSharedRedisSessions() public void AllowsSharedRedisSessions()
{ {
WithEnvironment("true", null, () => WithEnvironment("true", null, null, () =>
{ {
var options = AdminMigrationOptions.FromEnvironment( var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false, configuredNativeReadsEnabled: false,
@@ -62,22 +62,56 @@ public sealed class AdminMigrationOptionsTests
}); });
} }
[Fact]
public void OrganizationWritesRequireNativeReads()
{
WithEnvironment("false", "true", null, () =>
{
var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: true));
Assert.Contains("ADMIN_NATIVE_READS_ENABLED=true", exception.Message);
});
}
[Fact]
public void EnablesOrganizationWritesWithReadsAndSharedSessions()
{
WithEnvironment("true", "true", null, () =>
{
var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: true);
Assert.True(options.NativeReadsEnabled);
Assert.True(options.NativeOrganizationWritesEnabled);
});
}
private static void WithEnvironment( private static void WithEnvironment(
string? nativeReadsEnabled, string? nativeReadsEnabled,
string? nativeOrganizationWritesEnabled,
string? allowMemory, string? allowMemory,
Action test) Action test)
{ {
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
try try
{ {
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
test(); test();
} }
finally finally
{ {
Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled);
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
} }
} }