using System.Globalization; using System.Text.Json.Nodes; using Eis.Application.Candidate; using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Security; using QRCoder; namespace Eis.Infrastructure.Candidate; internal sealed partial class CandidateService { public async Task GetResultsAsync( string sessionToken, string verificationBaseUrl, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var user = context.User!; var profile = context.Profile!; var body = await cache.RememberJsonAsync( "results", $"candidate:{Uri.EscapeDataString(user.Id)}", () => LoadResultsAsync(user, profile, verificationBaseUrl, cancellationToken), PositiveInteger(Environment.GetEnvironmentVariable("REDIS_RESULTS_CACHE_TTL_SECONDS"), 86400)); return Success(body); } private async Task LoadResultsAsync( AuthenticationUser user, JsonObject profile, string verificationBaseUrl, CancellationToken cancellationToken) { var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); var registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal); var results = snapshot.Results .Where(item => item.Published && registrationById.ContainsKey(item.RegistrationId)) .Select(item => ResultView(snapshot, item, registrationById[item.RegistrationId])) .ToArray(); var summaries = new List(); foreach (var registration in snapshot.Registrations) { var summary = ExamResultSummary(snapshot, registration); if (summary is null || summary["publishedSubjects"]?.GetValue() is not > 0) { continue; } var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); var reportResults = snapshot.Results .Where(item => item.RegistrationId == registration.Id && item.Published) .Select(item => new ScoreSignatureItem(item.SubjectId, item.Score, item.PublishedAt, item.UpdatedAt)); var code = documentCodes.ScoreReportCode(registration.Id, registration.UserId, exam.Id, reportResults); summary["verificationCode"] = code; summary["verificationQr"] = CreateQrCodeDataUrl($"{verificationBaseUrl}/#verify/{Uri.EscapeDataString(code)}"); summaries.Add(summary); } return new JsonObject { ["ok"] = true, ["results"] = new JsonArray(results), ["summaries"] = new JsonArray(summaries.Select(item => (JsonNode)item).ToArray()), ["candidate"] = new JsonObject { ["name"] = Text(profile, "name").Length > 0 ? Text(profile, "name") : user.DisplayName, ["candidateNumber"] = user.CandidateNumber ?? string.Empty } }; } private static int PositiveInteger(string? value, int fallback) => int.TryParse(value, out var parsed) && parsed > 0 ? Math.Min(parsed, 86400) : fallback; public async Task CreateScoreAppealAsync( string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var user = context.User!; var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); var result = snapshot.Results.FirstOrDefault(item => item.Id == resultId && item.Published); var registration = result is null ? null : snapshot.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId); if (result is null || registration is null) { return Error(404, "已发布成绩不存在或不属于当前考生"); } var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); if (exam?.ArchivedAt is not null) { return Error(409, "该考试已归档,成绩及复议入口已永久锁定"); } if (FindInstance(snapshot, "score_appeal", result.Id) is { Status: "pending" }) { return Error(409, "该科成绩已有待处理复议,请勿重复提交"); } var reason = Clean(body, "reason", 500); if (reason.Length < 5) { return Error(400, "请至少填写 5 个字的复议理由"); } var workflowResult = CreateWorkflowSubmission( snapshot, "score_appeal", result.Id, context.Profile!, user.Id); if (workflowResult.Error is not null) { return workflowResult.Error; } var submission = workflowResult.Submission!; var action = submission.Action with { Note = reason }; var subject = exam?.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); var log = new CandidateAuditLog( Uid("log"), user.Id, "提交成绩复议", $"{exam?.Name ?? string.Empty} · {subject?.Name ?? string.Empty}", NowIso()); await writeRepository.CreateWorkflowAsync(submission.Instance, action, log, cancellationToken); var responseSnapshot = snapshot with { WorkflowActions = [.. snapshot.WorkflowActions, action] }; return new CandidateEndpointResult(201, new JsonObject { ["ok"] = true, ["workflow"] = WorkflowView(responseSnapshot, submission.Instance) }); } private static JsonObject ResultView( CandidateReadSnapshot snapshot, CandidateResult result, CandidateRegistration registration) { var exam = snapshot.Exams.First(item => item.Id == registration.ExamId); var subject = exam.Subjects.FirstOrDefault(item => item.Id == result.SubjectId); var appealInstance = FindInstance(snapshot, "score_appeal", result.Id); var appeal = WorkflowView(snapshot, appealInstance); if (appeal is not null) { appeal["reason"] = appeal["actions"]?.AsArray().OfType() .FirstOrDefault(item => item["action"]?.GetValue() == "submit")?["note"]?.GetValue() ?? string.Empty; } var json = ResultJson(result); json["rank"] = JsonValue.Create(result.Rank); json["cohortSize"] = result.CohortSize; json["rankPercent"] = JsonValue.Create(result.RankPercent); json["grade"] = result.Grade; json["examId"] = exam.Id; json["examName"] = exam.Name; json["examCode"] = exam.Code; json["examStart"] = exam.ExamStart; json["archivedAt"] = JsonValue.Create(exam.ArchivedAt); json["subjectName"] = subject?.Name ?? result.SubjectId; json["fullScore"] = subject?.FullScore ?? 150; json["passRule"] = subject?.PassRule ?? "fixed_score"; json["passValue"] = subject?.PassValue ?? subject?.PassScore; json["passScore"] = JsonValue.Create(result.PassScore); json["cutoffRank"] = JsonValue.Create(result.CutoffRank); json["passText"] = SubjectPassText(subject); json["qualified"] = JsonValue.Create(result.Qualified); json["appeal"] = appeal; return json; } private static JsonObject? ExamResultSummary( CandidateReadSnapshot snapshot, CandidateRegistration registration) { if (registration.Status != "approved") { return null; } var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); if (exam is null) { return null; } var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray(); var published = snapshot.Results .Where(item => item.RegistrationId == registration.Id && item.Published) .ToArray(); var stored = snapshot.ResultSummaries.FirstOrDefault(item => item.RegistrationId == registration.Id); var complete = stored?.Complete ?? false; var total = stored?.TotalScore ?? 0; var fullScore = stored?.FullScore ?? subjects.Sum(item => item.FullScore); var scoreRatio = stored?.ScoreRatio ?? 0; var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy; if (policy.Length == 0) { policy = "rank_percent"; } var value = exam.PassValue; return new JsonObject { ["examId"] = exam.Id, ["examName"] = exam.Name, ["examCode"] = exam.Code, ["examStart"] = exam.ExamStart, ["archivedAt"] = JsonValue.Create(exam.ArchivedAt), ["complete"] = complete, ["publishedSubjects"] = published.Length, ["subjectCount"] = subjects.Length, ["featureScore"] = registration.FeatureScore, ["total"] = total, ["fullScore"] = fullScore, ["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero), ["passPolicy"] = policy, ["passValue"] = value, ["qualified"] = JsonValue.Create(stored?.Qualified), ["rank"] = JsonValue.Create(stored?.Rank), ["cohortSize"] = JsonValue.Create(stored?.CohortSize), ["rankPercent"] = JsonValue.Create(stored?.RankPercent), ["cutoffRank"] = JsonValue.Create(stored?.CutoffRank) }; } private static string SubjectPassText(CandidateSubject? subject) => (subject?.PassRule ?? "fixed_score") switch { "none" => "不设单科线", "rank_percent" => $"本科排名前 {FormatNumber(subject?.PassValue ?? 60)}% 达线", _ => $"固定 {FormatNumber(Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero))} 分" }; private static string FormatNumber(double value) => value.ToString("0.#############################", CultureInfo.InvariantCulture); private static string CreateQrCodeDataUrl(string value) { using var generator = new QRCodeGenerator(); using var data = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.M); using var qrCode = new PngByteQRCode(data); return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}"; } }