diff --git a/.env.example b/.env.example index 63b3cd7..129226c 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,8 @@ ADMIN_NATIVE_READS_ENABLED=false ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false # 批量报名号申领、审批与账号生成;必须同时启用管理端只读接口。 ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false +# 报名号规则与审批流程定义维护;必须同时启用管理端只读接口。 +ADMIN_NATIVE_CONFIGURATION_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 0bebd23..7790f94 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护及批量报名号审批已原生化) +- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批及流程配置已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。 其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用: @@ -63,9 +63,10 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:ADMIN_NATIVE_READS_ENABLED = 'true' $env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' +$env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' ``` -`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 12e4c13..1bd163b 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -425,6 +425,7 @@ try { ADMIN_NATIVE_READS_ENABLED = 'true' ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' + ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -739,6 +740,54 @@ try { throw 'Native admins route did not preserve the class-admin boundary' } + foreach ($configurationRoute in @('number-rules', 'workflows')) { + $legacyConfiguration = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$configurationRoute" -WebSession $session + $nativeConfiguration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$configurationRoute" -WebSession $nativeSession + if ($nativeConfiguration.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Native admin configuration route '$configurationRoute' did not use ASP.NET Core" + } + Assert-JsonEquivalent -Expected $legacyConfiguration.Content -Actual $nativeConfiguration.Content -Label "Admin configuration '$configurationRoute'" + } + foreach ($configurationRoute in @('number-rules', 'workflows')) { + $schoolConfigurationForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$configurationRoute" -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolConfigurationForbidden.StatusCode -ne 403) { + throw "Admin configuration route '$configurationRoute' did not preserve the super-admin boundary" + } + } + + $numberRuleState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/number-rules" -WebSession $nativeSession + $activeNumberRule = $numberRuleState.activeRule + $numberRuleBody = @{ + id = $activeNumberRule.id + name = '原生年度学校流水号' + separator = $activeNumberRule.separator + segments = @($activeNumberRule.segments | ForEach-Object { + @{ type = $_.type; value = $_.value; width = $_.width } + }) + } | ConvertTo-Json -Depth 5 -Compress + $savedNumberRule = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/number-rules" -Method Post -ContentType 'application/json' -Body $numberRuleBody -WebSession $nativeSession + if ($savedNumberRule.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or ($savedNumberRule.Content | ConvertFrom-Json).rule.name -ne '原生年度学校流水号') { + throw 'Native number-rule update did not persist the requested definition' + } + + $workflowState = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/workflows" -WebSession $nativeSession + $accountWorkflow = @($workflowState.workflows | Where-Object { $_.businessType -eq 'candidate_account_batch' })[0] + $invalidWorkflowBody = @{ name = '无效流程'; steps = @(@{ name = '学校终审'; adminLevel = 'school' }) } | ConvertTo-Json -Depth 4 -Compress + $invalidWorkflow = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflows/candidate_account_batch" -Method Put -ContentType 'application/json' -Body $invalidWorkflowBody -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidWorkflow.StatusCode -ne 400) { + throw 'Native workflow update allowed a non-super final account-batch step' + } + $workflowBody = @{ + name = '原生批量报名号审批' + steps = @($accountWorkflow.steps | ForEach-Object { + @{ name = $_.name; adminLevel = $_.adminLevel } + }) + } | ConvertTo-Json -Depth 5 -Compress + $savedWorkflow = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/workflows/candidate_account_batch" -Method Put -ContentType 'application/json' -Body $workflowBody -WebSession $nativeSession + if ($savedWorkflow.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or ($savedWorkflow.Content | ConvertFrom-Json).workflow.name -ne '原生批量报名号审批') { + throw 'Native workflow update did not persist the account-batch definition' + } + $legacySuperBatches = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/candidate-account-batches" -WebSession $session $nativeSuperBatches = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/candidate-account-batches" -WebSession $nativeSession if ($nativeSuperBatches.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { @@ -989,6 +1038,7 @@ try { NativeAdminReads = 'passed' NativeAdminOrganizationWrites = 'passed' NativeAdminAccountBatches = 'passed' + NativeAdminConfiguration = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminConfigurationService.cs b/src/Eis.Application/Administration/IAdminConfigurationService.cs new file mode 100644 index 0000000..955eafb --- /dev/null +++ b/src/Eis.Application/Administration/IAdminConfigurationService.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace Eis.Application.Administration; + +public interface IAdminConfigurationService +{ + Task GetNumberRulesAsync(string sessionToken, CancellationToken cancellationToken); + Task SaveNumberRuleAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task GetWorkflowsAsync(string sessionToken, CancellationToken cancellationToken); + Task SaveWorkflowAsync(string sessionToken, string businessType, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs b/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs index 51930f0..5119feb 100644 --- a/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs +++ b/src/Eis.Infrastructure/Administration/AdminAccountBatchSnapshot.cs @@ -13,15 +13,17 @@ internal sealed record AdminAccountBatchSnapshot( IReadOnlyList Workflows, IReadOnlyList Instances, IReadOnlyList Actions, - IReadOnlyList NumberRules); + IReadOnlyList NumberRules, + string PreviewGender, + string? PreviewSchoolId); 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 Steps); +internal sealed record AccountWorkflow(string Id, string BusinessType, string Name, bool Active, string? UpdatedBy, string UpdatedAt, IReadOnlyList 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 Segments); +internal sealed record AccountNumberRule(string Id, string Name, string Separator, bool Active, string? CreatedBy, string UpdatedAt, IReadOnlyList Segments); internal sealed record AccountNumberSegment(string Id, string Type, string Value, int Width, int Position); internal sealed class AdminAccountBatchSnapshotLoader(IRelationalConnectionFactory connectionFactory) @@ -36,13 +38,15 @@ internal sealed class AdminAccountBatchSnapshotLoader(IRelationalConnectionFacto 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)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 workflows = await QueryAsync(connection, "SELECT id, business_type, name, active, updated_by, updated_at 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"), Optional(reader, "updated_by"), Text(reader, "updated_at"), 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)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); + var rules = await QueryAsync(connection, "SELECT id, name, `separator`, active, created_by, updated_at 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"), Optional(reader, "created_by"), Text(reader, "updated_at"), byRule.GetValueOrDefault(id) ?? []); }, cancellationToken); + var profiles = await QueryAsync(connection, "SELECT gender, school_id FROM candidate_profiles ORDER BY updated_at, id", reader => new PreviewProfile(Optional(reader, "gender") ?? "", Optional(reader, "school_id")), cancellationToken); + var preview = profiles.FirstOrDefault(); + return new(schools, classes, users, batches, items, workflows, instances, actions, rules, preview?.Gender ?? "女", preview?.SchoolId ?? schools.FirstOrDefault()?.Id); } private static async Task> QueryAsync(DbConnection connection, string sql, Func map, CancellationToken cancellationToken) @@ -57,4 +61,5 @@ internal sealed class AdminAccountBatchSnapshotLoader(IRelationalConnectionFacto 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); + private sealed record PreviewProfile(string Gender, string? SchoolId); } diff --git a/src/Eis.Infrastructure/Administration/AdminConfigurationRepository.cs b/src/Eis.Infrastructure/Administration/AdminConfigurationRepository.cs new file mode 100644 index 0000000..9a792aa --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminConfigurationRepository.cs @@ -0,0 +1,114 @@ +using System.Data.Common; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminConfigurationRepository(IRelationalConnectionFactory connectionFactory) +{ + public Task SaveNumberRuleAsync(AccountNumberRule rule, bool isNew, AdminAuditEntry audit, CancellationToken cancellationToken) + { + var operations = new List + { + new("UPDATE number_rules SET active = 0 WHERE active = 1", []) + }; + if (isNew) + { + operations.Add(new(""" + INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) + VALUES (@id, @name, @separator, @active, @createdBy, @updatedAt) + """, Parameters(rule))); + } + else + { + operations.Add(new(""" + UPDATE number_rules SET name = @name, `separator` = @separator, active = @active, + created_by = @createdBy, updated_at = @updatedAt WHERE id = @id + """, Parameters(rule))); + operations.Add(new("DELETE FROM number_rule_segments WHERE rule_id = @id", [new("@id", rule.Id)])); + } + operations.AddRange(rule.Segments.Select(segment => new SqlOperation(""" + INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) + VALUES (@id, @ruleId, @position, @type, @value, @width) + """, + [ + new("@id", segment.Id), new("@ruleId", rule.Id), new("@position", segment.Position), + new("@type", segment.Type), new("@value", Optional(segment.Value)), new("@width", segment.Width) + ]))); + operations.Add(AuditOperation(audit)); + return ExecuteAsync(operations, cancellationToken); + } + + public Task SaveWorkflowAsync(AccountWorkflow workflow, AdminAuditEntry audit, CancellationToken cancellationToken) + { + var operations = new List + { + new(""" + UPDATE workflow_definitions SET name = @name, active = @active, + updated_by = @updatedBy, updated_at = @updatedAt WHERE id = @id + """, + [ + new("@name", workflow.Name), new("@active", workflow.Active ? 1 : 0), + new("@updatedBy", workflow.UpdatedBy), new("@updatedAt", workflow.UpdatedAt), new("@id", workflow.Id) + ]), + new("DELETE FROM workflow_steps WHERE workflow_id = @id", [new("@id", workflow.Id)]) + }; + operations.AddRange(workflow.Steps.Select(step => new SqlOperation(""" + INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) + VALUES (@id, @workflowId, @position, @name, @adminLevel) + """, + [ + new("@id", step.Id), new("@workflowId", workflow.Id), new("@position", step.Position), + new("@name", step.Name), new("@adminLevel", step.AdminLevel) + ]))); + operations.Add(AuditOperation(audit)); + return ExecuteAsync(operations, cancellationToken); + } + + private async Task ExecuteAsync(IReadOnlyList 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 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); + } + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static IReadOnlyList Parameters(AccountNumberRule rule) => + [ + new("@id", rule.Id), new("@name", rule.Name), new("@separator", rule.Separator), + new("@active", rule.Active ? 1 : 0), new("@createdBy", rule.CreatedBy), new("@updatedAt", rule.UpdatedAt) + ]; + + private static SqlOperation AuditOperation(AdminAuditEntry audit) => new(""" + INSERT INTO audit_logs (id, actor_id, action, detail, created_at) + VALUES (@id, @actorId, @action, @detail, @createdAt) + """, + [ + new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action), + new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt) + ]); + + private static string? Optional(string value) => value.Length == 0 ? null : value; + private sealed record SqlOperation(string Sql, IReadOnlyList Parameters); + private sealed record SqlParameterValue(string Name, object? Value); +} diff --git a/src/Eis.Infrastructure/Administration/AdminConfigurationService.cs b/src/Eis.Infrastructure/Administration/AdminConfigurationService.cs new file mode 100644 index 0000000..c355cdd --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminConfigurationService.cs @@ -0,0 +1,209 @@ +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 AdminConfigurationService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + AdminAccountBatchSnapshotLoader snapshotLoader, + AdminConfigurationRepository repository) : IAdminConfigurationService +{ + private static readonly HashSet SegmentTypes = ["year", "school_code", "gender", "sequence", "literal"]; + private static readonly HashSet AdminLevels = ["class", "school", "super"]; + private static readonly HashSet WorkflowTypes = ["profile_change", "registration_review", "center_change", "candidate_account_batch", "score_appeal"]; + + public async Task GetNumberRulesAsync(string sessionToken, CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var active = snapshot.NumberRules.FirstOrDefault(item => item.Active); + var preview = active is null ? "" : GenerateCandidateNumber(snapshot, active); + return Success(new JsonObject + { + ["ok"] = true, + ["rules"] = new JsonArray(snapshot.NumberRules.Select(RuleJson).ToArray()), + ["activeRule"] = active is null ? null : RuleJson(active), + ["preview"] = preview + }); + } + + public async Task SaveNumberRuleAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var requestedArray = body["segments"] as JsonArray; + var requested = requestedArray?.Select(item => item as JsonObject).ToArray() ?? []; + if (requested.Length == 0 || requested.Any(item => item is null || !SegmentTypes.Contains(Text(item["type"]))) || + !requested.Any(item => item is not null && Text(item["type"]) == "sequence")) + return Error(400, "报名号规则至少包含一个流水号段"); + + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var existing = snapshot.NumberRules.FirstOrDefault(item => item.Id == Text(body["id"])); + var name = Clean(Text(body["name"]), 80); + var rule = new AccountNumberRule( + existing?.Id ?? Uid("rule"), + name.Length == 0 ? "自定义报名号规则" : name, + Clean(Text(body["separator"]), 3), + true, + user.Id, + NowIso(), + requested.Select((item, index) => new AccountNumberSegment( + Uid("segment"), + Text(item!["type"]), + Clean(Text(item["value"]), 20), + Math.Clamp((int)NumberOrZero(item["width"]), 0, 12), + index + 1)).ToArray()); + var audit = Audit(user, "更新报名号规则", $"{rule.Name} · {string.Join(" + ", rule.Segments.Select(item => item.Type))}"); + await repository.SaveNumberRuleAsync(rule, existing is null, audit, cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["rule"] = RuleJson(rule) }); + } + + public async Task GetWorkflowsAsync(string sessionToken, CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["workflows"] = new JsonArray(snapshot.Workflows.Select(WorkflowJson).ToArray()) + }); + } + + public async Task SaveWorkflowAsync( + string sessionToken, + string businessType, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + if (!WorkflowTypes.Contains(businessType)) return Error(404, "审批流程不存在"); + var snapshot = await snapshotLoader.LoadAsync(cancellationToken); + var existing = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == businessType && item.Active); + if (existing is null) return Error(404, "审批流程不存在"); + var stepArray = body["steps"] as JsonArray; + var requested = stepArray?.Select(item => item as JsonObject).ToArray() ?? []; + if (requested.Length == 0 || requested.Any(item => item is null || !AdminLevels.Contains(Text(item["adminLevel"])))) + return Error(400, "流程至少需要一个班级、校级或超级管理员审批步骤"); + if (businessType is "center_change" or "candidate_account_batch" && requested.Any(item => Text(item!["adminLevel"]) == "class")) + return Error(400, "该业务不对应单一班级,不能配置班级管理员审批步骤"); + if (businessType == "candidate_account_batch" && Text(requested[^1]!["adminLevel"]) != "super") + return Error(400, "批量报名号申领的最终步骤必须由超级管理员审批"); + var requestedName = Clean(Text(body["name"]), 80); + var workflow = existing with + { + Name = requestedName.Length == 0 ? existing.Name : requestedName, + UpdatedBy = user.Id, + UpdatedAt = NowIso(), + Steps = requested.Select((item, index) => + { + var requestedStepName = Clean(Text(item!["name"]), 80); + return new AccountWorkflowStep( + Uid("workflow_step"), + requestedStepName.Length == 0 ? $"第 {index + 1} 步" : requestedStepName, + Text(item["adminLevel"]), + index + 1); + }).ToArray() + }; + await repository.SaveWorkflowAsync( + workflow, + Audit(user, "修改审批流程", $"{workflow.Name} · {workflow.Steps.Count} 个步骤"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["workflow"] = WorkflowJson(workflow) }); + } + + private async Task ResolveSuperAsync(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, "请先登录")); + if (user.Role != "admin") return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作")); + return (user.AdminLevel ?? "super") == "super" + ? new ResolvedAdmin(user, null) + : ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作")); + } + + private static string GenerateCandidateNumber(AdminAccountBatchSnapshot snapshot, AccountNumberRule rule) + { + var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture); + var school = snapshot.Schools.FirstOrDefault(item => item.Id == snapshot.PreviewSchoolId); + var prefix = string.Join(rule.Separator, rule.Segments + .Where(item => item.Type != "sequence") + .Select(item => item.Type == "year" ? year : item.Type == "school_code" ? school?.Code ?? "" : "") + .Where(item => item.Length > 0)); + var sequence = snapshot.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" => school?.Code ?? "NOSCHOOL", + "gender" => snapshot.PreviewGender == "男" ? "M" : snapshot.PreviewGender == "女" ? "F" : "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 RuleJson(AccountNumberRule item) => new() + { + ["id"] = item.Id, + ["name"] = item.Name, + ["separator"] = item.Separator, + ["active"] = item.Active, + ["createdBy"] = JsonValue.Create(item.CreatedBy), + ["updatedAt"] = item.UpdatedAt, + ["segments"] = new JsonArray(item.Segments.Select(SegmentJson).ToArray()) + }; + + private static JsonObject SegmentJson(AccountNumberSegment item) => new() + { + ["id"] = item.Id, + ["type"] = item.Type, + ["value"] = item.Value, + ["width"] = item.Width, + ["position"] = item.Position + }; + + private static JsonObject WorkflowJson(AccountWorkflow item) => new() + { + ["id"] = item.Id, + ["businessType"] = item.BusinessType, + ["name"] = item.Name, + ["active"] = item.Active, + ["updatedBy"] = JsonValue.Create(item.UpdatedBy), + ["updatedAt"] = item.UpdatedAt, + ["steps"] = new JsonArray(item.Steps.Select(step => new JsonObject + { + ["id"] = step.Id, + ["name"] = step.Name, + ["adminLevel"] = step.AdminLevel, + ["position"] = step.Position + }).ToArray()) + }; + + private static double NumberOrZero(JsonNode? node) + { + var text = Text(node); + return text.Length == 0 || !double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? 0 : value; + } + + 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 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 AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso()); + 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); } +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 1d55d44..f5e758f 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -3,14 +3,16 @@ namespace Eis.Infrastructure.Administration; public sealed record AdminMigrationOptions( bool NativeReadsEnabled, bool NativeOrganizationWritesEnabled = false, - bool NativeAccountBatchesEnabled = false) + bool NativeAccountBatchesEnabled = false, + bool NativeConfigurationEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, bool authenticationNativeEnabled, bool sharesLegacySessions, bool configuredNativeOrganizationWritesEnabled = false, - bool configuredNativeAccountBatchesEnabled = false) + bool configuredNativeAccountBatchesEnabled = false, + bool configuredNativeConfigurationEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -21,12 +23,15 @@ public sealed record AdminMigrationOptions( var accountBatchesEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"), configuredNativeAccountBatchesEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled) && !readsEnabled) + var configurationEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"), + configuredNativeConfigurationEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -42,7 +47,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 9cc5636..03e0196 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -56,9 +56,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 36ab0fd..69fafce 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -45,13 +45,25 @@ public static class NativeAdminReadEndpoints Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken))); } - if (!options.NativeAccountBatchesEnabled) return endpoints; - endpoints.MapGet("/api/admin/candidate-account-batches", (HttpContext context, IAdminAccountBatchService service, CancellationToken cancellationToken) => - Execute(context, service.GetAsync(Token(context), cancellationToken))); - endpoints.MapPost("/api/admin/candidate-account-batches", (HttpContext context, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => - Execute(context, service.SubmitAsync(Token(context), body, cancellationToken))); - endpoints.MapPatch("/api/admin/candidate-account-batches/{batchId}", (HttpContext context, string batchId, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => - Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken))); + if (options.NativeAccountBatchesEnabled) + { + endpoints.MapGet("/api/admin/candidate-account-batches", (HttpContext context, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/candidate-account-batches", (HttpContext context, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.SubmitAsync(Token(context), body, cancellationToken))); + endpoints.MapPatch("/api/admin/candidate-account-batches/{batchId}", (HttpContext context, string batchId, JsonObject body, IAdminAccountBatchService service, CancellationToken cancellationToken) => + Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken))); + } + + if (!options.NativeConfigurationEnabled) return endpoints; + endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken))); + endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken))); + endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken))); return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 104bfe6..1334600 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -47,7 +47,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( authenticationOptions.NativeEnabled, authenticationOptions.SharesLegacySessions, builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -104,6 +105,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeReadsEnabled = adminMigrationOptions.NativeReadsEnabled, nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled, nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled, + nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -117,6 +119,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c .Concat(adminMigrationOptions.NativeAccountBatchesEnabled ? new[] { "GET/POST/PATCH candidate-account-batches" } : []) + .Concat(adminMigrationOptions.NativeConfigurationEnabled + ? new[] { "GET/POST number-rules", "GET/PUT workflows" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 34e5d82..816a242 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -12,7 +12,8 @@ "AdminMigration": { "NativeReadsEnabled": false, "NativeOrganizationWritesEnabled": false, - "NativeAccountBatchesEnabled": false + "NativeAccountBatchesEnabled": false, + "NativeConfigurationEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index cf68d30..c95917a 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -107,22 +107,40 @@ public sealed class AdminMigrationOptionsTests }, accountBatchesEnabled: "true"); } + [Fact] + public void EnablesAdminConfigurationWithReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeConfigurationEnabled); + }, configurationEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, string? allowMemory, Action test, - string? accountBatchesEnabled = null) + string? accountBatchesEnabled = null, + string? configurationEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); var previousAccountBatchesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"); + var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", nativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", accountBatchesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -131,6 +149,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED", previousNativeReadsEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", previousAccountBatchesEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } }