Files
EIS-dotnet/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs
T
biss 25501651e1 已完成第十一批迁移:考试维护功能已原生化到 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)。
2026-07-23 10:50:24 +08:00

343 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); }
}