已完成管理后台第四批 ASP.NET Core 10 迁移:报名号规则与审批流程配置。

原生接口:
GET/POST /api/admin/number-rules
GET /api/admin/workflows
PUT /api/admin/workflows/{businessType}
实现包括:
报名号规则预览、创建和更新
自动停用旧规则
审批流程步骤重建
班级、校级、超级管理员层级校验
考点及批量申领流程禁止班级审批
批量申领最终步骤强制为超级管理员
配置更新与审计日志事务提交
This commit is contained in:
2026-07-23 08:28:53 +08:00 Unverified
parent f7c34247fd
commit 3665aa3aa9
13 changed files with 460 additions and 24 deletions
+2
View File
@@ -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
+4 -3
View File
@@ -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 冒烟测试:
+50
View File
@@ -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 {
@@ -0,0 +1,11 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminConfigurationService
{
Task<AdminEndpointResult> GetNumberRulesAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveNumberRuleAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetWorkflowsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveWorkflowAsync(string sessionToken, string businessType, JsonObject body, CancellationToken cancellationToken);
}
@@ -13,15 +13,17 @@ internal sealed record AdminAccountBatchSnapshot(
IReadOnlyList<AccountWorkflow> Workflows,
IReadOnlyList<AccountWorkflowInstance> Instances,
IReadOnlyList<AccountWorkflowAction> Actions,
IReadOnlyList<AccountNumberRule> NumberRules);
IReadOnlyList<AccountNumberRule> 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<AccountWorkflowStep> Steps);
internal sealed record AccountWorkflow(string Id, string BusinessType, string Name, bool Active, string? UpdatedBy, string UpdatedAt, 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 AccountNumberRule(string Id, string Name, string Separator, bool Active, string? CreatedBy, string UpdatedAt, IReadOnlyList<AccountNumberSegment> 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<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 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<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);
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<IReadOnlyList<T>> QueryAsync<T>(DbConnection connection, string sql, Func<DbDataReader, T> 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);
}
@@ -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<SqlOperation>
{
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<SqlOperation>
{
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<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 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<SqlParameterValue> 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<SqlParameterValue> Parameters);
private sealed record SqlParameterValue(string Name, object? Value);
}
@@ -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<string> SegmentTypes = ["year", "school_code", "gender", "sequence", "literal"];
private static readonly HashSet<string> AdminLevels = ["class", "school", "super"];
private static readonly HashSet<string> WorkflowTypes = ["profile_change", "registration_review", "center_change", "candidate_account_batch", "score_appeal"];
public async Task<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> 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<AdminEndpointResult> 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<ResolvedAdmin> 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<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 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 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); }
}
@@ -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
@@ -56,9 +56,11 @@ public static class DependencyInjection
services.AddScoped<AdminWriteRepository>();
services.AddScoped<AdminAccountBatchSnapshotLoader>();
services.AddScoped<AdminAccountBatchRepository>();
services.AddScoped<AdminConfigurationRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
}
@@ -45,13 +45,25 @@ public static class NativeAdminReadEndpoints
Execute(context, service.UpdateSelfRegistrationAsync(Token(context), body, cancellationToken)));
}
if (!options.NativeAccountBatchesEnabled) return endpoints;
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;
}
+6 -1
View File
@@ -47,7 +47,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
authenticationOptions.NativeEnabled,
authenticationOptions.SharesLegacySessions,
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"));
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
builder.Configuration.GetValue<bool>("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)
+2 -1
View File
@@ -12,7 +12,8 @@
"AdminMigration": {
"NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false
},
"Logging": {
"LogLevel": {
@@ -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);
}
}