本批“考生账号维护”迁移完成:
POST /api/admin/candidate-accounts/archive
POST /api/admin/candidates/{profileId}/reset-password
PATCH /api/admin/candidates/{profileId}
实现了校级按班级/年级归档与恢复、超级管理员密码重置、多级资料审批、事务审计和会话失效。核心代码见
This commit is contained in:
@@ -39,6 +39,8 @@ ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
|
|||||||
ADMIN_NATIVE_CENTERS_ENABLED=false
|
ADMIN_NATIVE_CENTERS_ENABLED=false
|
||||||
# 考生档案、报名记录与缴费记录读取;必须同时启用管理端只读接口。
|
# 考生档案、报名记录与缴费记录读取;必须同时启用管理端只读接口。
|
||||||
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false
|
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false
|
||||||
|
# 考生账户归档、密码重置及资料流程审核;必须同时启用考生/报名/缴费读取。
|
||||||
|
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+4
-3
@@ -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,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。第八批覆盖校级按班级或年级归档及恢复考生账户、超级管理员重置考生密码、三级管理员按配置流程审核考生资料;账号与流程更新、审计日志在同一事务中提交,归档和密码重置会同步失效相关 ASP.NET Core 会话。
|
||||||
|
|
||||||
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
@@ -67,9 +67,10 @@ $env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
|||||||
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
|
$env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled`、`administration.nativeCandidateManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ try {
|
|||||||
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
|
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -819,6 +820,92 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$archiveBody = @{
|
||||||
|
scopeType = 'class'
|
||||||
|
scopeValue = $profileUpdate.profile.classId
|
||||||
|
archived = $true
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$superArchiveForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-accounts/archive" -Method Post -ContentType 'application/json' -Body $archiveBody -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($superArchiveForbidden.StatusCode -ne 403 -or $superArchiveForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native candidate archive did not preserve the school-admin boundary'
|
||||||
|
}
|
||||||
|
$classArchiveForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-accounts/archive" -Method Post -ContentType 'application/json' -Body $archiveBody -WebSession $nativeClassSession -SkipHttpErrorCheck
|
||||||
|
if ($classArchiveForbidden.StatusCode -ne 403) {
|
||||||
|
throw 'Native candidate archive accepted a class administrator'
|
||||||
|
}
|
||||||
|
$archiveResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-accounts/archive" -Method Post -ContentType 'application/json' -Body $archiveBody -WebSession $nativeSchoolSession
|
||||||
|
$archiveResult = $archiveResponse.Content | ConvertFrom-Json
|
||||||
|
if ($archiveResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $archiveResult.archived -ne $true -or $archiveResult.count -lt 1) {
|
||||||
|
throw 'Native candidate archive did not freeze the selected class accounts'
|
||||||
|
}
|
||||||
|
$archivedState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/candidates" -WebSession $nativeSession
|
||||||
|
$archivedCandidate = @($archivedState.candidates | Where-Object id -eq $profileUpdate.profile.id)[0]
|
||||||
|
if ($null -eq $archivedCandidate -or $archivedCandidate.accountArchived -ne $true) {
|
||||||
|
throw 'Native candidate archive was not reflected by the candidate read model'
|
||||||
|
}
|
||||||
|
|
||||||
|
$restoreBody = @{
|
||||||
|
scopeType = 'class'
|
||||||
|
scopeValue = $profileUpdate.profile.classId
|
||||||
|
archived = $false
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$restoreResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-accounts/archive" -Method Post -ContentType 'application/json' -Body $restoreBody -WebSession $nativeSchoolSession
|
||||||
|
$restoreResult = $restoreResponse.Content | ConvertFrom-Json
|
||||||
|
if ($restoreResult.archived -ne $false -or $restoreResult.count -ne $archiveResult.count) {
|
||||||
|
throw 'Native candidate archive could not restore the same class accounts'
|
||||||
|
}
|
||||||
|
|
||||||
|
$preResetSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $preResetSession | Out-Null
|
||||||
|
$schoolResetForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates/$($profileUpdate.profile.id)/reset-password" -Method Post -WebSession $nativeSchoolSession -SkipHttpErrorCheck
|
||||||
|
if ($schoolResetForbidden.StatusCode -ne 403 -or $schoolResetForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native candidate password reset did not preserve the super-admin boundary'
|
||||||
|
}
|
||||||
|
$resetCandidateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates/$($profileUpdate.profile.id)/reset-password" -Method Post -WebSession $nativeSession
|
||||||
|
$resetCandidate = $resetCandidateResponse.Content | ConvertFrom-Json
|
||||||
|
if ($resetCandidateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $resetCandidate.candidateNumber -ne $registration.registrationNumber -or $resetCandidate.temporaryPassword -notmatch '^Reset-[A-Za-z0-9_-]+$') {
|
||||||
|
throw 'Native candidate password reset did not return compatible temporary credentials'
|
||||||
|
}
|
||||||
|
$invalidatedCandidateSession = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $preResetSession
|
||||||
|
if ($null -ne $invalidatedCandidateSession.user) {
|
||||||
|
throw 'Native candidate password reset did not invalidate existing sessions'
|
||||||
|
}
|
||||||
|
$resetCandidateLoginBody = @{ username = $registration.registrationNumber; password = $resetCandidate.temporaryPassword } | ConvertTo-Json -Compress
|
||||||
|
$resetCandidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
$resetCandidateLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $resetCandidateLoginBody -WebSession $resetCandidateSession
|
||||||
|
if ($resetCandidateLogin.user.mustChangePassword -ne $true) {
|
||||||
|
throw 'Native candidate temporary password did not require a password change'
|
||||||
|
}
|
||||||
|
|
||||||
|
$invalidProfileReview = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates/$($profileUpdate.profile.id)" -Method Patch -ContentType 'application/json' -Body '{"status":"invalid"}' -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidProfileReview.StatusCode -ne 400 -or $invalidProfileReview.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native candidate profile review accepted an invalid status'
|
||||||
|
}
|
||||||
|
$candidateManagementState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/candidates" -WebSession $nativeSession
|
||||||
|
$managedCandidate = @($candidateManagementState.candidates | Where-Object id -eq $profileUpdate.profile.id)[0]
|
||||||
|
if ($null -eq $managedCandidate -or $managedCandidate.workflow.status -ne 'pending') {
|
||||||
|
throw 'Candidate profile smoke data did not retain a pending review workflow'
|
||||||
|
}
|
||||||
|
$reviewIterations = 0
|
||||||
|
do {
|
||||||
|
$reviewIterations += 1
|
||||||
|
if ($reviewIterations -gt 10) {
|
||||||
|
throw 'Native candidate profile workflow did not reach a terminal state'
|
||||||
|
}
|
||||||
|
$profileReviewBody = @{ status = 'approved'; reviewNote = "原生资料审核第 $reviewIterations 步" } | ConvertTo-Json -Compress
|
||||||
|
$profileReviewResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates/$($profileUpdate.profile.id)" -Method Patch -ContentType 'application/json' -Body $profileReviewBody -WebSession $nativeSession
|
||||||
|
if ($profileReviewResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Candidate profile review did not use ASP.NET Core'
|
||||||
|
}
|
||||||
|
$profileReview = $profileReviewResponse.Content | ConvertFrom-Json
|
||||||
|
} while ($profileReview.workflow.status -eq 'pending')
|
||||||
|
if ($profileReview.profile.status -ne 'approved' -or $profileReview.workflow.status -ne 'approved' -or $profileReview.workflow.actions.Count -lt 2) {
|
||||||
|
throw 'Native candidate profile review did not complete its configured workflow'
|
||||||
|
}
|
||||||
|
$legacyCandidatesAfterManagement = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/candidates" -WebSession $session
|
||||||
|
$nativeCandidatesAfterManagement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidates" -WebSession $nativeSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyCandidatesAfterManagement.Content -Actual $nativeCandidatesAfterManagement.Content -Label 'Candidate management follow-up'
|
||||||
|
|
||||||
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
||||||
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
||||||
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
@@ -1288,6 +1375,7 @@ try {
|
|||||||
NativeAdminNoticeManagement = 'passed'
|
NativeAdminNoticeManagement = 'passed'
|
||||||
NativeAdminCenters = 'passed'
|
NativeAdminCenters = 'passed'
|
||||||
NativeAdminOperationalReads = 'passed'
|
NativeAdminOperationalReads = 'passed'
|
||||||
|
NativeAdminCandidateManagement = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Eis.Application.Administration;
|
||||||
|
|
||||||
|
public interface IAdminCandidateManagementService
|
||||||
|
{
|
||||||
|
Task<AdminEndpointResult> ArchiveAccountsAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> ResetPasswordAsync(string sessionToken, string profileId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> ReviewProfileAsync(string sessionToken, string profileId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Administration;
|
||||||
|
using Eis.Infrastructure.Authentication;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed class AdminCandidateManagementService(
|
||||||
|
IAuthenticationStateStore authenticationState,
|
||||||
|
AuthenticationRepository authenticationRepository,
|
||||||
|
PasswordCompatibilityService passwords,
|
||||||
|
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
|
||||||
|
AdminOperationalSnapshotLoader operationalSnapshotLoader,
|
||||||
|
AdminWriteRepository repository) : IAdminCandidateManagementService
|
||||||
|
{
|
||||||
|
public async Task<AdminEndpointResult> ArchiveAccountsAsync(
|
||||||
|
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 scopeType = Clean(Text(body["scopeType"]), 20);
|
||||||
|
var scopeValue = Clean(Text(body["scopeValue"]), 100);
|
||||||
|
var archived = JsBoolean(body["archived"]);
|
||||||
|
if (scopeType is not ("class" or "grade") || scopeValue.Length == 0)
|
||||||
|
return Error(400, "请选择要归档的班级或年级");
|
||||||
|
|
||||||
|
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var scopedClasses = workflow.Classes.Where(item => item.SchoolId == user.SchoolId).ToArray();
|
||||||
|
var targetClassIds = scopeType == "class"
|
||||||
|
? scopedClasses.Where(item => item.Id == scopeValue).Select(item => item.Id).ToHashSet(StringComparer.Ordinal)
|
||||||
|
: scopedClasses.Where(item => item.Grade == scopeValue).Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (targetClassIds.Count == 0) return Error(404, "所选班级或年级不在本校范围内");
|
||||||
|
|
||||||
|
var targetUserIds = operational.Profiles
|
||||||
|
.Where(profile => profile.SchoolId == user.SchoolId && profile.ClassId is not null && targetClassIds.Contains(profile.ClassId))
|
||||||
|
.Select(profile => profile.UserId)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var targets = workflow.Users
|
||||||
|
.Where(item => item.Role == "candidate" && targetUserIds.Contains(item.Id) && (item.ArchivedAt is not null) != archived)
|
||||||
|
.ToArray();
|
||||||
|
var changedAt = archived ? NowIso() : null;
|
||||||
|
var scopeLabel = scopeType == "class"
|
||||||
|
? workflow.Classes.FirstOrDefault(item => item.Id == scopeValue)?.Name ?? ""
|
||||||
|
: scopeValue;
|
||||||
|
await repository.UpdateCandidateArchivesAsync(
|
||||||
|
targets.Select(item => item.Id).ToArray(),
|
||||||
|
changedAt,
|
||||||
|
archived ? user.Id : null,
|
||||||
|
Audit(user, archived ? "批量归档考生账户" : "批量恢复考生账户", $"{scopeLabel} · {targets.Length} 个账户"),
|
||||||
|
cancellationToken);
|
||||||
|
if (archived && targets.Length > 0)
|
||||||
|
{
|
||||||
|
await authenticationState.DeleteUsersSessionsAsync(targets.Select(item => item.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["archived"] = archived,
|
||||||
|
["count"] = targets.Length,
|
||||||
|
["scopeLabel"] = scopeLabel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> ResetPasswordAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string profileId,
|
||||||
|
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 workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.Id == profileId);
|
||||||
|
var target = workflow.Users.FirstOrDefault(item => item.Id == profile?.UserId && item.Role == "candidate");
|
||||||
|
if (profile is null || target is null) return Error(404, "考生账户不存在");
|
||||||
|
if (target.ArchivedAt is not null) return Error(409, "归档账户需由校方恢复后才能重置密码");
|
||||||
|
|
||||||
|
var temporaryPassword = $"Reset-{Base64Url(RandomNumberGenerator.GetBytes(7))}";
|
||||||
|
var name = profile.Data["name"]?.GetValue<string>() ?? "";
|
||||||
|
await repository.ResetCandidatePasswordAsync(
|
||||||
|
target.Id,
|
||||||
|
passwords.Hash(temporaryPassword),
|
||||||
|
Audit(user, "重置考生密码", $"{target.CandidateNumber} · {name}"),
|
||||||
|
cancellationToken);
|
||||||
|
await authenticationState.DeleteUserSessionsAsync(target.Id);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["candidateNumber"] = JsonValue.Create(target.CandidateNumber),
|
||||||
|
["temporaryPassword"] = temporaryPassword
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> ReviewProfileAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string profileId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var status = Text(body["status"]);
|
||||||
|
if (status is not ("approved" or "rejected")) return Error(400, "审核状态无效");
|
||||||
|
|
||||||
|
var workflowSnapshot = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.Id == profileId);
|
||||||
|
if (profile is null) return Error(404, "考生资料不存在");
|
||||||
|
if (Level(user) != "super" && !InScope(user, profile)) return Error(403, "该考生不在你的数据范围内");
|
||||||
|
|
||||||
|
var instance = workflowSnapshot.Instances.FirstOrDefault(item =>
|
||||||
|
item.BusinessType == "profile_change" && item.BusinessId == profile.Id && item.Status == "pending");
|
||||||
|
if (instance is null) return Error(409, "当前没有待处理的考生信息流程");
|
||||||
|
var workflow = workflowSnapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep);
|
||||||
|
if (workflow is null || step is null) return Error(409, "考生信息审批流程状态异常");
|
||||||
|
if (Level(user) != "super" && (instance.AssigneeId != user.Id || step.AdminLevel != Level(user)))
|
||||||
|
return Error(403, "该流程当前未分配给你,可由当前处理人转交");
|
||||||
|
|
||||||
|
var note = Clean(Text(body["reviewNote"]), 300);
|
||||||
|
var action = new AccountWorkflowAction(
|
||||||
|
Uid("flow_action"), instance.Id, user.Id, status == "approved" ? "approve" : "reject",
|
||||||
|
note, instance.AssigneeId, null, NowIso());
|
||||||
|
var updatedInstance = instance;
|
||||||
|
var output = profile.Data.DeepClone().AsObject();
|
||||||
|
if (status == "rejected")
|
||||||
|
{
|
||||||
|
updatedInstance = instance with { Status = "rejected", CompletedAt = NowIso(), AssigneeId = null };
|
||||||
|
output["status"] = "rejected";
|
||||||
|
output["reviewNote"] = note;
|
||||||
|
output["reviewedAt"] = NowIso();
|
||||||
|
output["reviewerId"] = user.Id;
|
||||||
|
}
|
||||||
|
else if (instance.CurrentStep < workflow.Steps.Count)
|
||||||
|
{
|
||||||
|
var nextStep = workflow.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep + 1);
|
||||||
|
var nextAssignee = nextStep is null ? null : SelectAssignee(workflowSnapshot, nextStep.AdminLevel, profile);
|
||||||
|
if (nextAssignee is null) return Error(409, $"没有可承接“{nextStep?.Name ?? "下一审批步骤"}”的管理员");
|
||||||
|
updatedInstance = instance with { CurrentStep = instance.CurrentStep + 1, AssigneeId = nextAssignee.Id };
|
||||||
|
action = action with { ToAssigneeId = nextAssignee.Id };
|
||||||
|
output["status"] = "pending";
|
||||||
|
output["reviewNote"] = note;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
updatedInstance = instance with { Status = "approved", CompletedAt = NowIso(), AssigneeId = null };
|
||||||
|
output["status"] = "approved";
|
||||||
|
output["reviewNote"] = note;
|
||||||
|
output["reviewedAt"] = NowIso();
|
||||||
|
output["reviewerId"] = user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var profileStatus = output["status"]?.GetValue<string>() ?? "pending";
|
||||||
|
var reviewedAt = output["reviewedAt"]?.GetValue<string>();
|
||||||
|
var reviewerId = output["reviewerId"]?.GetValue<string>();
|
||||||
|
var name = output["name"]?.GetValue<string>() ?? "";
|
||||||
|
await repository.ProcessProfileWorkflowAsync(
|
||||||
|
updatedInstance,
|
||||||
|
action,
|
||||||
|
profile.Id,
|
||||||
|
profileStatus,
|
||||||
|
note,
|
||||||
|
reviewedAt,
|
||||||
|
reviewerId,
|
||||||
|
Audit(user, status == "approved" ? "处理考生信息流程" : "退回考生信息", $"{name}:{(note.Length == 0 ? "无备注" : note)}"),
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["profile"] = output,
|
||||||
|
["workflow"] = WorkflowJson(workflowSnapshot, updatedInstance, action)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminUser? SelectAssignee(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
string level,
|
||||||
|
OperationalProfile profile)
|
||||||
|
{
|
||||||
|
var pending = snapshot.Instances.Where(item => item.Status == "pending" && item.AssigneeId is not null)
|
||||||
|
.GroupBy(item => item.AssigneeId!)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
var assigned = snapshot.Actions.Where(item => item.ToAssigneeId is not null)
|
||||||
|
.GroupBy(item => item.ToAssigneeId!)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
return snapshot.Users
|
||||||
|
.Where(item => item.Role == "admin" && item.Active && item.AdminLevel == level && level switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => profile.SchoolId is not null && item.SchoolId == profile.SchoolId,
|
||||||
|
"class" => profile.SchoolId is not null && profile.ClassId is not null &&
|
||||||
|
item.SchoolId == profile.SchoolId && item.ClassId == profile.ClassId,
|
||||||
|
_ => false
|
||||||
|
})
|
||||||
|
.OrderBy(item => pending.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => assigned.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
|
||||||
|
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject WorkflowJson(
|
||||||
|
AdminAccountBatchSnapshot snapshot,
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action)
|
||||||
|
{
|
||||||
|
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
|
||||||
|
var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id).Append(action)
|
||||||
|
.Select(item => ActionJson(snapshot, item)).ToArray();
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = instance.Id,
|
||||||
|
["workflowId"] = instance.WorkflowId,
|
||||||
|
["businessType"] = instance.BusinessType,
|
||||||
|
["businessId"] = instance.BusinessId,
|
||||||
|
["status"] = instance.Status,
|
||||||
|
["currentStep"] = instance.CurrentStep,
|
||||||
|
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
|
||||||
|
["createdAt"] = instance.CreatedAt,
|
||||||
|
["completedAt"] = JsonValue.Create(instance.CompletedAt),
|
||||||
|
["workflowName"] = workflow?.Name ?? "未命名流程",
|
||||||
|
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
|
||||||
|
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } currentStep ? StepJson(currentStep) : null,
|
||||||
|
["assignee"] = assignee is null ? null : SafeUser(assignee),
|
||||||
|
["actions"] = new JsonArray(actions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResolvedAdmin> ResolveAsync(string token, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var userId = await authenticationState.GetSessionUserIdAsync(token);
|
||||||
|
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
|
||||||
|
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
|
||||||
|
_ => user.ClassId is not null && profile.ClassId == user.ClassId
|
||||||
|
};
|
||||||
|
|
||||||
|
private static 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 Text(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node is null) return "";
|
||||||
|
if (node is JsonValue value && value.TryGetValue<string>(out var text)) return text;
|
||||||
|
return node.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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(buffer[position..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) =>
|
||||||
|
new(Uid("log"), user.Id, action, detail, NowIso());
|
||||||
|
|
||||||
|
private static JsonObject StepJson(AccountWorkflowStep item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["name"] = item.Name,
|
||||||
|
["adminLevel"] = item.AdminLevel,
|
||||||
|
["position"] = item.Position
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["instanceId"] = item.InstanceId,
|
||||||
|
["actorId"] = JsonValue.Create(item.ActorId),
|
||||||
|
["action"] = item.Action,
|
||||||
|
["note"] = item.Note,
|
||||||
|
["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId),
|
||||||
|
["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId),
|
||||||
|
["createdAt"] = item.CreatedAt,
|
||||||
|
["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统",
|
||||||
|
["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "",
|
||||||
|
["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SafeUser(AdminUser item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["username"] = item.Username,
|
||||||
|
["role"] = item.Role,
|
||||||
|
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
|
||||||
|
["schoolId"] = JsonValue.Create(item.SchoolId),
|
||||||
|
["classId"] = JsonValue.Create(item.ClassId),
|
||||||
|
["displayName"] = item.DisplayName,
|
||||||
|
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
|
||||||
|
["mustChangePassword"] = item.MustChangePassword,
|
||||||
|
["totpEnabled"] = item.TotpEnabled,
|
||||||
|
["archived"] = item.ArchivedAt is not null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
|
||||||
|
private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message });
|
||||||
|
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
|
||||||
|
{
|
||||||
|
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool NativeConfigurationEnabled = false,
|
bool NativeConfigurationEnabled = false,
|
||||||
bool NativeNoticeManagementEnabled = false,
|
bool NativeNoticeManagementEnabled = false,
|
||||||
bool NativeCentersEnabled = false,
|
bool NativeCentersEnabled = false,
|
||||||
bool NativeOperationalReadsEnabled = false)
|
bool NativeOperationalReadsEnabled = false,
|
||||||
|
bool NativeCandidateManagementEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -18,7 +19,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool configuredNativeConfigurationEnabled = false,
|
bool configuredNativeConfigurationEnabled = false,
|
||||||
bool configuredNativeNoticeManagementEnabled = false,
|
bool configuredNativeNoticeManagementEnabled = false,
|
||||||
bool configuredNativeCentersEnabled = false,
|
bool configuredNativeCentersEnabled = false,
|
||||||
bool configuredNativeOperationalReadsEnabled = false)
|
bool configuredNativeOperationalReadsEnabled = false,
|
||||||
|
bool configuredNativeCandidateManagementEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -42,12 +44,20 @@ public sealed record AdminMigrationOptions(
|
|||||||
var operationalReadsEnabled = ParseBoolean(
|
var operationalReadsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
|
||||||
configuredNativeOperationalReadsEnabled);
|
configuredNativeOperationalReadsEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled) && !readsEnabled)
|
var candidateManagementEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"),
|
||||||
|
configuredNativeCandidateManagementEnabled);
|
||||||
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
}
|
}
|
||||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled;
|
if (candidateManagementEnabled && !operationalReadsEnabled)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"启用原生考生账号维护接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
|
||||||
|
}
|
||||||
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -63,7 +73,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled);
|
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -127,16 +127,94 @@ internal sealed class AdminWriteRepository(IRelationalConnectionFactory connecti
|
|||||||
audit,
|
audit,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateCandidateArchivesAsync(
|
||||||
|
IReadOnlyList<string> userIds,
|
||||||
|
string? archivedAt,
|
||||||
|
string? archivedBy,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => ExecuteWithAuditAsync(
|
||||||
|
userIds.Select(userId => new SqlOperation(
|
||||||
|
"UPDATE users SET archived_at = @archivedAt, archived_by = @archivedBy WHERE id = @id",
|
||||||
|
[new("@archivedAt", archivedAt), new("@archivedBy", archivedBy), new("@id", userId)])),
|
||||||
|
audit,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task ResetCandidatePasswordAsync(
|
||||||
|
string userId,
|
||||||
|
string passwordHash,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => ExecuteWithAuditAsync(
|
||||||
|
new SqlOperation(
|
||||||
|
"UPDATE users SET password_hash = @passwordHash, must_change_password = 1 WHERE id = @id",
|
||||||
|
[new("@passwordHash", passwordHash), new("@id", userId)]),
|
||||||
|
audit,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task ProcessProfileWorkflowAsync(
|
||||||
|
AccountWorkflowInstance instance,
|
||||||
|
AccountWorkflowAction action,
|
||||||
|
string profileId,
|
||||||
|
string profileStatus,
|
||||||
|
string reviewNote,
|
||||||
|
string? reviewedAt,
|
||||||
|
string? reviewerId,
|
||||||
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => ExecuteWithAuditAsync(
|
||||||
|
[
|
||||||
|
new SqlOperation(
|
||||||
|
"""
|
||||||
|
UPDATE workflow_instances SET status = @status, current_step = @currentStep,
|
||||||
|
assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@status", instance.Status), new("@currentStep", instance.CurrentStep),
|
||||||
|
new("@assigneeId", instance.AssigneeId), new("@completedAt", instance.CompletedAt),
|
||||||
|
new("@id", instance.Id)
|
||||||
|
]),
|
||||||
|
new SqlOperation(
|
||||||
|
"""
|
||||||
|
INSERT INTO workflow_actions (
|
||||||
|
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@id", action.Id), new("@instanceId", action.InstanceId), new("@actorId", action.ActorId),
|
||||||
|
new("@action", action.Action), new("@note", action.Note), new("@fromAssigneeId", action.FromAssigneeId),
|
||||||
|
new("@toAssigneeId", action.ToAssigneeId), new("@createdAt", action.CreatedAt)
|
||||||
|
]),
|
||||||
|
new SqlOperation(
|
||||||
|
"""
|
||||||
|
UPDATE candidate_profiles SET status = @status, review_note = @reviewNote,
|
||||||
|
reviewed_at = @reviewedAt, reviewer_id = @reviewerId WHERE id = @id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
new("@status", profileStatus), new("@reviewNote", Optional(reviewNote)),
|
||||||
|
new("@reviewedAt", reviewedAt), new("@reviewerId", reviewerId), new("@id", profileId)
|
||||||
|
])
|
||||||
|
],
|
||||||
|
audit,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
private async Task ExecuteWithAuditAsync(
|
private async Task ExecuteWithAuditAsync(
|
||||||
SqlOperation operation,
|
SqlOperation operation,
|
||||||
AdminAuditEntry audit,
|
AdminAuditEntry audit,
|
||||||
|
CancellationToken cancellationToken) => await ExecuteWithAuditAsync([operation], audit, cancellationToken);
|
||||||
|
|
||||||
|
private async Task ExecuteWithAuditAsync(
|
||||||
|
IEnumerable<SqlOperation> operations,
|
||||||
|
AdminAuditEntry audit,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
foreach (var operation in operations)
|
||||||
{
|
{
|
||||||
await ExecuteAsync(connection, transaction, operation, cancellationToken);
|
await ExecuteAsync(connection, transaction, operation, cancellationToken);
|
||||||
|
}
|
||||||
await ExecuteAsync(connection, transaction, new SqlOperation(
|
await ExecuteAsync(connection, transaction, new SqlOperation(
|
||||||
"""
|
"""
|
||||||
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
|
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
||||||
services.AddScoped<IAdminCenterService, AdminCenterService>();
|
services.AddScoped<IAdminCenterService, AdminCenterService>();
|
||||||
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
|
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
|
||||||
|
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
|
||||||
services.AddSingleton<NoticeContentFormatter>();
|
services.AddSingleton<NoticeContentFormatter>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ public static class NativeAdminReadEndpoints
|
|||||||
endpoints.MapGet("/api/admin/payments", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) =>
|
endpoints.MapGet("/api/admin/payments", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) =>
|
||||||
Execute(context, service.GetPaymentsAsync(Token(context), cancellationToken)));
|
Execute(context, service.GetPaymentsAsync(Token(context), cancellationToken)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.NativeCandidateManagementEnabled)
|
||||||
|
{
|
||||||
|
endpoints.MapPost("/api/admin/candidate-accounts/archive", (HttpContext context, JsonObject body, IAdminCandidateManagementService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.ArchiveAccountsAsync(Token(context), body, cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/admin/candidates/{profileId}/reset-password", (HttpContext context, string profileId, IAdminCandidateManagementService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.ResetPasswordAsync(Token(context), profileId, cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/candidates/{profileId}", (HttpContext context, string profileId, JsonObject body, IAdminCandidateManagementService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.ReviewProfileAsync(Token(context), profileId, body, cancellationToken)));
|
||||||
|
}
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"));
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"),
|
||||||
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"));
|
||||||
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()),
|
||||||
@@ -112,6 +113,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
|
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
|
||||||
nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled,
|
nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled,
|
||||||
nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled,
|
nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled,
|
||||||
|
nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled,
|
||||||
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
||||||
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
||||||
: [])
|
: [])
|
||||||
@@ -137,6 +139,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
.Concat(adminMigrationOptions.NativeOperationalReadsEnabled
|
.Concat(adminMigrationOptions.NativeOperationalReadsEnabled
|
||||||
? new[] { "GET candidates", "GET registrations", "GET payments" }
|
? new[] { "GET candidates", "GET registrations", "GET payments" }
|
||||||
: [])
|
: [])
|
||||||
|
.Concat(adminMigrationOptions.NativeCandidateManagementEnabled
|
||||||
|
? new[] { "POST candidate-accounts/archive", "POST candidate reset-password", "PATCH candidates" }
|
||||||
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"NativeConfigurationEnabled": false,
|
"NativeConfigurationEnabled": false,
|
||||||
"NativeNoticeManagementEnabled": false,
|
"NativeNoticeManagementEnabled": false,
|
||||||
"NativeCentersEnabled": false,
|
"NativeCentersEnabled": false,
|
||||||
"NativeOperationalReadsEnabled": false
|
"NativeOperationalReadsEnabled": false,
|
||||||
|
"NativeCandidateManagementEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -181,6 +181,36 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}, operationalReadsEnabled: "true");
|
}, operationalReadsEnabled: "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CandidateManagementRequiresOperationalReads()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true));
|
||||||
|
|
||||||
|
Assert.Contains("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true", exception.Message);
|
||||||
|
}, candidateManagementEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnablesCandidateManagementWithOperationalReadsAndSharedSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeOperationalReadsEnabled);
|
||||||
|
Assert.True(options.NativeCandidateManagementEnabled);
|
||||||
|
}, operationalReadsEnabled: "true", candidateManagementEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
private static void WithEnvironment(
|
private static void WithEnvironment(
|
||||||
string? nativeReadsEnabled,
|
string? nativeReadsEnabled,
|
||||||
string? nativeOrganizationWritesEnabled,
|
string? nativeOrganizationWritesEnabled,
|
||||||
@@ -191,7 +221,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
string? noticeManagementEnabled = null,
|
string? noticeManagementEnabled = null,
|
||||||
string? legacyNoticeWritesEnabled = null,
|
string? legacyNoticeWritesEnabled = null,
|
||||||
string? centersEnabled = null,
|
string? centersEnabled = null,
|
||||||
string? operationalReadsEnabled = null)
|
string? operationalReadsEnabled = null,
|
||||||
|
string? candidateManagementEnabled = null)
|
||||||
{
|
{
|
||||||
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 previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
||||||
@@ -201,6 +232,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED");
|
var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED");
|
||||||
var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED");
|
var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED");
|
||||||
var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED");
|
var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED");
|
||||||
|
var previousCandidateManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED");
|
||||||
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -212,6 +244,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", candidateManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -225,6 +258,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED", previousCandidateManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user