原生成绩查询、科目/总分排名、达线判断、等级计算及成绩单防伪码:[CandidateService.Results.cs (line 11)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs:11)
原生成绩复议提交,工作流、分配人与审计日志同事务写入。 原生准考证下载窗口校验及 HTML 文书生成:[CandidateAdmitCardRenderer.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmitCardRenderer.cs:9) 三个路由已切换到 ASP.NET Core:[NativeCandidateEndpoints.cs (line 61)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:61) 更新迁移说明:[MIGRATION.md (line 50)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:50)
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Candidate;
|
||||
using Eis.Infrastructure.Security;
|
||||
using QRCoder;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed partial class CandidateService
|
||||
{
|
||||
public async Task<CandidateEndpointResult> 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 snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken, includeCohort: true);
|
||||
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<JsonObject>();
|
||||
foreach (var registration in snapshot.Registrations)
|
||||
{
|
||||
var summary = ExamResultSummary(snapshot, registration);
|
||||
if (summary is null || summary["publishedSubjects"]?.GetValue<int>() is not > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var exam = snapshot.Exams.First(item => item.Id == registration.ExamId);
|
||||
var reportResults = snapshot.AllResults
|
||||
.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 Success(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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<CandidateEndpointResult> 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 rank = ResultRankInfo(snapshot, result);
|
||||
var pass = SubjectPassEvaluation(snapshot, result, subject);
|
||||
var appealInstance = FindInstance(snapshot, "score_appeal", result.Id);
|
||||
var appeal = WorkflowView(snapshot, appealInstance);
|
||||
if (appeal is not null)
|
||||
{
|
||||
appeal["reason"] = appeal["actions"]?.AsArray().OfType<JsonObject>()
|
||||
.FirstOrDefault(item => item["action"]?.GetValue<string>() == "submit")?["note"]?.GetValue<string>() ?? string.Empty;
|
||||
}
|
||||
|
||||
var json = ResultJson(result);
|
||||
json["rank"] = JsonValue.Create(rank.Rank);
|
||||
json["cohortSize"] = rank.CohortSize;
|
||||
json["rankPercent"] = JsonValue.Create(rank.RankPercent);
|
||||
json["grade"] = rank.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(pass.PassScore);
|
||||
json["cutoffRank"] = JsonValue.Create(pass.CutoffRank);
|
||||
json["passText"] = SubjectPassText(subject);
|
||||
json["qualified"] = JsonValue.Create(pass.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.AllResults
|
||||
.Where(item => item.RegistrationId == registration.Id && item.Published)
|
||||
.ToArray();
|
||||
var resultsBySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal);
|
||||
var complete = subjects.Length > 0 && subjects.All(item => resultsBySubject.ContainsKey(item.Id));
|
||||
var total = subjects.Sum(item => resultsBySubject.GetValueOrDefault(item.Id)?.Score ?? 0);
|
||||
var fullScore = subjects.Sum(item => item.FullScore);
|
||||
var scoreRatio = fullScore > 0 ? total / fullScore * 100 : 0;
|
||||
var policy = exam.PassPolicy == "score_ratio" ? "rank_percent" : exam.PassPolicy;
|
||||
if (policy.Length == 0)
|
||||
{
|
||||
policy = "rank_percent";
|
||||
}
|
||||
|
||||
var value = exam.PassValue;
|
||||
bool? qualified = null;
|
||||
int? rank = null;
|
||||
int? cohortSize = null;
|
||||
if (complete && policy == "fixed_score")
|
||||
{
|
||||
qualified = total >= value;
|
||||
}
|
||||
else if (complete && policy == "subject_scores")
|
||||
{
|
||||
qualified = subjects.All(item => SubjectPassEvaluation(snapshot, resultsBySubject[item.Id], item).Qualified != false);
|
||||
}
|
||||
else if (complete && policy == "rank_percent")
|
||||
{
|
||||
var subjectKey = string.Join('|', registration.SubjectIds.Order(StringComparer.Ordinal));
|
||||
var totals = snapshot.AllRegistrations
|
||||
.Where(item => item.Status == "approved" && item.ExamId == exam.Id &&
|
||||
string.Join('|', item.SubjectIds.Order(StringComparer.Ordinal)) == subjectKey)
|
||||
.Select(item => CompleteTotal(snapshot, exam, item))
|
||||
.Where(item => item.HasValue)
|
||||
.Select(item => item!.Value)
|
||||
.Order()
|
||||
.ToArray();
|
||||
cohortSize = totals.Length;
|
||||
rank = 1 + totals.Count(item => item > total);
|
||||
qualified = rank <= Math.Max(1, (int)Math.Ceiling(cohortSize.Value * value / 100));
|
||||
}
|
||||
|
||||
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(qualified),
|
||||
["rank"] = JsonValue.Create(rank),
|
||||
["cohortSize"] = JsonValue.Create(cohortSize)
|
||||
};
|
||||
}
|
||||
|
||||
private static double? CompleteTotal(
|
||||
CandidateReadSnapshot snapshot,
|
||||
CandidateExam exam,
|
||||
CandidateRegistration registration)
|
||||
{
|
||||
var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray();
|
||||
var scores = snapshot.AllResults
|
||||
.Where(item => item.RegistrationId == registration.Id && item.Published)
|
||||
.ToDictionary(item => item.SubjectId, item => item.Score, StringComparer.Ordinal);
|
||||
return subjects.Length > 0 && subjects.All(item => scores.ContainsKey(item.Id))
|
||||
? subjects.Sum(item => scores[item.Id])
|
||||
: null;
|
||||
}
|
||||
|
||||
private static ResultRank ResultRankInfo(CandidateReadSnapshot snapshot, CandidateResult result)
|
||||
{
|
||||
var scores = snapshot.AllResults
|
||||
.Where(item => item.Published && item.SubjectId == result.SubjectId)
|
||||
.Select(item => item.Score)
|
||||
.Order()
|
||||
.ToArray();
|
||||
var cohortSize = scores.Length;
|
||||
var rank = 1 + scores.Count(item => item > result.Score);
|
||||
var rankPercent = cohortSize == 0
|
||||
? 0
|
||||
: Math.Round(rank / (double)cohortSize * 100, 2, MidpointRounding.AwayFromZero);
|
||||
return new ResultRank(rank, cohortSize, rankPercent, GradeForRank(rank, cohortSize));
|
||||
}
|
||||
|
||||
private static PassEvaluation SubjectPassEvaluation(
|
||||
CandidateReadSnapshot snapshot,
|
||||
CandidateResult result,
|
||||
CandidateSubject? subject)
|
||||
{
|
||||
var rank = ResultRankInfo(snapshot, result);
|
||||
var rule = subject?.PassRule ?? "fixed_score";
|
||||
if (rule == "none")
|
||||
{
|
||||
return new PassEvaluation(null, null, null, rank);
|
||||
}
|
||||
|
||||
if (rule == "rank_percent")
|
||||
{
|
||||
var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (subject?.PassValue ?? 60) / 100));
|
||||
return new PassEvaluation(rank.Rank <= cutoff, null, cutoff, rank);
|
||||
}
|
||||
|
||||
var passScore = Math.Round(subject?.PassValue ?? subject?.PassScore ?? 0, 2, MidpointRounding.AwayFromZero);
|
||||
return new PassEvaluation(result.Score >= passScore, passScore, null, rank);
|
||||
}
|
||||
|
||||
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 GradeForRank(int rank, int cohortSize)
|
||||
{
|
||||
int Cutoff(double ratio) => Math.Max(1, (int)Math.Ceiling(cohortSize * ratio));
|
||||
if (rank <= Cutoff(0.1)) return "A+";
|
||||
if (rank <= Cutoff(0.25)) return "A";
|
||||
if (rank <= Cutoff(0.5)) return "B+";
|
||||
if (rank <= Cutoff(0.7)) return "B";
|
||||
if (rank <= Cutoff(0.9)) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
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))}";
|
||||
}
|
||||
|
||||
private sealed record ResultRank(int Rank, int CohortSize, double RankPercent, string Grade);
|
||||
|
||||
private sealed record PassEvaluation(bool? Qualified, double? PassScore, int? CutoffRank, ResultRank Rank);
|
||||
}
|
||||
Reference in New Issue
Block a user