按 examId 延迟加载成绩工作区,未选择考试时不执行排名计算

单条及批量成绩草稿、发布
特长分单条及批量维护
成绩复议多级审批、终审改分、归档锁定
排名、等级、单科达线和考试汇总
成绩台账 Excel 导出
管理范围、超级管理员写权限及事务审计
独立开关 ADMIN_NATIVE_RESULTS_ENABLED
This commit is contained in:
2026-07-23 12:16:01 +08:00 Unverified
parent 8f83adbc1e
commit d47c614b53
15 changed files with 1523 additions and 15 deletions
@@ -0,0 +1,15 @@
using System.Text.Json.Nodes;
namespace Eis.Application.Administration;
public interface IAdminResultService
{
Task<AdminEndpointResult> GetAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveFeatureBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> RefreshCacheAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAsync(string sessionToken, string examId, CancellationToken cancellationToken);
}
@@ -12,7 +12,8 @@ public sealed record AdminMigrationOptions(
bool NativeRegistrationPaymentWritesEnabled = false,
bool NativeWorkflowOperationsEnabled = false,
bool NativeExamManagementEnabled = false,
bool NativeArrangementsEnabled = false)
bool NativeArrangementsEnabled = false,
bool NativeResultsEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -28,7 +29,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeRegistrationPaymentWritesEnabled = false,
bool configuredNativeWorkflowOperationsEnabled = false,
bool configuredNativeExamManagementEnabled = false,
bool configuredNativeArrangementsEnabled = false)
bool configuredNativeArrangementsEnabled = false,
bool configuredNativeResultsEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -67,17 +69,20 @@ public sealed record AdminMigrationOptions(
var arrangementsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
configuredNativeArrangementsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled) && !readsEnabled)
var resultsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"),
configuredNativeResultsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
if ((candidateManagementEnabled || registrationPaymentWritesEnabled) && !operationalReadsEnabled)
if ((candidateManagementEnabled || registrationPaymentWritesEnabled || resultsEnabled) && !operationalReadsEnabled)
{
throw new InvalidOperationException(
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -93,7 +98,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -0,0 +1,80 @@
using ClosedXML.Excel;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminResultExportRow(
string CandidateNumber,
string CardNumber,
string CandidateName,
string SchoolName,
string ClassName,
string ExamCode,
string ExamName,
string SubjectName,
object FullScore,
string PassRule,
object PassScore,
object Score,
object Rank,
object RankPercent,
string Qualified,
string Grade,
string Published,
string UpdatedAt);
internal static class AdminResultExporter
{
private static readonly string[] Headers =
[
"报名号*", "准考证号(只读参考)", "姓名(只读参考)", "学校(只读参考)", "班级(只读参考)",
"考试代码*", "考试名称(只读参考)", "科目*", "科目满分(只读参考)", "单科及格规则(只读参考)",
"实际及格分(只读参考)", "成绩*", "本科排名(导出计算)", "排名百分位(导出计算)",
"单科达线(导出计算)", "排名等级(自动计算)", "发布状态*", "更新时间(只读参考)"
];
public static byte[] Build(IReadOnlyList<AdminResultExportRow> rows)
{
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("成绩");
sheet.Cell(1, 1).Value = "考试成绩台账";
sheet.Range(1, 1, 1, Headers.Length).Merge();
sheet.Range(1, 1, 1, Headers.Length).Style
.Font.SetBold().Font.SetFontSize(16).Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(XLColor.FromHtml("#182552"));
for (var index = 0; index < Headers.Length; index++)
{
sheet.Cell(2, index + 1).Value = Headers[index];
}
sheet.Range(2, 1, 2, Headers.Length).Style
.Font.SetBold().Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(XLColor.FromHtml("#245365"));
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
var item = rows[rowIndex];
object[] values =
[
item.CandidateNumber, item.CardNumber, item.CandidateName, item.SchoolName, item.ClassName,
item.ExamCode, item.ExamName, item.SubjectName, item.FullScore, item.PassRule, item.PassScore,
item.Score, item.Rank, item.RankPercent, item.Qualified, item.Grade, item.Published, item.UpdatedAt
];
for (var column = 0; column < values.Length; column++)
{
sheet.Cell(rowIndex + 3, column + 1).Value = XLCellValue.FromObject(values[column]);
}
}
sheet.SheetView.FreezeRows(2);
sheet.RangeUsed()?.SetAutoFilter();
double[] widths =
[
24, 22, 14, 22, 16, 18, 26, 14, 16, 22, 18, 12, 16, 18, 16, 18, 14, 22
];
for (var index = 0; index < widths.Length; index++)
{
sheet.Column(index + 1).Width = widths[index];
}
sheet.Columns(9, 14).Style.NumberFormat.Format = "0.00";
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
}
@@ -0,0 +1,197 @@
using System.Data.Common;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record ResultWrite(AdminWorkflowResult Result, bool IsNew, AdminAuditEntry Audit);
internal sealed record FeatureScoreWrite(string RegistrationId, double Score, AdminAuditEntry Audit);
internal sealed class AdminResultRepository(IRelationalConnectionFactory connectionFactory)
{
public Task SaveAsync(ResultWrite entry, CancellationToken cancellationToken) =>
SaveManyAsync([entry], cancellationToken);
public async Task SaveManyAsync(IReadOnlyList<ResultWrite> entries, CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
foreach (var entry in entries)
{
var result = entry.Result;
await ExecuteAsync(
connection,
transaction,
entry.IsNew
? """
INSERT INTO results (
id, registration_id, subject_id, score, grade, published, updated_at, published_at
) VALUES (
@id, @registrationId, @subjectId, @score, @grade, @published, @updatedAt, @publishedAt
)
"""
: """
UPDATE results SET score = @score, grade = @grade, published = @published,
updated_at = @updatedAt, published_at = @publishedAt WHERE id = @id
""",
[
new("@id", result.Id),
new("@registrationId", result.RegistrationId),
new("@subjectId", result.SubjectId),
new("@score", result.Score),
new("@grade", result.Grade),
new("@published", result.Published ? 1 : 0),
new("@updatedAt", result.UpdatedAt),
new("@publishedAt", result.PublishedAt)
],
cancellationToken);
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
}
}, cancellationToken);
}
public async Task SaveFeatureScoresAsync(
IReadOnlyList<FeatureScoreWrite> entries,
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
foreach (var entry in entries)
{
await ExecuteAsync(
connection,
transaction,
"UPDATE registrations SET feature_score = @score WHERE id = @id",
[new("@score", entry.Score), new("@id", entry.RegistrationId)],
cancellationToken);
await InsertAuditAsync(connection, transaction, entry.Audit, cancellationToken);
}
}, cancellationToken);
}
public async Task ReviewAppealAsync(
AccountWorkflowInstance instance,
AccountWorkflowAction action,
AdminWorkflowResult? result,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
await ExecuteAsync(
connection,
transaction,
"""
UPDATE workflow_instances SET status = @status, current_step = @currentStep,
assignee_id = @assigneeId, completed_at = @completedAt WHERE id = @id
""",
[
new("@status", instance.Status),
new("@currentStep", instance.CurrentStep),
new("@assigneeId", instance.AssigneeId),
new("@completedAt", instance.CompletedAt),
new("@id", instance.Id)
],
cancellationToken);
await ExecuteAsync(
connection,
transaction,
"""
INSERT INTO workflow_actions (
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
) VALUES (
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
)
""",
[
new("@id", action.Id),
new("@instanceId", action.InstanceId),
new("@actorId", action.ActorId),
new("@action", action.Action),
new("@note", action.Note),
new("@fromAssigneeId", action.FromAssigneeId),
new("@toAssigneeId", action.ToAssigneeId),
new("@createdAt", action.CreatedAt)
],
cancellationToken);
if (result is not null)
{
await ExecuteAsync(
connection,
transaction,
"""
UPDATE results SET score = @score, grade = @grade, updated_at = @updatedAt
WHERE id = @id
""",
[
new("@score", result.Score),
new("@grade", result.Grade),
new("@updatedAt", result.UpdatedAt),
new("@id", result.Id)
],
cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
}, cancellationToken);
}
private async Task TransactionAsync(
Func<DbConnection, DbTransaction, Task> operation,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await operation(connection, transaction);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static Task InsertAuditAsync(
DbConnection connection,
DbTransaction transaction,
AdminAuditEntry audit,
CancellationToken cancellationToken) =>
ExecuteAsync(
connection,
transaction,
"""
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
VALUES (@id, @actorId, @action, @detail, @createdAt)
""",
[
new("@id", audit.Id),
new("@actorId", audit.ActorId),
new("@action", audit.Action),
new("@detail", audit.Detail),
new("@createdAt", audit.CreatedAt)
],
cancellationToken);
private static async Task ExecuteAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
IReadOnlyList<SqlParameterValue> parameters,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = sql;
foreach (var item in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = item.Name;
parameter.Value = item.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private sealed record SqlParameterValue(string Name, object? Value);
}
@@ -0,0 +1,86 @@
using System.Globalization;
using System.Text.Json.Nodes;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminRankInfo(int? Rank, int CohortSize, double? RankPercent, string Grade);
internal sealed record AdminPassInfo(bool? Qualified, double? PassScore, int? CutoffRank, AdminRankInfo Rank);
internal static class AdminResultScoring
{
public static AdminRankInfo Rank(
IReadOnlyList<AdminWorkflowResult> results,
AdminWorkflowResult result,
double? scoreOverride = null)
{
var score = scoreOverride ?? result.Score;
if (!double.IsFinite(score) || result.SubjectId.Length == 0)
return new(null, 0, null, "");
var scores = results
.Where(item => item.SubjectId == result.SubjectId && item.Published)
.Select(item => item.Score)
.Order()
.ToArray();
var cohortSize = scores.Length + (result.Published ? 0 : 1);
if (cohortSize == 0) return new(null, 0, null, "");
var greater = scores.Count(item => item > score);
if (result.Published && result.Score > score) greater--;
var rank = 1 + Math.Max(0, greater);
var percent = Math.Round((double)rank / cohortSize * 100, 2, MidpointRounding.AwayFromZero);
return new(rank, cohortSize, percent, Grade(rank, cohortSize));
}
public static AdminPassInfo Pass(
IReadOnlyList<AdminWorkflowResult> results,
AdminWorkflowResult result,
JsonObject? subject,
double? scoreOverride = null)
{
var score = scoreOverride ?? result.Score;
var rank = Rank(results, result, score);
var rule = Text(subject?["passRule"]);
if (rule.Length == 0) rule = "fixed_score";
if (rule == "none") return new(null, null, null, rank);
if (rule == "rank_percent")
{
var cutoff = Math.Max(1, (int)Math.Ceiling(rank.CohortSize * (Number(subject?["passValue"]) ?? 60) / 100));
return new(rank.Rank <= cutoff, null, cutoff, rank);
}
var threshold = Math.Round(
Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0,
2,
MidpointRounding.AwayFromZero);
return new(score >= threshold, threshold, null, rank);
}
public static string PassText(JsonObject? subject)
{
var rule = Text(subject?["passRule"]);
if (rule == "none") return "不设单科线";
if (rule == "rank_percent")
return $"本科排名前 {Format(Number(subject?["passValue"]) ?? 60)}% 达线";
return $"固定 {Format(Math.Round(Number(subject?["passValue"] ?? subject?["passScore"]) ?? 0, 2, MidpointRounding.AwayFromZero))} 分";
}
public static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
private static double? Number(JsonNode? node) =>
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? value
: null;
private static string Text(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<string>(out var text)
? text
: node?.ToString() ?? "";
private static string Grade(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";
return rank <= Cutoff(0.9) ? "C" : "D";
}
}
@@ -0,0 +1,906 @@
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 AdminResultService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminOperationalSnapshotLoader operationalSnapshotLoader,
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
AdminAccountBatchSnapshotLoader directorySnapshotLoader,
AdminResultRepository repository) : IAdminResultService
{
public async Task<AdminEndpointResult> GetAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
if (context.Error is not null) return context.Error;
if (examId.Length == 0)
{
return Success(new JsonObject
{
["ok"] = true,
["selectedExamId"] = "",
["results"] = new JsonArray(),
["appeals"] = new JsonArray(),
["registrations"] = new JsonArray(),
["exams"] = new JsonArray(),
["resultCache"] = CacheStatus()
});
}
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var selectedExam = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (selectedExam is null) return Error(404, "请选择有效的考试后再读取成绩");
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
var scoped = operational.Registrations.Where(item =>
{
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
}).ToArray();
var selectedScoped = scoped.Where(item => item.ExamId == selectedExam.Id).ToArray();
if (Level(user) != "super" && selectedScoped.Length == 0)
return Error(404, "该考试不在当前管理范围内");
var approved = selectedScoped.Where(item => item.Status == "approved").ToArray();
var selectedIds = selectedScoped.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var examRegistrations = operational.Registrations
.Where(item => item.ExamId == selectedExam.Id && item.Status == "approved")
.ToArray();
var examIds = examRegistrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var examResults = allResults.Where(item => examIds.Contains(item.RegistrationId)).ToArray();
var rawScopedResults = examResults.Where(item => selectedIds.Contains(item.RegistrationId)).ToArray();
var resultViews = rawScopedResults
.Select(item => ResultJson(operational, directory, selectedExam, examResults, item))
.ToArray();
var resultById = resultViews.ToDictionary(
item => Text(item["id"]),
item => item,
StringComparer.Ordinal);
var appealViews = directory.Instances
.Where(item => item.BusinessType == "score_appeal" && resultById.ContainsKey(item.BusinessId))
.Select(item =>
{
var output = WorkflowJson(directory, item);
output["result"] = resultById[item.BusinessId].DeepClone();
output["reason"] = directory.Actions
.FirstOrDefault(action => action.InstanceId == item.Id && action.Action == "submit")?.Note ?? "";
return output;
})
.ToArray();
var exams = operational.Exams.Select(exam =>
{
var output = PublicExamJson(exam);
if (exam.Id != selectedExam.Id) return output;
var summaries = approved.Select(item => Summary(selectedExam, examRegistrations, examResults, item)).ToArray();
var enrolled = approved.Sum(item => SubjectIds(item).Count);
output["registrationCount"] = approved.Length;
output["enrolledSubjects"] = enrolled;
output["scored"] = rawScopedResults.Length;
output["published"] = rawScopedResults.Count(item => item.Published);
output["missing"] = Math.Max(0, enrolled - rawScopedResults.Length);
output["complete"] = summaries.Count(item => item.Complete);
output["qualified"] = summaries.Count(item => item.Complete && item.Qualified is true);
output["unqualified"] = summaries.Count(item => item.Complete && item.Qualified is false);
output["appeals"] = appealViews.Length;
return output;
}).ToArray();
var registrations = Level(user) == "super"
? approved.Select(item => RegistrationJson(operational, directory, item)).ToArray()
: [];
return Success(new JsonObject
{
["ok"] = true,
["selectedExamId"] = selectedExam.Id,
["results"] = new JsonArray(resultViews),
["appeals"] = new JsonArray(appealViews),
["registrations"] = new JsonArray(registrations),
["exams"] = new JsonArray(exams),
["resultCache"] = CacheStatus()
});
}
public async Task<AdminEndpointResult> SaveAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var registration = operational.Registrations.FirstOrDefault(item =>
item.Id == Text(body["registrationId"]) && item.Status == "approved");
if (registration is null) return Error(404, "已通过的报名记录不存在");
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
if (exam?.Data["archivedAt"] is not null) return Error(409, "该考试已归档,成绩已永久锁定");
var subjectId = Text(body["subjectId"]);
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == subjectId);
if (subject is null || !SubjectIds(registration).Contains(subjectId))
return Error(400, "该考生未报名此科目");
var score = Number(body["score"]);
var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore)
return Error(400, $"成绩必须在 0—{AdminResultScoring.Format(fullScore)} 之间");
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == subjectId);
var published = JsBoolean(body["published"]);
var now = NowIso();
var candidate = new AdminWorkflowResult(
existing?.Id ?? Uid("result"),
registration.Id,
subjectId,
score,
published ? "" : "待发布",
published,
now,
published ? existing?.PublishedAt ?? now : null);
Replace(allResults, candidate);
candidate = candidate with
{
Grade = published ? AdminResultScoring.Rank(allResults, candidate).Grade : "待发布"
};
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
await repository.SaveAsync(
new ResultWrite(
candidate,
existing is null,
Audit(
user,
published ? "发布成绩" : "保存成绩",
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) });
}
public async Task<AdminEndpointResult> SaveBulkAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var exam = operational.Exams.FirstOrDefault(item =>
item.Id == Clean(Text(body["examId"]), 64) && item.Data["archivedAt"] is null);
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == Clean(Text(body["subjectId"]), 64));
if (exam is null || subject is null) return Error(400, "请选择有效且未归档的考试科目");
var sourceRows = body["rows"] as JsonArray ?? [];
if (sourceRows.Count == 0) return Error(400, "没有需要保存的成绩");
var published = JsBoolean(body["published"]);
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
var prepared = new List<PreparedResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < sourceRows.Count; index++)
{
var row = sourceRows[index] as JsonObject ?? new JsonObject();
var registration = operational.Registrations.FirstOrDefault(item =>
item.Id == Text(row["registrationId"]) &&
item.ExamId == exam.Id &&
item.Status == "approved" &&
SubjectIds(item).Contains(Text(subject["id"])));
if (registration is null || !seen.Add(registration.Id))
return Error(400, $"第 {index + 1} 条考生成绩无效或重复");
var score = Number(row["score"]);
var fullScore = Number(subject["fullScore"]);
if (!double.IsFinite(score) || score < 0 || score > fullScore)
return Error(400, $"第 {index + 3} 行:成绩须在 0—{AdminResultScoring.Format(fullScore)} 之间;请返回预览修正后重试");
var existing = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var now = NowIso();
var result = new AdminWorkflowResult(
existing?.Id ?? Uid("result"),
registration.Id,
Text(subject["id"]),
score,
published ? "" : "待发布",
published,
now,
published ? existing?.PublishedAt ?? now : null);
Replace(allResults, result);
prepared.Add(new(registration, existing, result));
}
var writes = prepared.Select(item =>
{
var result = item.Result with
{
Grade = published ? AdminResultScoring.Rank(allResults, item.Result).Grade : "待发布"
};
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.Registration.UserId);
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
return new ResultWrite(
result,
item.Existing is null,
Audit(
user,
published ? "Excel 批量发布成绩" : "Excel 批量保存成绩",
$"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
}).ToArray();
await repository.SaveManyAsync(writes, cancellationToken);
var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject
{
["ok"] = true,
["published"] = published,
["count"] = writes.Length,
["summary"] = new JsonObject
{
["total"] = writes.Length,
["valid"] = writes.Length,
["invalid"] = 0,
["create"] = create,
["update"] = writes.Length - create,
["publish"] = published ? writes.Length : 0
}
});
}
public async Task<AdminEndpointResult> SaveFeatureBulkAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var exam = operational.Exams.FirstOrDefault(item =>
item.Id == Clean(Text(body["examId"]), 64) && item.Data["archivedAt"] is null);
if (exam is null) return Error(400, "请选择有效且未归档的考试");
var rows = body["rows"] as JsonArray ?? [];
if (rows.Count == 0) return Error(400, "没有需要保存的特征分");
var seen = new HashSet<string>(StringComparer.Ordinal);
var entries = new List<FeatureScoreWrite>();
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index] as JsonObject ?? new JsonObject();
var registration = operational.Registrations.FirstOrDefault(item =>
item.Id == Text(row["registrationId"]) && item.ExamId == exam.Id && item.Status == "approved");
if (registration is null || !seen.Add(registration.Id))
return Error(400, $"第 {index + 1} 条考生记录无效或重复");
var score = Number(row["featureScore"]);
if (!double.IsFinite(score) || score < 0 || score > 1000)
return Error(400, $"第 {index + 1} 条特征分必须在 0—1000 之间");
score = Math.Round(score, 2, MidpointRounding.AwayFromZero);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
entries.Add(new(
registration.Id,
score,
Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultScoring.Format(score)}")));
}
await repository.SaveFeatureScoresAsync(entries, cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["count"] = entries.Count });
}
public async Task<AdminEndpointResult> SaveFeatureAsync(
string sessionToken,
string registrationId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var registration = operational.Registrations.FirstOrDefault(item =>
item.Id == registrationId && item.Status == "approved");
if (registration is null) return Error(404, "已通过的报名记录不存在");
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
if (exam?.Data["archivedAt"] is not null) return Error(409, "该考试已归档,特征分已永久锁定");
var score = Number(body["featureScore"]);
if (!double.IsFinite(score) || score < 0 || score > 1000)
return Error(400, "特征分必须在 0—1000 之间");
score = Math.Round(score, 2, MidpointRounding.AwayFromZero);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
await repository.SaveFeatureScoresAsync(
[new(
registration.Id,
score,
Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultScoring.Format(score)}"))],
cancellationToken);
var output = registration.Data.DeepClone().AsObject();
output["featureScore"] = score;
return Success(new JsonObject { ["ok"] = true, ["registration"] = output });
}
public async Task<AdminEndpointResult> RefreshCacheAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
return Success(new JsonObject
{
["ok"] = true,
["refreshed"] = false,
["cacheStatus"] = "disabled",
["message"] = "Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库"
});
}
public async Task<AdminDocumentResult> ExportAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
if (context.Error is not null) return DocumentError(context.Error);
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var allResults = await resultSnapshotLoader.LoadAsync(cancellationToken);
var registrations = operational.Registrations.Where(item =>
{
if (item.Status != "approved" || (examId.Length > 0 && item.ExamId != examId)) return false;
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile);
}).ToArray();
var rows = new List<AdminResultExportRow>();
foreach (var registration in registrations)
{
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
foreach (var subject in (exam?.Subjects ?? []).Where(item => SubjectIds(registration).Contains(Text(item["id"]))))
{
var result = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var pass = result is null ? null : AdminResultScoring.Pass(allResults, result, subject);
var card = registration.Data["admitCard"] as JsonObject;
rows.Add(new(
account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]),
Text(card?["number"]),
Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
Text(profile?.Data["school"]),
schoolClass?.Name ?? Text(profile?.Data["grade"]),
Text(exam?.Data["code"]),
Text(exam?.Data["name"]),
Text(subject["name"]),
Number(subject["fullScore"]),
AdminResultScoring.PassText(subject),
pass?.PassScore is double passScore ? passScore : "",
result is null ? "" : result.Score,
pass?.Rank.Rank is int rank ? rank : "",
pass?.Rank.RankPercent is double percent ? percent : "",
result is null || pass?.Qualified is null ? "" : pass.Qualified.Value ? "达线" : "未达线",
result is null ? "" : result.Published ? pass?.Rank.Grade ?? "" : "待发布",
result?.Published is true ? "发布" : "不发布",
result?.UpdatedAt ?? result?.PublishedAt ?? ""));
}
}
var content = AdminResultExporter.Build(rows);
return new(
200,
content,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"考试成绩台账.xlsx",
null);
}
public async Task<AdminEndpointResult> ReviewAppealAsync(
string sessionToken,
string resultId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var requestedStatus = Text(body["status"]);
if (requestedStatus is not ("approved" or "rejected"))
return Error(400, "复议处理状态无效");
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var allResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
var result = allResults.FirstOrDefault(item => item.Id == resultId && item.Published);
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result?.RegistrationId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
if (result is null || registration is null || profile is null)
return Error(404, "待处理的成绩复议不存在");
if (!InScope(user, profile)) return Error(403, "该成绩复议不在你的数据范围内");
var instance = directory.Instances.FirstOrDefault(item =>
item.BusinessType == "score_appeal" &&
item.BusinessId == result.Id &&
item.Status == "pending");
var workflow = directory.Workflows.FirstOrDefault(item => item.Id == instance?.WorkflowId);
var step = workflow?.Steps.FirstOrDefault(item => item.Position == instance?.CurrentStep);
if (instance is null || workflow is null || step is null)
return Error(409, "成绩复议流程状态异常");
if (Level(user) != "super" &&
(instance.AssigneeId != user.Id || step.AdminLevel != Level(user)))
return Error(403, "该流程当前未分配给你,可由当前处理人转交");
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
if (exam?.Data["archivedAt"] is not null)
return Error(409, "该考试已归档,成绩及复议流程已永久锁定");
var subject = exam?.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
var finalStep = instance.CurrentStep >= workflow.Steps.Count;
var reviewedScore = double.NaN;
if (requestedStatus == "approved" && finalStep)
{
reviewedScore = Number(body["reviewedScore"]);
var fullScore = Number(subject?["fullScore"]);
if (!double.IsFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > fullScore)
return Error(400, $"最终审批必须填写 0—{AdminResultScoring.Format(fullScore)} 之间的复核后分数");
}
var now = NowIso();
var note = Clean(Text(body["reviewNote"]), 300);
string actionName;
string? nextAssigneeId = null;
AccountWorkflowInstance updatedInstance;
AdminWorkflowResult? updatedResult = null;
if (requestedStatus == "rejected")
{
actionName = "reject";
updatedInstance = instance with { Status = "rejected", CompletedAt = now, AssigneeId = null };
}
else if (!finalStep)
{
actionName = "approve";
var nextStep = workflow.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep + 1);
var nextAssignee = nextStep is null
? null
: SelectAssignee(directory, nextStep.AdminLevel, profile);
if (nextAssignee is null)
return Error(409, $"没有可承接“{nextStep?.Name ?? ""}”的管理员");
nextAssigneeId = nextAssignee.Id;
updatedInstance = instance with
{
CurrentStep = instance.CurrentStep + 1,
AssigneeId = nextAssignee.Id
};
}
else
{
actionName = "approve";
updatedInstance = instance with { Status = "approved", CompletedAt = now, AssigneeId = null };
var originalScore = result.Score;
updatedResult = result with { Score = reviewedScore, UpdatedAt = now };
Replace(allResults, updatedResult);
updatedResult = updatedResult with
{
Grade = AdminResultScoring.Rank(allResults, updatedResult, reviewedScore).Grade
};
Replace(allResults, updatedResult);
var pass = AdminResultScoring.Pass(allResults, updatedResult, subject, reviewedScore);
var conclusion = pass.Qualified is null
? "本科不判定单科达线"
: pass.Qualified.Value
? $"达到{PassConclusion(subject, pass)}"
: $"未达到{PassConclusion(subject, pass)}";
note = string.Join(
'',
new[]
{
$"原分 {AdminResultScoring.Format(originalScore)} → 复核后 {AdminResultScoring.Format(reviewedScore)}",
conclusion,
note
}.Where(item => item.Length > 0));
}
var action = new AccountWorkflowAction(
Uid("flow_action"),
instance.Id,
user.Id,
actionName,
note,
instance.AssigneeId,
nextAssigneeId,
now);
var auditAction = requestedStatus == "rejected"
? "退回成绩复议"
: finalStep ? "复议终审并更新成绩" : "处理成绩复议";
var detail = $"{Text(profile.Data["name"])} · {Text(exam?.Data["name"])} · {Text(subject?["name"])}" +
(requestedStatus == "approved" && finalStep ? $" · {AdminResultScoring.Format(reviewedScore)} 分" : "");
await repository.ReviewAppealAsync(
updatedInstance,
action,
updatedResult,
Audit(user, auditAction, detail),
cancellationToken);
var responseResult = updatedResult ?? result;
var rank = AdminResultScoring.Rank(allResults, responseResult);
var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject);
var resultOutput = RawResultJson(responseResult);
resultOutput["rank"] = JsonValue.Create(rank.Rank);
resultOutput["cohortSize"] = rank.CohortSize;
resultOutput["rankPercent"] = JsonValue.Create(rank.RankPercent);
resultOutput["grade"] = rank.Grade;
resultOutput["passScore"] = JsonValue.Create(responsePass.PassScore);
resultOutput["cutoffRank"] = JsonValue.Create(responsePass.CutoffRank);
resultOutput["qualified"] = JsonValue.Create(responsePass.Qualified);
return Success(new JsonObject
{
["ok"] = true,
["workflow"] = WorkflowJson(directory, updatedInstance, action),
["result"] = resultOutput
});
}
private static JsonObject ResultJson(
AdminOperationalSnapshot operational,
AdminAccountBatchSnapshot directory,
OperationalExam exam,
IReadOnlyList<AdminWorkflowResult> cohortResults,
AdminWorkflowResult result)
{
var registration = operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration?.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration?.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
var subject = exam.Subjects.FirstOrDefault(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(cohortResults, result, subject);
var output = RawResultJson(result);
output["grade"] = result.Published ? pass.Rank.Grade : result.Grade;
output["rank"] = JsonValue.Create(pass.Rank.Rank);
output["cohortSize"] = pass.Rank.CohortSize;
output["rankPercent"] = JsonValue.Create(pass.Rank.RankPercent);
output["candidateName"] = Text(profile?.Data["name"]);
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]);
output["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]);
output["examId"] = exam.Id;
output["examCode"] = Text(exam.Data["code"]);
output["examName"] = Text(exam.Data["name"]);
output["subjectName"] = Text(subject?["name"]);
output["fullScore"] = subject?["fullScore"]?.DeepClone();
output["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score";
output["passValue"] = (subject?["passValue"] ?? subject?["passScore"])?.DeepClone();
output["passScore"] = JsonValue.Create(pass.PassScore);
output["cutoffRank"] = JsonValue.Create(pass.CutoffRank);
output["passText"] = AdminResultScoring.PassText(subject);
output["qualified"] = JsonValue.Create(pass.Qualified);
return output;
}
private static JsonObject RegistrationJson(
AdminOperationalSnapshot operational,
AdminAccountBatchSnapshot directory,
OperationalRegistration registration)
{
var output = registration.Data.DeepClone().AsObject();
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
var subjects = (exam?.Subjects ?? [])
.Where(item => SubjectIds(registration).Contains(Text(item["id"])))
.Select(item => item.DeepClone())
.ToArray();
output["exam"] = exam?.Data.DeepClone();
output["subjects"] = new JsonArray(subjects);
output["amountDue"] = Math.Round(subjects.Sum(item => Number(item?["fee"])), 2, MidpointRounding.AwayFromZero);
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
var instance = directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id && item.Status == "pending") ??
directory.Instances.FirstOrDefault(item =>
item.BusinessType == "registration_review" && item.BusinessId == registration.Id);
output["workflow"] = instance is null ? null : WorkflowJson(directory, instance);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
var account = operational.Users.FirstOrDefault(item => item.Id == registration.UserId);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
output["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "";
output["candidateNumber"] = account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]);
output["schoolName"] = Text(profile?.Data["school"]);
output["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]);
var category = Text(profile?.Data["specialtyCategory"]);
var type = Text(profile?.Data["specialtyType"]);
output["specialtyCategory"] = category;
output["specialtyType"] = type;
output["specialtyLabel"] = SpecialtyLabel(category, type);
return output;
}
private static ResultSummary Summary(
OperationalExam exam,
IReadOnlyList<OperationalRegistration> examRegistrations,
IReadOnlyList<AdminWorkflowResult> examResults,
OperationalRegistration registration)
{
var subjects = exam.Subjects.Where(item => SubjectIds(registration).Contains(Text(item["id"]))).ToArray();
var published = examResults.Where(item => item.RegistrationId == registration.Id && item.Published).ToArray();
var bySubject = published.ToDictionary(item => item.SubjectId, StringComparer.Ordinal);
var complete = subjects.Length > 0 && subjects.All(item => bySubject.ContainsKey(Text(item["id"])));
if (!complete) return new(false, null);
var total = subjects.Sum(item => bySubject[Text(item["id"])].Score);
var policy = Text(exam.Data["passPolicy"]);
if (policy == "score_ratio") policy = "rank_percent";
if (policy.Length == 0) policy = "rank_percent";
var value = Number(exam.Data["passValue"]);
if (policy == "none") return new(true, null);
if (policy == "fixed_score") return new(true, total >= value);
if (policy == "subject_scores")
return new(true, subjects.All(item =>
AdminResultScoring.Pass(examResults, bySubject[Text(item["id"])], item).Qualified is not false));
var key = string.Join('|', SubjectIds(registration).Order(StringComparer.Ordinal));
var totals = examRegistrations.Where(item =>
string.Join('|', SubjectIds(item).Order(StringComparer.Ordinal)) == key)
.Select(item =>
{
var itemResults = examResults.Where(result => result.RegistrationId == item.Id && result.Published)
.ToDictionary(result => result.SubjectId, StringComparer.Ordinal);
return subjects.All(subject => itemResults.ContainsKey(Text(subject["id"])))
? subjects.Sum(subject => itemResults[Text(subject["id"])].Score)
: (double?)null;
})
.Where(item => item.HasValue)
.Select(item => item!.Value)
.ToArray();
var rank = 1 + totals.Count(item => item > total);
var cutoff = Math.Max(1, (int)Math.Ceiling(totals.Length * value / 100));
return new(true, rank <= cutoff);
}
private static JsonObject WorkflowJson(
AdminAccountBatchSnapshot snapshot,
AccountWorkflowInstance instance,
AccountWorkflowAction? extraAction = null)
{
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id)
.Concat(extraAction is null ? [] : [extraAction])
.Select(item => ActionJson(snapshot, item))
.ToArray();
return new JsonObject
{
["id"] = instance.Id,
["workflowId"] = instance.WorkflowId,
["businessType"] = instance.BusinessType,
["businessId"] = instance.BusinessId,
["status"] = instance.Status,
["currentStep"] = instance.CurrentStep,
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
["createdAt"] = instance.CreatedAt,
["completedAt"] = JsonValue.Create(instance.CompletedAt),
["workflowName"] = workflow?.Name ?? "未命名流程",
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step ? StepJson(step) : null,
["assignee"] = assignee is null ? null : SafeUser(assignee),
["actions"] = new JsonArray(actions)
};
}
private async Task<ResolvedAdmin> ResolveAsync(
string token,
bool superOnly,
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, "当前账号无权执行此操作"));
if (superOnly && Level(user) != "super")
return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
return new(user, null);
}
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
{
"super" => true,
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
_ => user.ClassId is not null && profile.ClassId == user.ClassId
};
private static HashSet<string> SubjectIds(OperationalRegistration registration) =>
registration.Data["subjectIds"] is JsonArray items
? items.Select(Text).Where(item => item.Length > 0).ToHashSet(StringComparer.Ordinal)
: [];
private static JsonObject RawResultJson(AdminWorkflowResult result) => new()
{
["id"] = result.Id,
["registrationId"] = result.RegistrationId,
["subjectId"] = result.SubjectId,
["score"] = result.Score,
["grade"] = result.Grade,
["published"] = result.Published,
["updatedAt"] = JsonValue.Create(result.UpdatedAt),
["publishedAt"] = JsonValue.Create(result.PublishedAt)
};
private static JsonObject PublicExamJson(OperationalExam exam)
{
var output = exam.Data.DeepClone().AsObject();
output["totalScore"] = exam.Subjects.Sum(item => Number(item["fullScore"]));
var now = DateTimeOffset.UtcNow;
var starts = DateTimeOffset.TryParse(
Text(exam.Data["registrationStart"]),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var start)
? start
: DateTimeOffset.MinValue;
var ends = DateTimeOffset.TryParse(
Text(exam.Data["registrationEnd"]),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var end)
? end
: DateTimeOffset.MinValue;
output["registrationState"] = exam.Data["archivedAt"] is not null
? "archived"
: now < starts ? "upcoming" : now > ends ? "closed" : "open";
return output;
}
private static JsonObject CacheStatus() => new()
{
["enabled"] = false,
["status"] = "disabled"
};
private static void Replace(List<AdminWorkflowResult> results, AdminWorkflowResult result)
{
var index = results.FindIndex(item => item.Id == result.Id);
if (index >= 0) results[index] = result;
else results.Add(result);
}
private static string SpecialtyLabel(string category, string type)
{
var categoryName = category switch { "sports" => "体育", "arts" => "艺术", _ => "" };
var typeName = type switch
{
"track_field" => "田径",
"basketball" => "篮球",
"football" => "足球",
"volleyball" => "排球",
"table_tennis" => "乒乓球",
"badminton" => "羽毛球",
"swimming" => "游泳",
"martial_arts" => "武术",
"vocal_music" => "声乐",
"instrumental_music" => "器乐",
"dance" => "舞蹈",
"fine_arts" => "美术",
"calligraphy" => "书法",
"broadcasting" => "播音主持",
"drama" => "戏剧表演",
_ => ""
};
return categoryName.Length > 0 && typeName.Length > 0 ? $"{categoryName}·{typeName}" : "普通生";
}
private static AdminUser? SelectAssignee(
AdminAccountBatchSnapshot snapshot,
string level,
OperationalProfile profile)
{
var eligible = snapshot.Users.Where(item =>
item.Role == "admin" &&
item.Active &&
item.AdminLevel == level &&
level switch
{
"super" => true,
"school" => profile.SchoolId is not null && item.SchoolId == profile.SchoolId,
"class" => profile.SchoolId is not null && profile.ClassId is not null &&
item.SchoolId == profile.SchoolId && item.ClassId == profile.ClassId,
_ => false
});
var pending = snapshot.Instances
.Where(item => item.Status == "pending" && item.AssigneeId is not null)
.GroupBy(item => item.AssigneeId!)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
var assigned = snapshot.Actions
.Where(item => item.ToAssigneeId is not null)
.GroupBy(item => item.ToAssigneeId!)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
return eligible
.OrderBy(item => pending.GetValueOrDefault(item.Id))
.ThenBy(item => assigned.GetValueOrDefault(item.Id))
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
.ThenBy(item => item.Id, StringComparer.Ordinal)
.FirstOrDefault();
}
private static string PassConclusion(JsonObject? subject, AdminPassInfo pass)
{
if (Text(subject?["passRule"]) == "rank_percent")
return $"排名前 {AdminResultScoring.Format(Number(subject?["passValue"]))}%(当前第 {pass.Rank.Rank} 名,截止第 {pass.CutoffRank} 名)";
return $"固定及格线 {AdminResultScoring.Format(pass.PassScore ?? 0)} 分";
}
private static JsonObject StepJson(AccountWorkflowStep item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["adminLevel"] = item.AdminLevel,
["position"] = item.Position
};
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new()
{
["id"] = item.Id,
["instanceId"] = item.InstanceId,
["actorId"] = JsonValue.Create(item.ActorId),
["action"] = item.Action,
["note"] = item.Note,
["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId),
["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId),
["createdAt"] = item.CreatedAt,
["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统",
["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "",
["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? ""
};
private static JsonObject SafeUser(AdminUser item) => new()
{
["id"] = item.Id,
["username"] = item.Username,
["role"] = item.Role,
["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null,
["schoolId"] = JsonValue.Create(item.SchoolId),
["classId"] = JsonValue.Create(item.ClassId),
["displayName"] = item.DisplayName,
["candidateNumber"] = JsonValue.Create(item.CandidateNumber),
["mustChangePassword"] = item.MustChangePassword,
["totpEnabled"] = item.TotpEnabled,
["archived"] = item.ArchivedAt is not null
};
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static bool JsBoolean(JsonNode? node)
{
if (node is null) return false;
if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean;
if (node is JsonValue text && text.TryGetValue<string>(out var stringValue)) return stringValue.Length > 0;
if (node is JsonValue number && number.TryGetValue<double>(out var numberValue)) return numberValue != 0 && !double.IsNaN(numberValue);
return true;
}
private static double Number(JsonNode? node) =>
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? value
: double.NaN;
private static string Text(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string Clean(string value, int maximum)
{
var cleaned = value.Trim();
return cleaned[..Math.Min(cleaned.Length, maximum)];
}
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<char> 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) => new(200, body);
private static AdminEndpointResult Error(int status, string message) =>
new(status, new JsonObject { ["ok"] = false, ["message"] = message });
private static AdminDocumentResult DocumentError(AdminEndpointResult error) =>
new(error.StatusCode, null, null, null, error.Body);
private sealed record PreparedResult(
OperationalRegistration Registration,
AdminWorkflowResult? Existing,
AdminWorkflowResult Result);
private sealed record ResultSummary(bool Complete, bool? Qualified);
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
}
}
@@ -10,7 +10,9 @@ internal sealed record AdminWorkflowResult(
string SubjectId,
double Score,
string Grade,
bool Published);
bool Published,
string? UpdatedAt,
string? PublishedAt);
internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFactory connectionFactory)
{
@@ -18,7 +20,7 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published FROM results ORDER BY id";
command.CommandText = "SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results ORDER BY id";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var output = new List<AdminWorkflowResult>();
while (await reader.ReadAsync(cancellationToken))
@@ -29,7 +31,9 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
Text(reader, "subject_id"),
Number(reader, "score"),
Text(reader, "grade"),
Boolean(reader, "published")));
Boolean(reader, "published"),
Optional(reader, "updated_at"),
Optional(reader, "published_at")));
}
return output;
}
@@ -42,4 +46,12 @@ internal sealed class AdminWorkflowResultSnapshotLoader(IRelationalConnectionFac
private static bool Boolean(DbDataReader reader, string name) =>
Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
private static string? Optional(DbDataReader reader, string name)
{
var ordinal = reader.GetOrdinal(name);
return reader.IsDBNull(ordinal)
? null
: Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
}
}
@@ -66,6 +66,7 @@ public static class DependencyInjection
services.AddScoped<AdminExamRepository>();
services.AddScoped<AdminArrangementSnapshotLoader>();
services.AddScoped<AdminArrangementRepository>();
services.AddScoped<AdminResultRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
@@ -78,6 +79,7 @@ public static class DependencyInjection
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
services.AddScoped<IAdminResultService, AdminResultService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
@@ -164,6 +164,44 @@ public static class NativeAdminReadEndpoints
(HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) =>
Execute(context, service.RetiredSingleAsync(Token(context), cancellationToken)));
}
if (options.NativeResultsEnabled)
{
endpoints.MapGet("/api/admin/results",
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(
context,
service.GetAsync(
Token(context),
context.Request.Query["examId"].ToString(),
cancellationToken)));
endpoints.MapPost("/api/admin/results",
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admin/results/bulk",
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveBulkAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admin/feature-scores/bulk",
(HttpContext context, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveFeatureBulkAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/registrations/{registrationId}/feature-score",
(HttpContext context, string registrationId, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveFeatureAsync(Token(context), registrationId, body, cancellationToken)));
endpoints.MapPatch("/api/admin/score-appeals/{resultId}",
(HttpContext context, string resultId, JsonObject body, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAppealAsync(Token(context), resultId, body, cancellationToken)));
endpoints.MapPost("/api/admin/results/cache/refresh",
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
Execute(context, service.RefreshCacheAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admin/excel/results",
(HttpContext context, IAdminResultService service, CancellationToken cancellationToken) =>
ExecuteDocument(
context,
service.ExportAsync(
Token(context),
context.Request.Query["examId"].ToString(),
cancellationToken)));
}
return endpoints;
}
+10 -1
View File
@@ -56,7 +56,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeRegistrationPaymentWritesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeWorkflowOperationsEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeExamManagementEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeArrangementsEnabled"));
builder.Configuration.GetValue<bool>("AdminMigration:NativeArrangementsEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeResultsEnabled"));
builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
@@ -122,6 +123,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeWorkflowOperationsEnabled = adminMigrationOptions.NativeWorkflowOperationsEnabled,
nativeExamManagementEnabled = adminMigrationOptions.NativeExamManagementEnabled,
nativeArrangementsEnabled = adminMigrationOptions.NativeArrangementsEnabled,
nativeResultsEnabled = adminMigrationOptions.NativeResultsEnabled,
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: [])
@@ -166,6 +168,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
"GET admission exports"
}
: [])
.Concat(adminMigrationOptions.NativeResultsEnabled
? new[]
{
"GET/POST results", "POST result bulk", "POST/PATCH feature scores",
"PATCH score appeals", "GET result Excel export"
}
: [])
.ToArray()
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
+2 -1
View File
@@ -21,7 +21,8 @@
"NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false,
"NativeArrangementsEnabled": false
"NativeArrangementsEnabled": false,
"NativeResultsEnabled": false
},
"Logging": {
"LogLevel": {