已完成第十一批迁移:考试维护功能已原生化到 ASP.NET Core 10。

支持考试创建、草稿修改、发布状态切换及不可逆归档。
支持科目、费用、满分和合格线策略校验。
保留已有报名禁止修改科目、待处理成绩复议禁止归档等规则。
数据修改、科目替换与审计日志使用同一数据库事务。
新增 ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED 独立开关。
路由与实现见 [AdminExamManagementService.cs (line 9)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Administration/AdminExamManagementService.cs:9) 和 [NativeAdminReadEndpoints.cs (line 133)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs:133)。
迁移说明已更新至 [MIGRATION.md (line 57)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:57)。
This commit is contained in:
2026-07-23 10:50:24 +08:00 Unverified
parent 2589305f3c
commit 25501651e1
12 changed files with 688 additions and 11 deletions
@@ -0,0 +1,342 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminExamManagementService(
IAuthenticationStateStore authenticationState,
AuthenticationRepository authenticationRepository,
AdminOperationalSnapshotLoader operationalSnapshotLoader,
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
AdminWorkflowResultSnapshotLoader resultSnapshotLoader,
AdminExamRepository repository) : IAdminExamManagementService
{
private static readonly HashSet<string> PassPolicies = ["fixed_score", "rank_percent", "subject_scores", "none"];
private static readonly HashSet<string> SubjectPassRules = ["fixed_score", "rank_percent", "none"];
public async Task<AdminEndpointResult> CreateAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var name = Clean(Text(body["name"]), 100);
if (name.Length == 0 || !JsBoolean(body["registrationStart"]) || !JsBoolean(body["registrationEnd"]) ||
!JsBoolean(body["examStart"]) || !JsBoolean(body["examEnd"]))
return Error(400, "请完整填写考试名称和关键日期");
var examStart = Text(body["examStart"]);
var subjects = NormalizeSubjects(body["subjects"], examStart);
var requestedPolicy = Text(body["passPolicy"]);
if (requestedPolicy == "score_ratio") requestedPolicy = "rank_percent";
var passPolicy = PassPolicies.Contains(requestedPolicy) ? requestedPolicy : "rank_percent";
var passValue = passPolicy is "subject_scores" or "none" ? 0 : NumberOr(body["passValue"], 60);
var scoringError = ValidateScoring(subjects, passPolicy, passValue);
if (scoringError.Length > 0) return Error(400, scoringError);
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var code = Clean(Text(body["code"]), 30);
if (code.Length == 0) code = $"EX-{DateTime.Now.Year}-{operational.Exams.Count + 1:00}";
var exam = new ManagedExam(
Uid("exam"),
code,
name,
Clean(Text(body["description"]), 500),
Text(body["registrationStart"]),
Text(body["registrationEnd"]),
examStart,
Text(body["examEnd"]),
JsBoolean(body["admitDownloadStart"]) ? Text(body["admitDownloadStart"]) : Text(body["registrationEnd"]),
JsBoolean(body["admitDownloadEnd"]) ? Text(body["admitDownloadEnd"]) : examStart,
Clean(Text(body["location"]), 100),
passPolicy,
passValue,
Text(body["status"]) == "published" ? "published" : "draft",
null,
null,
NowIso(),
subjects);
await repository.CreateAsync(
exam,
Audit(user, "创建考试", $"{exam.Name} · {subjects.Count} 个科目"),
cancellationToken);
var responseExam = ExamJson(exam);
responseExam.Remove("archivedAt");
responseExam.Remove("archivedBy");
return Result(201, new JsonObject { ["ok"] = true, ["exam"] = responseExam });
}
public async Task<AdminEndpointResult> UpdateAsync(
string sessionToken,
string examId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var source = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (source is null) return Error(404, "考试不存在");
var exam = FromOperational(source);
if (exam.ArchivedAt is not null) return Error(409, "该考试已归档,所有配置和成绩均已锁定");
string[] detailFields =
[
"code", "name", "description", "location", "registrationStart", "registrationEnd",
"examStart", "examEnd", "admitDownloadStart", "admitDownloadEnd"
];
var editingDetails = detailFields.Any(body.ContainsKey) ||
body.ContainsKey("subjects") || body.ContainsKey("passPolicy") || body.ContainsKey("passValue");
if (editingDetails && exam.Status != "draft") return Error(409, "请先将考试撤回为草稿后再编辑");
var requestedStatus = Text(body["status"]);
if (requestedStatus is "draft" or "published" or "closed") exam = exam with { Status = requestedStatus };
foreach (var field in detailFields.Where(body.ContainsKey))
{
var value = Clean(Text(body[field]), field == "description" ? 500 : 100);
exam = field switch
{
"code" => exam with { Code = value },
"name" => exam with { Name = value },
"description" => exam with { Description = value },
"location" => exam with { Location = value },
"registrationStart" => exam with { RegistrationStart = value },
"registrationEnd" => exam with { RegistrationEnd = value },
"examStart" => exam with { ExamStart = value },
"examEnd" => exam with { ExamEnd = value },
"admitDownloadStart" => exam with { AdmitDownloadStart = value },
"admitDownloadEnd" => exam with { AdmitDownloadEnd = value },
_ => exam
};
}
if (body.ContainsKey("passPolicy"))
{
var policy = Text(body["passPolicy"]);
if (policy == "score_ratio") policy = "rank_percent";
if (PassPolicies.Contains(policy)) exam = exam with { PassPolicy = policy };
}
if (body.ContainsKey("passValue"))
exam = exam with { PassValue = body["passValue"] is null ? 0 : Number(body["passValue"]) };
var replaceSubjects = false;
if (body.ContainsKey("subjects"))
{
if (operational.Registrations.Any(item => item.ExamId == exam.Id))
return Error(409, "已有报名记录,不能修改考试科目");
exam = exam with { Subjects = NormalizeSubjects(body["subjects"], exam.ExamStart) };
replaceSubjects = true;
}
var scoringError = ValidateScoring(exam.Subjects, exam.PassPolicy, exam.PassValue);
if (scoringError.Length > 0) return Error(400, scoringError);
if (exam.Name.Length == 0 || exam.RegistrationStart.Length == 0 || exam.RegistrationEnd.Length == 0 ||
exam.ExamStart.Length == 0 || exam.ExamEnd.Length == 0)
return Error(400, "请完整填写考试名称和关键日期");
if (exam.Status == "published" && exam.Subjects.Count == 0) return Error(400, "请先配置考试科目再发布");
await repository.UpdateAsync(
exam,
replaceSubjects,
Audit(user, "更新考试", $"{exam.Name} · 状态 {exam.Status}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["exam"] = ExamJson(exam) });
}
public async Task<AdminEndpointResult> ArchiveAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken)
{
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
var source = operational.Exams.FirstOrDefault(item => item.Id == examId);
if (source is null) return Error(404, "考试不存在");
var exam = FromOperational(source);
if (exam.ArchivedAt is not null) return Error(409, "该考试已经归档,归档操作不可撤销");
var resultIds = (await resultSnapshotLoader.LoadAsync(cancellationToken))
.Where(result => operational.Registrations.FirstOrDefault(item => item.Id == result.RegistrationId)?.ExamId == exam.Id)
.Select(result => result.Id)
.ToHashSet(StringComparer.Ordinal);
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
var pendingAppeals = workflow.Instances.Count(item =>
item.BusinessType == "score_appeal" && item.Status == "pending" && resultIds.Contains(item.BusinessId));
if (pendingAppeals > 0) return Error(409, $"本场还有 {pendingAppeals} 项成绩复议待处理,请先办结后再归档");
var archived = exam with { ArchivedAt = NowIso(), ArchivedBy = user.Id, Status = "closed" };
await repository.ArchiveAsync(
archived,
Audit(user, "归档考试并永久锁定成绩", $"{archived.Name} · {archived.Code}"),
cancellationToken);
var output = ExamJson(archived);
output["totalScore"] = archived.Subjects.Sum(item => item.FullScore);
output["registrationState"] = "archived";
return Success(new JsonObject
{
["ok"] = true,
["exam"] = output,
["message"] = "考试已归档,全部成绩已永久锁定"
});
}
private static IReadOnlyList<ManagedSubject> NormalizeSubjects(JsonNode? input, string examStart)
{
IEnumerable<JsonNode?> source = input switch
{
JsonArray array => array,
_ => Text(input).Split([',', ''], StringSplitOptions.None).Select(value => (JsonNode?)JsonValue.Create(value))
};
var output = new List<ManagedSubject>();
var index = 0;
foreach (var item in source)
{
var structured = item is JsonObject;
var objectItem = item as JsonObject;
var name = Clean(Text(structured ? objectItem!["name"] : item), 50);
if (name.Length == 0) continue;
var fullScore = structured
? objectItem!.ContainsKey("fullScore")
? objectItem["fullScore"] is null ? 0 : Number(objectItem["fullScore"])
: double.NaN
: 150;
var requestedRule = Text(objectItem?["passRule"]);
if (requestedRule == "score_ratio") requestedRule = "rank_percent";
var passRule = SubjectPassRules.Contains(requestedRule) ? requestedRule : "fixed_score";
var rawPassValue = objectItem?["passValue"] ?? objectItem?["passScore"];
var passValue = passRule == "none" ? 0 : rawPassValue is null ? fullScore * 0.6 : Number(rawPassValue);
double? passScore = passRule == "fixed_score"
? Math.Round(passValue, 2, MidpointRounding.AwayFromZero)
: null;
output.Add(new ManagedSubject(
Uid("sub"),
name,
Clean(Text(objectItem?["date"]), 10) is { Length: > 0 } date ? date : examStart[..Math.Min(10, examStart.Length)],
Clean(Text(objectItem?["start"]), 5) is { Length: > 0 } start ? start : "09:00",
Clean(Text(objectItem?["end"]), 5) is { Length: > 0 } end ? end : "11:00",
objectItem?["fee"] is null ? 0 : Number(objectItem["fee"]),
fullScore,
passRule,
passValue,
passScore,
++index));
}
return output;
}
private static string ValidateScoring(IReadOnlyList<ManagedSubject> subjects, string passPolicy, double passValue)
{
if (subjects.Count == 0) return "请至少添加一个考试科目";
if (subjects.Any(item => !double.IsFinite(item.FullScore) || item.FullScore <= 0 || item.FullScore > 1000))
return "科目满分必须大于 0 且不超过 1000";
if (subjects.Any(item => !SubjectPassRules.Contains(item.PassRule))) return "请选择有效的单科合格线计算方式";
if (subjects.Any(item => item.PassRule == "fixed_score" && (!double.IsFinite(item.PassValue) || item.PassValue < 0 || item.PassValue > item.FullScore)))
return "固定单科合格分必须在 0 与该科满分之间";
if (subjects.Any(item => item.PassRule == "rank_percent" && (!double.IsFinite(item.PassValue) || item.PassValue <= 0 || item.PassValue > 100)))
return "单科排名比例必须大于 0 且不超过 100%";
if (subjects.Any(item => !double.IsFinite(item.Fee) || item.Fee < 0 || item.Fee > 100000))
return "科目费用必须在有效范围内";
if (!PassPolicies.Contains(passPolicy)) return "请选择有效的合格线策略";
var totalScore = subjects.Sum(item => item.FullScore);
if (passPolicy == "fixed_score" && (!double.IsFinite(passValue) || passValue < 0 || passValue > totalScore))
return $"固定合格线必须在 0 与总分 {Format(totalScore)} 之间";
if (passPolicy == "rank_percent" && (!double.IsFinite(passValue) || passValue <= 0 || passValue > 100))
return "排名比例必须大于 0 且不超过 100";
return "";
}
private static ManagedExam FromOperational(OperationalExam source)
{
var data = source.Data;
return new ManagedExam(
source.Id,
Text(data["code"]),
Text(data["name"]),
Text(data["description"]),
Text(data["registrationStart"]),
Text(data["registrationEnd"]),
Text(data["examStart"]),
Text(data["examEnd"]),
Text(data["admitDownloadStart"]),
Text(data["admitDownloadEnd"]),
Text(data["location"]),
Text(data["passPolicy"]),
Number(data["passValue"]),
Text(data["status"]),
OptionalText(data["archivedAt"]),
OptionalText(data["archivedBy"]),
Text(data["createdAt"]),
source.Subjects.Select((item, index) => new ManagedSubject(
Text(item["id"]), Text(item["name"]), Text(item["date"]), Text(item["start"]), Text(item["end"]),
Number(item["fee"]), Number(item["fullScore"]), Text(item["passRule"]), Number(item["passValue"]),
OptionalNumber(item["passScore"]), (int)NumberOr(item["order"], index + 1))).ToArray());
}
private static JsonObject ExamJson(ManagedExam item) => new()
{
["id"] = item.Id,
["code"] = item.Code,
["name"] = item.Name,
["description"] = item.Description,
["registrationStart"] = item.RegistrationStart,
["registrationEnd"] = item.RegistrationEnd,
["examStart"] = item.ExamStart,
["examEnd"] = item.ExamEnd,
["admitDownloadStart"] = item.AdmitDownloadStart,
["admitDownloadEnd"] = item.AdmitDownloadEnd,
["location"] = item.Location,
["passPolicy"] = item.PassPolicy,
["passValue"] = item.PassValue,
["status"] = item.Status,
["archivedAt"] = JsonValue.Create(item.ArchivedAt),
["archivedBy"] = JsonValue.Create(item.ArchivedBy),
["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray()),
["createdAt"] = item.CreatedAt
};
private static JsonObject SubjectJson(ManagedSubject item) => new()
{
["id"] = item.Id,
["name"] = item.Name,
["date"] = item.Date,
["start"] = item.Start,
["end"] = item.End,
["fee"] = item.Fee,
["fullScore"] = item.FullScore,
["passRule"] = item.PassRule,
["passValue"] = item.PassValue,
["passScore"] = JsonValue.Create(item.PassScore),
["order"] = item.Order
};
private async Task<ResolvedAdmin> ResolveSuperAsync(string token, CancellationToken cancellationToken)
{
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var userId = await authenticationState.GetSessionUserIdAsync(token);
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
if (user.Role != "admin") return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
return (user.AdminLevel ?? "super") == "super"
? new(user, null)
: ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作"));
}
private static bool JsBoolean(JsonNode? node) { if (node is null) return false; if (node is JsonValue value && value.TryGetValue<bool>(out var boolean)) return boolean; if (node is JsonValue textValue && textValue.TryGetValue<string>(out var text)) return text.Length > 0; if (node is JsonValue numberValue && numberValue.TryGetValue<double>(out var number)) return number != 0 && !double.IsNaN(number); return true; }
private static string Text(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string? OptionalText(JsonNode? node) => node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
private static double Number(JsonNode? node) => double.TryParse(Text(node), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : double.NaN;
private static double NumberOr(JsonNode? node, double fallback) => node is null ? fallback : Number(node);
private static double? OptionalNumber(JsonNode? node) => node is null ? null : Number(node);
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
private static string NowIso() => DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
private static string Uid(string prefix) => $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
private static string ToBase36(long value) { const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; Span<char> buffer = stackalloc char[16]; var position = buffer.Length; do { buffer[--position] = alphabet[(int)(value % 36)]; value /= 36; } while (value > 0); return new(buffer[position..]); }
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso());
private static AdminEndpointResult Success(JsonObject body) => Result(200, body);
private static AdminEndpointResult Result(int status, JsonObject body) => new(status, body);
private static AdminEndpointResult Error(int status, string message) => Result(status, new JsonObject { ["ok"] = false, ["message"] = message });
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
}
@@ -0,0 +1,185 @@
using System.Data.Common;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
internal sealed class AdminExamRepository(IRelationalConnectionFactory connectionFactory)
{
public Task CreateAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken) =>
SaveAsync(exam, replaceSubjects: true, isNew: true, audit, cancellationToken);
public Task UpdateAsync(ManagedExam exam, bool replaceSubjects, AdminAuditEntry audit, CancellationToken cancellationToken) =>
SaveAsync(exam, replaceSubjects, isNew: false, audit, cancellationToken);
public async Task ArchiveAsync(ManagedExam exam, AdminAuditEntry audit, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ExecuteAsync(connection, transaction, new SqlOperation(
"UPDATE exams SET status = @status, archived_at = @archivedAt, archived_by = @archivedBy WHERE id = @id AND archived_at IS NULL",
[new("@status", exam.Status), new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@id", exam.Id)]), cancellationToken);
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private async Task SaveAsync(
ManagedExam exam,
bool replaceSubjects,
bool isNew,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
var examOperation = isNew
? new SqlOperation(
"""
INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status,
archived_at, archived_by, created_at
) VALUES (
@id, @code, @name, @description, @registrationStart, @registrationEnd, @examStart, @examEnd,
@admitDownloadStart, @admitDownloadEnd, @location, @passPolicy, @passValue, @status,
@archivedAt, @archivedBy, @createdAt
)
""",
ExamParameters(exam))
: new SqlOperation(
"""
UPDATE exams SET code = @code, name = @name, description = @description,
registration_start = @registrationStart, registration_end = @registrationEnd,
exam_start = @examStart, exam_end = @examEnd,
admit_download_start = @admitDownloadStart, admit_download_end = @admitDownloadEnd,
location = @location, pass_policy = @passPolicy, pass_value = @passValue, status = @status
WHERE id = @id
""",
ExamParameters(exam));
await ExecuteAsync(connection, transaction, examOperation, cancellationToken);
if (replaceSubjects)
{
if (!isNew)
{
await ExecuteAsync(connection, transaction, new SqlOperation(
"DELETE FROM exam_subjects WHERE exam_id = @examId",
[new("@examId", exam.Id)]), cancellationToken);
}
foreach (var subject in exam.Subjects)
{
await ExecuteAsync(connection, transaction, new SqlOperation(
"""
INSERT INTO exam_subjects (
id, exam_id, name, subject_date, start_time, end_time, fee, full_score,
pass_score, pass_rule, pass_value, position
) VALUES (
@id, @examId, @name, @date, @start, @end, @fee, @fullScore,
@passScore, @passRule, @passValue, @position
)
""",
[
new("@id", subject.Id), new("@examId", exam.Id), new("@name", subject.Name),
new("@date", subject.Date), new("@start", subject.Start), new("@end", subject.End),
new("@fee", subject.Fee), new("@fullScore", subject.FullScore),
new("@passScore", subject.PassScore ?? 0),
new("@passRule", subject.PassRule == "rank_percent" ? "score_ratio" : subject.PassRule),
new("@passValue", subject.PassValue), new("@position", subject.Order)
]), cancellationToken);
}
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private static IReadOnlyList<SqlParameterValue> ExamParameters(ManagedExam exam) =>
[
new("@id", exam.Id), new("@code", exam.Code), new("@name", exam.Name),
new("@description", exam.Description), new("@registrationStart", exam.RegistrationStart),
new("@registrationEnd", exam.RegistrationEnd), new("@examStart", exam.ExamStart),
new("@examEnd", exam.ExamEnd), new("@admitDownloadStart", exam.AdmitDownloadStart),
new("@admitDownloadEnd", exam.AdmitDownloadEnd), new("@location", exam.Location),
new("@passPolicy", exam.PassPolicy), new("@passValue", exam.PassValue), new("@status", exam.Status),
new("@archivedAt", exam.ArchivedAt), new("@archivedBy", exam.ArchivedBy), new("@createdAt", exam.CreatedAt)
];
private static Task InsertAuditAsync(DbConnection connection, DbTransaction transaction, AdminAuditEntry audit, CancellationToken cancellationToken) =>
ExecuteAsync(connection, transaction, new SqlOperation(
"""
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
VALUES (@id, @actorId, @action, @detail, @createdAt)
""",
[
new("@id", audit.Id), new("@actorId", audit.ActorId), new("@action", audit.Action),
new("@detail", audit.Detail), new("@createdAt", audit.CreatedAt)
]), cancellationToken);
private static async Task ExecuteAsync(
DbConnection connection,
DbTransaction transaction,
SqlOperation operation,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = operation.Sql;
foreach (var item in operation.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = item.Name;
parameter.Value = item.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private sealed record SqlOperation(string Sql, IReadOnlyList<SqlParameterValue> Parameters);
private sealed record SqlParameterValue(string Name, object? Value);
}
internal sealed record ManagedExam(
string Id,
string Code,
string Name,
string Description,
string RegistrationStart,
string RegistrationEnd,
string ExamStart,
string ExamEnd,
string AdmitDownloadStart,
string AdmitDownloadEnd,
string Location,
string PassPolicy,
double PassValue,
string Status,
string? ArchivedAt,
string? ArchivedBy,
string CreatedAt,
IReadOnlyList<ManagedSubject> Subjects);
internal sealed record ManagedSubject(
string Id,
string Name,
string Date,
string Start,
string End,
double Fee,
double FullScore,
string PassRule,
double PassValue,
double? PassScore,
int Order);
@@ -10,7 +10,8 @@ public sealed record AdminMigrationOptions(
bool NativeOperationalReadsEnabled = false,
bool NativeCandidateManagementEnabled = false,
bool NativeRegistrationPaymentWritesEnabled = false,
bool NativeWorkflowOperationsEnabled = false)
bool NativeWorkflowOperationsEnabled = false,
bool NativeExamManagementEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -24,7 +25,8 @@ public sealed record AdminMigrationOptions(
bool configuredNativeOperationalReadsEnabled = false,
bool configuredNativeCandidateManagementEnabled = false,
bool configuredNativeRegistrationPaymentWritesEnabled = false,
bool configuredNativeWorkflowOperationsEnabled = false)
bool configuredNativeWorkflowOperationsEnabled = false,
bool configuredNativeExamManagementEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -57,7 +59,10 @@ public sealed record AdminMigrationOptions(
var workflowOperationsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
configuredNativeWorkflowOperationsEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled) && !readsEnabled)
var examManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
configuredNativeExamManagementEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
@@ -67,7 +72,7 @@ public sealed record AdminMigrationOptions(
throw new InvalidOperationException(
"启用原生考生、报名或缴费写接口前必须同时设置 ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -83,7 +88,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -63,6 +63,7 @@ public static class DependencyInjection
services.AddScoped<AdminOperationalSnapshotLoader>();
services.AddScoped<AdminWorkflowResultSnapshotLoader>();
services.AddScoped<AdminWorkflowRepository>();
services.AddScoped<AdminExamRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
@@ -73,6 +74,7 @@ public static class DependencyInjection
services.AddScoped<IAdminCandidateManagementService, AdminCandidateManagementService>();
services.AddScoped<IAdminRegistrationManagementService, AdminRegistrationManagementService>();
services.AddScoped<IAdminWorkflowService, AdminWorkflowService>();
services.AddScoped<IAdminExamManagementService, AdminExamManagementService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
return services;