Files
EIS-dotnet/src/Eis.Infrastructure/Administration/AdminAccountBatchService.cs
T
biss f7c34247fd 已完成管理后台第三批 ASP.NET Core 10 迁移:批量报名号申领与审批。
原生接口:
GET /api/admin/candidate-account-batches
POST /api/admin/candidate-account-batches
PATCH /api/admin/candidate-account-batches/{batchId}
实现包含:
校级管理员按班级提交名额
可配置多级审批流与待办负载分配
当前处理人及管理员层级校验
审批通过、退回和重复审批保护
终审时原子生成考生账号、报名号、初始密码和待补录资料
生成账号兼容现有 PBKDF2 登录及首次强制改密
2026-07-23 08:19:48 +08:00

215 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AdminAccountBatchService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
PasswordCompatibilityService passwords,
AdminAccountBatchSnapshotLoader snapshotLoader,
AdminAccountBatchRepository repository) : IAdminAccountBatchService
{
private static readonly IReadOnlyDictionary<string, string> LevelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["super"] = "超级管理员",
["school"] = "校级管理员",
["class"] = "班级管理员"
};
public async Task<AdminEndpointResult> GetAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作");
if (Level(user) is not ("school" or "super")) return Error(403, "只有校级管理员可以申领批量报名号");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var batches = snapshot.Batches.Where(item => Level(user) == "super" || item.SchoolId == user.SchoolId)
.Select(item => BatchView(snapshot, item)).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["batches"] = new JsonArray(batches),
["classes"] = new JsonArray(snapshot.Classes.Where(item => item.Active && (Level(user) == "super" || item.SchoolId == user.SchoolId)).Select(ClassJson).ToArray()),
["schools"] = new JsonArray(snapshot.Schools.Where(item => item.Active && item.IsSourceSchool).Select(SchoolJson).ToArray())
});
}
public async Task<AdminEndpointResult> SubmitAsync(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, "批量报名号由校级管理员发起申领");
if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作");
var quotas = ParseQuotas(body["quotas"] as JsonArray).Where(item => item.Count > 0).ToArray();
if (quotas.Length == 0) return Error(400, "请至少为一个班级填写申领数量");
if (quotas.Select(item => item.ClassId).Distinct(StringComparer.Ordinal).Count() != quotas.Length) return Error(400, "同一班级只能填写一次申领数量");
if (quotas.Any(item => item.Count != Math.Truncate(item.Count) || item.Count < 1 || item.Count > 200)) return Error(400, "每个班级一次可申领 1—200 个报名号");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
if (quotas.Any(item => !snapshot.Classes.Any(entry => entry.Id == item.ClassId && entry.SchoolId == user.SchoolId && entry.Active))) return Error(400, "只能为本校有效班级申领报名号");
var totalCount = quotas.Sum(item => (int)item.Count);
if (totalCount > 500) return Error(400, "单个批次最多申领 500 个报名号");
var workflow = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.Active);
if (workflow is null || workflow.Steps.Count == 0) return Error(409, "该业务尚未配置审批流程");
var firstStep = workflow.Steps[0];
var assignee = SelectAssignee(snapshot, firstStep.AdminLevel, user.SchoolId);
if (assignee is null) return Error(409, $"没有可承接“{firstStep.Name}”的{LevelNames[firstStep.AdminLevel]}");
var batch = new AccountBatch(Uid("account_batch"), user.SchoolId!, user.Id, "pending", "", NowIso(), null);
var items = new List<AccountBatchItem>();
var position = 1;
foreach (var quota in quotas)
for (var index = 0; index < quota.Count; index++)
items.Add(new(Uid("account_batch_item"), batch.Id, quota.ClassId, position++, "", "", null, null));
var instance = new AccountWorkflowInstance(Uid("flow"), workflow.Id, "candidate_account_batch", batch.Id, "pending", 1, assignee.Id, NowIso(), null);
var action = new AccountWorkflowAction(Uid("flow_action"), instance.Id, user.Id, "submit", "提交审批", null, assignee.Id, NowIso());
var summary = string.Join("", quotas.Select(item => $"{snapshot.Classes.First(entry => entry.Id == item.ClassId).Name} {(int)item.Count} 人"));
await repository.CreateAsync(batch, items, instance, action, Audit(user, "提交批量报名号申领", $"{totalCount} 个账户 · {summary}"), cancellationToken);
return Result(202, new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, batch, items, instance, [action]) });
}
public async Task<AdminEndpointResult> ReviewAsync(string sessionToken, string batchId, JsonObject body, CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
if (!CanWriteCandidates(user)) return Error(403, "当前管理员层级无权执行此操作");
var status = Text(body["status"]);
if (status is not ("approved" or "rejected")) return Error(400, "审批状态无效");
var snapshot = await snapshotLoader.LoadAsync(cancellationToken);
var batch = snapshot.Batches.FirstOrDefault(item => item.Id == batchId && item.Status == "pending");
if (batch is null) return Error(404, "待审批的批量报名号申请不存在");
var instance = snapshot.Instances.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.BusinessId == batch.Id && item.Status == "pending");
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance?.WorkflowId);
var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance?.CurrentStep);
if (instance is null || 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 schoolName = snapshot.Schools.FirstOrDefault(item => item.Id == batch.SchoolId)?.Name ?? "";
var audit = Audit(user, status == "approved" ? "审批批量报名号申领" : "退回批量报名号申领", $"{schoolName} · {(note.Length == 0 ? "无备注" : note)}");
if (status == "rejected")
{
var rejectedInstance = instance with { Status = "rejected", CompletedAt = NowIso(), AssigneeId = null };
var rejectedBatch = batch with { Status = "rejected", ReviewNote = note, ReviewedAt = NowIso() };
await repository.ProcessAsync(rejectedBatch, rejectedInstance, action, audit, cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, rejectedBatch, null, rejectedInstance, [action]) });
}
if (instance.CurrentStep < workflow.Steps.Count)
{
var nextStep = workflow.Steps.First(item => item.Position == instance.CurrentStep + 1);
var nextAssignee = SelectAssignee(snapshot, nextStep.AdminLevel, batch.SchoolId);
if (nextAssignee is null) return Error(409, $"没有可承接“{nextStep.Name}”的管理员");
var advancedInstance = instance with { CurrentStep = instance.CurrentStep + 1, AssigneeId = nextAssignee.Id };
var advancedAction = action with { ToAssigneeId = nextAssignee.Id };
var advancedBatch = batch with { ReviewNote = note };
await repository.ProcessAsync(advancedBatch, advancedInstance, advancedAction, audit, cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(snapshot, advancedBatch, null, advancedInstance, [advancedAction]) });
}
return await CompleteAsync(snapshot, user, batch, instance, action, note, audit, cancellationToken);
}
private async Task<AdminEndpointResult> CompleteAsync(AdminAccountBatchSnapshot snapshot, AuthenticationUser reviewer, AccountBatch batch, AccountWorkflowInstance instance, AccountWorkflowAction action, string note, AdminAuditEntry audit, CancellationToken cancellationToken)
{
var items = snapshot.Items.Where(item => item.BatchId == batch.Id).OrderBy(item => item.Position).ToArray();
if (items.Length == 0 || items.Any(item => item.UserId is not null || item.CandidateNumber.Length > 0)) return Error(409, "批次明细异常或已经生成过账号");
var rule = snapshot.NumberRules.FirstOrDefault(item => item.Active);
if (rule is null || rule.Segments.Count == 0) return Error(409, "尚未配置可用的报名号生成规则");
var users = snapshot.Users.ToList();
var generatedItems = new List<AccountBatchItem>();
var candidates = new List<GeneratedBatchCandidate>();
for (var index = 0; index < items.Length; index++)
{
var item = items[index];
var schoolClass = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId && entry.SchoolId == batch.SchoolId);
if (schoolClass is null) return Error(409, "批次包含无效班级,无法生成账号");
var number = GenerateCandidateNumber(snapshot, users, rule, batch.SchoolId);
var userId = Uid("usr");
var initialPassword = $"Init-{Base64Url(RandomNumberGenerator.GetBytes(6))}";
var displayName = $"待补录考生 {index + 1:000}";
var now = NowIso();
candidates.Add(new(userId, number, passwords.Hash(initialPassword), displayName, batch.SchoolId, item.ClassId, now, Uid("profile"), snapshot.Schools.FirstOrDefault(entry => entry.Id == batch.SchoolId)?.Name ?? "", schoolClass.Name, NowIso()));
generatedItems.Add(item with { CandidateNumber = number, InitialPassword = initialPassword, UserId = userId, CreatedAt = NowIso() });
users.Add(new AdminUser(userId, number, "candidate", null, batch.SchoolId, item.ClassId, displayName, number, true, true, false, null, now));
}
var completedInstance = instance with { Status = "approved", CompletedAt = NowIso(), AssigneeId = null };
var completedBatch = batch with { Status = "approved", ReviewNote = note, ReviewedAt = NowIso() };
await repository.CompleteAsync(completedBatch, generatedItems, candidates, completedInstance, action, audit, cancellationToken);
var outputSnapshot = snapshot with { Users = users };
return Success(new JsonObject { ["ok"] = true, ["batch"] = BatchView(outputSnapshot, completedBatch, generatedItems, completedInstance, [action]) });
}
private static AdminUser? SelectAssignee(AdminAccountBatchSnapshot snapshot, string level, string? schoolId)
{
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 == "super" || level == "school" && item.SchoolId == schoolId))
.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 string GenerateCandidateNumber(AdminAccountBatchSnapshot snapshot, IReadOnlyList<AdminUser> users, AccountNumberRule rule, string schoolId)
{
var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture);
var schoolCode = snapshot.Schools.FirstOrDefault(item => item.Id == schoolId)?.Code ?? "";
var prefix = string.Join(rule.Separator, rule.Segments.Where(item => item.Type != "sequence").Select(item => item.Type == "year" ? year : item.Type == "school_code" ? schoolCode : "").Where(item => item.Length > 0));
var sequence = users.Count(item => item.Role == "candidate" && item.CandidateNumber is not null && (prefix.Length == 0 || item.CandidateNumber.StartsWith(prefix, StringComparison.Ordinal))) + 1;
return string.Join(rule.Separator, rule.Segments.Select(segment => segment.Type switch
{
"year" => year[^Math.Min(year.Length, Math.Max(2, segment.Width == 0 ? 4 : segment.Width))..],
"school_code" => schoolCode.Length > 0 ? schoolCode : "NOSCHOOL",
"gender" => "X",
"sequence" => sequence.ToString(CultureInfo.InvariantCulture).PadLeft(Math.Max(1, segment.Width == 0 ? 4 : segment.Width), '0'),
_ => Clean(segment.Value, 20).ToUpperInvariant()
}));
}
private static JsonObject BatchView(AdminAccountBatchSnapshot snapshot, AccountBatch batch, IReadOnlyList<AccountBatchItem>? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList<AccountWorkflowAction>? extraActions = null)
{
var items = (itemOverride ?? snapshot.Items.Where(item => item.BatchId == batch.Id).ToArray()).OrderBy(item => item.Position).ToArray();
var quotas = items.GroupBy(item => item.ClassId).Select(group => { var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == group.Key); return new JsonObject { ["classId"] = group.Key, ["className"] = schoolClass?.Name ?? "未知班级", ["grade"] = schoolClass?.Grade ?? "", ["count"] = group.Count() }; }).ToArray();
var output = new JsonObject { ["id"] = batch.Id, ["schoolId"] = batch.SchoolId, ["requestedBy"] = JsonValue.Create(batch.RequestedBy), ["status"] = batch.Status, ["reviewNote"] = batch.ReviewNote, ["createdAt"] = batch.CreatedAt, ["reviewedAt"] = JsonValue.Create(batch.ReviewedAt), ["schoolName"] = snapshot.Schools.FirstOrDefault(item => item.Id == batch.SchoolId)?.Name ?? "", ["requesterName"] = snapshot.Users.FirstOrDefault(item => item.Id == batch.RequestedBy)?.DisplayName ?? "原提交人", ["totalCount"] = items.Length, ["quotas"] = new JsonArray(quotas), ["items"] = new JsonArray(items.Select(item => ItemJson(snapshot, item)).ToArray()) };
var instance = instanceOverride ?? snapshot.Instances.FirstOrDefault(item => item.BusinessType == "candidate_account_batch" && item.BusinessId == batch.Id);
output["workflow"] = instance is null ? null : WorkflowJson(snapshot, instance, extraActions);
return output;
}
private static JsonObject WorkflowJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance, IReadOnlyList<AccountWorkflowAction>? extraActions)
{
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).Concat(extraActions ?? []).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 { } step ? StepJson(step) : 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 IEnumerable<Quota> ParseQuotas(JsonArray? array) => array?.OfType<JsonObject>().Select(item => new Quota(Clean(Text(item["classId"]), 64), Number(item["count"]))) ?? [];
private static double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN;
private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
private static bool CanWriteCandidates(AuthenticationUser user) => Level(user) is "super" or "school";
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 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 string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso());
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 ItemJson(AdminAccountBatchSnapshot snapshot, AccountBatchItem item) => new() { ["id"] = item.Id, ["batchId"] = item.BatchId, ["classId"] = item.ClassId, ["position"] = item.Position, ["candidateNumber"] = item.CandidateNumber, ["initialPassword"] = item.InitialPassword, ["userId"] = JsonValue.Create(item.UserId), ["createdAt"] = JsonValue.Create(item.CreatedAt), ["className"] = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Name ?? "未知班级", ["grade"] = snapshot.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Grade ?? "" };
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) => Result(200, body);
private static AdminEndpointResult Result(int status, JsonObject body) => new(status, body);
private static AdminEndpointResult Error(int status, string message) => Result(status, new JsonObject { ["ok"] = false, ["message"] = message });
private sealed record Quota(string ClassId, double Count);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
}