本轮完成了完整的通知公告管理迁移:

原生 GET /api/admin/notices
原生五类系统公示聚合:计划、资格、录取、分数线、报到
原生 PATCH /api/admin/publications/{type}/{id} 显隐控制
权限、事务审计和 Node 返回结构保持一致
开关升级为 ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED
兼容旧变量 ADMIN_NATIVE_NOTICE_WRITES_ENABLED
冒烟脚本现在会自动构建 Web,避免使用过期程序集
This commit is contained in:
2026-07-23 08:58:12 +08:00 Unverified
parent 9e21a277f0
commit e845ba8d3a
13 changed files with 597 additions and 37 deletions
@@ -4,6 +4,13 @@ namespace Eis.Application.Administration;
public interface IAdminNoticeService
{
Task<AdminEndpointResult> ListAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string noticeId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> UpdatePublicationVisibilityAsync(
string sessionToken,
string sourceType,
string publicationId,
JsonObject body,
CancellationToken cancellationToken);
}
@@ -5,7 +5,7 @@ public sealed record AdminMigrationOptions(
bool NativeOrganizationWritesEnabled = false,
bool NativeAccountBatchesEnabled = false,
bool NativeConfigurationEnabled = false,
bool NativeNoticeWritesEnabled = false)
bool NativeNoticeManagementEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -14,7 +14,7 @@ public sealed record AdminMigrationOptions(
bool configuredNativeOrganizationWritesEnabled = false,
bool configuredNativeAccountBatchesEnabled = false,
bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeWritesEnabled = false)
bool configuredNativeNoticeManagementEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -28,15 +28,16 @@ public sealed record AdminMigrationOptions(
var configurationEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
configuredNativeConfigurationEnabled);
var noticeWritesEnabled = ParseBoolean(
var noticeManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED") ??
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
configuredNativeNoticeWritesEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled) && !readsEnabled)
configuredNativeNoticeManagementEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -52,7 +53,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeWritesEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -1,5 +1,6 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Eis.Infrastructure.Data;
namespace Eis.Infrastructure.Administration;
@@ -8,6 +9,28 @@ internal sealed record AdminNotice(string Id, string Title, string Summary, stri
internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connectionFactory)
{
public async Task<AdminNoticeManagementSnapshot> LoadManagementAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var notices = new List<AdminNotice>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id, title, summary, content, category, pinned, status, publish_at, created_at, author FROM notices ORDER BY created_at, id";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) notices.Add(Read(reader));
}
var records = new List<AdminPublicationRecord>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records ORDER BY created_at, id";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) records.Add(ReadPublication(reader));
}
var examNames = await ReadNamesAsync(connection, "SELECT id, name FROM exams ORDER BY created_at, id", cancellationToken);
var schoolNames = await ReadNamesAsync(connection, "SELECT id, name FROM schools ORDER BY name, id", cancellationToken);
return new AdminNoticeManagementSnapshot(notices, records, examNames, schoolNames);
}
public async Task<AdminNotice?> FindAsync(string id, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
@@ -30,6 +53,34 @@ internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connect
pinned = @pinned, status = @status, publish_at = @publishAt WHERE id = @id
""", notice, audit, cancellationToken);
public async Task UpdatePublicationAsync(
AdminPublicationRecord publication,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = "UPDATE admission_records SET payload_json = @payload, updated_at = @updatedAt WHERE id = @id";
Add(command, "@payload", publication.Payload.ToJsonString());
Add(command, "@updatedAt", publication.UpdatedAt);
Add(command, "@id", publication.Id);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
private async Task ExecuteAsync(string sql, AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
@@ -45,13 +96,7 @@ internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connect
Add(command, "@status", notice.Status); Add(command, "@publishAt", notice.PublishAt); Add(command, "@createdAt", notice.CreatedAt); Add(command, "@author", notice.Author);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = "INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt)";
Add(command, "@id", audit.Id); Add(command, "@actorId", audit.ActorId); Add(command, "@action", audit.Action); Add(command, "@detail", audit.Detail); Add(command, "@createdAt", audit.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
@@ -65,6 +110,48 @@ internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connect
Text(reader, "id"), Text(reader, "title"), Text(reader, "summary"), Text(reader, "content"), Text(reader, "category"),
Convert.ToInt64(reader.GetValue(reader.GetOrdinal("pinned")), CultureInfo.InvariantCulture) != 0, Text(reader, "status"),
Optional(reader, "publish_at"), Optional(reader, "created_at"), Text(reader, "author"));
private static AdminPublicationRecord ReadPublication(DbDataReader reader) => new(
Text(reader, "id"),
Text(reader, "kind"),
Text(reader, "exam_id"),
Optional(reader, "user_id"),
Optional(reader, "school_id"),
Text(reader, "status"),
JsonNode.Parse(Text(reader, "payload_json")) as JsonObject ?? new JsonObject(),
Text(reader, "created_at"),
Text(reader, "updated_at"));
private static async Task<IReadOnlyDictionary<string, string>> ReadNamesAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken)
{
var names = new Dictionary<string, string>(StringComparer.Ordinal);
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) names[Text(reader, "id")] = Text(reader, "name");
return names;
}
private static async Task InsertAuditAsync(
DbConnection connection,
DbTransaction transaction,
AdminAuditEntry audit,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt)";
Add(command, "@id", audit.Id);
Add(command, "@actorId", audit.ActorId);
Add(command, "@action", audit.Action);
Add(command, "@detail", audit.Detail);
Add(command, "@createdAt", audit.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
private static string? Optional(DbDataReader reader, string name) { var index = reader.GetOrdinal(name); return reader.IsDBNull(index) ? null : Convert.ToString(reader.GetValue(index), CultureInfo.InvariantCulture); }
private static void Add(DbCommand command, string name, object? value) { var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.Value = value ?? DBNull.Value; command.Parameters.Add(parameter); }
@@ -13,6 +13,23 @@ internal sealed class AdminNoticeService(
AdminNoticeRepository repository,
NoticeContentFormatter formatter) : IAdminNoticeService
{
public async Task<AdminEndpointResult> ListAsync(string sessionToken, CancellationToken cancellationToken)
{
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var snapshot = await repository.LoadManagementAsync(cancellationToken);
var notices = snapshot.Notices
.OrderByDescending(item => ParseDate(item.PublishAt ?? item.CreatedAt))
.Select(NoticeJson)
.ToArray();
return Success(new JsonObject
{
["ok"] = true,
["notices"] = new JsonArray(notices),
["publications"] = new JsonArray(AdminPublicationProjector.Build(snapshot).ToArray())
});
}
public async Task<AdminEndpointResult> CreateAsync(
string sessionToken,
JsonObject body,
@@ -86,6 +103,41 @@ internal sealed class AdminNoticeService(
return Success(new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) });
}
public async Task<AdminEndpointResult> UpdatePublicationVisibilityAsync(
string sessionToken,
string sourceType,
string publicationId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var snapshot = await repository.LoadManagementAsync(cancellationToken);
var existing = AdminPublicationProjector.FindSourceRecord(snapshot.Records, sourceType, publicationId);
if (existing is null) return Error(404, "系统公示不存在");
if (body["visible"] is not JsonValue value || !value.TryGetValue<bool>(out var visible))
{
return Error(400, "请明确设置是否显示");
}
var payload = existing.Payload.DeepClone().AsObject();
payload["publicVisible"] = visible;
var updated = existing with { Payload = payload, UpdatedAt = NowIso() };
await repository.UpdatePublicationAsync(
updated,
Audit(user, visible ? "显示系统公示" : "隐藏系统公示", $"{sourceType} · {updated.Id}"),
cancellationToken);
var updatedRecords = snapshot.Records.Select(item => item.Id == updated.Id ? updated : item).ToArray();
var updatedSnapshot = snapshot with { Records = updatedRecords };
var publication = AdminPublicationProjector.Build(updatedSnapshot)
.FirstOrDefault(item => item["id"]?.GetValue<string>() == publicationId &&
item["sourceType"]?.GetValue<string>() == sourceType);
var response = new JsonObject { ["ok"] = true };
if (publication is not null) response["publication"] = publication;
return Success(response);
}
private async Task<ResolvedAdmin> ResolveSuperAsync(string token, CancellationToken cancellationToken)
{
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
@@ -128,6 +180,8 @@ internal sealed class AdminNoticeService(
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
private static string NowIso() => DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
private static DateTimeOffset ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue;
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());
@@ -0,0 +1,240 @@
using System.Globalization;
using System.Text.Json.Nodes;
namespace Eis.Infrastructure.Administration;
internal sealed record AdminPublicationRecord(
string Id,
string Kind,
string ExamId,
string? UserId,
string? SchoolId,
string Status,
JsonObject Payload,
string CreatedAt,
string UpdatedAt);
internal sealed record AdminNoticeManagementSnapshot(
IReadOnlyList<AdminNotice> Notices,
IReadOnlyList<AdminPublicationRecord> Records,
IReadOnlyDictionary<string, string> ExamNames,
IReadOnlyDictionary<string, string> SchoolNames);
internal static class AdminPublicationProjector
{
private static readonly HashSet<string> AdmissionRoundStatuses = ["reporting", "supplementary", "completed"];
public static AdminPublicationRecord? FindSourceRecord(
IEnumerable<AdminPublicationRecord> records,
string sourceType,
string publicationId) => records.FirstOrDefault(item => item.Id == publicationId && sourceType switch
{
"plan" => item.Kind == "plan",
"qualification" => item.Kind == "qualification_publication",
"admission" => item.Kind == "setting" ||
(item.Kind == "notification" && Text(item.Payload, "type") == "admission_round_publication"),
"cutoff" => item.Kind == "cutoff_publication",
"reporting" => item.Kind == "notification" && Text(item.Payload, "type") == "admission_reporting",
_ => false
});
public static IReadOnlyList<JsonObject> Build(AdminNoticeManagementSnapshot snapshot)
{
var records = snapshot.Records;
var settings = records.Where(item => item.Kind == "setting")
.GroupBy(item => item.ExamId)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
var publications = new List<JsonObject>();
publications.AddRange(records
.Where(item => item.Kind == "plan" && item.Status == "approved")
.Select(item => View(
item.Id,
"plan",
"招生计划",
$"{ExamName(snapshot, item.ExamId)} · {SchoolName(snapshot, item.SchoolId)}招生计划公示",
Text(item.Payload, "reviewedAt") ?? item.UpdatedAt,
"审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。",
item.Payload)));
publications.AddRange(records
.Where(item => item.Kind == "qualification_publication" && item.Status == "published")
.Select(item => View(
item.Id,
"qualification",
"指标资格",
$"{ExamName(snapshot, item.ExamId)} · {SchoolName(snapshot, item.SchoolId)}指标分配资格公示",
Text(item.Payload, "publishedAt") ?? item.UpdatedAt,
"资格确认完成后由系统生成,内容随资格确认结果更新。",
item.Payload)));
var rounds = BuildRounds(records);
publications.AddRange(rounds
.Where(item => !settings.TryGetValue(item.ExamId, out var setting) || Boolean(setting.Payload, "autoPublish", true))
.Select(item => View(
item.SourceRecordId,
"admission",
"录取名单",
$"{ExamName(snapshot, item.ExamId)}第 {item.Round} 轮录取名单公示",
item.PublishedAt,
$"第 {item.Round} 轮录取通知书签发后由系统自动生成,共 {item.RowCount} 人。",
item.Payload)));
var virtualSourceIds = rounds.Where(item => item.Virtual)
.Select(item => item.SourceRecordId)
.ToHashSet(StringComparer.Ordinal);
publications.AddRange(records
.Where(item => item.Kind == "setting" && item.Status == "completed" &&
Boolean(item.Payload, "autoPublish", true) && !virtualSourceIds.Contains(item.Id))
.Select(item => View(
item.Id,
"admission",
"录取名单",
$"{ExamName(snapshot, item.ExamId)}最终录取名单",
Text(item.Payload, "completedAt") ?? item.UpdatedAt,
"录取结束后由系统生成,内容取自最终录取结果。",
item.Payload)));
publications.AddRange(records
.Where(item => item.Kind == "cutoff_publication" && item.Status == "published" &&
(!settings.TryGetValue(item.ExamId, out var setting) || Boolean(setting.Payload, "autoPublish", true)))
.Select(item => View(
item.Id,
"cutoff",
"录取分数线",
$"{ExamName(snapshot, item.ExamId)}录取分数线",
Text(item.Payload, "publishedAt") ?? item.UpdatedAt,
"录取结束后由系统生成,内容取自各招生类别最低录取分数。",
item.Payload)));
publications.AddRange(records
.Where(item => item.Kind == "notification" && item.UserId is null && item.Status == "approved" &&
Text(item.Payload, "type") == "admission_reporting")
.Select(item => ReportingView(snapshot, item)));
return publications
.OrderByDescending(item => ParseDate(item["publishedAt"]?.GetValue<string>()))
.ToArray();
}
private static IReadOnlyList<RoundPublication> BuildRounds(IReadOnlyList<AdminPublicationRecord> records)
{
var rounds = records
.Where(item => item.Kind == "notification" && item.UserId is null && item.Status == "published" &&
Text(item.Payload, "type") == "admission_round_publication")
.Select(item => new RoundPublication(
item.Id,
item.ExamId,
Math.Max(1, (int)Number(item.Payload, "round", 1)),
Text(item.Payload, "publishedAt") ?? item.UpdatedAt,
Array(item.Payload, "rows").Count,
item.Payload,
false))
.ToList();
var keys = rounds.Select(item => $"{item.ExamId}:{item.Round}").ToHashSet(StringComparer.Ordinal);
foreach (var setting in records.Where(item => item.Kind == "setting" && AdmissionRoundStatuses.Contains(item.Status)))
{
var finalizedRounds = records
.Where(item => item.Kind == "placement" && item.ExamId == setting.ExamId && item.Status is "final" or "forfeited")
.Select(item => Math.Max(1, (int)Number(item.Payload, "finalizedRound", 1)))
.ToArray();
var round = finalizedRounds.Length == 0 ? 0 : finalizedRounds.Max();
if (round == 0 || keys.Contains($"{setting.ExamId}:{round}")) continue;
var rowCount = records.Count(item => item.Kind == "placement" && item.ExamId == setting.ExamId &&
(item.Status is "final" or "forfeited") &&
Math.Max(1, (int)Number(item.Payload, "finalizedRound", 1)) == round);
var payload = new JsonObject
{
["publicVisible"] = Boolean(setting.Payload, "publicVisible", true)
};
rounds.Add(new RoundPublication(
setting.Id,
setting.ExamId,
round,
Text(setting.Payload, "roundPublishedAt") ?? setting.UpdatedAt,
rowCount,
payload,
true));
}
return rounds;
}
private static JsonObject ReportingView(AdminNoticeManagementSnapshot snapshot, AdminPublicationRecord item)
{
var statistics = item.Payload["statistics"] as JsonObject ?? new JsonObject();
var supplement = Text(item.Payload, "supplementDecision") == "supplement";
var examName = ExamName(snapshot, item.ExamId);
var schoolName = SchoolName(snapshot, item.SchoolId);
var summary = $"计划 {Format(Number(statistics, "totalQuota"))} 人,已报到 {Format(Number(statistics, "reportedCount"))} 人,完成率 {Format(Number(statistics, "reportingRate"))}%。";
return View(
item.Id,
"reporting",
"考生报到",
supplement ? $"{examName} · {schoolName}考生报到情况及补录说明" : $"{examName} · {schoolName}考生报到情况公示",
Text(item.Payload, "approvedAt") ?? item.UpdatedAt,
summary,
item.Payload);
}
private static JsonObject View(
string id,
string sourceType,
string category,
string title,
string publishedAt,
string summary,
JsonObject payload)
{
var visible = Boolean(payload, "publicVisible", true);
return new JsonObject
{
["id"] = id,
["sourceType"] = sourceType,
["category"] = category,
["title"] = title,
["summary"] = summary,
["author"] = "系统自动发布",
["publishedAt"] = publishedAt,
["visible"] = visible,
["status"] = visible ? "visible" : "hidden"
};
}
private static string ExamName(AdminNoticeManagementSnapshot snapshot, string examId) =>
snapshot.ExamNames.GetValueOrDefault(examId) ?? "未知考试";
private static string SchoolName(AdminNoticeManagementSnapshot snapshot, string? schoolId) =>
schoolId is not null ? snapshot.SchoolNames.GetValueOrDefault(schoolId) ?? "未知学校" : "未知学校";
private static string? Text(JsonObject payload, string name) =>
payload[name] is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
private static bool Boolean(JsonObject payload, string name, bool fallback) =>
payload[name] is JsonValue value && value.TryGetValue<bool>(out var result) ? result : fallback;
private static double Number(JsonObject payload, string name, double fallback = 0)
{
if (payload[name] is not JsonValue value) return fallback;
if (value.TryGetValue<double>(out var number)) return number;
if (value.TryGetValue<int>(out var integer32)) return integer32;
if (value.TryGetValue<long>(out var integer)) return integer;
if (value.TryGetValue<decimal>(out var decimalNumber)) return (double)decimalNumber;
if (value.TryGetValue<string>(out var text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) return number;
return fallback;
}
private static JsonArray Array(JsonObject payload, string name) => payload[name] as JsonArray ?? [];
private static string Format(double number) => number.ToString("0.################", CultureInfo.InvariantCulture);
private static DateTimeOffset ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue;
private sealed record RoundPublication(
string SourceRecordId,
string ExamId,
int Round,
string PublishedAt,
int RowCount,
JsonObject Payload,
bool Virtual);
}
@@ -67,11 +67,16 @@ public static class NativeAdminReadEndpoints
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
}
if (!options.NativeNoticeWritesEnabled) return endpoints;
if (!options.NativeNoticeManagementEnabled) return endpoints;
endpoints.MapGet("/api/admin/notices", (HttpContext context, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.ListAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken)));
endpoints.MapPatch("/api/admin/publications/{sourceType:regex(^(plan|qualification|admission|cutoff|reporting)$)}/{publicationId}",
(HttpContext context, string sourceType, string publicationId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdatePublicationVisibilityAsync(Token(context), sourceType, publicationId, body, cancellationToken)));
return endpoints;
}
+4 -4
View File
@@ -49,7 +49,7 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeWritesEnabled"));
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"));
builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
@@ -107,7 +107,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled,
nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled,
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
nativeNoticeWritesEnabled = adminMigrationOptions.NativeNoticeWritesEnabled,
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: [])
@@ -124,8 +124,8 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
.Concat(adminMigrationOptions.NativeConfigurationEnabled
? new[] { "GET/POST number-rules", "GET/PUT workflows" }
: [])
.Concat(adminMigrationOptions.NativeNoticeWritesEnabled
? new[] { "POST/PATCH notices" }
.Concat(adminMigrationOptions.NativeNoticeManagementEnabled
? new[] { "GET/POST/PATCH notices", "PATCH system publications" }
: [])
.ToArray()
},
+1 -1
View File
@@ -14,7 +14,7 @@
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false,
"NativeNoticeWritesEnabled": false
"NativeNoticeManagementEnabled": false
},
"Logging": {
"LogLevel": {