From fea91a19afe7bdb2f531d65614af9de036bccf46 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 14:14:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E9=98=B6=E6=AE=B5=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E3=80=82=20Excel=EF=BC=9A=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E3=80=81=E8=B4=A6=E5=8F=B7=E3=80=81=E8=80=83=E7=94=9F?= =?UTF-8?q?=E3=80=81=E7=BC=B4=E8=B4=B9=E3=80=81=E8=80=83=E5=9C=BA=E3=80=81?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E3=80=81=E5=BF=97=E6=84=BF=E3=80=81=E5=BD=95?= =?UTF-8?q?=E5=8F=96=E3=80=81=E6=8A=A5=E5=88=B0=E5=8F=8A=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E5=BD=95=E5=8F=96=E5=90=8D=E5=86=8C=E5=9D=87=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20ClosedXML=20=E5=8E=9F=E7=94=9F=E5=A4=84=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=A8=A1=E6=9D=BF=E3=80=81=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81=E7=AD=9B=E9=80=89=E5=92=8C=E5=AF=BC=E5=85=A5=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E3=80=82[SystemWorkbook.cs](C:\\Users\\BI\\Documents\?= =?UTF-8?q?\EIS-dotnet\\src\\Eis.Infrastructure\\Spreadsheets\\SystemWorkb?= =?UTF-8?q?ook.cs)=20=E6=96=87=E4=B9=A6=EF=BC=9A=E5=BD=95=E5=8F=96?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B9=A6=E6=A8=A1=E6=9D=BF=E3=80=81=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E5=8D=95/=E5=87=86=E8=80=83=E8=AF=81/=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B9=A6=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE=E4=B8=8E?= =?UTF-8?q?=E9=98=B2=E4=BC=AA=E9=AA=8C=E7=9C=9F=E5=B7=B2=E8=84=B1=E7=A6=BB?= =?UTF-8?q?=20Node=EF=BC=9B=E4=BF=9D=E7=95=99=E7=8E=B0=E6=9C=89=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=8E=92=E7=89=88=E5=AF=BC=E5=87=BA=E6=95=88=E6=9E=9C?= =?UTF-8?q?=E3=80=82[AdminAdmissionService.Documents.cs](C:\\Users\\BI\\Do?= =?UTF-8?q?cuments\\EIS-dotnet\\src\\Eis.Infrastructure\\Administration\\A?= =?UTF-8?q?dminAdmissionService.Documents.cs)=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=EF=BC=9A=E5=AE=8C=E6=88=90=20Redis=20=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E7=A9=BA=E9=97=B4=E7=89=88=E6=9C=AC=E7=BC=93=E5=AD=98=E3=80=81?= =?UTF-8?q?=E6=9C=89=E7=95=8C=E6=9C=AC=E5=9C=B0=E5=9B=9E=E9=80=80=E3=80=81?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=90=88=E5=B9=B6=E5=92=8C=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E4=BF=9D=E6=8A=A4=EF=BC=9B=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8F=8A=E6=88=90=E7=BB=A9=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=90=8E=E7=AB=8B=E5=8D=B3=E5=A4=B1=E6=95=88=E3=80=82[Applicat?= =?UTF-8?q?ionCache.cs](C:\\Users\\BI\\Documents\\EIS-dotnet\\src\\Eis.Inf?= =?UTF-8?q?rastructure\\Caching\\ApplicationCache.cs)=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E5=B7=B2=E6=9B=B4=E6=96=B0=E4=B8=BA=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E3=80=82[MIGRATION.md](C:\\Users\\BI\\Documents\\EIS-?= =?UTF-8?q?dotnet\\MIGRATION.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MIGRATION.md | 11 +- scripts/smoke-dotnet-migration.ps1 | 31 + .../Administration/IAdminAdmissionService.cs | 12 + .../Administration/IAdminExcelService.cs | 18 + .../Administration/IAdminResultService.cs | 1 + .../AdminAdmissionRepository.cs | 14 +- .../AdminAdmissionService.Documents.cs | 650 ++++++++++++++++++ .../Administration/AdminAdmissionService.cs | 2 +- .../Administration/AdminExamRepository.cs | 7 +- .../Administration/AdminExcelService.cs | 485 +++++++++++++ .../Administration/AdminNoticeRepository.cs | 7 +- .../Administration/AdminResultService.cs | 167 ++++- .../Administration/AdminWriteRepository.cs | 6 +- .../Caching/ApplicationCache.cs | 220 ++++++ .../Candidate/CandidateService.Results.cs | 22 +- .../Candidate/CandidateService.cs | 4 +- src/Eis.Infrastructure/DependencyInjection.cs | 3 + .../PublicQueryService.Announcements.cs | 8 +- .../Public/PublicQueryService.cs | 14 +- .../Spreadsheets/SystemWorkbook.cs | 308 +++++++++ .../NativeAdminReadEndpoints.cs | 93 ++- src/Eis.Web/Program.cs | 17 +- .../Caching/ApplicationCacheTests.cs | 78 +++ .../Spreadsheets/SystemWorkbookTests.cs | 66 ++ 24 files changed, 2209 insertions(+), 35 deletions(-) create mode 100644 src/Eis.Application/Administration/IAdminExcelService.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminAdmissionService.Documents.cs create mode 100644 src/Eis.Infrastructure/Administration/AdminExcelService.cs create mode 100644 src/Eis.Infrastructure/Caching/ApplicationCache.cs create mode 100644 src/Eis.Infrastructure/Spreadsheets/SystemWorkbook.cs create mode 100644 tests/Eis.Infrastructure.Tests/Caching/ApplicationCacheTests.cs create mode 100644 tests/Eis.Infrastructure.Tests/Spreadsheets/SystemWorkbookTests.cs diff --git a/MIGRATION.md b/MIGRATION.md index e477d57..55caf7e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -14,7 +14,7 @@ - [x] 考生业务 - [x] 管理后台、审批流和考务编排 - [x] 考生志愿填报与招生录取查询 -- [ ] Excel、文书和缓存 +- [x] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 ## 本地运行 @@ -54,7 +54,14 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一至十三批已经覆盖基础读取、组织账户、批量建号、流程定义与收件箱、通知公示、考点审批、报名缴费、考试维护、准考证编排和成绩管理。第十四批补齐指标资格确认、招生学校账号、志愿设置、招生计划审核、分数优先投档、逐轮录取公示、报到补录审批、退档监督和成绩预览提交;管理后台、审批流及考务编排的业务 API 至此全部由 ASP.NET Core 原生处理。Excel 文件解析、文书生成和缓存替换仍作为独立迁移阶段。 +管理后台第一至十三批已经覆盖基础读取、组织账户、批量建号、流程定义与收件箱、通知公示、考点审批、报名缴费、考试维护、准考证编排和成绩管理。第十四批补齐指标资格确认、招生学校账号、志愿设置、招生计划审核、分数优先投档、逐轮录取公示、报到补录审批、退档监督和成绩预览提交;管理后台、审批流及考务编排的业务 API 至此全部由 ASP.NET Core 原生处理。 + +Excel、文书和缓存阶段已经完成: + +- ClosedXML 原生处理班级、管理员、报名号配额及结果、考生、缴费、考点考场、成绩、志愿、录取、报到和正式录取名册工作簿;模板保留必填提示、数据验证、冻结标题、筛选与数值格式。 +- 成绩 Excel 上传先生成预检结果,不直接发布成绩;报到 Excel 只更新本校当前可维护批次,并返回逐行变更摘要。 +- 录取通知书模板由 ASP.NET Core 原生读取与保存;成绩单、准考证和录取通知书继续由现有前端排版导出,但其业务数据、防伪码和验真接口均已脱离 Node.js。 +- 公开首页和考生成绩改用 Redis 命名空间版本缓存;Redis 未配置或暂不可用时使用有界进程内缓存。考试、组织、通知、招生和成绩写入后会立即失效对应命名空间,`GET /health/migration` 会报告缓存状态。 管理端各功能域均可独立切换;在兼容运行期仍要求原生认证和共享 Redis: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 70af77c..b8a77f8 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -1111,6 +1111,37 @@ try { $resultExport.RawContentLength -lt 1000) { throw 'Native result workbook export is invalid' } + foreach ($excelResource in @('classes', 'class_admins', 'account_quotas', 'candidates', 'payments', 'centers')) { + $excelExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/excel/$excelResource" -WebSession $nativeSession + if ($excelExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or + $excelExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or + $excelExport.RawContentLength -lt 1000) { + throw "Native '$excelResource' workbook export is invalid" + } + } + $accountResultTemplate = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/excel/account_results?template=1" -WebSession $nativeSession + if ($accountResultTemplate.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or + $accountResultTemplate.Headers['Content-Type'] -notmatch 'spreadsheetml' -or + $accountResultTemplate.RawContentLength -lt 1000) { + throw "Native 'account_results' workbook template is invalid" + } + foreach ($ledgerKind in @('preferences', 'placements')) { + $ledgerExport = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/admissions/$ledgerKind/export" -WebSession $nativeSession + if ($ledgerExport.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or + $ledgerExport.Headers['Content-Type'] -notmatch 'spreadsheetml' -or + $ledgerExport.RawContentLength -lt 1000) { + throw "Native admission '$ledgerKind' ledger workbook export is invalid" + } + } + $admissionSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $admissionSchoolLoginBody = @{ username = 'admission_1_admin'; password = '12345678' } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $admissionSchoolLoginBody -WebSession $admissionSchoolSession | Out-Null + foreach ($admissionSchoolRoute in @('notice-template', 'reporting', 'placements')) { + $admissionSchoolResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admission/$admissionSchoolRoute" -WebSession $admissionSchoolSession + if ($admissionSchoolResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw "Native admission-school route '$admissionSchoolRoute' did not use ASP.NET Core" + } + } $legacyResultsAfterWrites = Invoke-WebRequest -Uri "$legacyBaseUrl$resultsUri" -WebSession $session $nativeResultsAfterWrites = Invoke-WebRequest -Uri "$nativeAuthBaseUrl$resultsUri" -WebSession $nativeSession Assert-JsonEquivalent -Expected $legacyResultsAfterWrites.Content -Actual $nativeResultsAfterWrites.Content -Label 'Result writes follow-up' diff --git a/src/Eis.Application/Administration/IAdminAdmissionService.cs b/src/Eis.Application/Administration/IAdminAdmissionService.cs index 13bbfb4..fff2521 100644 --- a/src/Eis.Application/Administration/IAdminAdmissionService.cs +++ b/src/Eis.Application/Administration/IAdminAdmissionService.cs @@ -17,4 +17,16 @@ public interface IAdminAdmissionService Task RunActionAsync(string sessionToken, string examId, string action, JsonObject body, CancellationToken cancellationToken); Task ReviewReportingAsync(string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken); Task ReviewWithdrawalAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken); + Task GetAdmissionSchoolNoticeTemplateAsync(string sessionToken, CancellationToken cancellationToken); + Task SaveAdmissionSchoolNoticeTemplateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task GetAdmissionSchoolReportingAsync(string sessionToken, CancellationToken cancellationToken); + Task ExportAdmissionSchoolReportingAsync(string sessionToken, string examId, CancellationToken cancellationToken); + Task ImportAdmissionSchoolReportingAsync(string sessionToken, string examId, byte[] content, CancellationToken cancellationToken); + Task GetAdmissionSchoolPlacementsAsync(string sessionToken, CancellationToken cancellationToken); + Task ExportAdmissionSchoolPlacementsAsync(string sessionToken, string examId, CancellationToken cancellationToken); + Task ExportAdminLedgerAsync( + string sessionToken, + string kind, + IReadOnlyDictionary filters, + CancellationToken cancellationToken); } diff --git a/src/Eis.Application/Administration/IAdminExcelService.cs b/src/Eis.Application/Administration/IAdminExcelService.cs new file mode 100644 index 0000000..c6afd15 --- /dev/null +++ b/src/Eis.Application/Administration/IAdminExcelService.cs @@ -0,0 +1,18 @@ +namespace Eis.Application.Administration; + +public interface IAdminExcelService +{ + Task ExportAsync( + string sessionToken, + string resource, + bool template, + string examId, + string batchId, + CancellationToken cancellationToken); + + Task ImportAsync( + string sessionToken, + string resource, + byte[] content, + CancellationToken cancellationToken); +} diff --git a/src/Eis.Application/Administration/IAdminResultService.cs b/src/Eis.Application/Administration/IAdminResultService.cs index 462b8e0..0150f63 100644 --- a/src/Eis.Application/Administration/IAdminResultService.cs +++ b/src/Eis.Application/Administration/IAdminResultService.cs @@ -8,6 +8,7 @@ public interface IAdminResultService Task SaveAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task SaveBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task ImportAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task PreviewImportAsync(string sessionToken, JsonArray rows, CancellationToken cancellationToken); Task SaveFeatureBulkAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken); Task ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken); diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs index bfe1db4..79bcbcc 100644 --- a/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionRepository.cs @@ -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 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, diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionService.Documents.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionService.Documents.cs new file mode 100644 index 0000000..c98f18a --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionService.Documents.cs @@ -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 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 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 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 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 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 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(StringComparer.Ordinal); + var changes = new JsonArray(); + var merged = (record.Payload["rows"] as JsonArray ?? []) + .OfType() + .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 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 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 ExportAdminLedgerAsync( + string sessionToken, + string kind, + IReadOnlyDictionary 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 rows; + string resource; + string fileName; + if (kind == "preferences") + { + rows = FilterLedgerRows(PreferenceSnapshotRows(data), filters).SelectMany(item => + { + var choices = (item["choices"] as JsonArray ?? []).OfType().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 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() + .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 FilterLedgerRows( + IEnumerable rows, + IReadOnlyDictionary 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); +} diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs index 4ff2032..9fbb670 100644 --- a/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionService.cs @@ -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, diff --git a/src/Eis.Infrastructure/Administration/AdminExamRepository.cs b/src/Eis.Infrastructure/Administration/AdminExamRepository.cs index 2f8bba9..f1ef60b 100644 --- a/src/Eis.Infrastructure/Administration/AdminExamRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminExamRepository.cs @@ -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 ExamParameters(ManagedExam exam) => diff --git a/src/Eis.Infrastructure/Administration/AdminExcelService.cs b/src/Eis.Infrastructure/Administration/AdminExcelService.cs new file mode 100644 index 0000000..59a4f07 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminExcelService.cs @@ -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 ResourceNames = + new Dictionary(StringComparer.Ordinal) + { + ["classes"] = "班级台账", + ["class_admins"] = "班级管理员", + ["account_quotas"] = "报名号班级配额", + ["account_results"] = "报名号下发结果", + ["candidates"] = "考生资料", + ["payments"] = "考试缴费名单", + ["centers"] = "考点考场档案", + ["results"] = "成绩台账" + }; + + public async Task 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() : + 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 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 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?> 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(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 ImportClassesAsync( + AuthenticationUser user, + IReadOnlyList 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 ImportClassAdminsAsync( + string token, + AuthenticationUser user, + IReadOnlyList 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 ImportQuotasAsync( + AuthenticationUser user, + IReadOnlyList 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 ImportCandidatesAsync( + AuthenticationUser user, + IReadOnlyList 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 ImportCentersAsync( + string token, + AuthenticationUser user, + IReadOnlyList 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 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(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); + } +} diff --git a/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs index 7d4738f..4e31d64 100644 --- a/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs @@ -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 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 { diff --git a/src/Eis.Infrastructure/Administration/AdminResultService.cs b/src/Eis.Infrastructure/Administration/AdminResultService.cs index 24b5d2a..2afd808 100644 --- a/src/Eis.Infrastructure/Administration/AdminResultService.cs +++ b/src/Eis.Infrastructure/Administration/AdminResultService.cs @@ -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 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 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(); + var seen = new HashSet(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(out var publishedBoolean) + ? publishedBoolean + : publishedText == "发布"; + if (publishedText.Length > 0 && publishedText is not ("发布" or "不发布") && + !(source["published"] is JsonValue booleanValue && booleanValue.TryGetValue(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(out var published) && + published && + (item["errors"] as JsonArray)?.Count == 0) + } + }); + } + public async Task 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 results, AdminWorkflowResult result) diff --git a/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs index 04ae51d..f15885e 100644 --- a/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminWriteRepository.cs @@ -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( diff --git a/src/Eis.Infrastructure/Caching/ApplicationCache.cs b/src/Eis.Infrastructure/Caching/ApplicationCache.cs new file mode 100644 index 0000000..f9d90fa --- /dev/null +++ b/src/Eis.Infrastructure/Caching/ApplicationCache.cs @@ -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 RememberJsonAsync( + string cacheNamespace, + string key, + Func> loader, + int? ttlSeconds = null); + Task InvalidateAsync(string cacheNamespace); +} + +internal sealed class ApplicationCache : IApplicationCache, IDisposable +{ + private readonly ConcurrentDictionary _values = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _pending = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _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 RememberJsonAsync( + string cacheNamespace, + string key, + Func> 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 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 RememberLocalAsync( + string cacheNamespace, + string key, + Func> 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 CoalescedLoadAsync( + string fullKey, + string cacheNamespace, + Func> 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(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(fullKey, pending)); + } + } + + private async Task 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 LoadJsonAsync(Func> 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 Loading); +} diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs index dab8cc5..5eafee9 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.Results.cs @@ -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 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 CreateScoreAppealAsync( string sessionToken, string resultId, diff --git a/src/Eis.Infrastructure/Candidate/CandidateService.cs b/src/Eis.Infrastructure/Candidate/CandidateService.cs index 92a9503..6f12df8 100644 --- a/src/Eis.Infrastructure/Candidate/CandidateService.cs +++ b/src/Eis.Infrastructure/Candidate/CandidateService.cs @@ -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 GetDashboardAsync( string sessionToken, diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 4cc98f0..6f89090 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -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(); + services.AddSingleton(); services.AddSingleton(documentVerificationOptions); services.AddSingleton(); services.AddSingleton(authenticationOptions); @@ -82,6 +84,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs index 1b71238..5c2ebf9 100644 --- a/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs +++ b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs @@ -32,7 +32,13 @@ public sealed partial class PublicQueryService ["drama_broadcasting"] = ("arts", "戏剧与播音") }; - public async Task GetAnnouncementsAsync(CancellationToken cancellationToken) + public Task GetAnnouncementsAsync(CancellationToken cancellationToken) => + cache.RememberJsonAsync( + "public", + "admission-announcements", + () => LoadAnnouncementsAsync(cancellationToken)); + + private async Task LoadAnnouncementsAsync(CancellationToken cancellationToken) { await using var connection = await connectionFactory.OpenAsync(cancellationToken); var exams = await QueryAsync(connection, diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.cs b/src/Eis.Infrastructure/Public/PublicQueryService.cs index 4857296..baa9eb0 100644 --- a/src/Eis.Infrastructure/Public/PublicQueryService.cs +++ b/src/Eis.Infrastructure/Public/PublicQueryService.cs @@ -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 GetHomeAsync(CancellationToken cancellationToken) + public Task GetHomeAsync(CancellationToken cancellationToken) => + cache.RememberJsonAsync("public", "home", () => LoadHomeAsync(cancellationToken)); + + private async Task 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 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() diff --git a/src/Eis.Infrastructure/Spreadsheets/SystemWorkbook.cs b/src/Eis.Infrastructure/Spreadsheets/SystemWorkbook.cs new file mode 100644 index 0000000..228cc53 --- /dev/null +++ b/src/Eis.Infrastructure/Spreadsheets/SystemWorkbook.cs @@ -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 Specs = + new Dictionary(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 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 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 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(); + 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(out var number)) { cell.Value = number; return; } + if (json.TryGetValue(out var integer)) { cell.Value = integer; return; } + if (json.TryGetValue(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; +} diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index ac71372..0f64454 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -18,6 +18,28 @@ public static class NativeAdminReadEndpoints Execute(context, service.GetDashboardAsync(Token(context), cancellationToken))); endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken))); + endpoints.MapGet( + "/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$)}", + (HttpContext context, string resource, IAdminExcelService service, CancellationToken cancellationToken) => + ExecuteDocument( + context, + service.ExportAsync( + Token(context), + resource, + context.Request.Query.ContainsKey("template"), + context.Request.Query["examId"].ToString(), + context.Request.Query["batchId"].ToString(), + cancellationToken))); + endpoints.MapPost( + "/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|candidates|centers|results)$)}", + async (HttpContext context, string resource, IAdminExcelService service, CancellationToken cancellationToken) => + await Execute( + context, + service.ImportAsync( + Token(context), + resource, + await ReadWorkbookAsync(context.Request, cancellationToken), + cancellationToken))); endpoints.MapGet("/api/admin/school-organization", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => Execute(context, service.GetSchoolOrganizationAsync(Token(context), cancellationToken))); endpoints.MapGet("/api/admin/admins", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => @@ -196,21 +218,63 @@ public static class NativeAdminReadEndpoints endpoints.MapPost("/api/admin/results/cache/refresh", (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => Execute(context, service.RefreshCacheAsync(Token(context), cancellationToken))); - endpoints.MapGet("/api/admin/excel/results", - (HttpContext context, IAdminResultService service, CancellationToken cancellationToken) => - ExecuteDocument( - context, - service.ExportAsync( - Token(context), - context.Request.Query["examId"].ToString(), - cancellationToken))); } if (options.NativeAdmissionsEnabled) { + endpoints.MapGet("/api/admission/notice-template", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.GetAdmissionSchoolNoticeTemplateAsync(Token(context), cancellationToken))); + endpoints.MapPut("/api/admission/notice-template", + (HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.SaveAdmissionSchoolNoticeTemplateAsync(Token(context), body, cancellationToken))); + endpoints.MapGet("/api/admission/reporting", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.GetAdmissionSchoolReportingAsync(Token(context), cancellationToken))); + endpoints.MapGet("/api/admission/reporting/export", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + ExecuteDocument( + context, + service.ExportAdmissionSchoolReportingAsync( + Token(context), + context.Request.Query["examId"].ToString(), + cancellationToken))); + endpoints.MapPost("/api/admission/reporting/import", + async (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + await Execute( + context, + service.ImportAdmissionSchoolReportingAsync( + Token(context), + context.Request.Query["examId"].ToString(), + await ReadWorkbookAsync(context.Request, cancellationToken), + cancellationToken))); + endpoints.MapGet("/api/admission/placements", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + Execute(context, service.GetAdmissionSchoolPlacementsAsync(Token(context), cancellationToken))); + endpoints.MapGet("/api/admission/placements/export", + (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => + ExecuteDocument( + context, + service.ExportAdmissionSchoolPlacementsAsync( + Token(context), + context.Request.Query["examId"].ToString(), + cancellationToken))); endpoints.MapGet("/api/admin/admissions", (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => Execute(context, service.GetAsync(Token(context), cancellationToken))); + endpoints.MapGet( + "/api/admin/admissions/{kind:regex(^(preferences|placements)$)}/export", + (HttpContext context, string kind, IAdminAdmissionService service, CancellationToken cancellationToken) => + ExecuteDocument( + context, + service.ExportAdminLedgerAsync( + Token(context), + kind, + context.Request.Query.ToDictionary( + item => item.Key, + item => item.Value.ToString(), + StringComparer.Ordinal), + cancellationToken))); endpoints.MapGet("/api/admin/indicator-qualifications", (HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) => Execute(context, service.GetQualificationsAsync(Token(context), cancellationToken))); @@ -287,6 +351,19 @@ public static class NativeAdminReadEndpoints private static string Token(HttpContext context) => context.Request.Cookies.TryGetValue("hz_session", out var token) ? token : string.Empty; + private static async Task ReadWorkbookAsync(HttpRequest request, CancellationToken cancellationToken) + { + const int maximumBytes = 20 * 1024 * 1024; + if (request.ContentLength is > maximumBytes) + { + return []; + } + + await using var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer, cancellationToken); + return buffer.Length <= maximumBytes ? buffer.ToArray() : []; + } + private static IResult NativeDeleteNotAllowed(HttpContext context) { context.Response.Headers.CacheControl = "no-store"; diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 1f2b507..87d0a95 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -2,6 +2,7 @@ using System.Net; using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Administration; using Eis.Infrastructure.Candidate; +using Eis.Infrastructure.Caching; using Eis.Application.Public; using Eis.Infrastructure; using Eis.Infrastructure.Data; @@ -85,7 +86,10 @@ app.MapGet("/health/live", () => Results.Json(new framework = ".NET 10" })); -app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken cancellationToken) => +app.MapGet("/health/migration", async ( + LegacyApiProxy proxy, + IApplicationCache cache, + CancellationToken cancellationToken) => { var legacyAvailable = await proxy.IsAvailableAsync(cancellationToken); var statusCode = legacyAvailable ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable; @@ -93,6 +97,13 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c { status = legacyAvailable ? "healthy" : "degraded", legacyApiAvailable = legacyAvailable, + cache = new + { + status = cache.Status, + redisEnabled = cache.Enabled, + localFallback = true, + namespaces = new[] { "public", "results" } + }, authentication = new { nativeEnabled = authenticationOptions.NativeEnabled, @@ -183,7 +194,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c "GET admissions/indicator qualifications", "PUT qualification single/bulk", "POST/PATCH/reset admission-school accounts", "PUT admission settings", "POST/PATCH admission plans", "POST admission match/finalize/supplementary", - "PATCH admission reporting/withdrawals" + "PATCH admission reporting/withdrawals", + "GET/POST admin Excel", "GET/PUT admission notice template", + "GET/POST admission reporting Excel", "GET admission placement Excel" } : []) .ToArray() diff --git a/tests/Eis.Infrastructure.Tests/Caching/ApplicationCacheTests.cs b/tests/Eis.Infrastructure.Tests/Caching/ApplicationCacheTests.cs new file mode 100644 index 0000000..096d759 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Caching/ApplicationCacheTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Nodes; +using Eis.Infrastructure.Caching; + +namespace Eis.Infrastructure.Tests.Caching; + +public sealed class ApplicationCacheTests +{ + [Fact] + public async Task RememberJsonAsync_CoalescesConcurrentLocalLoads() + { + using var cache = new ApplicationCache(redisUrl: null); + var calls = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task Load() + { + Interlocked.Increment(ref calls); + await release.Task; + return new JsonObject { ["value"] = 42 }; + } + + var first = cache.RememberJsonAsync("public", "home", Load); + var second = cache.RememberJsonAsync("public", "home", Load); + release.SetResult(); + + var values = await Task.WhenAll(first, second); + + Assert.Equal(1, calls); + Assert.All(values, value => Assert.Equal(42, value["value"]!.GetValue())); + } + + [Fact] + public async Task InvalidateAsync_DropsLocalNamespaceWithoutAffectingOthers() + { + using var cache = new ApplicationCache(redisUrl: null); + var publicCalls = 0; + var resultCalls = 0; + + Task PublicLoader() => + Task.FromResult(new JsonObject { ["generation"] = ++publicCalls }); + Task ResultLoader() => + Task.FromResult(new JsonObject { ["generation"] = ++resultCalls }); + + _ = await cache.RememberJsonAsync("public", "home", PublicLoader); + _ = await cache.RememberJsonAsync("results", "candidate:1", ResultLoader); + Assert.False(await cache.InvalidateAsync("public")); + var publicValue = await cache.RememberJsonAsync("public", "home", PublicLoader); + var resultValue = await cache.RememberJsonAsync("results", "candidate:1", ResultLoader); + + Assert.Equal(2, publicValue["generation"]!.GetValue()); + Assert.Equal(1, resultValue["generation"]!.GetValue()); + } + + [Fact] + public async Task InvalidateAsync_PreventsInFlightLoadFromRepopulatingOldGeneration() + { + using var cache = new ApplicationCache(redisUrl: null); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + async Task SlowLoader() + { + var value = Interlocked.Increment(ref calls); + await release.Task; + return new JsonObject { ["generation"] = value }; + } + + var staleLoad = cache.RememberJsonAsync("public", "home", SlowLoader); + _ = await cache.InvalidateAsync("public"); + var refreshed = await cache.RememberJsonAsync( + "public", + "home", + () => Task.FromResult(new JsonObject { ["generation"] = Interlocked.Increment(ref calls) })); + release.SetResult(); + _ = await staleLoad; + + Assert.Equal(2, refreshed["generation"]!.GetValue()); + } +} diff --git a/tests/Eis.Infrastructure.Tests/Spreadsheets/SystemWorkbookTests.cs b/tests/Eis.Infrastructure.Tests/Spreadsheets/SystemWorkbookTests.cs new file mode 100644 index 0000000..33523af --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Spreadsheets/SystemWorkbookTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using ClosedXML.Excel; +using Eis.Infrastructure.Spreadsheets; + +namespace Eis.Infrastructure.Tests.Spreadsheets; + +public sealed class SystemWorkbookTests +{ + [Fact] + public void AdmissionReporting_RoundTripsEditableFieldsAndKeepsProfessionalLayout() + { + var content = SystemWorkbook.Build( + "admission_reporting", + [ + new JsonObject + { + ["noticeNumber"] = "AD01-EX-2026-ZK-000001", + ["candidateNumber"] = "2026-HZ01-F-0001", + ["name"] = "张同学", + ["examCode"] = "EX-2026-ZK", + ["schoolCode"] = "AD01", + ["categoryName"] = "普通生", + ["reportingStatusCode"] = "Y", + ["reportingNote"] = "材料齐全" + } + ], + subtitle: "第 1 轮"); + + using var workbook = new XLWorkbook(new MemoryStream(content)); + var sheet = workbook.Worksheet("考生报到"); + + Assert.True(content.Length > 1000); + Assert.Equal(XLColor.FromHtml("#173F60"), sheet.Cell(1, 1).Style.Fill.BackgroundColor); + Assert.Equal(XLColor.FromHtml("#FFF3CD"), sheet.Cell(3, 7).Style.Fill.BackgroundColor); + Assert.Equal(XLColor.FromHtml("#FFF3CD"), sheet.Cell(3, 8).Style.Fill.BackgroundColor); + + var parsed = SystemWorkbook.Parse("admission_reporting", content); + Assert.Single(parsed); + Assert.Equal("Y", parsed[0]["reportingStatusCode"]!.GetValue()); + Assert.Equal("材料齐全", parsed[0]["reportingNote"]!.GetValue()); + Assert.Equal(3, parsed[0]["__row"]!.GetValue()); + } + + [Fact] + public void Template_ContainsExampleRowAndValidation() + { + var content = SystemWorkbook.Build("classes", [], template: true); + + using var workbook = new XLWorkbook(new MemoryStream(content)); + var sheet = workbook.Worksheet("班级"); + + Assert.Equal("HZ01", sheet.Cell(3, 1).GetString()); + Assert.Equal("启用", sheet.Cell(3, 4).GetString()); + Assert.NotEmpty(sheet.DataValidations); + } + + [Fact] + public void InvalidWorkbook_IsRejectedWithBusinessError() + { + var error = Assert.Throws(() => + SystemWorkbook.Parse("results", "not-an-xlsx"u8.ToArray())); + + Assert.Equal(400, error.StatusCode); + Assert.Contains("系统下载的 .xlsx 模板", error.Message, StringComparison.Ordinal); + } +}