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