本批“考生账号维护”迁移完成:

POST /api/admin/candidate-accounts/archive
POST /api/admin/candidates/{profileId}/reset-password
PATCH /api/admin/candidates/{profileId}
实现了校级按班级/年级归档与恢复、超级管理员密码重置、多级资料审批、事务审计和会话失效。核心代码见
This commit is contained in:
2026-07-23 09:52:40 +08:00 Unverified
parent 95a87f0276
commit 3f555929fd
12 changed files with 600 additions and 12 deletions
@@ -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 NativeNoticeManagementEnabled = false,
bool NativeCentersEnabled = false,
bool NativeOperationalReadsEnabled = false)
bool NativeOperationalReadsEnabled = false,
bool NativeCandidateManagementEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -18,7 +19,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeManagementEnabled = false,
bool configuredNativeCentersEnabled = false,
bool configuredNativeOperationalReadsEnabled = false)
bool configuredNativeOperationalReadsEnabled = false,
bool configuredNativeCandidateManagementEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -42,12 +44,20 @@ public sealed record AdminMigrationOptions(
var operationalReadsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
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(
"启用原生组织维护接口前必须同时设置 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)
{
throw new InvalidOperationException(
@@ -63,7 +73,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 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
@@ -127,16 +127,94 @@ internal sealed class AdminWriteRepository(IRelationalConnectionFactory connecti
audit,
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(
SqlOperation operation,
AdminAuditEntry audit,
CancellationToken cancellationToken) => await ExecuteWithAuditAsync([operation], audit, cancellationToken);
private async Task ExecuteWithAuditAsync(
IEnumerable<SqlOperation> operations,
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);
foreach (var operation in operations)
{
await ExecuteAsync(connection, transaction, operation, cancellationToken);
}
await ExecuteAsync(connection, transaction, new SqlOperation(
"""
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<IAdminCenterService, AdminCenterService>();
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;