已完成管理后台第四批 ASP.NET Core 10 迁移:报名号规则与审批流程配置。
原生接口:
GET/POST /api/admin/number-rules
GET /api/admin/workflows
PUT /api/admin/workflows/{businessType}
实现包括:
报名号规则预览、创建和更新
自动停用旧规则
审批流程步骤重建
班级、校级、超级管理员层级校验
考点及批量申领流程禁止班级审批
批量申领最终步骤强制为超级管理员
配置更新与审计日志事务提交
This commit is contained in:
1 parent
f7c34247fd
commit
3665aa3aa9
13 files changed
+460
-24
No files matched your search
@@ -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);
|
||||
}
|
||||
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user