成绩提交时按考试一次性计算并存储单科名次、排名百分比、等级、合格状态、总分排名及整场合格状态。

新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。
SQL 查询首先通过 examId 限定范围。
科目成绩只排序一次,并使用 registration/subject 字典组织数据。
汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。
所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。
前端也增加了相同的即时校验。
修复其他考试卡片显示 undefined 的问题。
This commit is contained in:
2026-07-23 19:21:56 +08:00 Unverified
parent b139834396
commit cde754621a
20 changed files with 1365 additions and 489 deletions
@@ -36,7 +36,7 @@ internal sealed partial class CandidateService
string verificationBaseUrl,
CancellationToken cancellationToken)
{
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken, includeCohort: true);
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))
@@ -52,7 +52,7 @@ internal sealed partial class CandidateService
}
var exam = snapshot.Exams.First(item => item.Id == registration.ExamId);
var reportResults = snapshot.AllResults
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);
@@ -157,8 +157,6 @@ internal sealed partial class CandidateService
{
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)
@@ -168,10 +166,10 @@ internal sealed partial class CandidateService
}
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["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;
@@ -181,10 +179,10 @@ internal sealed partial class CandidateService
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["passScore"] = JsonValue.Create(result.PassScore);
json["cutoffRank"] = JsonValue.Create(result.CutoffRank);
json["passText"] = SubjectPassText(subject);
json["qualified"] = JsonValue.Create(pass.Qualified);
json["qualified"] = JsonValue.Create(result.Qualified);
json["appeal"] = appeal;
return json;
}
@@ -205,14 +203,14 @@ internal sealed partial class CandidateService
}
var subjects = exam.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray();
var published = snapshot.AllResults
var published = snapshot.Results
.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 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)
{
@@ -220,32 +218,6 @@ internal sealed partial class CandidateService
}
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
{
@@ -263,63 +235,14 @@ internal sealed partial class CandidateService
["scoreRatio"] = Math.Round(scoreRatio, 2, MidpointRounding.AwayFromZero),
["passPolicy"] = policy,
["passValue"] = value,
["qualified"] = JsonValue.Create(qualified),
["rank"] = JsonValue.Create(rank),
["cohortSize"] = JsonValue.Create(cohortSize)
["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 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" => "不设单科线",
@@ -327,16 +250,6 @@ internal sealed partial class CandidateService
_ => $"固定 {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);
@@ -349,7 +262,4 @@ internal sealed partial class CandidateService
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);
}