diff --git a/.env.example b/.env.example index 129226c..ba74200 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,8 @@ ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false # 报名号规则与审批流程定义维护;必须同时启用管理端只读接口。 ADMIN_NATIVE_CONFIGURATION_ENABLED=false +# 手工通知公告创建和修改;公告列表及系统公示暂时仍由 Node 提供。 +ADMIN_NATIVE_NOTICE_WRITES_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 7790f94..89d253b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批及流程配置已原生化) +- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置及手工公告写入已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,9 +54,9 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖超级管理员创建和编辑手工公告,包含 HTML 净化、草稿发布状态及事务内审计;管理端公告列表与系统自动发布内容暂时仍由 Node 处理。 -其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用: +其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: ```powershell $env:AUTH_NATIVE_ENABLED = 'true' @@ -64,9 +64,10 @@ $env:ADMIN_NATIVE_READS_ENABLED = 'true' $env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' $env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' +$env:ADMIN_NATIVE_NOTICE_WRITES_ENABLED = 'true' ``` -`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 1bd163b..4e60aef 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -426,6 +426,7 @@ try { ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true' ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true' ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true' + ADMIN_NATIVE_NOTICE_WRITES_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -953,6 +954,48 @@ try { $restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null + $noticeCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '越权公告'; content = '

无权发布

' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($noticeCreateForbidden.StatusCode -ne 403 -or $noticeCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native notice creation did not preserve the super-admin boundary' + } + $invalidNotice = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '无正文公告'; content = '' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidNotice.StatusCode -ne 400 -or $invalidNotice.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native notice creation accepted content that became empty after sanitization' + } + $noticeCreateBody = @{ + title = '原生迁移公告' + summary = '' + content = '

公告正文

危险链接' + category = '迁移公告' + pinned = $true + status = 'draft' + } | ConvertTo-Json -Compress + $noticeCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body $noticeCreateBody -WebSession $nativeSession + $createdNativeNotice = ($noticeCreateResponse.Content | ConvertFrom-Json).notice + if ($noticeCreateResponse.StatusCode -ne 201 -or $noticeCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native notice creation did not use ASP.NET Core' + } + if ($createdNativeNotice.status -ne 'draft' -or $null -ne $createdNativeNotice.publishAt -or $createdNativeNotice.summary -ne '公告正文 危险链接') { + throw 'Native notice creation did not preserve draft state or derive its summary' + } + if ($createdNativeNotice.content -match '(?i) CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); + Task UpdateAsync(string sessionToken, string noticeId, JsonObject body, CancellationToken cancellationToken); +} diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index f5e758f..80cb02e 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -4,7 +4,8 @@ public sealed record AdminMigrationOptions( bool NativeReadsEnabled, bool NativeOrganizationWritesEnabled = false, bool NativeAccountBatchesEnabled = false, - bool NativeConfigurationEnabled = false) + bool NativeConfigurationEnabled = false, + bool NativeNoticeWritesEnabled = false) { public static AdminMigrationOptions FromEnvironment( bool configuredNativeReadsEnabled, @@ -12,7 +13,8 @@ public sealed record AdminMigrationOptions( bool sharesLegacySessions, bool configuredNativeOrganizationWritesEnabled = false, bool configuredNativeAccountBatchesEnabled = false, - bool configuredNativeConfigurationEnabled = false) + bool configuredNativeConfigurationEnabled = false, + bool configuredNativeNoticeWritesEnabled = false) { var readsEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), @@ -26,12 +28,15 @@ public sealed record AdminMigrationOptions( var configurationEnabled = ParseBoolean( Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"), configuredNativeConfigurationEnabled); - if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled) && !readsEnabled) + var noticeWritesEnabled = ParseBoolean( + Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"), + configuredNativeNoticeWritesEnabled); + if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled) && !readsEnabled) { throw new InvalidOperationException( "启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true"); } - var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled; + var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled; if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled) { throw new InvalidOperationException( @@ -47,7 +52,7 @@ public sealed record AdminMigrationOptions( "管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话"); } - return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled); + return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeWritesEnabled); } private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch diff --git a/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs new file mode 100644 index 0000000..9d52ba8 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs @@ -0,0 +1,71 @@ +using System.Data.Common; +using System.Globalization; +using Eis.Infrastructure.Data; + +namespace Eis.Infrastructure.Administration; + +internal sealed record AdminNotice(string Id, string Title, string Summary, string Content, string Category, bool Pinned, string Status, string? PublishAt, string? CreatedAt, string Author); + +internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connectionFactory) +{ + public async Task FindAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, title, summary, content, category, pinned, status, publish_at, created_at, author FROM notices WHERE id = @id LIMIT 1"; + Add(command, "@id", id); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? Read(reader) : null; + } + + public Task CreateAsync(AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken) => + ExecuteAsync(""" + INSERT INTO notices (id, title, summary, content, category, pinned, status, publish_at, created_at, author) + VALUES (@id, @title, @summary, @content, @category, @pinned, @status, @publishAt, @createdAt, @author) + """, notice, audit, cancellationToken); + + public Task UpdateAsync(AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken) => + ExecuteAsync(""" + UPDATE notices SET title = @title, summary = @summary, content = @content, category = @category, + pinned = @pinned, status = @status, publish_at = @publishAt WHERE id = @id + """, notice, audit, cancellationToken); + + private async Task ExecuteAsync(string sql, AdminNotice notice, 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 = sql; + Add(command, "@id", notice.Id); Add(command, "@title", notice.Title); Add(command, "@summary", notice.Summary); + Add(command, "@content", notice.Content); Add(command, "@category", notice.Category); Add(command, "@pinned", notice.Pinned ? 1 : 0); + 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 transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static AdminNotice Read(DbDataReader reader) => new( + 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 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); } +} diff --git a/src/Eis.Infrastructure/Administration/AdminNoticeService.cs b/src/Eis.Infrastructure/Administration/AdminNoticeService.cs new file mode 100644 index 0000000..2283870 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminNoticeService.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Administration; +using Eis.Infrastructure.Authentication; +using Eis.Infrastructure.Public; + +namespace Eis.Infrastructure.Administration; + +internal sealed class AdminNoticeService( + IAuthenticationStateStore authenticationState, + AuthenticationRepository authenticationRepository, + AdminNoticeRepository repository, + NoticeContentFormatter formatter) : IAdminNoticeService +{ + public async Task 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 title = Clean(Text(body["title"]), 120); + var content = formatter.Sanitize(Text(body["content"])); + var contentText = formatter.PlainText(content); + if (title.Length == 0 || contentText.Length == 0) return Error(400, "通知标题和正文不能为空"); + var summary = Clean(Text(body["summary"]), 260); + var category = Clean(Text(body["category"]), 30); + var draft = Text(body["status"]) == "draft"; + var notice = new AdminNotice( + Uid("notice"), + title, + summary.Length == 0 ? contentText[..Math.Min(contentText.Length, 80)] : summary, + content, + category.Length == 0 ? "通知公告" : category, + JsBoolean(body["pinned"]), + draft ? "draft" : "published", + draft ? null : NowIso(), + NowIso(), + user.DisplayName); + await repository.CreateAsync( + notice, + Audit(user, draft ? "保存通知草稿" : "发布通知", notice.Title), + cancellationToken); + return new AdminEndpointResult(201, new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) }); + } + + public async Task UpdateAsync( + string sessionToken, + string noticeId, + JsonObject body, + CancellationToken cancellationToken) + { + var context = await ResolveSuperAsync(sessionToken, cancellationToken); + if (context.Error is not null) return context.Error; + var user = context.User!; + var existing = await repository.FindAsync(noticeId, cancellationToken); + if (existing is null) return Error(404, "通知不存在"); + var notice = existing with + { + Title = body["title"] is null ? existing.Title : Clean(Text(body["title"]), 260), + Summary = body["summary"] is null ? existing.Summary : Clean(Text(body["summary"]), 260), + Category = body["category"] is null ? existing.Category : Clean(Text(body["category"]), 260), + Pinned = body["pinned"] is null ? existing.Pinned : JsBoolean(body["pinned"]) + }; + if (body["content"] is not null) + { + var content = formatter.Sanitize(Text(body["content"])); + if (formatter.PlainText(content).Length == 0) return Error(400, "通知正文不能为空"); + notice = notice with { Content = content }; + } + var requestedStatus = Text(body["status"]); + if (requestedStatus is "draft" or "published") + { + notice = notice with + { + Status = requestedStatus, + PublishAt = requestedStatus == "published" && notice.PublishAt is null ? NowIso() : notice.PublishAt + }; + } + await repository.UpdateAsync( + notice, + Audit(user, "更新通知", $"{notice.Title} · {notice.Status}"), + cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) }); + } + + private async Task 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 ResolvedAdmin(user, null) + : ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作")); + } + + private JsonObject NoticeJson(AdminNotice item) => new() + { + ["id"] = item.Id, + ["title"] = item.Title, + ["summary"] = item.Summary, + ["content"] = formatter.Sanitize(item.Content), + ["category"] = item.Category, + ["pinned"] = item.Pinned, + ["status"] = item.Status, + ["publishAt"] = JsonValue.Create(item.PublishAt), + ["createdAt"] = JsonValue.Create(item.CreatedAt), + ["author"] = item.Author, + ["contentHtml"] = formatter.ContentHtml(item.Content) + }; + + private static bool JsBoolean(JsonNode? node) + { + if (node is null) return false; + if (node is not JsonValue value) return true; + if (value.TryGetValue(out var boolean)) return boolean; + if (value.TryGetValue(out var text)) return text.Length > 0; + if (value.TryGetValue(out var number)) return number != 0 && !double.IsNaN(number); + return true; + } + + private static string Text(JsonNode? node) => + node is JsonValue value && value.TryGetValue(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 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 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) => new(200, body); + private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message }); + private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); } +} diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 03e0196..370bb7e 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -57,10 +57,13 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddScoped(); return services; } diff --git a/src/Eis.Infrastructure/Public/NoticeContentFormatter.cs b/src/Eis.Infrastructure/Public/NoticeContentFormatter.cs index b66465d..ceb352d 100644 --- a/src/Eis.Infrastructure/Public/NoticeContentFormatter.cs +++ b/src/Eis.Infrastructure/Public/NoticeContentFormatter.cs @@ -59,6 +59,14 @@ internal sealed partial class NoticeContentFormatter .Select(paragraph => $"

{WebUtility.HtmlEncode(paragraph).Replace("\r\n", "
", StringComparison.Ordinal).Replace("\n", "
", StringComparison.Ordinal)}

")); } + public string PlainText(string? value) + { + var sanitized = BlockTagRegex().Replace(Sanitize(value), " "); + var parser = new HtmlParser(); + var document = parser.ParseDocument($"{sanitized}"); + return WhitespaceRegex().Replace(document.Body?.TextContent ?? string.Empty, " ").Trim(); + } + private static void SanitizeChildren(INode parent) { foreach (var child in parent.ChildNodes.ToArray()) @@ -176,4 +184,10 @@ internal sealed partial class NoticeContentFormatter [GeneratedRegex("\\r?\\n{2,}")] private static partial Regex ParagraphRegex(); + + [GeneratedRegex("]*>", RegexOptions.IgnoreCase)] + private static partial Regex BlockTagRegex(); + + [GeneratedRegex("\\s+")] + private static partial Regex WhitespaceRegex(); } diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 69fafce..22a8c80 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -55,15 +55,23 @@ public static class NativeAdminReadEndpoints Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken))); } - if (!options.NativeConfigurationEnabled) return endpoints; - endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => - Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken))); - endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => - Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken))); - endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => - Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken))); - endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => - Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken))); + if (options.NativeConfigurationEnabled) + { + endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken))); + endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken))); + endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken))); + endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) => + Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken))); + } + + if (!options.NativeNoticeWritesEnabled) return endpoints; + 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))); return endpoints; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 1334600..7916953 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -48,7 +48,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( authenticationOptions.SharesLegacySessions, builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled")); + builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled"), + builder.Configuration.GetValue("AdminMigration:NativeNoticeWritesEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), @@ -106,6 +107,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled, nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled, nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled, + nativeNoticeWritesEnabled = adminMigrationOptions.NativeNoticeWritesEnabled, nativeRoutes = (adminMigrationOptions.NativeReadsEnabled ? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" } : []) @@ -122,6 +124,9 @@ 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" } + : []) .ToArray() }, features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled) diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 816a242..c9c4c4a 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -13,7 +13,8 @@ "NativeReadsEnabled": false, "NativeOrganizationWritesEnabled": false, "NativeAccountBatchesEnabled": false, - "NativeConfigurationEnabled": false + "NativeConfigurationEnabled": false, + "NativeNoticeWritesEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index c95917a..5e06e3d 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -122,18 +122,35 @@ public sealed class AdminMigrationOptionsTests }, configurationEnabled: "true"); } + [Fact] + public void EnablesNoticeWritesWithReadsAndSharedSessions() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeReadsEnabled); + Assert.True(options.NativeNoticeWritesEnabled); + }, noticeWritesEnabled: "true"); + } + private static void WithEnvironment( string? nativeReadsEnabled, string? nativeOrganizationWritesEnabled, string? allowMemory, Action test, string? accountBatchesEnabled = null, - string? configurationEnabled = null) + string? configurationEnabled = null, + string? noticeWritesEnabled = null) { var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"); var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"); var previousAccountBatchesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"); var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"); + var previousNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -141,6 +158,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", accountBatchesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", noticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -150,6 +168,7 @@ public sealed class AdminMigrationOptionsTests Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", previousAccountBatchesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } } diff --git a/tests/Eis.Infrastructure.Tests/Public/NoticeContentFormatterTests.cs b/tests/Eis.Infrastructure.Tests/Public/NoticeContentFormatterTests.cs index 8b5d7f3..c8d45ae 100644 --- a/tests/Eis.Infrastructure.Tests/Public/NoticeContentFormatterTests.cs +++ b/tests/Eis.Infrastructure.Tests/Public/NoticeContentFormatterTests.cs @@ -33,6 +33,16 @@ public sealed class NoticeContentFormatterTests Assert.Contains("rel=\"noopener noreferrer\"", result, StringComparison.Ordinal); } + [Fact] + public void PlainText_PreservesBlockBoundariesAndDecodesEntities() + { + const string source = "

第一段 内容

  • 第二项
"; + + var result = _formatter.PlainText(source); + + Assert.Equal("第一段 内容 第二项", result); + } + [Fact] public void ContentHtml_ConvertsPlainTextParagraphsWithoutTrustingMarkup() {