本轮迁移已完成,生产运行链路现已切换为纯 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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user