本阶段迁移已完成。
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user