已完成本轮迁移:管理后台、审批流和考务编排的业务 API 已全部由 ASP.NET Core 10 原生处理。
主要完成: 管理端招生工作台、指标资格、招生学校账号。 招生计划审核、分数优先多志愿投档、退档监督。 逐轮录取公示、报到审批、补录轮次推进、最低录取线公示。 成绩预览导入与管理员账号禁止物理删除。 新增迁移开关及健康状态:[MIGRATION.md (line 57)](C:\\Users\\BI\\Documents\\EIS-dotnet\\MIGRATION.md:57) 核心实现:[AdminAdmissionService.cs (line 10)](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Infrastructure\\Administration\\AdminAdmissionService.cs:10) 原生路由:[NativeAdminReadEndpoints.cs (line 209)](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Web\\Administration\\NativeAdminReadEndpoints.cs:209)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
using System.Data.Common;
|
||||
using Eis.Infrastructure.Candidate;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal sealed class AdminAdmissionRepository(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
public Task SaveRecordsAsync(
|
||||
IReadOnlyList<CandidateAdmissionRecord> records,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken) =>
|
||||
TransactionAsync(async (connection, transaction) =>
|
||||
{
|
||||
foreach (var record in records)
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, "DELETE FROM admission_records WHERE id = @id",
|
||||
[new("@id", record.Id)], cancellationToken);
|
||||
await ExecuteAsync(connection, transaction,
|
||||
"""
|
||||
INSERT INTO admission_records (
|
||||
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @kind, @examId, @userId, @schoolId, @status, @payload, @createdAt, @updatedAt
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@id", record.Id), new("@kind", record.Kind), new("@examId", record.ExamId),
|
||||
new("@userId", record.UserId), new("@schoolId", record.SchoolId),
|
||||
new("@status", record.Status), new("@payload", record.Payload.ToJsonString()),
|
||||
new("@createdAt", record.CreatedAt), new("@updatedAt", record.UpdatedAt)
|
||||
], cancellationToken);
|
||||
}
|
||||
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task CreateAccountAsync(
|
||||
AdminUser account,
|
||||
string passwordHash,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken) =>
|
||||
TransactionAsync(async (connection, transaction) =>
|
||||
{
|
||||
await ExecuteAsync(connection, transaction,
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, password_hash, role, admin_level, school_id, class_id,
|
||||
display_name, active, must_change_password, created_at
|
||||
) VALUES (
|
||||
@id, @username, @passwordHash, 'admission_school', NULL, @schoolId, NULL,
|
||||
@displayName, 1, 0, @createdAt
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@id", account.Id), new("@username", account.Username), new("@passwordHash", passwordHash),
|
||||
new("@schoolId", account.SchoolId), new("@displayName", account.DisplayName),
|
||||
new("@createdAt", account.CreatedAt)
|
||||
], cancellationToken);
|
||||
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task UpdateAccountAsync(
|
||||
string accountId,
|
||||
string displayName,
|
||||
bool active,
|
||||
string? passwordHash,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken) =>
|
||||
TransactionAsync(async (connection, transaction) =>
|
||||
{
|
||||
await ExecuteAsync(connection, transaction,
|
||||
passwordHash is null
|
||||
? "UPDATE users SET display_name = @displayName, active = @active WHERE id = @id"
|
||||
: "UPDATE users SET display_name = @displayName, active = @active, password_hash = @passwordHash WHERE id = @id",
|
||||
[
|
||||
new("@displayName", displayName), new("@active", active ? 1 : 0),
|
||||
new("@passwordHash", passwordHash), new("@id", accountId)
|
||||
], cancellationToken);
|
||||
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||
}, cancellationToken);
|
||||
|
||||
private async Task TransactionAsync(
|
||||
Func<DbConnection, DbTransaction, Task> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await operation(connection, transaction);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task InsertAuditAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(connection, transaction,
|
||||
"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)
|
||||
], cancellationToken);
|
||||
|
||||
private static async Task ExecuteAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
string sql,
|
||||
IReadOnlyList<SqlValue> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
foreach (var value in values)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = value.Name;
|
||||
parameter.Value = value.Value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record SqlValue(string Name, object? Value);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool NativeWorkflowOperationsEnabled = false,
|
||||
bool NativeExamManagementEnabled = false,
|
||||
bool NativeArrangementsEnabled = false,
|
||||
bool NativeResultsEnabled = false)
|
||||
bool NativeResultsEnabled = false,
|
||||
bool NativeAdmissionsEnabled = false)
|
||||
{
|
||||
public static AdminMigrationOptions FromEnvironment(
|
||||
bool configuredNativeReadsEnabled,
|
||||
@@ -30,7 +31,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool configuredNativeWorkflowOperationsEnabled = false,
|
||||
bool configuredNativeExamManagementEnabled = false,
|
||||
bool configuredNativeArrangementsEnabled = false,
|
||||
bool configuredNativeResultsEnabled = false)
|
||||
bool configuredNativeResultsEnabled = false,
|
||||
bool configuredNativeAdmissionsEnabled = false)
|
||||
{
|
||||
var readsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||
@@ -72,17 +74,20 @@ public sealed record AdminMigrationOptions(
|
||||
var resultsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"),
|
||||
configuredNativeResultsEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled) && !readsEnabled)
|
||||
var admissionsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"),
|
||||
configuredNativeAdmissionsEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled) && !readsEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||
}
|
||||
if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled) && !operationalReadsEnabled)
|
||||
if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled || admissionsEnabled) && !operationalReadsEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
|
||||
}
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled;
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled;
|
||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -98,7 +103,7 @@ public sealed record AdminMigrationOptions(
|
||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||
}
|
||||
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled);
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled, admissionsEnabled);
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||
|
||||
@@ -244,6 +244,104 @@ internal sealed class AdminResultService(
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<AdminEndpointResult> ImportAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
|
||||
if (context.Error is not null) return context.Error;
|
||||
var user = context.User!;
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
|
||||
var rows = body["rows"] as JsonArray ?? [];
|
||||
var prepared = new List<PreparedImport>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index] as JsonObject ?? new JsonObject();
|
||||
var sourceRow = Integer(row["__row"] ?? row["sourceRow"], index + 3);
|
||||
var candidateNumber = Clean(Text(row["candidateNumber"]), 120);
|
||||
var examCode = Clean(Text(row["examCode"]), 60);
|
||||
var subjectName = Clean(Text(row["subjectName"]), 50);
|
||||
var account = operational.Users.FirstOrDefault(item => item.CandidateNumber == candidateNumber);
|
||||
var exam = operational.Exams.FirstOrDefault(item =>
|
||||
Text(item.Data["code"]).Equals(examCode, StringComparison.OrdinalIgnoreCase));
|
||||
var registration = operational.Registrations.FirstOrDefault(item =>
|
||||
item.UserId == account?.Id && item.ExamId == exam?.Id && item.Status == "approved");
|
||||
var subject = exam?.Subjects.FirstOrDefault(item =>
|
||||
Text(item["name"]).Equals(subjectName, StringComparison.OrdinalIgnoreCase));
|
||||
string? message = null;
|
||||
if (candidateNumber.Length == 0) message = "报名号不能为空";
|
||||
else if (account is null) message = "报名号不存在";
|
||||
else if (examCode.Length == 0) message = "考试代码不能为空";
|
||||
else if (exam is null) message = "考试代码不存在";
|
||||
else if (exam.Data["archivedAt"] is not null) message = "该考试已归档,成绩已永久锁定";
|
||||
else if (subjectName.Length == 0) message = "科目不能为空";
|
||||
else if (subject is null) message = "该考试中不存在此科目";
|
||||
else if (registration is null) message = "该考生没有已通过的本场考试报名";
|
||||
else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目";
|
||||
var score = Number(row["score"]);
|
||||
if (message is null && (!double.IsFinite(score) || score < 0 || score > Number(subject?["fullScore"])))
|
||||
message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间";
|
||||
var publishText = Text(row["published"]);
|
||||
bool published;
|
||||
if (row["published"] is JsonValue publishedValue &&
|
||||
publishedValue.TryGetValue<bool>(out var publishedBoolean))
|
||||
published = publishedBoolean;
|
||||
else if (publishText is "" or "不发布")
|
||||
published = false;
|
||||
else if (publishText == "发布")
|
||||
published = true;
|
||||
else
|
||||
{
|
||||
published = false;
|
||||
message = "发布状态只能是“发布”或“不发布”";
|
||||
}
|
||||
var key = registration is null || subject is null
|
||||
? $"{candidateNumber}|{examCode}|{subjectName}"
|
||||
: $"{registration.Id}|{Text(subject["id"])}";
|
||||
if (!seen.Add(key) && message is null) message = "同一考生、考试和科目在文件中重复";
|
||||
if (message is not null) return Error(400, $"第 {sourceRow} 行:{message};请返回预览修正后重试");
|
||||
var existing = allResults.FirstOrDefault(item =>
|
||||
item.RegistrationId == registration!.Id && item.SubjectId == Text(subject!["id"]));
|
||||
var now = NowIso();
|
||||
var result = new AdminWorkflowResult(
|
||||
existing?.Id ?? Uid("result"), registration!.Id, Text(subject!["id"]), score,
|
||||
published ? "" : "待发布", published, now, published ? existing?.PublishedAt ?? now : null);
|
||||
Replace(allResults, result);
|
||||
prepared.Add(new(registration, exam!, subject!, existing, result, candidateNumber));
|
||||
}
|
||||
var writes = prepared.Select(item =>
|
||||
{
|
||||
var result = item.Result with
|
||||
{
|
||||
Grade = item.Result.Published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
|
||||
};
|
||||
return new ResultWrite(
|
||||
result,
|
||||
item.Existing is null,
|
||||
Audit(user, result.Published ? "Excel 批量发布成绩" : "Excel 批量保存成绩",
|
||||
$"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
|
||||
}).ToArray();
|
||||
await repository.SaveManyAsync(writes, cancellationToken);
|
||||
var create = prepared.Count(item => item.Existing is null);
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["count"] = writes.Length,
|
||||
["summary"] = new JsonObject
|
||||
{
|
||||
["total"] = writes.Length,
|
||||
["valid"] = writes.Length,
|
||||
["invalid"] = 0,
|
||||
["create"] = create,
|
||||
["update"] = writes.Length - create,
|
||||
["publish"] = prepared.Count(item => item.Result.Published)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<AdminEndpointResult> SaveFeatureBulkAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
@@ -862,6 +960,8 @@ internal sealed class AdminResultService(
|
||||
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: double.NaN;
|
||||
private static int Integer(JsonNode? node, int fallback = 0) =>
|
||||
int.TryParse(Text(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
|
||||
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)
|
||||
@@ -898,6 +998,13 @@ internal sealed class AdminResultService(
|
||||
OperationalRegistration Registration,
|
||||
AdminWorkflowResult? Existing,
|
||||
AdminWorkflowResult Result);
|
||||
private sealed record PreparedImport(
|
||||
OperationalRegistration Registration,
|
||||
OperationalExam Exam,
|
||||
JsonObject Subject,
|
||||
AdminWorkflowResult? Existing,
|
||||
AdminWorkflowResult Result,
|
||||
string CandidateNumber);
|
||||
private sealed record ResultSummary(bool Complete, bool? Qualified);
|
||||
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<AdminArrangementSnapshotLoader>();
|
||||
services.AddScoped<AdminArrangementRepository>();
|
||||
services.AddScoped<AdminResultRepository>();
|
||||
services.AddScoped<AdminAdmissionRepository>();
|
||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||
@@ -80,6 +81,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
|
||||
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
|
||||
services.AddScoped<IAdminResultService, AdminResultService>();
|
||||
services.AddScoped<IAdminAdmissionService, AdminAdmissionService>();
|
||||
services.AddSingleton<NoticeContentFormatter>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
|
||||
Reference in New Issue
Block a user