本轮迁移已完成,生产运行链路现已切换为纯 ASP.NET Core 10。

主要完成:
补齐招生学校计划、投档审核、报到、扫码、补录等原生接口。
新增 SQLite/MySQL 空库初始化及默认审批流、号码规则。
删除 Node 兼容代理,未知 API 直接返回原生 404。
Docker、Compose、Gitea CI 全部切换到 Eis.Web.dll。
CKEditor 已固化到 Web 发布资源,不再依赖 node_modules。
新增纯 .NET 冒烟脚本:[smoke-dotnet-native.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/smoke-dotnet-native.ps1:1)。
迁移状态已更新:[MIGRATION.md (line 14)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:14)。
容器入口见 [Dockerfile (line 31)](C:/Users/BI/Documents/EIS-dotnet/Dockerfile:31)。
This commit is contained in:
2026-07-23 15:14:42 +08:00 Unverified
parent fea91a19af
commit e3c04dad8a
36 changed files with 1449 additions and 491 deletions
@@ -18,12 +18,22 @@ public interface IAdminAdmissionService
Task<AdminEndpointResult> ReviewReportingAsync(string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewWithdrawalAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolNoticeTemplateAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(string sessionToken, JsonObject body, 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> SaveAdmissionSchoolReportingDraftAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlacementsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdmissionSchoolPlacementsAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdminLedgerAsync(
string sessionToken,
string kind,
@@ -0,0 +1,501 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
internal sealed partial class AdminAdmissionService
{
private static readonly Regex AdmissionNoticePattern =
new(@"AN-[A-F0-9]{24}", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
public async Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(
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 plans = Records(data, "plan")
.Where(item => item.SchoolId == school.Id && item.Status == "approved")
.Select(item =>
{
var output = RecordJson(item);
output["examName"] = Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
var home = await publicQueries.GetHomeAsync(cancellationToken);
var notifications = (home["notices"] as JsonArray ?? []).OfType<JsonObject>()
.Where(item => Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id)
.Take(6)
.Select(item =>
{
var output = item.DeepClone().AsObject();
if (Text(output["noticeId"]).Length > 0) output["id"] = output["noticeId"]!.DeepClone();
return (JsonNode)output;
}).ToArray();
var exams = data.Operational.Exams.Where(item =>
item.Data["archivedAt"] is null &&
Records(data, "setting", item.Id).Any(setting => Boolean(setting.Payload["enabled"])))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["notifications"] = new JsonArray(notifications),
["exams"] = new JsonArray(exams)
});
}
public async Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(
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 plans = Records(data, "plan").Where(item => item.SchoolId == school.Id).Select(item =>
{
var output = RecordJson(item);
output["remainingCategories"] = RemainingPlanQuota(data, item);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
["sourceSchools"] = new JsonArray(data.Directory.Schools
.Where(item => item.Active && item.IsSourceSchool)
.Select(item => (JsonNode)SchoolJson(item)).ToArray())
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(
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 examId = Clean(Text(body["examId"]), 64);
var exam = data.Operational.Exams.FirstOrDefault(item =>
item.Id == examId && item.Data["archivedAt"] is null);
if (exam is null) return Error(404, "考试不存在或已经归档");
var categories = NormalizeCategories(body["categories"]);
if (categories.Count == 0) return Error(400, "请至少填写一个有效招生类别和计划人数");
if (categories.Select(item => Text(item["code"])).Distinct(StringComparer.Ordinal).Count() != categories.Count)
return Error(400, "招生类别代码不能重复");
if (categories.Any(item => !ValidSpecialty(Text(item["specialtyCategory"]), Text(item["specialtyType"]))))
return Error(400, "特长生招生类别的大类与小类不对应");
foreach (var category in categories)
{
var allocations = (category["indicatorAllocations"] as JsonArray ?? []).OfType<JsonObject>().ToArray();
if (allocations.Select(item => Text(item["sourceSchoolId"])).Distinct(StringComparer.Ordinal).Count() != allocations.Length)
return Error(400, "同一招生类别不能重复分配同一生源校指标");
if (allocations.Sum(item => Number(item["quota"])) > Number(category["quota"]))
return Error(400, "指标分配合计不能超过该类别计划人数");
if (allocations.Any(item => !data.Directory.Schools.Any(schoolItem =>
schoolItem.Id == Text(item["sourceSchoolId"]) && schoolItem.Active && schoolItem.IsSourceSchool)))
return Error(400, "指标分配中包含无效的生源学校");
}
var existing = Records(data, "plan", exam.Id).FirstOrDefault(item => item.SchoolId == school.Id);
if (existing?.Status == "approved") return Error(409, "已审核通过的招生计划只能由超级管理员调整");
var now = NowIso();
var plan = new CandidateAdmissionRecord(
existing?.Id ?? Uid("plan"),
"plan",
exam.Id,
existing?.UserId ?? user.Id,
school.Id,
"pending",
new JsonObject
{
["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray()),
["note"] = Clean(Text(body["note"]), 500),
["submittedBy"] = user.DisplayName,
["reviewNote"] = ""
},
existing?.CreatedAt ?? now,
now);
await repository.SaveRecordsAsync(
[plan],
Audit(user, "提交招生计划", $"{school.Name} · {Text(exam.Data["name"])}"),
cancellationToken);
return new AdminEndpointResult(existing is null ? 201 : 200,
new JsonObject { ["ok"] = true, ["plan"] = RecordJson(plan) });
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDraftAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var available = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!)
.Select(item => Text(item["placementId"])).ToHashSet(StringComparer.Ordinal);
var now = NowIso();
var updates = (body["rows"] as JsonArray ?? []).OfType<JsonObject>().Select(item => new JsonObject
{
["placementId"] = Clean(Text(item["placementId"]), 64),
["status"] = Clean(Text(item["status"]), 30),
["note"] = Clean(Text(item["note"]), 300),
["updatedAt"] = now,
["source"] = "manual"
}).ToArray();
if (updates.Length == 0 || updates.Any(item =>
!available.Contains(Text(item["placementId"])) ||
Text(item["status"]) is not ("pending" or "reported" or "not_reported")))
return Error(400, "报到暂存数据无效");
var merged = ReportingRowMap(resolved.Record!);
foreach (var update in updates) merged[Text(update["placementId"])] = update;
var updated = UpdateReportingRecord(
resolved.Record!, "draft", merged.Values, resolved.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(resolved.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "暂存考生报到状态", $"{resolved.School!.Name} · {updates.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var row = ReportingRows(target.Data!, target.Plan!, target.Record!)
.First(item => Text(item["placementId"]) == target.Placement!.Id);
return Success(new JsonObject
{
["ok"] = true,
["code"] = target.Code,
["examId"] = target.Placement!.ExamId,
["row"] = row
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var status = Clean(Text(body["status"]), 30);
if (status is not ("reported" or "not_reported" or "pending"))
return Error(400, "请选择有效的报到确认状态");
var now = NowIso();
var fallback = status switch
{
"reported" => "扫描录取通知书二维码确认报到",
"not_reported" => "扫描录取通知书二维码确认未报到",
_ => "扫描录取通知书二维码后暂待确认"
};
var merged = ReportingRowMap(target.Record!);
merged[target.Placement!.Id] = new JsonObject
{
["placementId"] = target.Placement.Id,
["status"] = status,
["note"] = Clean(Text(body["note"]), 300) is { Length: > 0 } note ? note : fallback,
["updatedAt"] = now,
["source"] = "qr_scan"
};
var updated = UpdateReportingRecord(
target.Record!, "draft", merged.Values, target.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(target.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(target.User, "扫码确认并暂存考生报到",
$"{target.School!.Name} · {(Text(target.Placement.Payload["noticeNumber"]) is { Length: > 0 } number ? number : target.Placement.Id)} · {ReportingCode(status)}"),
cancellationToken);
var data = ReplaceRecord(target.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["row"] = ReportingRows(data, target.Plan!, updated)
.First(item => Text(item["placementId"]) == target.Placement.Id),
["batch"] = ReportingBatch(data, target.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var rows = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!);
var pending = rows.Count(item => Text(item["status"]) == "pending");
if (pending > 0) return Error(409, $"仍有 {pending} 名考生待确认,请全部标记后提交");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["submittedAt"] = now;
payload["submittedBy"] = resolved.User!.DisplayName;
var updated = resolved.Record with { Status = "submitted", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "提交考生报到情况", $"{resolved.School!.Name} · {rows.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(
sessionToken, Text(body["examId"]), cancellationToken, requiredStatus: "submitted");
if (resolved.Error is not null) return resolved.Error;
var progress = PlanProgress(resolved.Data!, resolved.Plan!);
var gap = Integer(progress["reportingGap"]);
var supplement = ExactlyTrue(body["supplement"]) && gap > 0;
var note = Clean(Text(body["decisionNote"]), 500);
if (supplement && note.Length < 4)
return Error(400, "申请补录时请填写至少 4 个字的补录说明");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["supplementDecision"] = supplement ? "supplement" : "no_supplement";
payload["decisionNote"] = note.Length > 0 ? note :
gap > 0 ? "经学校研究决定,本轮不进行补录。" : "本校招生计划已完成。";
payload["decisionSubmittedAt"] = now;
payload["decisionSubmittedBy"] = resolved.User!.DisplayName;
payload["statistics"] = progress.DeepClone();
var updated = resolved.Record with { Status = "pending_approval", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, supplement ? "提交补录申请" : "提交不补录决定",
$"{resolved.School!.Name} · 缺额 {gap} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(
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 ids = (body["ids"] as JsonArray ?? []).Select(Text).Select(item => Clean(item, 64))
.Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (ids.Length == 0) return Error(400, "请至少选择一名待审核考生");
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "批量申请退档必须填写至少 8 个字的特殊理由");
var placements = Records(data, "placement").Where(item =>
ids.Contains(item.Id, StringComparer.Ordinal) && item.SchoolId == school.Id && item.Status == "school_review").ToArray();
if (placements.Length != ids.Length)
return Error(409, "所选记录中包含已处理或不属于本校的投档记录,请刷新后重试");
var now = NowIso();
var updated = placements.Select(item => ReviewedPlacement(item, decision, note, now)).ToArray();
await repository.SaveRecordsAsync(
updated,
Audit(user, decision == "accept" ? "批量接收投档考生" : "批量申请退档",
$"{school.Name} · {updated.Length} 人"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["count"] = updated.Length, ["decision"] = decision });
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(
string sessionToken,
string placementId,
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 placement = Records(data, "placement").FirstOrDefault(item =>
item.Id == placementId && item.SchoolId == school.Id && item.Status == "school_review");
if (placement is null) return Error(404, "待审核投档记录不存在");
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "申请退档必须填写至少 8 个字的特殊理由");
var updated = ReviewedPlacement(placement, decision, note, NowIso());
await repository.SaveRecordsAsync(
[updated],
Audit(user, decision == "accept" ? "接收投档考生" : "申请退档", $"{school.Name} · {placement.Id}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["placement"] = RecordJson(updated) });
}
private async Task<ResolvedReporting> ResolveReportingAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken,
string? requiredStatus = null)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedReporting.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedReporting.Failed(Error(403, "招生学校账号未绑定有效学校"));
var cleanExamId = Clean(examId, 64);
var plan = Records(data, "plan", cleanExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, cleanExamId, school.Id);
var valid = requiredStatus is null
? record?.Status is "draft" or "rejected"
: record?.Status == requiredStatus;
if (plan is null || record is null || !valid)
{
var message = requiredStatus == "submitted"
? "请先提交本轮考生报到情况"
: "当前报到批次不能修改暂存状态";
return ResolvedReporting.Failed(Error(409, message));
}
return new(context.User, school, data, plan, record, null);
}
private async Task<ResolvedScan> ResolveScanTargetAsync(
string sessionToken,
string rawCode,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedScan.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedScan.Failed(Error(403, "招生学校账号未绑定有效学校"));
var match = AdmissionNoticePattern.Match(rawCode.ToUpperInvariant());
if (!match.Success) return ResolvedScan.Failed(Error(400, "未识别到有效的录取通知书防伪码"));
var code = match.Value.ToUpperInvariant();
var placement = Records(data, "placement").FirstOrDefault(item =>
item.SchoolId == school.Id && item.Status == "final" &&
DocumentVerificationCodeService.SafeEquals(code, NoticeCode(item)));
if (placement is null) return ResolvedScan.Failed(Error(404, "该二维码不属于本校有效录取通知书"));
var plan = Records(data, "plan", placement.ExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, placement.ExamId, school.Id);
if (plan is null || record is null || record.Status is not ("draft" or "rejected") ||
!(record.Payload["rows"] as JsonArray ?? []).Any(item => Text(item?["placementId"]) == placement.Id))
return ResolvedScan.Failed(Error(409, "该考生不在当前可维护的报到批次"));
return new(context.User, school, data, plan, record, placement, code, null);
}
private string NoticeCode(CandidateAdmissionRecord placement) =>
documentCodes.AdmissionNoticeCode(
placement.Id,
placement.UserId ?? "",
placement.SchoolId ?? "",
placement.ExamId,
Text(placement.Payload["categoryCode"]),
Text(placement.Payload["noticeNumber"]),
placement.UpdatedAt);
private static Dictionary<string, JsonObject> ReportingRowMap(CandidateAdmissionRecord record) =>
(record.Payload["rows"] as JsonArray ?? []).OfType<JsonObject>()
.ToDictionary(item => Text(item["placementId"]), item => item.DeepClone().AsObject(), StringComparer.Ordinal);
private static CandidateAdmissionRecord UpdateReportingRecord(
CandidateAdmissionRecord record,
string status,
IEnumerable<JsonObject> rows,
string actor,
string now,
params (string Key, JsonNode? Value)[] additions)
{
var payload = record.Payload.DeepClone().AsObject();
payload["rows"] = new JsonArray(rows.Select(item => (JsonNode)item).ToArray());
foreach (var addition in additions) payload[addition.Key] = addition.Value;
return record with { Status = status, UpdatedAt = now, Payload = payload };
}
private static CandidateAdmissionRecord ReviewedPlacement(
CandidateAdmissionRecord placement,
string decision,
string note,
string now)
{
var payload = placement.Payload.DeepClone().AsObject();
payload["schoolDecisionNote"] = note;
if (decision == "withdraw") payload["withdrawalReason"] = note;
return placement with
{
Status = decision == "accept" ? "admitted" : "withdrawal_pending",
Payload = payload,
UpdatedAt = now
};
}
private static string ReportingCode(string status) =>
status switch { "reported" => "Y", "not_reported" => "N", _ => "P" };
private sealed record ResolvedReporting(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
AdminEndpointResult? Error)
{
public static ResolvedReporting Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, error);
}
private sealed record ResolvedScan(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
CandidateAdmissionRecord? Placement,
string Code,
AdminEndpointResult? Error)
{
public static ResolvedScan Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, null, "", error);
}
}
@@ -2,8 +2,10 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
@@ -15,7 +17,9 @@ internal sealed partial class AdminAdmissionService(
AdminOperationalSnapshotLoader operationalLoader,
AdminAccountBatchSnapshotLoader directoryLoader,
AdminWorkflowResultSnapshotLoader resultLoader,
AdminAdmissionRepository repository) : IAdminAdmissionService
AdminAdmissionRepository repository,
DocumentVerificationCodeService documentCodes,
IPublicQueryService publicQueries) : IAdminAdmissionService
{
private static readonly HashSet<string> Phases =
["draft", "filling", "closed", "matching", "school_review", "reporting", "supplementary", "completed"];
@@ -34,49 +34,36 @@ public sealed record AdminMigrationOptions(
bool configuredNativeResultsEnabled = false,
bool configuredNativeAdmissionsEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled);
var organizationWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"),
configuredNativeOrganizationWritesEnabled);
var accountBatchesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"),
configuredNativeAccountBatchesEnabled);
var configurationEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
configuredNativeConfigurationEnabled);
var noticeManagementEnabled = ParseBoolean(
var readsEnabled = configuredNativeReadsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), false);
var organizationWritesEnabled = configuredNativeOrganizationWritesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"), false);
var accountBatchesEnabled = configuredNativeAccountBatchesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"), false);
var configurationEnabled = configuredNativeConfigurationEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"), false);
var noticeManagementEnabled = configuredNativeNoticeManagementEnabled || ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED") ??
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
configuredNativeNoticeManagementEnabled);
var centersEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
configuredNativeCentersEnabled);
var operationalReadsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
configuredNativeOperationalReadsEnabled);
var candidateManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"),
configuredNativeCandidateManagementEnabled);
var registrationPaymentWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"),
configuredNativeRegistrationPaymentWritesEnabled);
var workflowOperationsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
configuredNativeWorkflowOperationsEnabled);
var examManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
configuredNativeExamManagementEnabled);
var arrangementsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
configuredNativeArrangementsEnabled);
var resultsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"),
configuredNativeResultsEnabled);
var admissionsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"),
configuredNativeAdmissionsEnabled);
false);
var centersEnabled = configuredNativeCentersEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"), false);
var operationalReadsEnabled = configuredNativeOperationalReadsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"), false);
var candidateManagementEnabled = configuredNativeCandidateManagementEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"), false);
var registrationPaymentWritesEnabled = configuredNativeRegistrationPaymentWritesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"), false);
var workflowOperationsEnabled = configuredNativeWorkflowOperationsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"), false);
var examManagementEnabled = configuredNativeExamManagementEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"), false);
var arrangementsEnabled = configuredNativeArrangementsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"), false);
var resultsEnabled = configuredNativeResultsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"), false);
var admissionsEnabled = configuredNativeAdmissionsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"), false);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
@@ -94,15 +81,6 @@ public sealed record AdminMigrationOptions(
"启用原生管理端接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (anyNativeAdminEndpointEnabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled, admissionsEnabled);
}
@@ -58,7 +58,8 @@ public sealed class AuthenticationOptions
public static AuthenticationOptions FromEnvironment(bool production, bool configuredNativeEnabled = false)
{
var nativeEnabled = ParseBoolean(Environment.GetEnvironmentVariable("AUTH_NATIVE_ENABLED"), configuredNativeEnabled);
var nativeEnabled = configuredNativeEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("AUTH_NATIVE_ENABLED"), false);
var cacheUrl = Clean(Environment.GetEnvironmentVariable("REDIS_URL"));
var explicitSessionUrl = Clean(Environment.GetEnvironmentVariable("REDIS_SESSION_URL"));
var sessionUrl = explicitSessionUrl ?? cacheUrl;
@@ -78,12 +79,6 @@ public sealed class AuthenticationOptions
"Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL");
}
if (nativeEnabled && production && sessionUrl is null)
{
throw new InvalidOperationException(
"渐进迁移期间在生产环境启用原生认证必须配置 REDIS_URL 或 REDIS_SESSION_URL,以便 Node 与 ASP.NET Core 共享会话");
}
var configuredTotpKey = Environment.GetEnvironmentVariable("TOTP_ENCRYPTION_KEY") ?? string.Empty;
if (nativeEnabled && production && configuredTotpKey.Length < 32)
{
@@ -7,24 +7,14 @@ public sealed record CandidateMigrationOptions(bool NativeEnabled)
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
configuredNativeEnabled);
var enabled = configuredNativeEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"), false);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生考生接口前必须同时设置 AUTH_NATIVE_ENABLED=true,以确保 ASP.NET Core 能识别登录会话");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"应用仍有受保护接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
}
return new CandidateMigrationOptions(enabled);
}
@@ -0,0 +1,306 @@
using System.Data.Common;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Eis.Infrastructure.Authentication;
using Microsoft.Extensions.DependencyInjection;
namespace Eis.Infrastructure.Data;
internal sealed partial class DatabaseInitializer(
IRelationalConnectionFactory connectionFactory,
DatabaseOptions options,
PasswordCompatibilityService passwords)
{
private const int SchemaVersion = 20;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var source = await ReadSchemaSourceAsync(cancellationToken);
if (options.Client == "sqlite")
{
await ExecuteAsync(connection, ExtractSqliteSchema(source), cancellationToken);
}
else
{
foreach (Match match in MySqlStatementPattern().Matches(ExtractMySqlSection(source)))
{
await ExecuteAsync(connection, match.Groups["sql"].Value, cancellationToken);
}
}
await SeedBaseStateAsync(connection, cancellationToken);
}
private async Task SeedBaseStateAsync(DbConnection connection, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
if (!await ExistsAsync(connection, "schema_metadata", cancellationToken))
{
await ExecuteAsync(connection,
"""
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, @version, @version, 0, @createdAt)
""",
[("@version", SchemaVersion), ("@createdAt", now)], cancellationToken);
}
else
{
var version = await ScalarLongAsync(connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1", cancellationToken);
if (version < SchemaVersion)
{
throw new InvalidOperationException(
$"数据库结构版本为 {version},低于 ASP.NET Core 要求的 {SchemaVersion};请先备份并执行旧版本升级流程");
}
}
if (!await ExistsAsync(connection, "organization", cancellationToken))
{
await ExecuteAsync(connection,
"INSERT INTO organization (id, name, code, phone, address) VALUES (1, @name, @code, '', '')",
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], cancellationToken);
}
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken) == 0)
{
var username = Environment.GetEnvironmentVariable("INITIAL_ADMIN_USERNAME")?.Trim();
var password = Environment.GetEnvironmentVariable("INITIAL_ADMIN_PASSWORD") ?? "Admin123!";
var displayName = Environment.GetEnvironmentVariable("INITIAL_ADMIN_DISPLAY_NAME")?.Trim();
await ExecuteAsync(connection,
"""
INSERT INTO users (
id, username, password_hash, role, admin_level, active, must_change_password,
totp_enabled, totp_recovery_codes, display_name, created_at
) VALUES (
'usr_admin', @username, @passwordHash, 'admin', 'super', 1, 0, 0, '[]', @displayName, @createdAt
)
""",
[
("@username", string.IsNullOrWhiteSpace(username) ? "admin" : username),
("@passwordHash", passwords.Hash(password)),
("@displayName", string.IsNullOrWhiteSpace(displayName) ? "系统管理员" : displayName),
("@createdAt", now)
], cancellationToken);
}
await SeedNumberRuleAsync(connection, now, cancellationToken);
await SeedAdmissionNumberRulesAsync(connection, now, cancellationToken);
await SeedWorkflowsAsync(connection, now, cancellationToken);
}
private static async Task SeedNumberRuleAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", cancellationToken) > 0) return;
await ExecuteAsync(connection,
"""
INSERT INTO number_rules (id, name, separator, active, created_by, updated_at)
VALUES ('rule_default', @name, '-', 1, 'usr_admin', @updatedAt)
""",
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], cancellationToken);
var segments = new[]
{
("segment_year", 1, "year", "", 4),
("segment_school", 2, "school_code", "", 0),
("segment_gender", 3, "gender", "", 0),
("segment_sequence", 4, "sequence", "", 4)
};
foreach (var segment in segments)
{
await ExecuteAsync(connection,
"""
INSERT INTO number_rule_segments (id, rule_id, position, type, value, width)
VALUES (@id, 'rule_default', @position, @type, @value, @width)
""",
[
("@id", segment.Item1), ("@position", segment.Item2), ("@type", segment.Item3),
("@value", segment.Item4), ("@width", segment.Item5)
], cancellationToken);
}
}
private static async Task SeedAdmissionNumberRulesAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", cancellationToken) > 0) return;
var rules = new[]
{
new AdmissionRule("admit_rule_district_room_seat", "district_room_seat", "县区编号 + 考场号 + 座位号",
"适合县区统一组织,号码直接反映县区、考试考场与座位。", "32070603108",
[new("district_code", "县区编号", 6), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)]),
new AdmissionRule("admit_rule_district_room_sequence", "district_room_sequence", "县区号 + 考场号 + 流水号",
"以县区为流水边界,适合不希望座位号直接出现在号码中的场景。", "3207060310028",
[new("district_code", "县区号", 6), new("exam_room_code", "考场号", 3), new("sequence", "流水号", 4)]),
new AdmissionRule("admit_rule_center_school_room_seat", "center_school_room_seat", "考点学校代码 + 考场号 + 座位号",
"号码前缀取考点所属学校代码,便于考点现场快速识别。", "HZ0303108",
[new("center_school_code", "考点学校代码", null), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)]),
new AdmissionRule("admit_rule_candidate_school_room_seat", "candidate_school_room_seat", "考生学校代码 + 考场号 + 座位号",
"号码前缀保留考生学籍学校代码,适合按生源学校归档。", "HZ0103108",
[new("candidate_school_code", "考生学校代码", null), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)])
};
foreach (var rule in rules)
{
var segments = JsonSerializer.Serialize(rule.Segments.Select(item => new
{
source = item.Source, label = item.Label, width = item.Width
}));
await ExecuteAsync(connection,
"""
INSERT INTO admission_number_rules (
id, code, name, description, separator, segments_json, example, active, created_at
) VALUES (
@id, @code, @name, @description, '', @segments, @example, 1, @createdAt
)
""",
[
("@id", rule.Id), ("@code", rule.Code), ("@name", rule.Name),
("@description", rule.Description), ("@segments", segments),
("@example", rule.Example), ("@createdAt", now)
], cancellationToken);
}
}
private static async Task SeedWorkflowsAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", cancellationToken) > 0) return;
var workflows = new[]
{
new Workflow("workflow_profile", "profile_change", "考生信息修改审批",
[new("workflow_profile_step_1", "学校学籍复核", "school"), new("workflow_profile_step_2", "考试中心终审", "super")]),
new Workflow("workflow_registration", "registration_review", "考试报名审核",
[new("workflow_registration_step_1", "学校报名初审", "school"), new("workflow_registration_step_2", "考试中心终审", "super")]),
new Workflow("workflow_center", "center_change", "考点考场变更审批",
[new("workflow_center_step_1", "考试中心考务终审", "super")]),
new Workflow("workflow_account_batch", "candidate_account_batch", "批量报名号申领审批",
[new("workflow_account_batch_step_1", "考试中心账号终审", "super")]),
new Workflow("workflow_score_appeal", "score_appeal", "考生成绩复议",
[
new("workflow_score_appeal_step_1", "班级情况核验", "class"),
new("workflow_score_appeal_step_2", "学校成绩复核", "school"),
new("workflow_score_appeal_step_3", "考试中心终审", "super")
])
};
foreach (var workflow in workflows)
{
await ExecuteAsync(connection,
"""
INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at)
VALUES (@id, @type, @name, 1, 'usr_admin', @updatedAt)
""",
[
("@id", workflow.Id), ("@type", workflow.Type),
("@name", workflow.Name), ("@updatedAt", now)
], cancellationToken);
for (var index = 0; index < workflow.Steps.Length; index++)
{
var step = workflow.Steps[index];
await ExecuteAsync(connection,
"""
INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level)
VALUES (@id, @workflowId, @position, @name, @level)
""",
[
("@id", step.Id), ("@workflowId", workflow.Id), ("@position", index + 1),
("@name", step.Name), ("@level", step.Level)
], cancellationToken);
}
}
}
private static async Task<bool> ExistsAsync(
DbConnection connection,
string table,
CancellationToken cancellationToken) =>
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", cancellationToken) > 0;
private static async Task<long> ScalarLongAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
private static Task ExecuteAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken) =>
ExecuteAsync(connection, sql, [], cancellationToken);
private static async Task ExecuteAsync(
DbConnection connection,
string sql,
IReadOnlyList<(string Name, object Value)> values,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var value in values)
{
var parameter = command.CreateParameter();
parameter.ParameterName = value.Name;
parameter.Value = value.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<string> ReadSchemaSourceAsync(CancellationToken cancellationToken)
{
var assembly = typeof(DatabaseInitializer).Assembly;
var name = assembly.GetManifestResourceNames()
.Single(item => item.EndsWith("database-schema.mjs", StringComparison.Ordinal));
await using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException("内置数据库结构资源不存在");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken);
}
private static string ExtractSqliteSchema(string source)
{
const string startToken = "export const sqliteSchema = `";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var mysqlStart = source.IndexOf("export const mysqlSchema", StringComparison.Ordinal);
var end = mysqlStart < 0 ? -1 : source.LastIndexOf('`', mysqlStart);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 SQLite 数据库结构");
return source[(start + startToken.Length)..end];
}
private static string ExtractMySqlSection(string source)
{
const string startToken = "export const mysqlSchema = [";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var end = source.IndexOf("];", start, StringComparison.Ordinal);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 MySQL 数据库结构");
return source[(start + startToken.Length)..end];
}
[GeneratedRegex(@"`(?<sql>[\s\S]*?)`\s*,?", RegexOptions.CultureInvariant)]
private static partial Regex MySqlStatementPattern();
private sealed record AdmissionRule(
string Id, string Code, string Name, string Description, string Example, AdmissionSegment[] Segments);
private sealed record AdmissionSegment(string Source, string Label, int? Width);
private sealed record Workflow(string Id, string Type, string Name, WorkflowStep[] Steps);
private sealed record WorkflowStep(string Id, string Name, string Level);
}
public static class DatabaseInitializationExtensions
{
public static async Task InitializeEisDatabaseAsync(
this IServiceProvider serviceProvider,
CancellationToken cancellationToken = default)
{
await using var scope = serviceProvider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.InitializeAsync(cancellationToken);
}
}
@@ -17,6 +17,9 @@ public sealed class DatabaseOptions
public string? MySqlConnectionString { get; }
internal static DatabaseOptions CreateSqliteForTests(string path) =>
new("sqlite", Path.GetFullPath(path), null);
public static DatabaseOptions FromEnvironment(string applicationRoot, bool production)
{
var client = (Environment.GetEnvironmentVariable("DATABASE_CLIENT")
@@ -8,15 +8,21 @@ public sealed class RelationalConnectionFactory(DatabaseOptions options) : IRela
{
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken)
{
if (options.Client == "sqlite" && options.SqlitePath is { } sqlitePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(sqlitePath)
?? throw new InvalidOperationException("SQLite 数据库路径无效"));
}
DbConnection connection = options.Client switch
{
"sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = options.SqlitePath,
Mode = SqliteOpenMode.ReadWrite,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
ForeignKeys = true,
DefaultTimeout = 5
DefaultTimeout = 5,
Pooling = false
}.ConnectionString),
"mysql" => new MySqlConnection(options.MySqlConnectionString),
_ => throw new InvalidOperationException($"不支持的数据库类型:{options.Client}")
@@ -35,6 +35,7 @@ public static class DependencyInjection
{
services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
services.AddScoped<DatabaseInitializer>();
services.AddSingleton<IApplicationCache, ApplicationCache>();
services.AddSingleton(documentVerificationOptions);
services.AddSingleton<DocumentVerificationCodeService>();
@@ -18,5 +18,6 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
<EmbeddedResource Include="..\..\src\database\schema.mjs" Link="Data\database-schema.mjs" />
</ItemGroup>
</Project>
@@ -10,10 +10,10 @@ public static class MigrationFeatureCatalog
new(FeatureArea.Public, true, "/api/public"),
new(FeatureArea.Authentication, authenticationNative, "/api/auth"),
new(FeatureArea.Candidate, candidateNative, "/api/candidate"),
new(FeatureArea.Administration, false, "/api/admin"),
new(FeatureArea.Admission, false, "/api/admission"),
new(FeatureArea.Documents, false, "/api"),
new(FeatureArea.Excel, false, "/api"),
new(FeatureArea.Caching, false, "/api")
new(FeatureArea.Administration, true, "/api/admin"),
new(FeatureArea.Admission, true, "/api/admission"),
new(FeatureArea.Documents, true, "/api"),
new(FeatureArea.Excel, true, "/api"),
new(FeatureArea.Caching, true, "/api")
];
}
@@ -14,11 +14,7 @@ public sealed class DocumentVerificationOptions
public static DocumentVerificationOptions FromEnvironment(bool production)
{
var configured = Environment.GetEnvironmentVariable("DOCUMENT_VERIFICATION_SECRET") ?? string.Empty;
var nodeProduction = string.Equals(
Environment.GetEnvironmentVariable("NODE_ENV"),
"production",
StringComparison.OrdinalIgnoreCase);
if ((production || nodeProduction) && configured.Length < 32)
if (production && configured.Length < 32)
{
throw new InvalidOperationException("生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET");
}
@@ -222,6 +222,15 @@ public static class NativeAdminReadEndpoints
if (options.NativeAdmissionsEnabled)
{
endpoints.MapGet("/api/admission/context",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolContextAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admission/plans",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlansAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admission/plans",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolPlanAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/notice-template",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolNoticeTemplateAsync(Token(context), cancellationToken)));
@@ -248,6 +257,21 @@ public static class NativeAdminReadEndpoints
context.Request.Query["examId"].ToString(),
await ReadWorkbookAsync(context.Request, cancellationToken),
cancellationToken)));
endpoints.MapPut("/api/admission/reporting/draft",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDraftAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan-preview",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.PreviewAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/submit",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SubmitAdmissionSchoolReportingAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/decision",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDecisionAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/placements",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlacementsAsync(Token(context), cancellationToken)));
@@ -259,6 +283,12 @@ public static class NativeAdminReadEndpoints
Token(context),
context.Request.Query["examId"].ToString(),
cancellationToken)));
endpoints.MapPost("/api/admission/placements/bulk",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementsAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admission/placements/{placementId}",
(HttpContext context, string placementId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementAsync(Token(context), placementId, body, cancellationToken)));
endpoints.MapGet("/api/admin/admissions",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAsync(Token(context), cancellationToken)));
-3
View File
@@ -15,8 +15,5 @@
<Content Include="..\..\src\client\**\*.mjs" Link="wwwroot\src\client\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\china-regions.mjs" Link="wwwroot\src\data\china-regions.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\specialty-types.mjs" Link="wwwroot\src\data\specialty-types.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js" Link="wwwroot\vendor\ckeditor5\ckeditor5.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js')" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css" Link="wwwroot\vendor\ckeditor5\ckeditor5.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css')" />
<Content Include="..\..\node_modules\ckeditor5\dist\translations\zh-cn.js" Link="wwwroot\vendor\ckeditor5\translations\zh-cn.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\translations\zh-cn.js')" />
</ItemGroup>
</Project>
+3 -20
View File
@@ -1,4 +1,3 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.StaticFiles;
namespace Eis.Web.Frontend;
@@ -22,11 +21,12 @@ public static class FrontendAssets
public static void MapFrontendAssets(this WebApplication app)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
if (repositoryRoot is null)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
app.MapFallbackToFile("index.html");
return;
}
@@ -42,23 +42,6 @@ public static class FrontendAssets
MapFile(app, "/styles.css", Path.Combine(repositoryRoot, "styles.css"), "text/css; charset=utf-8");
MapFile(app, "/app.js", Path.Combine(repositoryRoot, "app.js"), "text/javascript; charset=utf-8");
var ckeditorRoot = Path.Combine(repositoryRoot, "node_modules", "ckeditor5", "dist");
if (Directory.Exists(ckeditorRoot))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "browser")),
RequestPath = "/vendor/ckeditor5",
OnPrepareResponse = SetNoCache
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "translations")),
RequestPath = "/vendor/ckeditor5/translations",
OnPrepareResponse = SetNoCache
});
}
var indexPath = Path.Combine(repositoryRoot, "index.html");
MapFile(app, "/", indexPath, "text/html; charset=utf-8");
MapFile(app, "/index.html", indexPath, "text/html; charset=utf-8");
-135
View File
@@ -1,135 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.Options;
namespace Eis.Web.Legacy;
public sealed class LegacyApiProxy(
HttpClient httpClient,
IOptions<LegacyNodeOptions> options,
ILogger<LegacyApiProxy> logger)
{
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade"
};
private readonly LegacyNodeOptions _options = options.Value;
public async Task ForwardAsync(HttpContext context)
{
if (!_options.Enabled)
{
context.Response.StatusCode = StatusCodes.Status501NotImplemented;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
message = "该接口尚未迁移到 ASP.NET Core",
migration = new { native = false, legacyProxyEnabled = false }
}, context.RequestAborted);
return;
}
var target = new Uri(_options.BaseUrl, $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}");
using var outbound = CreateRequest(context, target);
try
{
using var upstream = await httpClient.SendAsync(
outbound,
HttpCompletionOption.ResponseHeadersRead,
context.RequestAborted);
context.Response.StatusCode = (int)upstream.StatusCode;
CopyResponseHeaders(upstream, context.Response);
if (context.Request.Method != HttpMethods.Head && upstream.StatusCode != HttpStatusCode.NoContent)
{
await upstream.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
}
}
catch (HttpRequestException exception)
{
logger.LogWarning(exception, "Legacy Node API at {Target} is unavailable", target);
if (context.Response.HasStarted)
{
context.Abort();
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
message = "迁移期间的旧版 API 服务暂时不可用",
migration = new { native = false, legacyProxyEnabled = true }
}, context.RequestAborted);
}
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return true;
}
try
{
using var response = await httpClient.GetAsync("api/public/home", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
}
private static HttpRequestMessage CreateRequest(HttpContext context, Uri target)
{
var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), target);
var hasBody = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0;
if (hasBody)
{
request.Content = new StreamContent(context.Request.Body);
}
foreach (var (name, values) in context.Request.Headers)
{
if (name.Equals("Host", StringComparison.OrdinalIgnoreCase) || HopByHopHeaders.Contains(name))
{
continue;
}
var valueArray = values.ToArray();
if (!request.Headers.TryAddWithoutValidation(name, valueArray) && request.Content is not null)
{
request.Content.Headers.TryAddWithoutValidation(name, valueArray);
}
}
request.Headers.TryAddWithoutValidation("X-Forwarded-Host", context.Request.Host.Value);
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", context.Request.Scheme);
return request;
}
private static void CopyResponseHeaders(HttpResponseMessage upstream, HttpResponse response)
{
foreach (var header in upstream.Headers.Concat(upstream.Content.Headers))
{
if (!HopByHopHeaders.Contains(header.Key))
{
response.Headers.Append(header.Key, header.Value.ToArray());
}
}
response.Headers.Remove("transfer-encoding");
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace Eis.Web.Legacy;
public sealed class LegacyNodeOptions
{
public const string SectionName = "LegacyNode";
public bool Enabled { get; init; } = true;
public Uri BaseUrl { get; init; } = new("http://127.0.0.1:4174");
}
+14 -25
View File
@@ -1,4 +1,3 @@
using System.Net;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
@@ -13,7 +12,6 @@ using Eis.Web.Authentication;
using Eis.Web.Administration;
using Eis.Web.Candidate;
using Eis.Web.Frontend;
using Eis.Web.Legacy;
using Eis.Web.Public;
var applicationRoot = ApplicationPaths.FindApplicationRoot();
@@ -21,19 +19,6 @@ EnvironmentFile.Load(applicationRoot);
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
builder.Services.Configure<LegacyNodeOptions>(builder.Configuration.GetSection(LegacyNodeOptions.SectionName));
builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
{
var options = services.GetRequiredService<Microsoft.Extensions.Options.IOptions<LegacyNodeOptions>>().Value;
client.BaseAddress = options.BaseUrl;
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Eis.AspNetCore.Migration/1.0");
}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
UseCookies = false
});
builder.Services.AddProblemDetails();
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
var authenticationOptions = AuthenticationOptions.FromEnvironment(
@@ -68,6 +53,7 @@ builder.Services.AddEisInfrastructure(
adminMigrationOptions);
var app = builder.Build();
await app.Services.InitializeEisDatabaseAsync();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
app.UseExceptionHandler();
@@ -86,17 +72,13 @@ app.MapGet("/health/live", () => Results.Json(new
framework = ".NET 10"
}));
app.MapGet("/health/migration", async (
LegacyApiProxy proxy,
IApplicationCache cache,
CancellationToken cancellationToken) =>
app.MapGet("/health/migration", (IApplicationCache cache) =>
{
var legacyAvailable = await proxy.IsAvailableAsync(cancellationToken);
var statusCode = legacyAvailable ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
return Results.Json(new
{
status = legacyAvailable ? "healthy" : "degraded",
legacyApiAvailable = legacyAvailable,
status = "healthy",
legacyApiAvailable = false,
legacyApiRemoved = true,
cache = new
{
status = cache.Status,
@@ -202,7 +184,7 @@ app.MapGet("/health/migration", async (
.ToArray()
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode);
}, statusCode: StatusCodes.Status200OK);
});
app.MapNativePublicEndpoints();
@@ -220,7 +202,14 @@ string[] methods =
HttpMethods.Delete,
HttpMethods.Options
];
app.MapMethods("/api/{**path}", methods, (HttpContext context, LegacyApiProxy proxy) => proxy.ForwardAsync(context));
app.MapMethods("/api/{**path}", methods, (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
return Results.Json(
new { ok = false, message = "API 接口不存在" },
statusCode: StatusCodes.Status404NotFound);
});
app.MapFrontendAssets();
+16 -20
View File
@@ -1,29 +1,25 @@
{
"LegacyNode": {
"Enabled": true,
"BaseUrl": "http://127.0.0.1:4174"
},
"AuthenticationMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"CandidateMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"AdminMigration": {
"NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false,
"NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false,
"NativeOperationalReadsEnabled": false,
"NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false,
"NativeArrangementsEnabled": false,
"NativeResultsEnabled": false,
"NativeAdmissionsEnabled": false
"NativeReadsEnabled": true,
"NativeOrganizationWritesEnabled": true,
"NativeAccountBatchesEnabled": true,
"NativeConfigurationEnabled": true,
"NativeNoticeManagementEnabled": true,
"NativeCentersEnabled": true,
"NativeOperationalReadsEnabled": true,
"NativeCandidateManagementEnabled": true,
"NativeRegistrationPaymentWritesEnabled": true,
"NativeWorkflowOperationsEnabled": true,
"NativeExamManagementEnabled": true,
"NativeArrangementsEnabled": true,
"NativeResultsEnabled": true,
"NativeAdmissionsEnabled": true
},
"Logging": {
"LogLevel": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long