已完成第十一批迁移:考试维护功能已原生化到 ASP.NET Core 10。
支持考试创建、草稿修改、发布状态切换及不可逆归档。 支持科目、费用、满分和合格线策略校验。 保留已有报名禁止修改科目、待处理成绩复议禁止归档等规则。 数据修改、科目替换与审计日志使用同一数据库事务。 新增 ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED 独立开关。 路由与实现见 [AdminExamManagementService.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs:9) 和 [NativeAdminReadEndpoints.cs (line 133)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs:133)。 迁移说明已更新至 [MIGRATION.md (line 57)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:57)。
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Eis.Application.Administration;
|
||||
|
||||
public interface IAdminExamManagementService
|
||||
{
|
||||
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string examId, JsonObject body, CancellationToken cancellationToken);
|
||||
Task<AdminEndpointResult> ArchiveAsync(string sessionToken, string examId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
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 AdminExamManagementService(
|
||||
IAuthenticationStateStore authenticationState,
|
||||
AuthenticationRepository authenticationRepository,
|
||||
AdminOperationalSnapshotLoader operationalSnapshotLoader,
|
||||
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
|
||||
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
|
||||
AdminExamRepository repository) : IAdminExamManagementService
|
||||
{
|
||||
private static readonly HashSet<string> PassPolicies = ["fixed_score", "rank_percent", "subject_scores", "none"];
|
||||
private static readonly HashSet<string> SubjectPassRules = ["fixed_score", "rank_percent", "none"];
|
||||
|
||||
public async Task<AdminEndpointResult> CreateAsync(
|
||||
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 name = Clean(Text(body["name"]), 100);
|
||||
if (name.Length == 0 || !JsBoolean(body["registrationStart"]) || !JsBoolean(body["registrationEnd"]) ||
|
||||
!JsBoolean(body["examStart"]) || !JsBoolean(body["examEnd"]))
|
||||
return Error(400, "请完整填写考试名称和关键日期");
|
||||
var examStart = Text(body["examStart"]);
|
||||
var subjects = NormalizeSubjects(body["subjects"], examStart);
|
||||
var requestedPolicy = Text(body["passPolicy"]);
|
||||
if (requestedPolicy == "score_ratio") requestedPolicy = "rank_percent";
|
||||
var passPolicy = PassPolicies.Contains(requestedPolicy) ? requestedPolicy : "rank_percent";
|
||||
var passValue = passPolicy is "subject_scores" or "none" ? 0 : NumberOr(body["passValue"], 60);
|
||||
var scoringError = ValidateScoring(subjects, passPolicy, passValue);
|
||||
if (scoringError.Length > 0) return Error(400, scoringError);
|
||||
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var code = Clean(Text(body["code"]), 30);
|
||||
if (code.Length == 0) code = $"EX-{DateTime.Now.Year}-{operational.Exams.Count + 1:00}";
|
||||
var exam = new ManagedExam(
|
||||
Uid("exam"),
|
||||
code,
|
||||
name,
|
||||
Clean(Text(body["description"]), 500),
|
||||
Text(body["registrationStart"]),
|
||||
Text(body["registrationEnd"]),
|
||||
examStart,
|
||||
Text(body["examEnd"]),
|
||||
JsBoolean(body["admitDownloadStart"]) ? Text(body["admitDownloadStart"]) : Text(body["registrationEnd"]),
|
||||
JsBoolean(body["admitDownloadEnd"]) ? Text(body["admitDownloadEnd"]) : examStart,
|
||||
Clean(Text(body["location"]), 100),
|
||||
passPolicy,
|
||||
passValue,
|
||||
Text(body["status"]) == "published" ? "published" : "draft",
|
||||
null,
|
||||
null,
|
||||
NowIso(),
|
||||
subjects);
|
||||
await repository.CreateAsync(
|
||||
exam,
|
||||
Audit(user, "创建考试", $"{exam.Name} · {subjects.Count} 个科目"),
|
||||
cancellationToken);
|
||||
var responseExam = ExamJson(exam);
|
||||
responseExam.Remove("archivedAt");
|
||||
responseExam.Remove("archivedBy");
|
||||
return Result(201, new JsonObject { ["ok"] = true, ["exam"] = responseExam });
|
||||
}
|
||||
|
||||
public async Task<AdminEndpointResult> UpdateAsync(
|
||||
string sessionToken,
|
||||
string examId,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
|
||||
if (context.Error is not null) return context.Error;
|
||||
var user = context.User!;
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var source = operational.Exams.FirstOrDefault(item => item.Id == examId);
|
||||
if (source is null) return Error(404, "考试不存在");
|
||||
var exam = FromOperational(source);
|
||||
if (exam.ArchivedAt is not null) return Error(409, "该考试已归档,所有配置和成绩均已锁定");
|
||||
|
||||
string[] detailFields =
|
||||
[
|
||||
"code", "name", "description", "location", "registrationStart", "registrationEnd",
|
||||
"examStart", "examEnd", "admitDownloadStart", "admitDownloadEnd"
|
||||
];
|
||||
var editingDetails = detailFields.Any(body.ContainsKey) ||
|
||||
body.ContainsKey("subjects") || body.ContainsKey("passPolicy") || body.ContainsKey("passValue");
|
||||
if (editingDetails && exam.Status != "draft") return Error(409, "请先将考试撤回为草稿后再编辑");
|
||||
var requestedStatus = Text(body["status"]);
|
||||
if (requestedStatus is "draft" or "published" or "closed") exam = exam with { Status = requestedStatus };
|
||||
foreach (var field in detailFields.Where(body.ContainsKey))
|
||||
{
|
||||
var value = Clean(Text(body[field]), field == "description" ? 500 : 100);
|
||||
exam = field switch
|
||||
{
|
||||
"code" => exam with { Code = value },
|
||||
"name" => exam with { Name = value },
|
||||
"description" => exam with { Description = value },
|
||||
"location" => exam with { Location = value },
|
||||
"registrationStart" => exam with { RegistrationStart = value },
|
||||
"registrationEnd" => exam with { RegistrationEnd = value },
|
||||
"examStart" => exam with { ExamStart = value },
|
||||
"examEnd" => exam with { ExamEnd = value },
|
||||
"admitDownloadStart" => exam with { AdmitDownloadStart = value },
|
||||
"admitDownloadEnd" => exam with { AdmitDownloadEnd = value },
|
||||
_ => exam
|
||||
};
|
||||
}
|
||||
if (body.ContainsKey("passPolicy"))
|
||||
{
|
||||
var policy = Text(body["passPolicy"]);
|
||||
if (policy == "score_ratio") policy = "rank_percent";
|
||||
if (PassPolicies.Contains(policy)) exam = exam with { PassPolicy = policy };
|
||||
}
|
||||
if (body.ContainsKey("passValue"))
|
||||
exam = exam with { PassValue = body["passValue"] is null ? 0 : Number(body["passValue"]) };
|
||||
var replaceSubjects = false;
|
||||
if (body.ContainsKey("subjects"))
|
||||
{
|
||||
if (operational.Registrations.Any(item => item.ExamId == exam.Id))
|
||||
return Error(409, "已有报名记录,不能修改考试科目");
|
||||
exam = exam with { Subjects = NormalizeSubjects(body["subjects"], exam.ExamStart) };
|
||||
replaceSubjects = true;
|
||||
}
|
||||
var scoringError = ValidateScoring(exam.Subjects, exam.PassPolicy, exam.PassValue);
|
||||
if (scoringError.Length > 0) return Error(400, scoringError);
|
||||
if (exam.Name.Length == 0 || exam.RegistrationStart.Length == 0 || exam.RegistrationEnd.Length == 0 ||
|
||||
exam.ExamStart.Length == 0 || exam.ExamEnd.Length == 0)
|
||||
return Error(400, "请完整填写考试名称和关键日期");
|
||||
if (exam.Status == "published" && exam.Subjects.Count == 0) return Error(400, "请先配置考试科目再发布");
|
||||
|
||||
await repository.UpdateAsync(
|
||||
exam,
|
||||
replaceSubjects,
|
||||
Audit(user, "更新考试", $"{exam.Name} · 状态 {exam.Status}"),
|
||||
cancellationToken);
|
||||
return Success(new JsonObject { ["ok"] = true, ["exam"] = ExamJson(exam) });
|
||||
}
|
||||
|
||||
public async Task<AdminEndpointResult> ArchiveAsync(
|
||||
string sessionToken,
|
||||
string examId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
|
||||
if (context.Error is not null) return context.Error;
|
||||
var user = context.User!;
|
||||
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var source = operational.Exams.FirstOrDefault(item => item.Id == examId);
|
||||
if (source is null) return Error(404, "考试不存在");
|
||||
var exam = FromOperational(source);
|
||||
if (exam.ArchivedAt is not null) return Error(409, "该考试已经归档,归档操作不可撤销");
|
||||
var resultIds = (await resultSnapshotLoader.LoadAsync(cancellationToken))
|
||||
.Where(result => operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId)?.ExamId == exam.Id)
|
||||
.Select(result => result.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||
var pendingAppeals = workflow.Instances.Count(item =>
|
||||
item.BusinessType == "score_appeal" && item.Status == "pending" && resultIds.Contains(item.BusinessId));
|
||||
if (pendingAppeals > 0) return Error(409, $"本场还有 {pendingAppeals} 项成绩复议待处理,请先办结后再归档");
|
||||
var archived = exam with { ArchivedAt = NowIso(), ArchivedBy = user.Id, Status = "closed" };
|
||||
await repository.ArchiveAsync(
|
||||
archived,
|
||||
Audit(user, "归档考试并永久锁定成绩", $"{archived.Name} · {archived.Code}"),
|
||||
cancellationToken);
|
||||
var output = ExamJson(archived);
|
||||
output["totalScore"] = archived.Subjects.Sum(item => item.FullScore);
|
||||
output["registrationState"] = "archived";
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["exam"] = output,
|
||||
["message"] = "考试已归档,全部成绩已永久锁定"
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ManagedSubject> NormalizeSubjects(JsonNode? input, string examStart)
|
||||
{
|
||||
IEnumerable<JsonNode?> source = input switch
|
||||
{
|
||||
JsonArray array => array,
|
||||
_ => Text(input).Split([',', ','], StringSplitOptions.None).Select(value => (JsonNode?)JsonValue.Create(value))
|
||||
};
|
||||
var output = new List<ManagedSubject>();
|
||||
var index = 0;
|
||||
foreach (var item in source)
|
||||
{
|
||||
var structured = item is JsonObject;
|
||||
var objectItem = item as JsonObject;
|
||||
var name = Clean(Text(structured ? objectItem!["name"] : item), 50);
|
||||
if (name.Length == 0) continue;
|
||||
var fullScore = structured
|
||||
? objectItem!.ContainsKey("fullScore")
|
||||
? objectItem["fullScore"] is null ? 0 : Number(objectItem["fullScore"])
|
||||
: double.NaN
|
||||
: 150;
|
||||
var requestedRule = Text(objectItem?["passRule"]);
|
||||
if (requestedRule == "score_ratio") requestedRule = "rank_percent";
|
||||
var passRule = SubjectPassRules.Contains(requestedRule) ? requestedRule : "fixed_score";
|
||||
var rawPassValue = objectItem?["passValue"] ?? objectItem?["passScore"];
|
||||
var passValue = passRule == "none" ? 0 : rawPassValue is null ? fullScore * 0.6 : Number(rawPassValue);
|
||||
double? passScore = passRule == "fixed_score"
|
||||
? Math.Round(passValue, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
output.Add(new ManagedSubject(
|
||||
Uid("sub"),
|
||||
name,
|
||||
Clean(Text(objectItem?["date"]), 10) is { Length: > 0 } date ? date : examStart[..Math.Min(10, examStart.Length)],
|
||||
Clean(Text(objectItem?["start"]), 5) is { Length: > 0 } start ? start : "09:00",
|
||||
Clean(Text(objectItem?["end"]), 5) is { Length: > 0 } end ? end : "11:00",
|
||||
objectItem?["fee"] is null ? 0 : Number(objectItem["fee"]),
|
||||
fullScore,
|
||||
passRule,
|
||||
passValue,
|
||||
passScore,
|
||||
++index));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string ValidateScoring(IReadOnlyList<ManagedSubject> subjects, string passPolicy, double passValue)
|
||||
{
|
||||
if (subjects.Count == 0) return "请至少添加一个考试科目";
|
||||
if (subjects.Any(item => !double.IsFinite(item.FullScore) || item.FullScore <= 0 || item.FullScore > 1000))
|
||||
return "科目满分必须大于 0 且不超过 1000";
|
||||
if (subjects.Any(item => !SubjectPassRules.Contains(item.PassRule))) return "请选择有效的单科合格线计算方式";
|
||||
if (subjects.Any(item => item.PassRule == "fixed_score" && (!double.IsFinite(item.PassValue) || item.PassValue < 0 || item.PassValue > item.FullScore)))
|
||||
return "固定单科合格分必须在 0 与该科满分之间";
|
||||
if (subjects.Any(item => item.PassRule == "rank_percent" && (!double.IsFinite(item.PassValue) || item.PassValue <= 0 || item.PassValue > 100)))
|
||||
return "单科排名比例必须大于 0 且不超过 100%";
|
||||
if (subjects.Any(item => !double.IsFinite(item.Fee) || item.Fee < 0 || item.Fee > 100000))
|
||||
return "科目费用必须在有效范围内";
|
||||
if (!PassPolicies.Contains(passPolicy)) return "请选择有效的合格线策略";
|
||||
var totalScore = subjects.Sum(item => item.FullScore);
|
||||
if (passPolicy == "fixed_score" && (!double.IsFinite(passValue) || passValue < 0 || passValue > totalScore))
|
||||
return $"固定合格线必须在 0 与总分 {Format(totalScore)} 之间";
|
||||
if (passPolicy == "rank_percent" && (!double.IsFinite(passValue) || passValue <= 0 || passValue > 100))
|
||||
return "排名比例必须大于 0 且不超过 100";
|
||||
return "";
|
||||
}
|
||||
|
||||
private static ManagedExam FromOperational(OperationalExam source)
|
||||
{
|
||||
var data = source.Data;
|
||||
return new ManagedExam(
|
||||
source.Id,
|
||||
Text(data["code"]),
|
||||
Text(data["name"]),
|
||||
Text(data["description"]),
|
||||
Text(data["registrationStart"]),
|
||||
Text(data["registrationEnd"]),
|
||||
Text(data["examStart"]),
|
||||
Text(data["examEnd"]),
|
||||
Text(data["admitDownloadStart"]),
|
||||
Text(data["admitDownloadEnd"]),
|
||||
Text(data["location"]),
|
||||
Text(data["passPolicy"]),
|
||||
Number(data["passValue"]),
|
||||
Text(data["status"]),
|
||||
OptionalText(data["archivedAt"]),
|
||||
OptionalText(data["archivedBy"]),
|
||||
Text(data["createdAt"]),
|
||||
source.Subjects.Select((item, index) => new ManagedSubject(
|
||||
Text(item["id"]), Text(item["name"]), Text(item["date"]), Text(item["start"]), Text(item["end"]),
|
||||
Number(item["fee"]), Number(item["fullScore"]), Text(item["passRule"]), Number(item["passValue"]),
|
||||
OptionalNumber(item["passScore"]), (int)NumberOr(item["order"], index + 1))).ToArray());
|
||||
}
|
||||
|
||||
private static JsonObject ExamJson(ManagedExam item) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["code"] = item.Code,
|
||||
["name"] = item.Name,
|
||||
["description"] = item.Description,
|
||||
["registrationStart"] = item.RegistrationStart,
|
||||
["registrationEnd"] = item.RegistrationEnd,
|
||||
["examStart"] = item.ExamStart,
|
||||
["examEnd"] = item.ExamEnd,
|
||||
["admitDownloadStart"] = item.AdmitDownloadStart,
|
||||
["admitDownloadEnd"] = item.AdmitDownloadEnd,
|
||||
["location"] = item.Location,
|
||||
["passPolicy"] = item.PassPolicy,
|
||||
["passValue"] = item.PassValue,
|
||||
["status"] = item.Status,
|
||||
["archivedAt"] = JsonValue.Create(item.ArchivedAt),
|
||||
["archivedBy"] = JsonValue.Create(item.ArchivedBy),
|
||||
["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray()),
|
||||
["createdAt"] = item.CreatedAt
|
||||
};
|
||||
|
||||
private static JsonObject SubjectJson(ManagedSubject item) => new()
|
||||
{
|
||||
["id"] = item.Id,
|
||||
["name"] = item.Name,
|
||||
["date"] = item.Date,
|
||||
["start"] = item.Start,
|
||||
["end"] = item.End,
|
||||
["fee"] = item.Fee,
|
||||
["fullScore"] = item.FullScore,
|
||||
["passRule"] = item.PassRule,
|
||||
["passValue"] = item.PassValue,
|
||||
["passScore"] = JsonValue.Create(item.PassScore),
|
||||
["order"] = item.Order
|
||||
};
|
||||
|
||||
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(user, null)
|
||||
: ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作"));
|
||||
}
|
||||
|
||||
private static bool JsBoolean(JsonNode? node) { if (node is null) return false; if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text)) return text.Length > 0; if (node is JsonValue numberValue && numberValue.TryGetValue<double>(out var number)) return number != 0 && !double.IsNaN(number); return true; }
|
||||
private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
|
||||
private static string? OptionalText(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
private static double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN;
|
||||
private static double NumberOr(JsonNode? node, double fallback) => node is null ? fallback : Number(node);
|
||||
private static double? OptionalNumber(JsonNode? node) => node is null ? null : Number(node);
|
||||
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
|
||||
private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
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) => Result(200, body);
|
||||
private static AdminEndpointResult Result(int status, JsonObject body) => new(status, body);
|
||||
private static AdminEndpointResult Error(int status, string message) => Result(status, new JsonObject { ["ok"] = false, ["message"] = message });
|
||||
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Data.Common;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Administration;
|
||||
|
||||
internal sealed class AdminExamRepository(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
public Task CreateAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken) =>
|
||||
SaveAsync(exam, replaceSubjects: true, isNew: true, audit, cancellationToken);
|
||||
|
||||
public Task UpdateAsync(ManagedExam exam, bool replaceSubjects, AdminAuditEntry audit, CancellationToken cancellationToken) =>
|
||||
SaveAsync(exam, replaceSubjects, isNew: false, audit, cancellationToken);
|
||||
|
||||
public async Task ArchiveAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, new SqlOperation(
|
||||
"UPDATE exams SET status = @status, archived_at = @archivedAt, archived_by = @archivedBy WHERE id = @id AND archived_at IS NULL",
|
||||
[new("@status", exam.Status), new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@id", exam.Id)]), cancellationToken);
|
||||
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync(
|
||||
ManagedExam exam,
|
||||
bool replaceSubjects,
|
||||
bool isNew,
|
||||
AdminAuditEntry audit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var examOperation = isNew
|
||||
? new SqlOperation(
|
||||
"""
|
||||
INSERT INTO exams (
|
||||
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
|
||||
admit_download_start, admit_download_end, location, pass_policy, pass_value, status,
|
||||
archived_at, archived_by, created_at
|
||||
) VALUES (
|
||||
@id, @code, @name, @description, @registrationStart, @registrationEnd, @examStart, @examEnd,
|
||||
@admitDownloadStart, @admitDownloadEnd, @location, @passPolicy, @passValue, @status,
|
||||
@archivedAt, @archivedBy, @createdAt
|
||||
)
|
||||
""",
|
||||
ExamParameters(exam))
|
||||
: new SqlOperation(
|
||||
"""
|
||||
UPDATE exams SET code = @code, name = @name, description = @description,
|
||||
registration_start = @registrationStart, registration_end = @registrationEnd,
|
||||
exam_start = @examStart, exam_end = @examEnd,
|
||||
admit_download_start = @admitDownloadStart, admit_download_end = @admitDownloadEnd,
|
||||
location = @location, pass_policy = @passPolicy, pass_value = @passValue, status = @status
|
||||
WHERE id = @id
|
||||
""",
|
||||
ExamParameters(exam));
|
||||
await ExecuteAsync(connection, transaction, examOperation, cancellationToken);
|
||||
if (replaceSubjects)
|
||||
{
|
||||
if (!isNew)
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, new SqlOperation(
|
||||
"DELETE FROM exam_subjects WHERE exam_id = @examId",
|
||||
[new("@examId", exam.Id)]), cancellationToken);
|
||||
}
|
||||
foreach (var subject in exam.Subjects)
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, new SqlOperation(
|
||||
"""
|
||||
INSERT INTO exam_subjects (
|
||||
id, exam_id, name, subject_date, start_time, end_time, fee, full_score,
|
||||
pass_score, pass_rule, pass_value, position
|
||||
) VALUES (
|
||||
@id, @examId, @name, @date, @start, @end, @fee, @fullScore,
|
||||
@passScore, @passRule, @passValue, @position
|
||||
)
|
||||
""",
|
||||
[
|
||||
new("@id", subject.Id), new("@examId", exam.Id), new("@name", subject.Name),
|
||||
new("@date", subject.Date), new("@start", subject.Start), new("@end", subject.End),
|
||||
new("@fee", subject.Fee), new("@fullScore", subject.FullScore),
|
||||
new("@passScore", subject.PassScore ?? 0),
|
||||
new("@passRule", subject.PassRule == "rank_percent" ? "score_ratio" : subject.PassRule),
|
||||
new("@passValue", subject.PassValue), new("@position", subject.Order)
|
||||
]), cancellationToken);
|
||||
}
|
||||
}
|
||||
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SqlParameterValue> ExamParameters(ManagedExam exam) =>
|
||||
[
|
||||
new("@id", exam.Id), new("@code", exam.Code), new("@name", exam.Name),
|
||||
new("@description", exam.Description), new("@registrationStart", exam.RegistrationStart),
|
||||
new("@registrationEnd", exam.RegistrationEnd), new("@examStart", exam.ExamStart),
|
||||
new("@examEnd", exam.ExamEnd), new("@admitDownloadStart", exam.AdmitDownloadStart),
|
||||
new("@admitDownloadEnd", exam.AdmitDownloadEnd), new("@location", exam.Location),
|
||||
new("@passPolicy", exam.PassPolicy), new("@passValue", exam.PassValue), new("@status", exam.Status),
|
||||
new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@createdAt", exam.CreatedAt)
|
||||
];
|
||||
|
||||
private static Task InsertAuditAsync(DbConnection connection, DbTransaction transaction, AdminAuditEntry audit, CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(connection, transaction, new SqlOperation(
|
||||
"""
|
||||
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,
|
||||
SqlOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = operation.Sql;
|
||||
foreach (var item in operation.Parameters)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = item.Name;
|
||||
parameter.Value = item.Value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record SqlOperation(string Sql, IReadOnlyList<SqlParameterValue> Parameters);
|
||||
private sealed record SqlParameterValue(string Name, object? Value);
|
||||
}
|
||||
|
||||
internal sealed record ManagedExam(
|
||||
string Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string Description,
|
||||
string RegistrationStart,
|
||||
string RegistrationEnd,
|
||||
string ExamStart,
|
||||
string ExamEnd,
|
||||
string AdmitDownloadStart,
|
||||
string AdmitDownloadEnd,
|
||||
string Location,
|
||||
string PassPolicy,
|
||||
double PassValue,
|
||||
string Status,
|
||||
string? ArchivedAt,
|
||||
string? ArchivedBy,
|
||||
string CreatedAt,
|
||||
IReadOnlyList<ManagedSubject> Subjects);
|
||||
|
||||
internal sealed record ManagedSubject(
|
||||
string Id,
|
||||
string Name,
|
||||
string Date,
|
||||
string Start,
|
||||
string End,
|
||||
double Fee,
|
||||
double FullScore,
|
||||
string PassRule,
|
||||
double PassValue,
|
||||
double? PassScore,
|
||||
int Order);
|
||||
@@ -10,7 +10,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool NativeOperationalReadsEnabled = false,
|
||||
bool NativeCandidateManagementEnabled = false,
|
||||
bool NativeRegistrationPaymentWritesEnabled = false,
|
||||
bool NativeWorkflowOperationsEnabled = false)
|
||||
bool NativeWorkflowOperationsEnabled = false,
|
||||
bool NativeExamManagementEnabled = false)
|
||||
{
|
||||
public static AdminMigrationOptions FromEnvironment(
|
||||
bool configuredNativeReadsEnabled,
|
||||
@@ -24,7 +25,8 @@ public sealed record AdminMigrationOptions(
|
||||
bool configuredNativeOperationalReadsEnabled = false,
|
||||
bool configuredNativeCandidateManagementEnabled = false,
|
||||
bool configuredNativeRegistrationPaymentWritesEnabled = false,
|
||||
bool configuredNativeWorkflowOperationsEnabled = false)
|
||||
bool configuredNativeWorkflowOperationsEnabled = false,
|
||||
bool configuredNativeExamManagementEnabled = false)
|
||||
{
|
||||
var readsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||
@@ -57,7 +59,10 @@ public sealed record AdminMigrationOptions(
|
||||
var workflowOperationsEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
|
||||
configuredNativeWorkflowOperationsEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled) && !readsEnabled)
|
||||
var examManagementEnabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
|
||||
configuredNativeExamManagementEnabled);
|
||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled) && !readsEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||
@@ -67,7 +72,7 @@ public sealed record AdminMigrationOptions(
|
||||
throw new InvalidOperationException(
|
||||
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
|
||||
}
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled;
|
||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled;
|
||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -83,7 +88,7 @@ public sealed record AdminMigrationOptions(
|
||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||
}
|
||||
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled);
|
||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled);
|
||||
}
|
||||
|
||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||
|
||||
@@ -63,6 +63,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<AdminOperationalSnapshotLoader>();
|
||||
services.AddScoped<AdminWorkflowResultSnapshotLoader>();
|
||||
services.AddScoped<AdminWorkflowRepository>();
|
||||
services.AddScoped<AdminExamRepository>();
|
||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||
@@ -73,6 +74,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
|
||||
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
|
||||
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
|
||||
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
|
||||
services.AddSingleton<NoticeContentFormatter>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
|
||||
@@ -129,6 +129,16 @@ public static class NativeAdminReadEndpoints
|
||||
endpoints.MapPatch("/api/admin/workflow-instances/{instanceId}/supervise", (HttpContext context, string instanceId, JsonObject body, IAdminWorkflowService service, CancellationToken cancellationToken) =>
|
||||
Execute(context, service.SuperviseAsync(Token(context), instanceId, body, cancellationToken)));
|
||||
}
|
||||
|
||||
if (options.NativeExamManagementEnabled)
|
||||
{
|
||||
endpoints.MapPost("/api/admin/exams", (HttpContext context, JsonObject body, IAdminExamManagementService service, CancellationToken cancellationToken) =>
|
||||
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
|
||||
endpoints.MapPatch("/api/admin/exams/{examId}", (HttpContext context, string examId, JsonObject body, IAdminExamManagementService service, CancellationToken cancellationToken) =>
|
||||
Execute(context, service.UpdateAsync(Token(context), examId, body, cancellationToken)));
|
||||
endpoints.MapPost("/api/admin/exams/{examId}/archive", (HttpContext context, string examId, IAdminExamManagementService service, CancellationToken cancellationToken) =>
|
||||
Execute(context, service.ArchiveAsync(Token(context), examId, cancellationToken)));
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"),
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeCandidateManagementEnabled"),
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeRegistrationPaymentWritesEnabled"),
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled"));
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled"),
|
||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeExamManagementEnabled"));
|
||||
builder.Services.AddEisInfrastructure(
|
||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
||||
@@ -118,6 +119,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
||||
nativeCandidateManagementEnabled = adminMigrationOptions.NativeCandidateManagementEnabled,
|
||||
nativeRegistrationPaymentWritesEnabled = adminMigrationOptions.NativeRegistrationPaymentWritesEnabled,
|
||||
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
|
||||
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
|
||||
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
||||
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
||||
: [])
|
||||
@@ -152,6 +154,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
||||
.Concat(adminMigrationOptions.NativeWorkflowOperationsEnabled
|
||||
? new[] { "GET workflow-instances", "PATCH workflow transfer/supervise" }
|
||||
: [])
|
||||
.Concat(adminMigrationOptions.NativeExamManagementEnabled
|
||||
? new[] { "POST/PATCH exams", "POST exam archive" }
|
||||
: [])
|
||||
.ToArray()
|
||||
},
|
||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"NativeOperationalReadsEnabled": false,
|
||||
"NativeCandidateManagementEnabled": false,
|
||||
"NativeRegistrationPaymentWritesEnabled": false,
|
||||
"NativeWorkflowOperationsEnabled": false
|
||||
"NativeWorkflowOperationsEnabled": false,
|
||||
"NativeExamManagementEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
Reference in New Issue
Block a user