已完成管理后台第三批 ASP.NET Core 10 迁移:批量报名号申领与审批。

原生接口:
GET /api/admin/candidate-account-batches
POST /api/admin/candidate-account-batches
PATCH /api/admin/candidate-account-batches/{batchId}
实现包含:
校级管理员按班级提交名额
可配置多级审批流与待办负载分配
当前处理人及管理员层级校验
审批通过、退回和重复审批保护
终审时原子生成考生账号、报名号、初始密码和待补录资料
生成账号兼容现有 PBKDF2 登录及首次强制改密
This commit is contained in:
2026-07-23 08:19:48 +08:00 Unverified
parent 875e59b6ce
commit f7c34247fd
13 changed files with 567 additions and 28 deletions
@@ -0,0 +1,146 @@
using System.Data.Common;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record GeneratedBatchCandidate(
string UserId,
string CandidateNumber,
string PasswordHash,
string DisplayName,
string SchoolId,
string ClassId,
string CreatedAt,
string ProfileId,
string SchoolName,
string ClassName,
string UpdatedAt);
internal sealed class AdminAccountBatchRepository(IRelationalConnectionFactory connectionFactory)
{
public Task CreateAsync(
AccountBatch batch,
IReadOnlyList<AccountBatchItem> items,
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operations = new List<SqlOperation>
{
new("""
INSERT INTO candidate_account_batches (id, school_id, requested_by, status, review_note, created_at, reviewed_at)
VALUES (@id, @schoolId, @requestedBy, @status, @reviewNote, @createdAt, @reviewedAt)
""", [new("@id", batch.Id), new("@schoolId", batch.SchoolId), new("@requestedBy", batch.RequestedBy), new("@status", batch.Status), new("@reviewNote", Optional(batch.ReviewNote)), new("@createdAt", batch.CreatedAt), new("@reviewedAt", batch.ReviewedAt)])
};
operations.AddRange(items.Select(item => new SqlOperation("""
INSERT INTO candidate_account_batch_items (id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at)
VALUES (@id, @batchId, @classId, @position, @candidateNumber, @initialPassword, @userId, @createdAt)
""", [new("@id", item.Id), new("@batchId", item.BatchId), new("@classId", item.ClassId), new("@position", item.Position), new("@candidateNumber", Optional(item.CandidateNumber)), new("@initialPassword", Optional(item.InitialPassword)), new("@userId", item.UserId), new("@createdAt", item.CreatedAt)])));
operations.Add(InsertInstance(instance));
operations.Add(InsertAction(action));
operations.Add(InsertAudit(audit));
return ExecuteTransactionAsync(operations, cancellationToken);
}
public Task ProcessAsync(
AccountBatch batch,
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminAuditEntry audit,
CancellationToken cancellationToken) => ExecuteTransactionAsync(
[UpdateInstance(instance), InsertAction(action), UpdateBatch(batch), InsertAudit(audit)], cancellationToken);
public Task CompleteAsync(
AccountBatch batch,
IReadOnlyList<AccountBatchItem> items,
IReadOnlyList<GeneratedBatchCandidate> candidates,
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
var operations = new List<SqlOperation> { UpdateInstance(instance), InsertAction(action), UpdateBatch(batch) };
for (var index = 0; index < candidates.Count; index++)
{
var candidate = candidates[index];
var item = items[index];
operations.Add(new SqlOperation("""
INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (
@id, @username, @candidateNumber, @passwordHash, 'candidate', NULL, @schoolId, @classId, 1,
1, NULL, NULL, @displayName, @createdAt
)
""", [new("@id", candidate.UserId), new("@username", candidate.CandidateNumber), new("@candidateNumber", candidate.CandidateNumber), new("@passwordHash", candidate.PasswordHash), new("@schoolId", candidate.SchoolId), new("@classId", candidate.ClassId), new("@displayName", candidate.DisplayName), new("@createdAt", candidate.CreatedAt)]));
operations.Add(new SqlOperation("""
INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
province_code, province_name, city_code, city_name, district_code, district_name, address,
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
) VALUES (
@id, @userId, @name, NULL, @idNumber, '', NULL, @school, @grade, @schoolId, @classId,
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
NULL, 0, 'pending', NULL, NULL, NULL, @updatedAt
)
""", [new("@id", candidate.ProfileId), new("@userId", candidate.UserId), new("@name", candidate.DisplayName), new("@idNumber", $"PENDING-{candidate.UserId}"), new("@school", candidate.SchoolName), new("@grade", candidate.ClassName), new("@schoolId", candidate.SchoolId), new("@classId", candidate.ClassId), new("@updatedAt", candidate.UpdatedAt)]));
operations.Add(new SqlOperation("""
UPDATE candidate_account_batch_items SET candidate_number = @candidateNumber,
initial_password = @initialPassword, user_id = @userId, created_at = @createdAt WHERE id = @id
""", [new("@candidateNumber", item.CandidateNumber), new("@initialPassword", item.InitialPassword), new("@userId", item.UserId), new("@createdAt", item.CreatedAt), new("@id", item.Id)]));
}
operations.Add(InsertAudit(audit));
return ExecuteTransactionAsync(operations, cancellationToken);
}
private async Task ExecuteTransactionAsync(IReadOnlyList<SqlOperation> operations, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
foreach (var operation in operations) await ExecuteAsync(connection, transaction, operation, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static SqlOperation InsertInstance(AccountWorkflowInstance item) => new("""
INSERT INTO workflow_instances (id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at)
VALUES (@id, @workflowId, @businessType, @businessId, @status, @currentStep, @assigneeId, @createdAt, @completedAt)
""", [new("@id", item.Id), new("@workflowId", item.WorkflowId), new("@businessType", item.BusinessType), new("@businessId", item.BusinessId), new("@status", item.Status), new("@currentStep", item.CurrentStep), new("@assigneeId", item.AssigneeId), new("@createdAt", item.CreatedAt), new("@completedAt", item.CompletedAt)]);
private static SqlOperation UpdateInstance(AccountWorkflowInstance item) => new("""
UPDATE workflow_instances SET status = @status, current_step = @currentStep,
assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id
""", [new("@status", item.Status), new("@currentStep", item.CurrentStep), new("@assigneeId", item.AssigneeId), new("@completedAt", item.CompletedAt), new("@id", item.Id)]);
private static SqlOperation InsertAction(AccountWorkflowAction item) => new("""
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", item.Id), new("@instanceId", item.InstanceId), new("@actorId", item.ActorId), new("@action", item.Action), new("@note", Optional(item.Note)), new("@fromAssigneeId", item.FromAssigneeId), new("@toAssigneeId", item.ToAssigneeId), new("@createdAt", item.CreatedAt)]);
private static SqlOperation UpdateBatch(AccountBatch item) => new("""
UPDATE candidate_account_batches SET status = @status, review_note = @reviewNote, reviewed_at = @reviewedAt WHERE id = @id
""", [new("@status", item.Status), new("@reviewNote", Optional(item.ReviewNote)), new("@reviewedAt", item.ReviewedAt), new("@id", item.Id)]);
private static SqlOperation InsertAudit(AdminAuditEntry item) => new("""
INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt)
""", [new("@id", item.Id), new("@actorId", item.ActorId), new("@action", item.Action), new("@detail", item.Detail), new("@createdAt", item.CreatedAt)]);
private static async Task ExecuteAsync(DbConnection connection, DbTransaction transaction, SqlOperation operation, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = operation.Sql;
foreach (var item in operation.Parameters) { var parameter = command.CreateParameter(); parameter.ParameterName = item.Name; parameter.Value = item.Value ?? DBNull.Value; command.Parameters.Add(parameter); }
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string? Optional(string value) => value.Length == 0 ? null : value;
private sealed record SqlOperation(string Sql, IReadOnlyList<SqlParameterValue> Parameters);
private sealed record SqlParameterValue(string Name, object? Value);
}
@@ -0,0 +1,214 @@
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); }
}
@@ -0,0 +1,60 @@
using System.Data.Common;
using System.Globalization;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminAccountBatchSnapshot(
IReadOnlyList<AdminSchool> Schools,
IReadOnlyList<AdminClass> Classes,
IReadOnlyList<AdminUser> Users,
IReadOnlyList<AccountBatch> Batches,
IReadOnlyList<AccountBatchItem> Items,
IReadOnlyList<AccountWorkflow> Workflows,
IReadOnlyList<AccountWorkflowInstance> Instances,
IReadOnlyList<AccountWorkflowAction> Actions,
IReadOnlyList<AccountNumberRule> NumberRules);
internal sealed record AccountBatch(string Id, string SchoolId, string? RequestedBy, string Status, string ReviewNote, string CreatedAt, string? ReviewedAt);
internal sealed record AccountBatchItem(string Id, string BatchId, string ClassId, int Position, string CandidateNumber, string InitialPassword, string? UserId, string? CreatedAt);
internal sealed record AccountWorkflow(string Id, string BusinessType, string Name, bool Active, IReadOnlyList<AccountWorkflowStep> Steps);
internal sealed record AccountWorkflowStep(string Id, string Name, string AdminLevel, int Position);
internal sealed record AccountWorkflowInstance(string Id, string WorkflowId, string BusinessType, string BusinessId, string Status, int CurrentStep, string? AssigneeId, string CreatedAt, string? CompletedAt);
internal sealed record AccountWorkflowAction(string Id, string InstanceId, string? ActorId, string Action, string Note, string? FromAssigneeId, string? ToAssigneeId, string CreatedAt);
internal sealed record AccountNumberRule(string Id, string Name, string Separator, bool Active, IReadOnlyList<AccountNumberSegment> Segments);
internal sealed record AccountNumberSegment(string Id, string Type, string Value, int Width, int Position);
internal sealed class AdminAccountBatchSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminAccountBatchSnapshot> LoadAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var schools = await QueryAsync(connection, "SELECT id, name, code, address, is_source_school, is_admission_school, active FROM schools ORDER BY name, id", reader => new AdminSchool(Text(reader, "id"), Text(reader, "name"), Text(reader, "code"), Optional(reader, "address") ?? "", Boolean(reader, "is_source_school"), Boolean(reader, "is_admission_school"), Boolean(reader, "active")), cancellationToken);
var classes = await QueryAsync(connection, "SELECT id, school_id, name, grade, active FROM school_classes ORDER BY school_id, grade, name, id", reader => new AdminClass(Text(reader, "id"), Text(reader, "school_id"), Text(reader, "name"), Text(reader, "grade"), Boolean(reader, "active")), cancellationToken);
var users = await QueryAsync(connection, "SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id", reader => new AdminUser(Text(reader, "id"), Text(reader, "username"), Text(reader, "role"), Optional(reader, "admin_level"), Optional(reader, "school_id"), Optional(reader, "class_id"), Text(reader, "display_name"), Optional(reader, "candidate_number"), Boolean(reader, "active"), Boolean(reader, "must_change_password"), Boolean(reader, "totp_enabled"), Optional(reader, "archived_at"), Text(reader, "created_at")), cancellationToken);
var batches = await QueryAsync(connection, "SELECT id, school_id, requested_by, status, review_note, created_at, reviewed_at FROM candidate_account_batches ORDER BY created_at DESC, id", reader => new AccountBatch(Text(reader, "id"), Text(reader, "school_id"), Optional(reader, "requested_by"), Text(reader, "status"), Optional(reader, "review_note") ?? "", Text(reader, "created_at"), Optional(reader, "reviewed_at")), cancellationToken);
var items = await QueryAsync(connection, "SELECT id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at FROM candidate_account_batch_items ORDER BY batch_id, position, id", reader => new AccountBatchItem(Text(reader, "id"), Text(reader, "batch_id"), Text(reader, "class_id"), Integer(reader, "position"), Optional(reader, "candidate_number") ?? "", Optional(reader, "initial_password") ?? "", Optional(reader, "user_id"), Optional(reader, "created_at")), cancellationToken);
var steps = await QueryAsync(connection, "SELECT id, workflow_id, name, admin_level, position FROM workflow_steps ORDER BY workflow_id, position, id", reader => new StepRow(Text(reader, "workflow_id"), new AccountWorkflowStep(Text(reader, "id"), Text(reader, "name"), Text(reader, "admin_level"), Integer(reader, "position"))), cancellationToken);
var byWorkflow = steps.GroupBy(item => item.WorkflowId).ToDictionary(group => group.Key, group => (IReadOnlyList<AccountWorkflowStep>)group.Select(item => item.Step).ToArray(), StringComparer.Ordinal);
var workflows = await QueryAsync(connection, "SELECT id, business_type, name, active FROM workflow_definitions ORDER BY business_type, id", reader => { var id = Text(reader, "id"); return new AccountWorkflow(id, Text(reader, "business_type"), Text(reader, "name"), Boolean(reader, "active"), byWorkflow.GetValueOrDefault(id) ?? []); }, cancellationToken);
var instances = await QueryAsync(connection, "SELECT id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at FROM workflow_instances ORDER BY created_at DESC, id", reader => new AccountWorkflowInstance(Text(reader, "id"), Text(reader, "workflow_id"), Text(reader, "business_type"), Text(reader, "business_id"), Text(reader, "status"), Integer(reader, "current_step"), Optional(reader, "assignee_id"), Text(reader, "created_at"), Optional(reader, "completed_at")), cancellationToken);
var actions = await QueryAsync(connection, "SELECT id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at FROM workflow_actions ORDER BY created_at, id", reader => new AccountWorkflowAction(Text(reader, "id"), Text(reader, "instance_id"), Optional(reader, "actor_id"), Text(reader, "action"), Optional(reader, "note") ?? "", Optional(reader, "from_assignee_id"), Optional(reader, "to_assignee_id"), Text(reader, "created_at")), cancellationToken);
var segments = await QueryAsync(connection, "SELECT id, rule_id, type, value, width, position FROM number_rule_segments ORDER BY rule_id, position, id", reader => new SegmentRow(Text(reader, "rule_id"), new AccountNumberSegment(Text(reader, "id"), Text(reader, "type"), Optional(reader, "value") ?? "", Integer(reader, "width"), Integer(reader, "position"))), cancellationToken);
var byRule = segments.GroupBy(item => item.RuleId).ToDictionary(group => group.Key, group => (IReadOnlyList<AccountNumberSegment>)group.Select(item => item.Segment).ToArray(), StringComparer.Ordinal);
var rules = await QueryAsync(connection, "SELECT id, name, `separator`, active FROM number_rules ORDER BY updated_at DESC, id", reader => { var id = Text(reader, "id"); return new AccountNumberRule(id, Text(reader, "name"), Text(reader, "separator"), Boolean(reader, "active"), byRule.GetValueOrDefault(id) ?? []); }, cancellationToken);
return new(schools, classes, users, batches, items, workflows, instances, actions, rules);
}
private static async Task<IReadOnlyList<T>> QueryAsync<T>(DbConnection connection, string sql, Func<DbDataReader, T> map, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand(); command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<T>(); while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader)); return output;
}
private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static string? Optional(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
private static bool Boolean(DbDataReader reader, string name) => Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
private static int Integer(DbDataReader reader, string name) => Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
private sealed record StepRow(string WorkflowId, AccountWorkflowStep Step);
private sealed record SegmentRow(string RuleId, AccountNumberSegment Segment);
}
@@ -2,13 +2,15 @@ namespace Eis.Infrastructure.Administration;
public sealed record AdminMigrationOptions(
bool NativeReadsEnabled,
bool NativeOrganizationWritesEnabled = false)
bool NativeOrganizationWritesEnabled = false,
bool NativeAccountBatchesEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
bool authenticationNativeEnabled,
bool sharesLegacySessions,
bool configuredNativeOrganizationWritesEnabled = false)
bool configuredNativeOrganizationWritesEnabled = false,
bool configuredNativeAccountBatchesEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -16,12 +18,15 @@ public sealed record AdminMigrationOptions(
var organizationWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"),
configuredNativeOrganizationWritesEnabled);
if (organizationWritesEnabled && !readsEnabled)
var accountBatchesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"),
configuredNativeAccountBatchesEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -37,7 +42,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -54,8 +54,11 @@ public static class DependencyInjection
services.AddSingleton(adminMigrationOptions);
services.AddScoped<AdminReadSnapshotLoader>();
services.AddScoped<AdminWriteRepository>();
services.AddScoped<AdminAccountBatchSnapshotLoader>();
services.AddScoped<AdminAccountBatchRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
}