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 PassPolicies = ["fixed_score", "rank_percent", "subject_scores", "none"]; private static readonly HashSet SubjectPassRules = ["fixed_score", "rank_percent", "none"]; public async Task 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 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 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 NormalizeSubjects(JsonNode? input, string examStart) { IEnumerable source = input switch { JsonArray array => array, _ => Text(input).Split([',', ','], StringSplitOptions.None).Select(value => (JsonNode?)JsonValue.Create(value)) }; var output = new List(); 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 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 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(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue(out var text)) return text.Length > 0; if (node is JsonValue numberValue && numberValue.TryGetValue(out var number)) return number != 0 && !double.IsNaN(number); return true; } private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : node?.ToString() ?? ""; private static string? OptionalText(JsonNode? node) => node is JsonValue value && value.TryGetValue(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 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); } }