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 LevelNames = new Dictionary(StringComparer.Ordinal) { ["super"] = "超级管理员", ["school"] = "校级管理员", ["class"] = "班级管理员" }; public async Task 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 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(); 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 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 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(); var candidates = new List(); 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 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? itemOverride = null, AccountWorkflowInstance? instanceOverride = null, IReadOnlyList? 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? 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 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 ParseQuotas(JsonArray? array) => array?.OfType().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(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 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); } }