新增 result_statistics、registration_result_summaries 两张统计表,现有数据库首次启动时自动回填。 SQL 查询首先通过 examId 限定范围。 科目成绩只排序一次,并使用 registration/subject 字典组织数据。 汇总接口与成绩明细接口已拆分,明细、成绩录入名单和特征分名单均改为服务端分页。 所有成绩写入口统一限制为:0—满分,且只能是整数或 .5,包括手工录入、批量录入、Excel 导入和复议改分。 前端也增加了相同的即时校验。 修复其他考试卡片显示 undefined 的问题。
266 lines
11 KiB
C#
266 lines
11 KiB
C#
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<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 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<JsonObject> 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<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.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<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 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(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))}";
|
|
}
|
|
|
|
}
|