本阶段迁移已完成。

Excel:班级、账号、考生、缴费、考场、成绩、志愿、录取、报到及正式录取名册均改为 ClosedXML 原生处理,支持模板、校验、筛选和导入预检。[SystemWorkbook.cs](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Infrastructure\\Spreadsheets\\SystemWorkbook.cs)
文书:录取通知书模板、成绩单/准考证/通知书业务数据与防伪验真已脱离 Node;保留现有前端排版导出效果。[AdminAdmissionService.Documents.cs](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Infrastructure\\Administration\\AdminAdmissionService.Documents.cs)
缓存:完成 Redis 命名空间版本缓存、有界本地回退、请求合并和并发失效保护;公开数据及成绩写入后立即失效。[ApplicationCache.cs](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Infrastructure\\Caching\\ApplicationCache.cs)
迁移清单已更新为完成。[MIGRATION.md](C:\\Users\\BI\\Documents\\EIS-dotnet\\MIGRATION.md)
This commit is contained in:
2026-07-23 14:14:51 +08:00 Unverified
parent 4df6303153
commit fea91a19af
24 changed files with 2209 additions and 35 deletions
@@ -1,16 +1,20 @@
using System.Data.Common;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminAdmissionRepository(IRelationalConnectionFactory connectionFactory)
internal sealed class AdminAdmissionRepository(
IRelationalConnectionFactory connectionFactory,
IApplicationCache cache)
{
public Task SaveRecordsAsync(
public async Task SaveRecordsAsync(
IReadOnlyList<CandidateAdmissionRecord> records,
AdminAuditEntry audit,
CancellationToken cancellationToken) =>
TransactionAsync(async (connection, transaction) =>
CancellationToken cancellationToken)
{
await TransactionAsync(async (connection, transaction) =>
{
foreach (var record in records)
{
@@ -33,6 +37,8 @@ internal sealed class AdminAdmissionRepository(IRelationalConnectionFactory conn
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
}, cancellationToken);
await cache.InvalidateAsync("public");
}
public Task CreateAccountAsync(
AdminUser account,
@@ -0,0 +1,650 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Spreadsheets;
namespace Eis.Infrastructure.Administration;
internal sealed partial class AdminAdmissionService
{
private const string WorkbookContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
public async Task<AdminEndpointResult> GetAdmissionSchoolNoticeTemplateAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var record = Records(data, "notification")
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "template");
var template = record?.Payload["template"]?.DeepClone().AsObject() ?? DefaultNoticeTemplate();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["exams"] = new JsonArray(data.Operational.Exams
.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
["template"] = template,
["updatedAt"] = JsonValue.Create(record?.UpdatedAt)
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolNoticeTemplateAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var requestedExamId = Clean(Text(body["examId"]), 64);
var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == requestedExamId)
?? data.Operational.Exams.FirstOrDefault(item => item.Data["archivedAt"] is null)
?? data.Operational.Exams.FirstOrDefault();
if (exam is null) return Error(409, "系统中还没有可关联的考试,暂时无法保存模板");
var template = new JsonObject
{
["eyebrow"] = Clean(Text(body["eyebrow"]) is { Length: > 0 } eyebrow ? eyebrow : "ADMISSION NOTICE", 60),
["title"] = Clean(Text(body["title"]) is { Length: > 0 } title ? title : "录 取 通 知 书", 80),
["body"] = Clean(Text(body["body"]), 1600),
["footer"] = Clean(Text(body["footer"]), 300),
["primaryColor"] = ValidColor(Text(body["primaryColor"])) ? Text(body["primaryColor"]) : "#8d2028",
["accentColor"] = ValidColor(Text(body["accentColor"])) ? Text(body["accentColor"]) : "#c9a45b"
};
if (Text(template["body"]).Length == 0) return Error(400, "请填写录取通知书正文");
var now = NowIso();
var existing = Records(data, "notification")
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "template");
var record = new CandidateAdmissionRecord(
existing?.Id ?? Uid("notice_template"),
"notification",
exam.Id,
null,
school.Id,
"template",
new JsonObject
{
["template"] = template.DeepClone(),
["updatedBy"] = user.DisplayName
},
existing?.CreatedAt ?? now,
now);
await repository.SaveRecordsAsync(
[record],
Audit(user, "保存录取通知书模板", school.Name),
cancellationToken);
return Success(new JsonObject
{
["ok"] = true,
["template"] = template,
["updatedAt"] = now
});
}
public async Task<AdminEndpointResult> GetAdmissionSchoolReportingAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var batches = Records(data, "plan")
.Where(item => item.SchoolId == school.Id && item.Status == "approved")
.Select(plan => ReportingBatch(data, plan, LatestReportingRecord(data, plan.ExamId, school.Id)))
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["batches"] = new JsonArray(batches)
});
}
public async Task<AdminDocumentResult> ExportAdmissionSchoolReportingAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
{
var resolved = await ResolveAdmissionSchoolDocumentAsync(sessionToken, cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, resolved.User!);
if (school is null) return DocumentError(Error(403, "招生学校账号未绑定有效学校"));
var plan = Records(data, "plan", Clean(examId, 64))
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, plan.ExamId, school.Id);
if (plan is null || record is null)
return DocumentError(Error(404, "当前考试还没有可维护的报到批次"));
var rows = ReportingRows(data, plan, record).Select(item => new JsonObject
{
["noticeNumber"] = item["noticeNumber"]?.DeepClone(),
["candidateNumber"] = item["candidateNumber"]?.DeepClone(),
["name"] = item["name"]?.DeepClone(),
["examCode"] = item["examCode"]?.DeepClone(),
["schoolCode"] = item["schoolCode"]?.DeepClone(),
["categoryName"] = item["categoryName"]?.DeepClone(),
["reportingStatusCode"] = item["statusCode"]?.DeepClone(),
["reportingNote"] = item["note"]?.DeepClone()
}).ToArray();
var round = Integer(record.Payload["round"], 1);
return new AdminDocumentResult(
200,
SystemWorkbook.Build("admission_reporting", rows, subtitle: $"{round} 轮|{school.Name}"),
WorkbookContentType,
$"{round}轮-{school.Name}-考生报到状态.xlsx",
null);
}
public async Task<AdminEndpointResult> ImportAdmissionSchoolReportingAsync(
string sessionToken,
string examId,
byte[] content,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var plan = Records(data, "plan", Clean(examId, 64))
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, plan.ExamId, school.Id);
if (plan is null || record is null || record.Status is not ("draft" or "rejected"))
return Error(409, "当前报到批次不能导入暂存数据");
IReadOnlyList<JsonObject> imported;
try
{
imported = SystemWorkbook.Parse("admission_reporting", content);
}
catch (WorkbookException error)
{
return Error(error.StatusCode, error.Message);
}
var available = ReportingRows(data, plan, record);
var byNotice = available.ToDictionary(item => Text(item["noticeNumber"]), StringComparer.Ordinal);
var byCandidate = available.ToDictionary(item => Text(item["candidateNumber"]), StringComparer.Ordinal);
var seen = new HashSet<string>(StringComparer.Ordinal);
var changes = new JsonArray();
var merged = (record.Payload["rows"] as JsonArray ?? [])
.OfType<JsonObject>()
.ToDictionary(item => Text(item["placementId"]), item => item.DeepClone().AsObject(), StringComparer.Ordinal);
var changedCount = 0;
var unchangedCount = 0;
var importedAt = NowIso();
foreach (var item in imported)
{
var rowNumber = Integer(item["__row"]);
var noticeNumber = Clean(Text(item["noticeNumber"]), 100);
var candidateNumber = Clean(Text(item["candidateNumber"]), 100);
if (!byNotice.TryGetValue(noticeNumber, out var target) ||
!byCandidate.TryGetValue(candidateNumber, out var candidateTarget) ||
Text(candidateTarget["placementId"]) != Text(target["placementId"]))
return Error(400, $"Excel 第 {rowNumber} 行的通知书编号与报名号不属于本校当前报到批次");
var placementId = Text(target["placementId"]);
if (!seen.Add(placementId)) return Error(400, $"Excel 第 {rowNumber} 行重复填写同一考生");
var code = Text(item["reportingStatusCode"]).Trim().ToUpperInvariant();
var status = code switch { "Y" => "reported", "N" => "not_reported", "P" => "pending", _ => "" };
if (status.Length == 0) return Error(400, $"Excel 第 {rowNumber} 行报到状态码只能填写 Y、N 或 P");
var note = Clean(Text(item["reportingNote"]), 300);
if (Text(target["status"]) == status && Text(target["note"]) == note)
{
unchangedCount++;
continue;
}
merged[placementId] = new JsonObject
{
["placementId"] = placementId,
["status"] = status,
["note"] = note,
["updatedAt"] = importedAt,
["source"] = "excel"
};
changes.Add(new JsonObject
{
["placementId"] = placementId,
["name"] = target["name"]?.DeepClone(),
["candidateNumber"] = candidateNumber,
["noticeNumber"] = noticeNumber,
["from"] = target["status"]?.DeepClone(),
["to"] = status,
["fromCode"] = target["statusCode"]?.DeepClone(),
["toCode"] = code,
["noteChanged"] = Text(target["note"]) != note
});
changedCount++;
}
var updated = record;
if (changedCount > 0)
{
var payload = record.Payload.DeepClone().AsObject();
payload["rows"] = new JsonArray(merged.Values.Select(item => (JsonNode)item).ToArray());
payload["lastImportedAt"] = importedAt;
payload["lastImportedBy"] = user.DisplayName;
updated = record with { Status = "draft", UpdatedAt = importedAt, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(user, "Excel 暂存考生报到状态", $"{school.Name} · 实际更新 {changedCount} 人"),
cancellationToken);
data = ReplaceRecord(data, updated);
}
return Success(new JsonObject
{
["ok"] = true,
["count"] = imported.Count,
["changedCount"] = changedCount,
["unchangedCount"] = unchangedCount,
["changes"] = changes,
["batch"] = ReportingBatch(data, plan, updated)
});
}
public async Task<AdminEndpointResult> GetAdmissionSchoolPlacementsAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var placements = Records(data, "placement").Where(item => item.SchoolId == school.Id)
.Select(item => AdmissionSchoolPlacement(data, item)).ToArray();
var completed = data.Operational.Exams.Where(exam =>
Records(data, "setting", exam.Id).Any(setting => setting.Status == "completed") &&
placements.Any(item => Text(item["examId"]) == exam.Id && Text(item["status"]) == "final"))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["placements"] = new JsonArray(placements),
["completedExams"] = new JsonArray(completed)
});
}
public async Task<AdminDocumentResult> ExportAdmissionSchoolPlacementsAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
{
var resolved = await ResolveAdmissionSchoolDocumentAsync(sessionToken, cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, resolved.User!);
if (school is null) return DocumentError(Error(403, "招生学校账号未绑定有效学校"));
var cleanExamId = Clean(examId, 64);
var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == cleanExamId);
var setting = Records(data, "setting", cleanExamId).FirstOrDefault();
if (exam is null || setting?.Status != "completed")
return DocumentError(Error(409, "录取工作结束后才能下载正式录取名单"));
var rows = Records(data, "placement", cleanExamId)
.Where(item => item.SchoolId == school.Id && item.Status == "final")
.Select(item => AdmittedCandidateRow(data, exam, school, item)).ToArray();
var examName = Text(exam.Data["name"]);
return new AdminDocumentResult(
200,
SystemWorkbook.Build("admitted_candidates", rows, subtitle: $"{examName}{school.Name}"),
WorkbookContentType,
$"{examName}-{school.Name}-录取考生信息.xlsx",
null);
}
public async Task<AdminDocumentResult> ExportAdminLedgerAsync(
string sessionToken,
string kind,
IReadOnlyDictionary<string, string> filters,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, "只有超级管理员可以导出志愿与录取台账", cancellationToken);
if (context.Error is not null) return DocumentError(context.Error);
if (kind is not ("preferences" or "placements"))
return DocumentError(Error(404, "招生台账类型不存在"));
var data = await LoadAsync(cancellationToken);
var selectedExam = data.Operational.Exams.FirstOrDefault(item =>
item.Id == filters.GetValueOrDefault("examId"));
var subtitle = $"{(selectedExam is null ? "" : Text(selectedExam.Data["name"]))}|按当前筛选条件导出|生成时间 {DateTime.Now:yyyy/M/d HH:mm:ss}";
IReadOnlyList<JsonObject> rows;
string resource;
string fileName;
if (kind == "preferences")
{
rows = FilterLedgerRows(PreferenceSnapshotRows(data), filters).SelectMany(item =>
{
var choices = (item["choices"] as JsonArray ?? []).OfType<JsonObject>().ToArray();
if (choices.Length == 0) choices = [new JsonObject()];
return choices.Select((choice, index) => new JsonObject
{
["examCode"] = item["examCode"]?.DeepClone(),
["examName"] = item["examName"]?.DeepClone(),
["round"] = item["round"]?.DeepClone(),
["fillStatus"] = item["fillStatus"]?.DeepClone(),
["lockStatus"] = item["lockStatus"]?.DeepClone(),
["submissionCount"] = item["submissionCount"]?.DeepClone(),
["maxSubmissions"] = item["maxSubmissions"]?.DeepClone(),
["candidateNumber"] = item["candidate"]?["registrationNumber"]?.DeepClone(),
["candidateName"] = item["candidate"]?["name"]?.DeepClone(),
["sourceSchoolCode"] = item["sourceSchoolCode"]?.DeepClone(),
["sourceSchoolName"] = item["sourceSchoolName"]?.DeepClone(),
["className"] = item["className"]?.DeepClone(),
["specialty"] = item["specialty"]?.DeepClone(),
["indicatorStatus"] = item["indicatorStatus"]?.DeepClone(),
["preferenceOrder"] = choice.Count == 0 ? "" : Integer(choice["order"], index + 1),
["preferenceType"] = choice.Count == 0 ? "" :
Text(choice["preferenceType"]) == "indicator" ? "指标志愿" : "普通志愿",
["targetSchoolCode"] = Text(choice["schoolCode"]),
["targetSchoolName"] = Text(choice["schoolName"]),
["categoryName"] = Text(choice["categoryName"]) is { Length: > 0 } category
? category : Text(choice["categoryCode"]),
["submittedAt"] = item["submittedAt"]?.DeepClone()
});
}).ToArray();
resource = "admission_preferences";
fileName = $"志愿填报实时台账-{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
}
else
{
var placements = Records(data, "placement").Select(item => PlacementJson(data, item));
rows = FilterLedgerRows(placements, filters).Select(item =>
{
var payload = item["payload"] as JsonObject ?? new JsonObject();
return new JsonObject
{
["examCode"] = item["examCode"]?.DeepClone(),
["examName"] = item["examName"]?.DeepClone(),
["round"] = Integer(payload["round"], 1),
["candidateNumber"] = item["candidate"]?["registrationNumber"]?.DeepClone(),
["candidateName"] = item["candidate"]?["name"]?.DeepClone(),
["sourceSchoolCode"] = item["sourceSchoolCode"]?.DeepClone(),
["sourceSchoolName"] = item["sourceSchoolName"]?.DeepClone(),
["className"] = item["className"]?.DeepClone(),
["specialty"] = item["candidate"]?["specialtyLabel"]?.DeepClone(),
["culturalScore"] = Number(payload["culturalScore"]) is var cultural && cultural != 0
? cultural : Number(payload["totalScore"]),
["featureScore"] = Number(payload["featureScore"]),
["totalScore"] = Number(payload["totalScore"]),
["preferenceOrder"] = Integer(payload["preferenceOrder"]),
["admissionSchoolCode"] = item["schoolCode"]?.DeepClone(),
["admissionSchoolName"] = item["schoolName"]?.DeepClone(),
["categoryName"] = payload["categoryName"]?.DeepClone(),
["quotaBucket"] = Text(payload["quotaBucket"]).StartsWith("indicator", StringComparison.Ordinal)
? "指标分配" : "普通计划",
["admissionStatus"] = item["admissionStatusLabel"]?.DeepClone(),
["reportingStatus"] = item["reportingStatusLabel"]?.DeepClone(),
["noticeNumber"] = payload["noticeNumber"]?.DeepClone(),
["withdrawalReason"] = Text(payload["withdrawalReason"]) is { Length: > 0 } reason
? reason : Text(payload["reportingNote"]),
["updatedAt"] = Text(item["updatedAt"]) is { Length: > 0 } updated
? updated : Text(payload["finalizedAt"])
};
}).ToArray();
resource = "admission_placements";
fileName = $"招生录取情况台账-{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
}
return new AdminDocumentResult(
200,
SystemWorkbook.Build(resource, rows, subtitle: subtitle),
WorkbookContentType,
fileName,
null);
}
private async Task<ResolvedAdmin> ResolveAdmissionSchoolAsync(
string token,
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, "请先登录"));
return user.Role == "admission_school"
? new ResolvedAdmin(user, null)
: ResolvedAdmin.Failed(Error(403, "当前账号不是招生学校账号"));
}
private async Task<(AuthenticationUser? User, AdminDocumentResult? Error)> ResolveAdmissionSchoolDocumentAsync(
string token,
CancellationToken cancellationToken)
{
var result = await ResolveAdmissionSchoolAsync(token, cancellationToken);
return (result.User, result.Error is null ? null : DocumentError(result.Error));
}
private static AdminSchool? AdmissionSchool(AdmissionData data, AuthenticationUser user) =>
data.Directory.Schools.FirstOrDefault(item =>
item.Id == user.SchoolId && item.Active && item.IsAdmissionSchool);
private static CandidateAdmissionRecord? LatestReportingRecord(
AdmissionData data,
string examId,
string schoolId) =>
Records(data, "notification", examId)
.Where(item => item.SchoolId == schoolId && item.UserId is null &&
Text(item.Payload["type"]) == "admission_reporting")
.OrderByDescending(item => Integer(item.Payload["round"], 1))
.ThenByDescending(item => item.UpdatedAt, StringComparer.Ordinal)
.FirstOrDefault();
private static CandidateAdmissionRecord? EditableReportingRecord(
AdmissionData data,
string examId,
string schoolId)
{
var round = Integer(Records(data, "setting", examId).FirstOrDefault()?.Payload["round"], 1);
return ReportingRecord(data, examId, schoolId, round) ??
LatestReportingRecord(data, examId, schoolId);
}
private static JsonObject[] ReportingRows(
AdmissionData data,
CandidateAdmissionRecord plan,
CandidateAdmissionRecord? record)
{
var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == plan.ExamId);
var school = School(data, plan.SchoolId);
var rowByPlacement = (record?.Payload["rows"] as JsonArray ?? []).OfType<JsonObject>()
.ToDictionary(item => Text(item["placementId"]), StringComparer.Ordinal);
var placementIds = rowByPlacement.Keys.ToHashSet(StringComparer.Ordinal);
var round = Integer(record?.Payload["round"], 1);
return Records(data, "placement", plan.ExamId)
.Where(item => item.SchoolId == plan.SchoolId && item.Status == "final" &&
(placementIds.Contains(item.Id) ||
(record is null && Integer(item.Payload["finalizedRound"], 1) == round)))
.Select(item =>
{
rowByPlacement.TryGetValue(item.Id, out var row);
var account = data.Directory.Users.FirstOrDefault(user => user.Id == item.UserId);
var profile = data.Operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
var status = Text(row?["status"]) is { Length: > 0 } current ? current : "pending";
return new JsonObject
{
["placementId"] = item.Id,
["noticeNumber"] = Text(item.Payload["noticeNumber"]),
["candidateNumber"] = account?.CandidateNumber ?? "",
["name"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
["idNumberMasked"] = MaskId(Text(profile?.Data["idNumber"])),
["examCode"] = Text(exam?.Data["code"]),
["schoolCode"] = school?.Code ?? "",
["categoryName"] = Text(item.Payload["categoryName"]),
["status"] = status,
["statusCode"] = status switch { "reported" => "Y", "not_reported" => "N", _ => "P" },
["note"] = Text(row?["note"]),
["updatedAt"] = JsonValue.Create(row?["updatedAt"])
};
})
.OrderBy(item => Text(item["candidateNumber"]), StringComparer.Ordinal)
.ToArray();
}
private static JsonObject ReportingBatch(
AdmissionData data,
CandidateAdmissionRecord plan,
CandidateAdmissionRecord? record)
{
var exam = data.Operational.Exams.FirstOrDefault(item => item.Id == plan.ExamId);
return new JsonObject
{
["id"] = record?.Id ?? "",
["exam"] = new JsonObject
{
["id"] = exam?.Id ?? plan.ExamId,
["code"] = Text(exam?.Data["code"]),
["name"] = Text(exam?.Data["name"])
},
["round"] = Integer(record?.Payload["round"], 1),
["status"] = record?.Status ?? "not_started",
["rows"] = new JsonArray(ReportingRows(data, plan, record)),
["progress"] = PlanProgress(data, plan),
["supplementDecision"] = Text(record?.Payload["supplementDecision"]),
["decisionNote"] = Text(record?.Payload["decisionNote"]),
["approvalNote"] = Text(record?.Payload["approvalNote"]),
["updatedAt"] = JsonValue.Create(record?.UpdatedAt)
};
}
private static JsonObject AdmissionSchoolPlacement(AdmissionData data, CandidateAdmissionRecord item)
{
var output = RecordJson(item);
var profile = data.Operational.Profiles.FirstOrDefault(entry => entry.UserId == item.UserId);
var account = data.Directory.Users.FirstOrDefault(entry => entry.Id == item.UserId);
var registration = data.Operational.Registrations.FirstOrDefault(entry =>
entry.ExamId == item.ExamId && entry.UserId == item.UserId);
var exam = data.Operational.Exams.FirstOrDefault(entry => entry.Id == item.ExamId);
var results = registration is null ? [] : data.Results.Where(entry =>
entry.RegistrationId == registration.Id && entry.Published).Select(entry => (JsonNode)new JsonObject
{
["subjectName"] = Text(exam?.Subjects.FirstOrDefault(subject => Text(subject["id"]) == entry.SubjectId)?["name"]) is { Length: > 0 } subjectName ? subjectName : entry.SubjectId,
["score"] = entry.Score
}).ToArray();
output["examName"] = Text(exam?.Data["name"]) is { Length: > 0 } examName ? examName : item.ExamId;
output["candidate"] = new JsonObject
{
["registrationNumber"] = account?.CandidateNumber ?? "",
["name"] = Text(profile?.Data["name"]),
["gender"] = Text(profile?.Data["gender"]),
["idNumberMasked"] = MaskId(Text(profile?.Data["idNumber"])),
["specialtyCategory"] = ResolveSpecialty(profile?.Data ?? new JsonObject()).Category,
["specialtyType"] = ResolveSpecialty(profile?.Data ?? new JsonObject()).Type,
["specialtyLabel"] = SpecialtyLabel(
ResolveSpecialty(profile?.Data ?? new JsonObject()).Category,
ResolveSpecialty(profile?.Data ?? new JsonObject()).Type),
["specialtyCertificate"] = Text(profile?.Data["specialtyCertificate"]),
["policyEligibility"] = Text(profile?.Data["policyEligibility"])
};
output["featureScore"] = Number(registration?.Data["featureScore"]);
output["results"] = new JsonArray(results);
return output;
}
private static JsonObject AdmittedCandidateRow(
AdmissionData data,
OperationalExam exam,
AdminSchool school,
CandidateAdmissionRecord item)
{
var profile = data.Operational.Profiles.FirstOrDefault(entry => entry.UserId == item.UserId);
var account = data.Directory.Users.FirstOrDefault(entry => entry.Id == item.UserId);
var registration = data.Operational.Registrations.FirstOrDefault(entry =>
entry.ExamId == exam.Id && entry.UserId == item.UserId);
var sourceSchool = School(data, profile?.SchoolId);
var schoolClass = data.Directory.Classes.FirstOrDefault(entry => entry.Id == profile?.ClassId);
var specialty = ResolveSpecialty(profile?.Data ?? new JsonObject());
var scores = registration is null ? [] : data.Results.Where(entry =>
entry.RegistrationId == registration.Id && entry.Published).ToArray();
string SubjectName(string id) =>
Text(exam.Subjects.FirstOrDefault(subject => Text(subject["id"]) == id)?["name"]) is { Length: > 0 } value
? value : id;
return new JsonObject
{
["candidateNumber"] = account?.CandidateNumber ?? Text(registration?.Data["registrationNumber"]),
["name"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
["gender"] = Text(profile?.Data["gender"]),
["idNumber"] = Text(profile?.Data["idNumber"]),
["phone"] = Text(profile?.Data["phone"]),
["email"] = Text(profile?.Data["email"]),
["birthDate"] = Text(profile?.Data["birthDate"]),
["ethnicity"] = Text(profile?.Data["ethnicity"]),
["nativePlace"] = Text(profile?.Data["nativePlace"]),
["sourceSchoolCode"] = sourceSchool?.Code ?? "",
["sourceSchool"] = sourceSchool?.Name ?? Text(profile?.Data["school"]),
["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]),
["address"] = string.Join(' ', new[]
{
Text(profile?.Data["provinceName"]), Text(profile?.Data["cityName"]),
Text(profile?.Data["districtName"]), Text(profile?.Data["address"])
}.Where(value => value.Length > 0)),
["guardianName"] = Text(profile?.Data["guardianName"]) is { Length: > 0 } guardian
? guardian : Text(profile?.Data["emergencyContact"]),
["guardianPhone"] = Text(profile?.Data["guardianPhone"]) is { Length: > 0 } phone
? phone : Text(profile?.Data["emergencyPhone"]),
["specialty"] = SpecialtyLabel(specialty.Category, specialty.Type),
["specialtyCertificate"] = Text(profile?.Data["specialtyCertificate"]),
["policyEligibility"] = Text(profile?.Data["policyEligibility"]),
["featureScore"] = Number(registration?.Data["featureScore"]),
["subjectScores"] = string.Join('', scores.Select(score =>
$"{SubjectName(score.SubjectId)} {score.Score.ToString("0.##", CultureInfo.InvariantCulture)}")),
["totalScore"] = Number(item.Payload["totalScore"]),
["admittedSchool"] = school.Name,
["categoryName"] = Text(item.Payload["categoryName"]),
["preferenceOrder"] = Integer(item.Payload["preferenceOrder"])
};
}
private static AdmissionData ReplaceRecord(AdmissionData data, CandidateAdmissionRecord updated)
{
var records = data.Admission.Records.Select(item => item.Id == updated.Id ? updated : item).ToArray();
return data with { Admission = data.Admission with { Records = records } };
}
private static JsonObject DefaultNoticeTemplate() => new()
{
["eyebrow"] = "ADMISSION NOTICE",
["title"] = "录 取 通 知 书",
["body"] = "经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。",
["footer"] = "请妥善保管本通知书,报到时出示。",
["primaryColor"] = "#8d2028",
["accentColor"] = "#c9a45b"
};
private static bool ValidColor(string value) =>
value.Length == 7 && value[0] == '#' && value[1..].All(Uri.IsHexDigit);
private static IEnumerable<JsonObject> FilterLedgerRows(
IEnumerable<JsonObject> rows,
IReadOnlyDictionary<string, string> filters)
{
var query = filters.GetValueOrDefault("q", "").Trim();
foreach (var item in rows)
{
var payload = item["payload"] as JsonObject;
var matches = new[] { "examId", "schoolId", "sourceSchoolId", "status", "round" }.All(key =>
{
var expected = filters.GetValueOrDefault(key, "");
return expected.Length == 0 || Text(item[key]) == expected || Text(payload?[key]) == expected;
});
if (!matches) continue;
var haystack = item.ToJsonString().ToLowerInvariant();
if (query.Length > 0 && query.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Any(word => !haystack.Contains(word.ToLowerInvariant(), StringComparison.Ordinal)))
continue;
yield return item;
}
}
private static AdminDocumentResult DocumentError(AdminEndpointResult error) =>
new(error.StatusCode, null, null, null, error.Body);
}
@@ -7,7 +7,7 @@ using Eis.Infrastructure.Candidate;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminAdmissionService(
internal sealed partial class AdminAdmissionService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
PasswordCompatibilityService passwords,
@@ -1,9 +1,12 @@
using System.Data.Common;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminExamRepository(IRelationalConnectionFactory connectionFactory)
internal sealed class AdminExamRepository(
IRelationalConnectionFactory connectionFactory,
IApplicationCache cache)
{
public Task CreateAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken) =>
SaveAsync(exam, replaceSubjects: true, isNew: true, audit, cancellationToken);
@@ -28,6 +31,7 @@ internal sealed class AdminExamRepository(IRelationalConnectionFactory connectio
await transaction.RollbackAsync(cancellationToken);
throw;
}
await cache.InvalidateAsync("public");
}
private async Task SaveAsync(
@@ -104,6 +108,7 @@ internal sealed class AdminExamRepository(IRelationalConnectionFactory connectio
await transaction.RollbackAsync(cancellationToken);
throw;
}
await cache.InvalidateAsync("public");
}
private static IReadOnlyList<SqlParameterValue> ExamParameters(ManagedExam exam) =>
@@ -0,0 +1,485 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Spreadsheets;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminExcelService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminAccountBatchSnapshotLoader directoryLoader,
AdminOperationalSnapshotLoader operationalLoader,
AdminCenterSnapshotLoader centerLoader,
AdminWriteRepository writeRepository,
CandidateWriteRepository candidateWriteRepository,
RegionCatalog regionCatalog,
IAdminOrganizationService organizationService,
IAdminCenterService centerService,
IAdminResultService resultService) : IAdminExcelService
{
private static readonly IReadOnlyDictionary<string, string> ResourceNames =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["classes"] = "班级台账",
["class_admins"] = "班级管理员",
["account_quotas"] = "报名号班级配额",
["account_results"] = "报名号下发结果",
["candidates"] = "考生资料",
["payments"] = "考试缴费名单",
["centers"] = "考点考场档案",
["results"] = "成绩台账"
};
public async Task<AdminDocumentResult> ExportAsync(
string sessionToken,
string resource,
bool template,
string examId,
string batchId,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return DocumentError(context.Error);
if (!ResourceNames.ContainsKey(resource) || !SystemWorkbook.Supports(resource))
return DocumentError(Error(404, "Excel 数据类型不存在"));
var user = context.User!;
if (!CanRead(user, resource)) return DocumentError(Error(403, "当前账号不能导出该数据"));
if (resource == "results")
return await resultService.ExportAsync(sessionToken, examId, cancellationToken);
var rows = template ? Array.Empty<JsonObject>() :
await ExportRowsAsync(user, resource, batchId, cancellationToken);
if (rows is null) return DocumentError(Error(404, "批次不存在或不在当前学校范围内"));
var content = SystemWorkbook.Build(resource, rows, template);
var name = $"{ResourceNames[resource]}-{(template ? "" : "")}-{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
return new(200, content,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", name, null);
}
public async Task<AdminEndpointResult> ImportAsync(
string sessionToken,
string resource,
byte[] content,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
if (!ResourceNames.ContainsKey(resource) || !SystemWorkbook.Supports(resource))
return Error(404, "Excel 数据类型不存在");
if (resource is "account_results" or "payments")
return Error(400, "该清单只支持导出");
IReadOnlyList<JsonObject> rows;
try
{
rows = SystemWorkbook.Parse(resource, content);
}
catch (WorkbookException error)
{
return Error(error.StatusCode, error.Message);
}
if (resource == "results")
return await resultService.PreviewImportAsync(
sessionToken,
new JsonArray(rows.Select(item => (JsonNode)item).ToArray()),
cancellationToken);
var user = context.User!;
return resource switch
{
"classes" => await ImportClassesAsync(user, rows, cancellationToken),
"class_admins" => await ImportClassAdminsAsync(sessionToken, user, rows, cancellationToken),
"account_quotas" => await ImportQuotasAsync(user, rows, cancellationToken),
"candidates" => await ImportCandidatesAsync(user, rows, cancellationToken),
"centers" => await ImportCentersAsync(sessionToken, user, rows, cancellationToken),
_ => Error(400, "该 Excel 类型仅支持导出")
};
}
private async Task<IReadOnlyList<JsonObject>?> ExportRowsAsync(
AuthenticationUser user,
string resource,
string batchId,
CancellationToken cancellationToken)
{
var directory = await directoryLoader.LoadAsync(cancellationToken);
var schoolIds = Level(user) == "super"
? directory.Schools.Select(item => item.Id).ToHashSet(StringComparer.Ordinal)
: new HashSet<string>(user.SchoolId is null ? [] : [user.SchoolId], StringComparer.Ordinal);
if (resource == "classes")
return directory.Classes.Where(item => schoolIds.Contains(item.SchoolId)).Select(item =>
new JsonObject
{
["schoolCode"] = directory.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Code ?? "",
["grade"] = item.Grade, ["name"] = item.Name, ["status"] = item.Active ? "启用" : "停用"
}).ToArray();
if (resource == "class_admins")
return directory.Users.Where(item => item.Role == "admin" && item.AdminLevel == "class" &&
item.SchoolId is not null && schoolIds.Contains(item.SchoolId))
.Select(item => new JsonObject
{
["schoolCode"] = directory.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Code ?? "",
["className"] = directory.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Name ?? "",
["displayName"] = item.DisplayName, ["username"] = item.Username, ["initialPassword"] = "",
["status"] = item.Active ? "启用" : "停用"
}).ToArray();
if (resource == "account_quotas")
return directory.Classes.Where(item => item.Active && schoolIds.Contains(item.SchoolId))
.Select(item => new JsonObject { ["className"] = item.Name, ["count"] = 0 }).ToArray();
if (resource == "account_results")
{
var batch = directory.Batches.FirstOrDefault(item =>
item.Id == batchId && schoolIds.Contains(item.SchoolId));
if (batch is null) return null;
return directory.Items.Where(item => item.BatchId == batch.Id)
.OrderBy(item => item.Position).Select(item => new JsonObject
{
["batchId"] = batch.Id,
["className"] = directory.Classes.FirstOrDefault(entry => entry.Id == item.ClassId)?.Name ?? "",
["candidateNumber"] = item.CandidateNumber,
["initialPassword"] = item.InitialPassword
}).ToArray();
}
var operational = await operationalLoader.LoadAsync(cancellationToken);
if (resource == "candidates")
return operational.Profiles.Where(item => InScope(user, item)).Select(profile =>
new JsonObject
{
["candidateNumber"] = operational.Users.FirstOrDefault(item => item.Id == profile.UserId)?.CandidateNumber ?? "",
["name"] = Text(profile.Data["name"]), ["gender"] = Text(profile.Data["gender"]),
["idNumber"] = Text(profile.Data["idNumber"]).StartsWith("PENDING-", StringComparison.Ordinal) ? "" : Text(profile.Data["idNumber"]),
["phone"] = Text(profile.Data["phone"]), ["email"] = Text(profile.Data["email"]),
["nativePlace"] = Text(profile.Data["nativePlace"]), ["provinceCode"] = Text(profile.Data["provinceCode"]),
["provinceName"] = Text(profile.Data["provinceName"]), ["cityCode"] = Text(profile.Data["cityCode"]),
["cityName"] = Text(profile.Data["cityName"]), ["districtCode"] = Text(profile.Data["districtCode"]),
["districtName"] = Text(profile.Data["districtName"]), ["address"] = Text(profile.Data["address"]),
["className"] = directory.Classes.FirstOrDefault(item => item.Id == profile.ClassId)?.Name ?? Text(profile.Data["grade"]),
["ethnicity"] = Text(profile.Data["ethnicity"]), ["birthDate"] = Text(profile.Data["birthDate"]),
["postalCode"] = Text(profile.Data["postalCode"]), ["guardianName"] = Text(profile.Data["guardianName"]),
["guardianPhone"] = Text(profile.Data["guardianPhone"])
}).ToArray();
if (resource == "payments")
return operational.Registrations.Where(item => item.Status == "approved")
.Select(registration => (Registration: registration,
Profile: operational.Profiles.FirstOrDefault(profile => profile.UserId == registration.UserId)))
.Where(item => item.Profile is not null && InScope(user, item.Profile))
.Select(item =>
{
var profile = item.Profile!;
var exam = operational.Exams.FirstOrDefault(exam => exam.Id == item.Registration.ExamId);
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
var subjectIds = (item.Registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToHashSet(StringComparer.Ordinal);
var subjects = (exam?.Subjects ?? []).Where(subject => subjectIds.Contains(Text(subject["id"]))).ToArray();
return new JsonObject
{
["examCode"] = Text(exam?.Data["code"]), ["examName"] = Text(exam?.Data["name"]),
["schoolName"] = directory.Schools.FirstOrDefault(school => school.Id == profile.SchoolId)?.Name ?? Text(profile.Data["school"]),
["className"] = directory.Classes.FirstOrDefault(entry => entry.Id == profile.ClassId)?.Name ?? Text(profile.Data["grade"]),
["candidateNumber"] = account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"]),
["candidateName"] = Text(profile.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
["subjectNames"] = string.Join('、', subjects.Select(subject => Text(subject["name"]))),
["amountDue"] = Math.Round(subjects.Sum(subject => Number(subject["fee"])), 2),
["paymentStatus"] = item.Registration.PaymentStatus == "paid" ? "已缴费" : "待缴费",
["paidAt"] = Text(item.Registration.Data["paidAt"]),
["paidByName"] = directory.Users.FirstOrDefault(entry => entry.Id == item.Registration.PaidBy)?.DisplayName ?? ""
};
}).ToArray();
if (resource == "centers")
{
var centers = await centerLoader.LoadAsync(cancellationToken);
return centers.Centers.Where(item => schoolIds.Contains(item.SchoolId)).SelectMany(center =>
{
var rooms = centers.Rooms.Where(item => item.CenterId == center.Id).ToArray();
if (rooms.Length == 0) rooms = [new AdminCenterRoom("", center.Id, "", "", "", "", 0, "", 1, 0, "", "active", "")];
return rooms.Select(room => new JsonObject
{
["schoolCode"] = directory.Schools.FirstOrDefault(item => item.Id == center.SchoolId)?.Code ?? "",
["centerCode"] = center.Code, ["centerName"] = center.Name,
["provinceCode"] = center.ProvinceCode, ["provinceName"] = center.ProvinceName,
["cityCode"] = center.CityCode, ["cityName"] = center.CityName,
["districtCode"] = center.DistrictCode, ["districtName"] = center.DistrictName,
["address"] = center.Address, ["managerName"] = center.ManagerName, ["managerPhone"] = center.ManagerPhone,
["contact"] = center.Contact, ["emergencyPhone"] = center.EmergencyPhone,
["gateOpenTime"] = center.GateOpenTime, ["transport"] = center.Transport,
["centerStatus"] = center.Status == "inactive" ? "停用" : "启用", ["centerNotes"] = center.Notes,
["roomCode"] = room.Code, ["roomName"] = room.Name, ["building"] = room.Building, ["floor"] = room.Floor,
["capacity"] = room.Id.Length == 0 ? "" : room.Capacity, ["seatPlan"] = room.SeatPlan,
["roomType"] = RoomTypeName(room.RoomType),
["roomStatus"] = room.Status == "inactive" ? "停用" : "启用", ["roomNotes"] = room.Notes
});
}).ToArray();
}
return [];
}
private async Task<AdminEndpointResult> ImportClassesAsync(
AuthenticationUser user,
IReadOnlyList<JsonObject> rows,
CancellationToken cancellationToken)
{
if (Level(user) is not ("school" or "super")) return Error(403, "当前账号不能导入班级");
var snapshot = await directoryLoader.LoadAsync(cancellationToken);
var classes = snapshot.Classes.ToList();
foreach (var row in rows)
{
var school = snapshot.Schools.FirstOrDefault(item =>
item.Code.Equals(Text(row["schoolCode"]), StringComparison.OrdinalIgnoreCase));
if (school is null || Level(user) == "school" && school.Id != user.SchoolId)
return RowError(row, "学校代码无效或不在管理范围内");
var name = Clean(Text(row["name"]), 100);
var grade = Clean(Text(row["grade"]), 60);
if (name.Length == 0 || grade.Length == 0) return RowError(row, "年级和班级名称不能为空");
var existing = classes.FirstOrDefault(item => item.SchoolId == school.Id && item.Name == name);
var entry = existing is null
? new AdminClass(Uid("class"), school.Id, name, grade, Text(row["status"]) != "停用")
: existing with { Grade = grade, Active = Text(row["status"]) != "停用" };
await writeRepository.SaveClassAsync(
entry, existing is null,
Audit(user, existing is null ? "Excel 新增班级" : "Excel 更新班级", $"{school.Name} · {name}"),
cancellationToken);
if (existing is null) classes.Add(entry);
}
return Success(new JsonObject { ["ok"] = true, ["count"] = rows.Count });
}
private async Task<AdminEndpointResult> ImportClassAdminsAsync(
string token,
AuthenticationUser user,
IReadOnlyList<JsonObject> rows,
CancellationToken cancellationToken)
{
if (Level(user) != "school") return Error(403, "班级管理员 Excel 导入由校级管理员执行");
foreach (var row in rows)
{
var snapshot = await directoryLoader.LoadAsync(cancellationToken);
var school = snapshot.Schools.FirstOrDefault(item =>
item.Id == user.SchoolId && item.Code.Equals(Text(row["schoolCode"]), StringComparison.OrdinalIgnoreCase));
var schoolClass = snapshot.Classes.FirstOrDefault(item =>
item.SchoolId == user.SchoolId && item.Name == Clean(Text(row["className"]), 100));
if (school is null || schoolClass is null) return RowError(row, "学校代码或班级名称无效");
var username = Clean(Text(row["username"]), 50);
var displayName = Clean(Text(row["displayName"]), 50);
var password = Text(row["initialPassword"]);
if (username.Length == 0 || displayName.Length == 0) return RowError(row, "管理员姓名和登录账号不能为空");
var existing = snapshot.Users.FirstOrDefault(item =>
item.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (existing is not null && (existing.AdminLevel != "class" || existing.SchoolId != user.SchoolId))
return RowError(row, "登录账号已被其他用户占用");
if (existing is null && password.Length < 8) return RowError(row, "新建管理员的初始密码至少 8 位");
var body = new JsonObject
{
["username"] = username, ["displayName"] = displayName, ["password"] = password,
["classId"] = schoolClass.Id, ["active"] = Text(row["status"]) != "停用"
};
var result = existing is null
? await organizationService.CreateAdminAsync(token, body, cancellationToken)
: await organizationService.UpdateAdminAsync(token, existing.Id, body, cancellationToken);
if (result.StatusCode is < 200 or >= 300) return RowError(row, Text(result.Body["message"]));
}
return Success(new JsonObject { ["ok"] = true, ["count"] = rows.Count });
}
private async Task<AdminEndpointResult> ImportQuotasAsync(
AuthenticationUser user,
IReadOnlyList<JsonObject> rows,
CancellationToken cancellationToken)
{
if (Level(user) != "school") return Error(403, "班级配额模板仅供校级管理员使用");
var snapshot = await directoryLoader.LoadAsync(cancellationToken);
var quotas = new JsonArray();
foreach (var row in rows.Where(item => Number(item["count"]) > 0))
{
var count = Number(row["count"]);
var schoolClass = snapshot.Classes.FirstOrDefault(item =>
item.SchoolId == user.SchoolId && item.Name == Clean(Text(row["className"]), 100) && item.Active);
if (schoolClass is null || count % 1 != 0 || count is < 1 or > 200)
return RowError(row, "班级不存在,或申领数量不在 1—200 之间");
quotas.Add(new JsonObject
{
["classId"] = schoolClass.Id, ["className"] = schoolClass.Name, ["count"] = (int)count
});
}
if (quotas.Count == 0) return Error(400, "模板中没有大于 0 的申领数量");
return Success(new JsonObject { ["ok"] = true, ["count"] = quotas.Count, ["quotas"] = quotas });
}
private async Task<AdminEndpointResult> ImportCandidatesAsync(
AuthenticationUser user,
IReadOnlyList<JsonObject> rows,
CancellationToken cancellationToken)
{
var operational = await operationalLoader.LoadAsync(cancellationToken);
var directory = await directoryLoader.LoadAsync(cancellationToken);
foreach (var row in rows)
{
var account = operational.Users.FirstOrDefault(item =>
item.CandidateNumber == Clean(Text(row["candidateNumber"]), 120));
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == account?.Id);
if (account is null || profile is null || !InScope(user, profile))
return RowError(row, "报名号不存在或不在数据范围内");
if (directory.Instances.Any(item =>
item.BusinessType == "profile_change" && item.BusinessId == profile.Id && item.Status == "pending"))
return RowError(row, "该考生已有待审批资料流程");
var schoolClass = directory.Classes.FirstOrDefault(item =>
item.SchoolId == profile.SchoolId && item.Name == Clean(Text(row["className"]), 100));
if (schoolClass is null) return RowError(row, "班级名称无效");
if (new[] { "name", "gender", "idNumber", "phone" }.Any(key => Clean(Text(row[key]), 200).Length == 0))
return RowError(row, "姓名、性别、证件号码和手机号必填");
var region = regionCatalog.Resolve(Text(row["provinceCode"]), Text(row["cityCode"]), Text(row["districtCode"]));
if (region is null) return RowError(row, "省、市或区县代码无效,或上下级不匹配");
var data = profile.Data.DeepClone().AsObject();
data["name"] = Clean(Text(row["name"]), 50);
data["gender"] = Clean(Text(row["gender"]), 10);
data["idNumber"] = Clean(Text(row["idNumber"]), 40);
data["phone"] = Clean(Text(row["phone"]), 30);
data["email"] = Clean(Text(row["email"]), 100);
data["nativePlace"] = Clean(Text(row["nativePlace"]), 100);
data["provinceCode"] = region.ProvinceCode; data["provinceName"] = region.ProvinceName;
data["cityCode"] = region.CityCode; data["cityName"] = region.CityName;
data["districtCode"] = region.DistrictCode; data["districtName"] = region.DistrictName;
data["address"] = Clean(Text(row["address"]), 200);
data["classId"] = schoolClass.Id; data["grade"] = schoolClass.Name;
data["ethnicity"] = Clean(Text(row["ethnicity"]), 30);
data["birthDate"] = Clean(Text(row["birthDate"]), 20);
data["postalCode"] = Clean(Text(row["postalCode"]), 20);
data["guardianName"] = Clean(Text(row["guardianName"]), 50);
data["guardianPhone"] = Clean(Text(row["guardianPhone"]), 30);
data["profileCompleted"] = true; data["status"] = "pending"; data["reviewNote"] = "";
data["reviewedAt"] = null; data["reviewerId"] = null; data["updatedAt"] = NowIso();
var workflow = directory.Workflows.FirstOrDefault(item => item.BusinessType == "profile_change" && item.Active);
var step = workflow?.Steps.OrderBy(item => item.Position).FirstOrDefault();
if (workflow is null || step is null) return Error(409, "该业务尚未配置审批流程");
var assignee = directory.Users.Where(item =>
item.Role == "admin" && item.Active && (item.AdminLevel ?? "super") == step.AdminLevel &&
(step.AdminLevel == "super" ||
step.AdminLevel == "school" && item.SchoolId == profile.SchoolId ||
step.AdminLevel == "class" && item.SchoolId == profile.SchoolId && item.ClassId == schoolClass.Id))
.OrderBy(item => directory.Instances.Count(instance => instance.Status == "pending" && instance.AssigneeId == item.Id))
.ThenBy(item => directory.Actions.Count(action => action.ToAssigneeId == item.Id))
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal).ThenBy(item => item.Id, StringComparer.Ordinal)
.FirstOrDefault();
if (assignee is null) return Error(409, $"没有可承接“{step.Name}”的管理员");
var now = NowIso();
var instance = new CandidateWorkflowInstance(
Uid("flow"), workflow.Id, "profile_change", profile.Id, "pending", 1, assignee.Id, now, null);
var action = new CandidateWorkflowAction(
Uid("flow_action"), instance.Id, user.Id, "submit", "提交审批", null, assignee.Id, now);
await candidateWriteRepository.UpdateProfileAsync(data, Text(data["name"]), instance, action, cancellationToken);
}
return Success(new JsonObject { ["ok"] = true, ["count"] = rows.Count });
}
private async Task<AdminEndpointResult> ImportCentersAsync(
string token,
AuthenticationUser user,
IReadOnlyList<JsonObject> rows,
CancellationToken cancellationToken)
{
if (Level(user) is not ("super" or "school")) return Error(403, "当前账号不能导入考点考场");
var directory = await directoryLoader.LoadAsync(cancellationToken);
var centers = await centerLoader.LoadAsync(cancellationToken);
var groups = rows.GroupBy(item => Clean(Text(item["centerCode"]), 30).ToUpperInvariant());
var count = 0;
foreach (var group in groups)
{
var first = group.First();
var school = directory.Schools.FirstOrDefault(item =>
item.Code.Equals(Text(first["schoolCode"]), StringComparison.OrdinalIgnoreCase) &&
(Level(user) == "super" || item.Id == user.SchoolId));
if (school is null || group.Key.Length == 0) return RowError(first, "学校代码或考点代码无效");
var existing = centers.Centers.FirstOrDefault(item =>
item.Code.Equals(group.Key, StringComparison.OrdinalIgnoreCase));
if (existing is not null && existing.SchoolId != school.Id) return RowError(first, "考点代码已属于其他学校");
var rooms = new JsonArray(group.Select(row => (JsonNode)new JsonObject
{
["code"] = Text(row["roomCode"]), ["name"] = Text(row["roomName"]),
["building"] = Text(row["building"]), ["floor"] = Text(row["floor"]),
["capacity"] = Number(row["capacity"]), ["seatPlan"] = Text(row["seatPlan"]),
["roomType"] = RoomTypeCode(Text(row["roomType"])),
["status"] = Text(row["roomStatus"]) == "停用" ? "inactive" : "active",
["notes"] = Text(row["roomNotes"])
}).ToArray());
var body = new JsonObject
{
["schoolId"] = school.Id, ["code"] = group.Key, ["name"] = Text(first["centerName"]),
["provinceCode"] = Text(first["provinceCode"]), ["cityCode"] = Text(first["cityCode"]),
["districtCode"] = Text(first["districtCode"]), ["address"] = Text(first["address"]),
["managerName"] = Text(first["managerName"]), ["managerPhone"] = Text(first["managerPhone"]),
["contact"] = Text(first["contact"]), ["emergencyPhone"] = Text(first["emergencyPhone"]),
["gateOpenTime"] = Text(first["gateOpenTime"]), ["transport"] = Text(first["transport"]),
["status"] = Text(first["centerStatus"]) == "停用" ? "inactive" : "active",
["notes"] = Text(first["centerNotes"]), ["rooms"] = rooms
};
var result = existing is null
? await centerService.CreateAsync(token, body, cancellationToken)
: await centerService.UpdateAsync(token, existing.Id, body, cancellationToken);
if (result.StatusCode is < 200 or >= 300) return RowError(first, Text(result.Body["message"]));
count++;
}
return Success(new JsonObject { ["ok"] = true, ["count"] = count });
}
private async Task<ResolvedAdmin> ResolveAsync(string token, 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);
return user is { Role: "admin", Active: true, ArchivedAt: null }
? new(user, null)
: ResolvedAdmin.Failed(Error(user is null ? 401 : 403, user is null ? "请先登录" : "当前账号无权执行此操作"));
}
private static bool CanRead(AuthenticationUser user, string resource) => resource switch
{
"classes" or "class_admins" or "account_quotas" or "account_results" => Level(user) is "school" or "super",
"centers" => Level(user) is "school" or "super",
_ => true
};
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 string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
private static string RoomTypeName(string value) => value switch
{
"standard" => "标准考场", "computer" => "机考考场", "accessible" => "无障碍考场",
"spare" => "备用考场", _ => value
};
private static string RoomTypeCode(string value) => value switch
{
"标准考场" => "standard", "机考考场" => "computer", "无障碍考场" => "accessible",
"备用考场" => "spare", _ => value
};
private static AdminEndpointResult RowError(JsonObject row, string message) =>
Error(400, $"Excel 第 {Integer(row["__row"], 0)} 行:{message}");
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 static string Text(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static double Number(JsonNode? node) =>
double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0;
private static int Integer(JsonNode? node, int fallback = 0) =>
int.TryParse(Text(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
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}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds():x}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) =>
new(Uid("log"), user.Id, action, detail, NowIso());
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error)
{
public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error);
}
}
@@ -1,13 +1,16 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminNotice(string Id, string Title, string Summary, string Content, string Category, bool Pinned, string Status, string? PublishAt, string? CreatedAt, string Author);
internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connectionFactory)
internal sealed class AdminNoticeRepository(
IRelationalConnectionFactory connectionFactory,
IApplicationCache cache)
{
public async Task<AdminNoticeManagementSnapshot> LoadManagementAsync(CancellationToken cancellationToken)
{
@@ -73,6 +76,7 @@ internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connect
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.InvalidateAsync("public");
}
catch
{
@@ -98,6 +102,7 @@ internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connect
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
await cache.InvalidateAsync("public");
}
catch
{
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Caching;
namespace Eis.Infrastructure.Administration;
@@ -12,7 +13,8 @@ internal sealed class AdminResultService(
AdminOperationalSnapshotLoader operationalSnapshotLoader,
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
AdminAccountBatchSnapshotLoader directorySnapshotLoader,
AdminResultRepository repository) : IAdminResultService
AdminResultRepository repository,
IApplicationCache cache) : IAdminResultService
{
public async Task<AdminEndpointResult> GetAsync(
string sessionToken,
@@ -158,6 +160,7 @@ internal sealed class AdminResultService(
published ? "发布成绩" : "保存成绩",
$"{Text(profile?.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(score)}")),
cancellationToken);
await cache.InvalidateAsync("results");
return Success(new JsonObject { ["ok"] = true, ["result"] = RawResultJson(candidate) });
}
@@ -226,6 +229,7 @@ internal sealed class AdminResultService(
$"{account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"])} · {Text(exam.Data["name"])} · {Text(subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
}).ToArray();
await repository.SaveManyAsync(writes, cancellationToken);
await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject
{
@@ -281,7 +285,7 @@ internal sealed class AdminResultService(
else if (subject is null) message = "该考试中不存在此科目";
else if (registration is null) message = "该考生没有已通过的本场考试报名";
else if (!SubjectIds(registration).Contains(Text(subject["id"]))) message = "该考生未报考此科目";
var score = Number(row["score"]);
var score = Text(row["score"]).Length == 0 ? double.NaN : Number(row["score"]);
if (message is null && (!double.IsFinite(score) || score < 0 || score > Number(subject?["fullScore"])))
message = $"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间";
var publishText = Text(row["published"]);
@@ -325,6 +329,7 @@ internal sealed class AdminResultService(
$"{item.CandidateNumber} · {Text(item.Exam.Data["name"])} · {Text(item.Subject["name"])} · {AdminResultScoring.Format(result.Score)}"));
}).ToArray();
await repository.SaveManyAsync(writes, cancellationToken);
await cache.InvalidateAsync("results");
var create = prepared.Count(item => item.Existing is null);
return Success(new JsonObject
{
@@ -342,6 +347,143 @@ internal sealed class AdminResultService(
});
}
public async Task<AdminEndpointResult> PreviewImportAsync(
string sessionToken,
JsonArray rows,
CancellationToken cancellationToken)
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var directory = await directorySnapshotLoader.LoadAsync(cancellationToken);
var previewResults = (await resultSnapshotLoader.LoadAsync(cancellationToken)).ToList();
var output = new List<JsonObject>();
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < rows.Count; index++)
{
var source = rows[index] as JsonObject ?? new JsonObject();
var sourceRow = Integer(source["__row"] ?? source["sourceRow"], index + 3);
var candidateNumber = Clean(Text(source["candidateNumber"]), 120);
var examCode = Clean(Text(source["examCode"]), 60);
var subjectName = Clean(Text(source["subjectName"]), 50);
var account = operational.Users.FirstOrDefault(item => item.CandidateNumber == candidateNumber);
var exam = operational.Exams.FirstOrDefault(item =>
Text(item.Data["code"]).Equals(examCode, StringComparison.OrdinalIgnoreCase));
var registration = operational.Registrations.FirstOrDefault(item =>
item.UserId == account?.Id && item.ExamId == exam?.Id && item.Status == "approved");
var subject = exam?.Subjects.FirstOrDefault(item =>
Text(item["name"]).Equals(subjectName, StringComparison.OrdinalIgnoreCase));
var errors = new JsonArray();
void AddError(string message) => errors.Add(message);
if (candidateNumber.Length == 0) AddError("报名号不能为空");
else if (account is null) AddError("报名号不存在");
if (examCode.Length == 0) AddError("考试代码不能为空");
else if (exam is null) AddError("考试代码不存在");
else if (exam.Data["archivedAt"] is not null) AddError("该考试已归档,成绩已永久锁定");
if (subjectName.Length == 0) AddError("科目不能为空");
else if (exam is not null && subject is null) AddError("该考试中不存在此科目");
if (exam is not null && account is not null && registration is null)
AddError("该考生没有已通过的本场考试报名");
if (registration is not null && subject is not null &&
!SubjectIds(registration).Contains(Text(subject["id"])))
AddError("该考生未报考此科目");
var score = Text(source["score"]).Length == 0 ? double.NaN : Number(source["score"]);
if (!double.IsFinite(score) || score < 0 || subject is not null && score > Number(subject["fullScore"]))
AddError($"成绩须在 0—{AdminResultScoring.Format(Number(subject?["fullScore"]))} 之间");
var publishedText = Text(source["published"]);
var published = source["published"] is JsonValue publishedValue &&
publishedValue.TryGetValue<bool>(out var publishedBoolean)
? publishedBoolean
: publishedText == "发布";
if (publishedText.Length > 0 && publishedText is not ("发布" or "不发布") &&
!(source["published"] is JsonValue booleanValue && booleanValue.TryGetValue<bool>(out _)))
AddError("发布状态只能是“发布”或“不发布”");
var key = registration is not null && subject is not null
? $"{registration.Id}|{Text(subject["id"])}"
: $"{candidateNumber}|{examCode}|{subjectName}";
if (!seen.Add(key)) AddError("同一考生、考试和科目在文件中重复");
var existing = registration is null || subject is null
? null
: previewResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
var preview = new AdminWorkflowResult(
existing?.Id ?? $"preview-{sourceRow}",
registration?.Id ?? "",
Text(subject?["id"]),
double.IsFinite(score) ? score : 0,
published ? "" : "待发布",
published,
existing?.UpdatedAt ?? NowIso(),
published ? existing?.PublishedAt ?? NowIso() : null);
if (errors.Count == 0) Replace(previewResults, preview);
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == account?.Id);
var schoolClass = directory.Classes.FirstOrDefault(item => item.Id == profile?.ClassId);
output.Add(new JsonObject
{
["sourceRow"] = sourceRow,
["candidateNumber"] = candidateNumber,
["candidateName"] = Text(profile?.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
["schoolName"] = Text(profile?.Data["school"]),
["className"] = schoolClass?.Name ?? Text(profile?.Data["grade"]),
["examId"] = exam?.Id ?? "",
["examCode"] = examCode,
["examName"] = Text(exam?.Data["name"]),
["registrationId"] = registration?.Id ?? "",
["subjectId"] = Text(subject?["id"]),
["subjectName"] = subjectName,
["fullScore"] = subject is null ? null : Number(subject["fullScore"]),
["passRule"] = Text(subject?["passRule"]) is { Length: > 0 } rule ? rule : "fixed_score",
["passValue"] = subject is null ? null : Number(subject["passValue"] ?? subject["passScore"]),
["score"] = double.IsFinite(score) ? score : null,
["published"] = published,
["existingResultId"] = existing?.Id ?? "",
["mode"] = existing is null ? "create" : "update",
["errors"] = errors
});
}
foreach (var row in output.Where(item => (item["errors"] as JsonArray)?.Count == 0))
{
var result = previewResults.First(item =>
item.RegistrationId == Text(row["registrationId"]) && item.SubjectId == Text(row["subjectId"]));
var exam = operational.Exams.First(item => item.Id == Text(row["examId"]));
var subject = exam.Subjects.First(item => Text(item["id"]) == result.SubjectId);
var pass = AdminResultScoring.Pass(previewResults, result, subject);
row["rank"] = pass.Rank.Rank;
row["cohortSize"] = pass.Rank.CohortSize;
row["rankPercent"] = pass.Rank.RankPercent;
row["grade"] = result.Published ? pass.Rank.Grade : "待发布";
row["passScore"] = pass.PassScore;
row["cutoffRank"] = pass.CutoffRank;
row["qualified"] = JsonValue.Create(pass.Qualified);
row["passText"] = AdminResultScoring.PassText(subject);
}
var invalid = output.Count(item => (item["errors"] as JsonArray)?.Count > 0);
return Success(new JsonObject
{
["ok"] = true,
["rows"] = new JsonArray(output.Select(item => (JsonNode)item).ToArray()),
["errors"] = new JsonArray(output.SelectMany(item =>
(item["errors"] as JsonArray ?? []).Select(error => (JsonNode)new JsonObject
{
["row"] = Integer(item["sourceRow"]),
["message"] = Text(error)
})).ToArray()),
["summary"] = new JsonObject
{
["total"] = output.Count,
["valid"] = output.Count - invalid,
["invalid"] = invalid,
["create"] = output.Count(item => Text(item["mode"]) == "create" && (item["errors"] as JsonArray)?.Count == 0),
["update"] = output.Count(item => Text(item["mode"]) == "update" && (item["errors"] as JsonArray)?.Count == 0),
["publish"] = output.Count(item =>
item["published"] is JsonValue value &&
value.TryGetValue<bool>(out var published) &&
published &&
(item["errors"] as JsonArray)?.Count == 0)
}
});
}
public async Task<AdminEndpointResult> SaveFeatureBulkAsync(
string sessionToken,
JsonObject body,
@@ -376,6 +518,7 @@ internal sealed class AdminResultService(
Audit(user, "批量登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam.Data["name"])} · {AdminResultScoring.Format(score)}")));
}
await repository.SaveFeatureScoresAsync(entries, cancellationToken);
await cache.InvalidateAsync("results");
return Success(new JsonObject { ["ok"] = true, ["count"] = entries.Count });
}
@@ -405,6 +548,7 @@ internal sealed class AdminResultService(
score,
Audit(user, "登记特征分", $"{Text(profile?.Data["name"])} · {Text(exam?.Data["name"])} · {AdminResultScoring.Format(score)}"))],
cancellationToken);
await cache.InvalidateAsync("results");
var output = registration.Data.DeepClone().AsObject();
output["featureScore"] = score;
return Success(new JsonObject { ["ok"] = true, ["registration"] = output });
@@ -416,12 +560,15 @@ internal sealed class AdminResultService(
{
var context = await ResolveAsync(sessionToken, superOnly: true, cancellationToken);
if (context.Error is not null) return context.Error;
var refreshed = await cache.InvalidateAsync("results");
return Success(new JsonObject
{
["ok"] = true,
["refreshed"] = false,
["cacheStatus"] = "disabled",
["message"] = "Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库"
["refreshed"] = refreshed,
["cacheStatus"] = cache.Status,
["message"] = refreshed
? "成绩 Redis 缓存已刷新,后续查询将重新生成缓存"
: "Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库"
});
}
@@ -605,6 +752,10 @@ internal sealed class AdminResultService(
updatedResult,
Audit(user, auditAction, detail),
cancellationToken);
if (updatedResult is not null)
{
await cache.InvalidateAsync("results");
}
var responseResult = updatedResult ?? result;
var rank = AdminResultScoring.Rank(allResults, responseResult);
var responsePass = AdminResultScoring.Pass(allResults, responseResult, subject);
@@ -831,10 +982,10 @@ internal sealed class AdminResultService(
return output;
}
private static JsonObject CacheStatus() => new()
private JsonObject CacheStatus() => new()
{
["enabled"] = false,
["status"] = "disabled"
["enabled"] = cache.Enabled,
["status"] = cache.Status
};
private static void Replace(List<AdminWorkflowResult> results, AdminWorkflowResult result)
@@ -1,4 +1,5 @@
using System.Data.Common;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
@@ -10,7 +11,9 @@ internal sealed record AdminAuditEntry(
string Detail,
string CreatedAt);
internal sealed class AdminWriteRepository(IRelationalConnectionFactory connectionFactory)
internal sealed class AdminWriteRepository(
IRelationalConnectionFactory connectionFactory,
IApplicationCache cache)
{
public Task SaveSchoolAsync(
AdminSchool school,
@@ -305,6 +308,7 @@ internal sealed class AdminWriteRepository(IRelationalConnectionFactory connecti
await transaction.RollbackAsync(cancellationToken);
throw;
}
await cache.InvalidateAsync("public");
}
private static async Task ExecuteAsync(
@@ -0,0 +1,220 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using StackExchange.Redis;
namespace Eis.Infrastructure.Caching;
public interface IApplicationCache
{
bool Enabled { get; }
string Status { get; }
Task<JsonObject> RememberJsonAsync(
string cacheNamespace,
string key,
Func<Task<JsonObject>> loader,
int? ttlSeconds = null);
Task<bool> InvalidateAsync(string cacheNamespace);
}
internal sealed class ApplicationCache : IApplicationCache, IDisposable
{
private readonly ConcurrentDictionary<string, LocalEntry> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, PendingEntry> _pending = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, long> _generations = new(StringComparer.Ordinal);
private readonly object _pruneLock = new();
private readonly int _defaultTtlSeconds;
private readonly int _maxEntries;
private readonly string _prefix;
private readonly IConnectionMultiplexer? _connection;
private readonly IDatabase? _database;
private volatile bool _unavailable;
public ApplicationCache() : this(
Environment.GetEnvironmentVariable("REDIS_URL")?.Trim(),
PositiveInteger("REDIS_CACHE_TTL_SECONDS", 60, 86400),
PositiveInteger("LOCAL_CACHE_MAX_ENTRIES", 200, 5000),
SanitizePrefix(Environment.GetEnvironmentVariable("REDIS_CACHE_PREFIX")))
{
}
internal ApplicationCache(string? redisUrl, int defaultTtlSeconds = 60, int maxEntries = 200, string prefix = "exam-information")
{
_defaultTtlSeconds = Math.Clamp(defaultTtlSeconds, 1, 86400);
_maxEntries = Math.Clamp(maxEntries, 1, 5000);
_prefix = SanitizePrefix(prefix);
var url = redisUrl?.Trim();
if (string.IsNullOrEmpty(url)) return;
try
{
var configuration = ConfigurationOptions.Parse(url);
configuration.AbortOnConnectFail = false;
configuration.ConnectTimeout = PositiveInteger("REDIS_CONNECT_TIMEOUT_MS", 1500, 30000);
configuration.ConnectRetry = 1;
_connection = ConnectionMultiplexer.Connect(configuration);
_database = _connection.GetDatabase();
_unavailable = !_connection.IsConnected;
}
catch (RedisException)
{
_unavailable = true;
}
catch (ArgumentException)
{
_unavailable = true;
}
}
public bool Enabled => _database is not null && _connection?.IsConnected == true;
public string Status => _database is null ? (_unavailable ? "unavailable" : "disabled") :
Enabled ? "ready" : "unavailable";
public async Task<JsonObject> RememberJsonAsync(
string cacheNamespace,
string key,
Func<Task<JsonObject>> loader,
int? ttlSeconds = null)
{
var lifetime = Math.Clamp(ttlSeconds ?? _defaultTtlSeconds, 1, 86400);
if (Enabled)
{
try
{
var version = await NamespaceVersionAsync(cacheNamespace);
var redisKey = $"{_prefix}:{cacheNamespace}:{version}:{key}";
var cached = await _database!.StringGetAsync(redisKey);
if (cached.HasValue && JsonNode.Parse(cached.ToString()) is JsonObject parsed)
return parsed;
var loaded = await CoalescedLoadAsync(
$"redis:{redisKey}",
cacheNamespace,
loader,
lifetime,
storeLocal: false);
await _database.StringSetAsync(redisKey, loaded.ToJsonString(), TimeSpan.FromSeconds(lifetime));
return loaded;
}
catch (RedisException)
{
_unavailable = true;
}
}
return await RememberLocalAsync(cacheNamespace, key, loader, lifetime);
}
public async Task<bool> InvalidateAsync(string cacheNamespace)
{
_generations.AddOrUpdate(cacheNamespace, 1, (_, value) => value + 1);
var prefix = $"{cacheNamespace}:";
foreach (var key in _values.Keys.Where(key => key.StartsWith(prefix, StringComparison.Ordinal)))
_values.TryRemove(key, out _);
if (!Enabled) return false;
try
{
await _database!.StringIncrementAsync(VersionKey(cacheNamespace));
return true;
}
catch (RedisException)
{
_unavailable = true;
return false;
}
}
private async Task<JsonObject> RememberLocalAsync(
string cacheNamespace,
string key,
Func<Task<JsonObject>> loader,
int ttlSeconds)
{
var fullKey = $"{cacheNamespace}:{key}";
if (_values.TryGetValue(fullKey, out var cached))
{
if (cached.ExpiresAt > DateTimeOffset.UtcNow) return Parse(cached.Json);
_values.TryRemove(fullKey, out _);
}
return await CoalescedLoadAsync(fullKey, cacheNamespace, loader, ttlSeconds, storeLocal: true);
}
private async Task<JsonObject> CoalescedLoadAsync(
string fullKey,
string cacheNamespace,
Func<Task<JsonObject>> loader,
int ttlSeconds,
bool storeLocal)
{
var generation = Generation(cacheNamespace);
while (_pending.TryGetValue(fullKey, out var existing))
{
if (existing.Generation == generation)
return Parse(await existing.Loading);
_pending.TryRemove(new KeyValuePair<string, PendingEntry>(fullKey, existing));
}
var loading = LoadJsonAsync(loader);
var pending = new PendingEntry(generation, loading);
if (!_pending.TryAdd(fullKey, pending))
{
var active = _pending[fullKey];
return Parse(await active.Loading);
}
try
{
var json = await loading;
if (storeLocal && Generation(cacheNamespace) == generation)
{
_values[fullKey] = new(json, DateTimeOffset.UtcNow.AddSeconds(ttlSeconds));
Prune();
}
return Parse(json);
}
finally
{
_pending.TryRemove(new KeyValuePair<string, PendingEntry>(fullKey, pending));
}
}
private async Task<string> NamespaceVersionAsync(string cacheNamespace)
{
var key = VersionKey(cacheNamespace);
var current = await _database!.StringGetAsync(key);
if (current.HasValue) return current.ToString();
await _database.StringSetAsync(key, "1", when: When.NotExists);
current = await _database.StringGetAsync(key);
return current.HasValue ? current.ToString() : "1";
}
private void Prune()
{
lock (_pruneLock)
{
var now = DateTimeOffset.UtcNow;
foreach (var item in _values.Where(item => item.Value.ExpiresAt <= now))
_values.TryRemove(item.Key, out _);
foreach (var key in _values.OrderBy(item => item.Value.ExpiresAt).Select(item => item.Key)
.Take(Math.Max(0, _values.Count - _maxEntries)))
_values.TryRemove(key, out _);
}
}
private long Generation(string cacheNamespace) => _generations.GetValueOrDefault(cacheNamespace);
private string VersionKey(string cacheNamespace) => $"{_prefix}:namespace:{cacheNamespace}";
private static async Task<string> LoadJsonAsync(Func<Task<JsonObject>> loader) =>
(await loader()).ToJsonString();
private static JsonObject Parse(string json) => JsonNode.Parse(json)?.AsObject() ?? new JsonObject();
private static int PositiveInteger(string name, int fallback, int maximum) =>
int.TryParse(Environment.GetEnvironmentVariable(name), out var value) && value > 0
? Math.Min(value, maximum)
: fallback;
private static string SanitizePrefix(string? value)
{
var source = string.IsNullOrWhiteSpace(value) ? "exam-information" : value.Trim();
var chars = source.Select(character =>
char.IsAsciiLetterOrDigit(character) || character is ':' or '_' or '-' ? character : '-').ToArray();
var result = new string(chars);
return result.Length == 0 ? "exam-information" : result;
}
public void Dispose() => _connection?.Dispose();
private sealed record LocalEntry(string Json, DateTimeOffset ExpiresAt);
private sealed record PendingEntry(long Generation, Task<string> Loading);
}
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Application.Candidate;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Security;
using QRCoder;
@@ -21,6 +22,20 @@ internal sealed partial class CandidateService
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, includeCohort: true);
var registrationById = snapshot.Registrations.ToDictionary(item => item.Id, StringComparer.Ordinal);
var results = snapshot.Results
@@ -46,7 +61,7 @@ internal sealed partial class CandidateService
summaries.Add(summary);
}
return Success(new JsonObject
return new JsonObject
{
["ok"] = true,
["results"] = new JsonArray(results),
@@ -56,9 +71,12 @@ internal sealed partial class CandidateService
["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,
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
using Eis.Application.Candidate;
using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Candidate;
@@ -15,7 +16,8 @@ internal sealed partial class CandidateService(
CandidateWriteRepository writeRepository,
RegionCatalog regionCatalog,
DocumentVerificationCodeService documentCodes,
CandidateAdmissionRepository admissionRepository) : ICandidateService
CandidateAdmissionRepository admissionRepository,
IApplicationCache cache) : ICandidateService
{
public async Task<CandidateEndpointResult> GetDashboardAsync(
string sessionToken,
@@ -5,6 +5,7 @@ using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
using Eis.Infrastructure.Public;
using Eis.Infrastructure.Security;
@@ -34,6 +35,7 @@ public static class DependencyInjection
{
services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
services.AddSingleton<IApplicationCache, ApplicationCache>();
services.AddSingleton(documentVerificationOptions);
services.AddSingleton<DocumentVerificationCodeService>();
services.AddSingleton(authenticationOptions);
@@ -82,6 +84,7 @@ public static class DependencyInjection
services.AddScoped<IAdminArrangementService, AdminArrangementService>();
services.AddScoped<IAdminResultService, AdminResultService>();
services.AddScoped<IAdminAdmissionService, AdminAdmissionService>();
services.AddScoped<IAdminExcelService, AdminExcelService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;
@@ -32,7 +32,13 @@ public sealed partial class PublicQueryService
["drama_broadcasting"] = ("arts", "戏剧与播音")
};
public async Task<JsonObject> GetAnnouncementsAsync(CancellationToken cancellationToken)
public Task<JsonObject> GetAnnouncementsAsync(CancellationToken cancellationToken) =>
cache.RememberJsonAsync(
"public",
"admission-announcements",
() => LoadAnnouncementsAsync(cancellationToken));
private async Task<JsonObject> LoadAnnouncementsAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var exams = await QueryAsync(connection,
@@ -4,6 +4,7 @@ using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Eis.Application.Public;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Data;
using Eis.Infrastructure.Security;
@@ -12,12 +13,16 @@ namespace Eis.Infrastructure.Public;
public sealed partial class PublicQueryService(
IRelationalConnectionFactory connectionFactory,
IPublicSiteConfiguration siteConfiguration,
DocumentVerificationCodeService documentCodes) : IPublicQueryService
DocumentVerificationCodeService documentCodes,
IApplicationCache cache) : IPublicQueryService
{
private readonly NoticeContentFormatter _noticeFormatter = new();
private readonly DocumentVerificationCodeService _documentCodes = documentCodes;
public async Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken)
public Task<JsonObject> GetHomeAsync(CancellationToken cancellationToken) =>
cache.RememberJsonAsync("public", "home", () => LoadHomeAsync(cancellationToken));
private async Task<JsonObject> LoadHomeAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var metadata = await QuerySingleAsync(connection,
@@ -100,7 +105,10 @@ public sealed partial class PublicQueryService(
public async Task<JsonObject?> GetNoticeAsync(string id, CancellationToken cancellationToken)
{
var home = await GetHomeAsync(cancellationToken);
// Detail reads deliberately bypass the list cache. During the compatibility
// window Node.js may publish a notice in another process without a shared
// Redis cache, and a just-created notice must still be immediately readable.
var home = await LoadHomeAsync(cancellationToken);
var notices = home["notices"]?.AsArray();
return notices?
.OfType<JsonObject>()
@@ -0,0 +1,308 @@
using System.Globalization;
using System.Text.Json.Nodes;
using ClosedXML.Excel;
namespace Eis.Infrastructure.Spreadsheets;
internal sealed record WorkbookColumn(
string Key,
string Header,
double Width,
object Example,
string NumberFormat = "@",
string[]? Validation = null);
internal sealed record WorkbookSpec(string Title, string Sheet, WorkbookColumn[] Columns);
internal static class SystemWorkbook
{
private static readonly IReadOnlyDictionary<string, WorkbookSpec> Specs =
new Dictionary<string, WorkbookSpec>(StringComparer.Ordinal)
{
["classes"] = new("班级台账", "班级",
[
C("schoolCode", "学校代码*", 16, "HZ01"), C("grade", "年级*", 14, "九年级"),
C("name", "班级名称*", 22, "九年级(3)班"), C("status", "状态*", 12, "启用", values: ["启用", "停用"])
]),
["class_admins"] = new("班级管理员台账", "班级管理员",
[
C("schoolCode", "学校代码*", 16, "HZ01"), C("className", "班级名称*", 22, "九年级(1)班"),
C("displayName", "管理员姓名*", 18, "张老师"), C("username", "登录账号*", 20, "hz01_g901"),
C("initialPassword", "初始密码(新建必填)", 24, "ChangeMe123!"),
C("status", "状态*", 12, "启用", values: ["启用", "停用"])
]),
["account_quotas"] = new("批量报名号申领配额", "班级配额",
[
C("className", "班级名称*", 24, "九年级(1)班"),
C("count", "申领数量*", 16, 30, "0")
]),
["account_results"] = new("报名号下发清单", "账号结果",
[
C("batchId", "批次编号", 30, ""), C("className", "班级", 22, ""),
C("candidateNumber", "固定报名号", 26, ""), C("initialPassword", "初始密码", 22, "")
]),
["candidates"] = new("考生资料台账", "考生资料",
[
C("candidateNumber", "报名号*", 26, "2026-HZ01-X-0001"), C("name", "姓名*", 16, "李明"),
C("gender", "性别*", 10, "男", values: ["男", "女"]), C("idNumber", "证件号码*", 24, "320101201101011234"),
C("phone", "手机号*", 18, "13800138000"), C("email", "邮箱", 24, "student@example.com"),
C("nativePlace", "籍贯", 18, "江苏海州"), C("provinceCode", "省级代码*", 14, "320000"),
C("provinceName", "省份", 18, "江苏省"), C("cityCode", "市级代码*", 14, "320700"),
C("cityName", "城市", 18, "连云港市"), C("districtCode", "区县代码*", 14, "320706"),
C("districtName", "区县", 18, "海州区"), C("address", "详细住址*", 32, "示例路 1 号"),
C("className", "班级*", 22, "九年级(1)班"), C("ethnicity", "民族", 12, "汉族"),
C("birthDate", "出生日期", 16, "2011-01-01"), C("postalCode", "邮编", 14, "222000"),
C("guardianName", "监护人", 16, "李家长"), C("guardianPhone", "监护人电话", 18, "13900139000")
]),
["payments"] = new("考试缴费名单", "缴费名单",
[
C("examCode", "考试代码", 18, ""), C("examName", "考试名称", 28, ""),
C("schoolName", "学校", 24, ""), C("className", "班级", 20, ""),
C("candidateNumber", "报名号", 26, ""), C("candidateName", "考生姓名", 16, ""),
C("subjectNames", "报考科目", 34, ""), C("amountDue", "应缴金额(元)", 18, 0, "0.00"),
C("paymentStatus", "缴费状态", 14, ""), C("paidAt", "确认时间", 24, ""),
C("paidByName", "确认人", 16, "")
]),
["centers"] = new("考点考场档案", "考点考场",
[
C("schoolCode", "学校代码*", 14, "HZ01"), C("centerCode", "考点代码*", 18, "HZ01-C02"),
C("centerName", "考点名称*", 24, "第一中学考点"), C("provinceCode", "省级代码*", 14, "320000"),
C("provinceName", "省份", 18, "江苏省"), C("cityCode", "市级代码*", 14, "320700"),
C("cityName", "城市", 18, "连云港市"), C("districtCode", "区县代码*", 14, "320706"),
C("districtName", "区县", 18, "海州区"), C("address", "详细地址*", 30, "示例路 8 号"),
C("managerName", "负责人", 16, "王老师"), C("managerPhone", "负责人手机", 18, "13800138000"),
C("contact", "值班电话", 18, "0518-86020000"), C("emergencyPhone", "应急电话", 18, "0518-120"),
C("gateOpenTime", "开放时间", 14, "07:00"), C("transport", "交通提示", 30, "东门入场"),
C("centerStatus", "考点状态*", 14, "启用", values: ["启用", "停用"]), C("centerNotes", "考点备注", 26, ""),
C("roomCode", "考场代码*", 16, "001"), C("roomName", "考场名称*", 22, "第 001 考场"),
C("building", "楼栋*", 18, "教学楼 A"), C("floor", "楼层", 12, "1 层"),
C("capacity", "容量*", 12, 30, "0"), C("seatPlan", "座位编排说明", 28, "按现场座次表编排"),
C("roomType", "考场类型*", 16, "标准考场", values: ["标准考场", "机考考场", "无障碍考场", "备用考场"]),
C("roomStatus", "考场状态*", 14, "启用", values: ["启用", "停用"]), C("roomNotes", "考场备注", 26, "")
]),
["results"] = new("考试成绩台账", "成绩",
[
C("candidateNumber", "报名号*", 26, "2026-HZ01-X-0001"), C("cardNumber", "准考证号(只读参考)", 24, ""),
C("candidateName", "姓名(只读参考)", 16, ""), C("schoolName", "学校(只读参考)", 24, ""),
C("className", "班级(只读参考)", 20, ""), C("examCode", "考试代码*", 20, "EX-2026-AUT"),
C("examName", "考试名称(只读参考)", 28, ""), C("subjectName", "科目*", 16, "语文"),
C("fullScore", "科目满分(只读参考)", 18, 0, "0.00"), C("passRule", "单科及格规则(只读参考)", 26, ""),
C("passScore", "实际及格分(只读参考)", 20, 0, "0.00"), C("score", "成绩*", 12, 120, "0.00"),
C("rank", "本科排名(导出计算)", 18, 0, "0"), C("rankPercent", "排名百分位(导出计算)", 20, 0, "0.00"),
C("qualified", "单科达线(导出计算)", 16, ""), C("grade", "排名等级(自动计算)", 18, ""),
C("published", "发布状态*", 14, "发布", values: ["发布", "不发布"]), C("updatedAt", "更新时间(只读参考)", 24, "")
]),
["admission_reporting"] = new("录取考生报到状态维护表", "考生报到",
[
C("noticeNumber", "录取通知书编号*", 34, "AD01-EX-2026-ZK-000001"),
C("candidateNumber", "报名号*", 26, "2026-HZ01-F-0001"), C("name", "姓名(只读)", 14, "张同学"),
C("examCode", "考试代码(只读)", 20, "EX-2026-ZK"), C("schoolCode", "招生学校代码(只读)", 18, "AD01"),
C("categoryName", "录取类别(只读)", 20, "普通生"),
C("reportingStatusCode", "报到状态码*Y/N/P", 22, "P", values: ["Y", "N", "P"]),
C("reportingNote", "报到备注", 36, "")
]),
["admitted_candidates"] = new("录取考生信息表", "录取考生",
[
C("candidateNumber", "报名号", 26, ""), C("name", "姓名", 14, ""), C("gender", "性别", 10, ""),
C("idNumber", "证件号码", 24, ""), C("phone", "手机号", 18, ""), C("email", "邮箱", 24, ""),
C("birthDate", "出生日期", 14, ""), C("ethnicity", "民族", 12, ""), C("nativePlace", "籍贯", 18, ""),
C("sourceSchoolCode", "生源学校代码", 16, ""), C("sourceSchool", "生源学校", 26, ""), C("className", "班级", 18, ""),
C("address", "家庭住址", 36, ""), C("guardianName", "监护人", 14, ""), C("guardianPhone", "监护人电话", 18, ""),
C("specialty", "特长生资格", 20, ""), C("specialtyCertificate", "特长证明编号", 20, ""),
C("policyEligibility", "政策资格说明", 24, ""), C("featureScore", "特征分", 12, 0, "0.00"),
C("subjectScores", "各科成绩", 42, ""), C("totalScore", "考生总成绩", 14, 0, "0.00"),
C("admittedSchool", "录取学校", 26, ""), C("categoryName", "录取类别", 20, ""),
C("preferenceOrder", "志愿序号", 12, 0, "0")
]),
["admission_preferences"] = new("志愿填报实时台账", "志愿填报",
[
C("examCode", "考试代码", 18, ""), C("examName", "考试名称", 28, ""), C("round", "轮次", 10, 1, "0"),
C("fillStatus", "填报状态", 14, ""), C("lockStatus", "锁定状态", 14, ""),
C("submissionCount", "已提交次数", 14, 0, "0"), C("maxSubmissions", "最多提交次数", 16, 0, "0"),
C("candidateNumber", "报名号", 26, ""), C("candidateName", "考生姓名", 16, ""),
C("sourceSchoolCode", "生源学校代码", 16, ""), C("sourceSchoolName", "生源学校", 26, ""),
C("className", "班级", 18, ""), C("specialty", "特长生资格", 20, ""),
C("indicatorStatus", "指标资格", 14, ""), C("preferenceOrder", "志愿序号", 12, "", "@"),
C("preferenceType", "志愿类型", 14, ""), C("targetSchoolCode", "目标学校代码", 18, ""),
C("targetSchoolName", "目标学校", 26, ""), C("categoryName", "招生类别", 20, ""),
C("submittedAt", "提交时间", 24, "")
]),
["admission_placements"] = new("招生录取情况台账", "招生录取",
[
C("examCode", "考试代码", 18, ""), C("examName", "考试名称", 28, ""), C("round", "轮次", 10, 1, "0"),
C("candidateNumber", "报名号", 26, ""), C("candidateName", "考生姓名", 16, ""),
C("sourceSchoolCode", "生源学校代码", 16, ""), C("sourceSchoolName", "生源学校", 26, ""),
C("className", "班级", 18, ""), C("specialty", "特长生资格", 20, ""),
C("culturalScore", "文化成绩", 14, 0, "0.00"), C("featureScore", "特征分", 12, 0, "0.00"),
C("totalScore", "投档总分", 14, 0, "0.00"), C("preferenceOrder", "志愿序号", 12, 0, "0"),
C("admissionSchoolCode", "招生学校代码", 16, ""), C("admissionSchoolName", "招生学校", 26, ""),
C("categoryName", "招生类别", 20, ""), C("quotaBucket", "计划类型", 14, ""),
C("admissionStatus", "录取状态", 14, ""), C("reportingStatus", "报到状态", 14, ""),
C("noticeNumber", "通知书编号", 34, ""), C("withdrawalReason", "退档或未报到原因", 32, ""),
C("updatedAt", "更新时间", 24, "")
])
};
public static bool Supports(string resource) => Specs.ContainsKey(resource);
public static byte[] Build(
string resource,
IReadOnlyList<JsonObject> rows,
bool template = false,
string subtitle = "")
{
var spec = Get(resource);
using var workbook = new XLWorkbook();
workbook.Properties.Author = "衡准考试信息管理系统";
var sheet = workbook.Worksheets.Add(spec.Sheet);
sheet.ShowGridLines = false;
sheet.SheetView.FreezeRows(2);
var title = sheet.Range(1, 1, 1, spec.Columns.Length);
title.Merge();
title.Value = subtitle.Length > 0 ? $"{spec.Title}{subtitle}" : spec.Title;
title.Style.Fill.BackgroundColor = XLColor.FromHtml("#173F60");
title.Style.Font.FontName = "微软雅黑";
title.Style.Font.FontSize = 16;
title.Style.Font.Bold = true;
title.Style.Font.FontColor = XLColor.White;
title.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Row(1).Height = 34;
for (var index = 0; index < spec.Columns.Length; index++)
{
var column = spec.Columns[index];
var cell = sheet.Cell(2, index + 1);
cell.Value = column.Header;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#2C7080");
cell.Style.Font.FontName = "微软雅黑";
cell.Style.Font.Bold = true;
cell.Style.Font.FontColor = column.Header.Contains('*') ? XLColor.FromHtml("#FFE7A3") : XLColor.White;
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
cell.Style.Alignment.WrapText = true;
sheet.Column(index + 1).Width = column.Width;
sheet.Column(index + 1).Style.NumberFormat.Format = column.NumberFormat;
}
sheet.Row(2).Height = 25;
IReadOnlyList<JsonObject> output = rows.Count > 0
? rows
: template ? [new JsonObject(spec.Columns.ToDictionary(
item => item.Key, item => ToNode(item.Example), StringComparer.Ordinal))] : [];
for (var rowIndex = 0; rowIndex < output.Count; rowIndex++)
{
for (var columnIndex = 0; columnIndex < spec.Columns.Length; columnIndex++)
{
var value = output[rowIndex][spec.Columns[columnIndex].Key];
SetValue(sheet.Cell(rowIndex + 3, columnIndex + 1), value);
sheet.Cell(rowIndex + 3, columnIndex + 1).Style.Font.FontName = "微软雅黑";
sheet.Cell(rowIndex + 3, columnIndex + 1).Style.Font.FontSize = 10;
sheet.Cell(rowIndex + 3, columnIndex + 1).Style.Border.BottomBorder = XLBorderStyleValues.Hair;
sheet.Cell(rowIndex + 3, columnIndex + 1).Style.Border.BottomBorderColor = XLColor.FromHtml("#D8E2E7");
sheet.Cell(rowIndex + 3, columnIndex + 1).Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
}
sheet.Row(rowIndex + 3).Height = 23;
}
var lastRow = Math.Max(202, output.Count + 2);
for (var index = 0; index < spec.Columns.Length; index++)
{
var validation = spec.Columns[index].Validation;
if (validation is null) continue;
var range = sheet.Range(3, index + 1, lastRow, index + 1);
range.CreateDataValidation().List($"\"{string.Join(',', validation)}\"", true);
}
if (resource == "admission_reporting")
{
var status = Array.FindIndex(spec.Columns, item => item.Key == "reportingStatusCode") + 1;
var note = Array.FindIndex(spec.Columns, item => item.Key == "reportingNote") + 1;
sheet.Range(3, status, lastRow, status).Style.Fill.BackgroundColor = XLColor.FromHtml("#FFF3CD");
sheet.Range(3, note, lastRow, note).Style.Fill.BackgroundColor = XLColor.FromHtml("#FFF3CD");
title.Value = $"{spec.Title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认";
}
sheet.Range(2, 1, Math.Max(2, output.Count + 2), spec.Columns.Length).SetAutoFilter();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
public static IReadOnlyList<JsonObject> Parse(string resource, byte[] content)
{
var spec = Get(resource);
XLWorkbook workbook;
try
{
workbook = new XLWorkbook(new MemoryStream(content));
}
catch
{
throw new WorkbookException(400, "无法读取 Excel 文件,请使用系统下载的 .xlsx 模板");
}
using (workbook)
{
var sheet = workbook.Worksheets.FirstOrDefault(item => item.Name == spec.Sheet)
?? workbook.Worksheets.FirstOrDefault()
?? throw new WorkbookException(400, "Excel 文件中没有可读取的工作表");
var headers = sheet.Row(2).CellsUsed().ToDictionary(
item => NormalizeHeader(item.GetString()),
item => item.Address.ColumnNumber,
StringComparer.Ordinal);
var missing = spec.Columns.Where(item => !headers.ContainsKey(NormalizeHeader(item.Header))).ToArray();
if (missing.Length > 0)
throw new WorkbookException(400, $"模板列不完整:缺少 {string.Join('、', missing.Select(item => item.Header))}");
var rows = new List<JsonObject>();
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 2;
for (var rowNumber = 3; rowNumber <= lastRow; rowNumber++)
{
var output = new JsonObject { ["__row"] = rowNumber };
var populated = false;
foreach (var column in spec.Columns)
{
var cell = sheet.Cell(rowNumber, headers[NormalizeHeader(column.Header)]);
var value = CellValue(cell);
output[column.Key] = value;
if (value is not null && value.ToString().Length > 0) populated = true;
}
if (populated) rows.Add(output);
}
if (rows.Count == 0) throw new WorkbookException(400, "Excel 中没有可导入的数据行");
return rows;
}
}
private static WorkbookSpec Get(string resource) =>
Specs.GetValueOrDefault(resource) ?? throw new WorkbookException(404, "不支持的 Excel 数据类型");
private static WorkbookColumn C(
string key, string header, double width, object example,
string numberFormat = "@", string[]? values = null) =>
new(key, header, width, example, numberFormat, values);
private static string NormalizeHeader(string value) => value.Replace("*", "", StringComparison.Ordinal).Trim();
private static JsonNode? ToNode(object value) => value switch
{
int integer => JsonValue.Create(integer),
double number => JsonValue.Create(number),
bool boolean => JsonValue.Create(boolean),
_ => JsonValue.Create(Convert.ToString(value, CultureInfo.InvariantCulture) ?? "")
};
private static JsonNode? CellValue(IXLCell cell)
{
if (cell.IsEmpty()) return JsonValue.Create("");
if (cell.DataType == XLDataType.DateTime)
return JsonValue.Create(cell.GetDateTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
if (cell.DataType == XLDataType.Number) return JsonValue.Create(cell.GetDouble());
if (cell.DataType == XLDataType.Boolean) return JsonValue.Create(cell.GetBoolean());
return JsonValue.Create(cell.GetFormattedString().Trim());
}
private static void SetValue(IXLCell cell, JsonNode? value)
{
if (value is JsonValue json)
{
if (json.TryGetValue<double>(out var number)) { cell.Value = number; return; }
if (json.TryGetValue<int>(out var integer)) { cell.Value = integer; return; }
if (json.TryGetValue<bool>(out var boolean)) { cell.Value = boolean; return; }
}
cell.Value = value?.ToString() ?? "";
}
}
internal sealed class WorkbookException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}