From e845ba8d3ae7d4df31a80a2d1dc20051694f992b Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 23 Jul 2026 08:58:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AC=E8=BD=AE=E5=AE=8C=E6=88=90=E4=BA=86?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=9A=84=E9=80=9A=E7=9F=A5=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E8=BF=81=E7=A7=BB=EF=BC=9A=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20GET=20/api/admin/notices=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E4=BA=94=E7=B1=BB=E7=B3=BB=E7=BB=9F=E5=85=AC=E7=A4=BA=E8=81=9A?= =?UTF-8?q?=E5=90=88=EF=BC=9A=E8=AE=A1=E5=88=92=E3=80=81=E8=B5=84=E6=A0=BC?= =?UTF-8?q?=E3=80=81=E5=BD=95=E5=8F=96=E3=80=81=E5=88=86=E6=95=B0=E7=BA=BF?= =?UTF-8?q?=E3=80=81=E6=8A=A5=E5=88=B0=20=E5=8E=9F=E7=94=9F=20PATCH=20/api?= =?UTF-8?q?/admin/publications/{type}/{id}=20=E6=98=BE=E9=9A=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=20=E6=9D=83=E9=99=90=E3=80=81=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=92=8C=20Node=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BF=9D=E6=8C=81=E4=B8=80=E8=87=B4=20?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=8D=87=E7=BA=A7=E4=B8=BA=20ADMIN=5FNATIVE?= =?UTF-8?q?=5FNOTICE=5FMANAGEMENT=5FENABLED=20=E5=85=BC=E5=AE=B9=E6=97=A7?= =?UTF-8?q?=E5=8F=98=E9=87=8F=20ADMIN=5FNATIVE=5FNOTICE=5FWRITES=5FENABLED?= =?UTF-8?q?=20=E5=86=92=E7=83=9F=E8=84=9A=E6=9C=AC=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E4=BC=9A=E8=87=AA=E5=8A=A8=E6=9E=84=E5=BB=BA=20Web=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E4=BD=BF=E7=94=A8=E8=BF=87=E6=9C=9F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- MIGRATION.md | 8 +- scripts/smoke-dotnet-migration.ps1 | 68 ++++- .../Administration/IAdminNoticeService.cs | 7 + .../Administration/AdminMigrationOptions.cs | 15 +- .../Administration/AdminNoticeRepository.cs | 101 +++++++- .../Administration/AdminNoticeService.cs | 54 ++++ .../AdminPublicationProjector.cs | 240 ++++++++++++++++++ .../NativeAdminReadEndpoints.cs | 7 +- src/Eis.Web/Program.cs | 8 +- src/Eis.Web/appsettings.json | 2 +- .../AdminMigrationOptionsTests.cs | 32 ++- .../AdminPublicationProjectorTests.cs | 88 +++++++ 13 files changed, 597 insertions(+), 37 deletions(-) create mode 100644 src/Eis.Infrastructure/Administration/AdminPublicationProjector.cs create mode 100644 tests/Eis.Infrastructure.Tests/Administration/AdminPublicationProjectorTests.cs diff --git a/.env.example b/.env.example index ba74200..65fa821 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +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 +# 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。 +ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false # 仅在首次创建空数据库时使用。部署前务必修改初始密码。 INITIAL_ADMIN_USERNAME=admin diff --git a/MIGRATION.md b/MIGRATION.md index 89d253b..8170612 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,7 @@ - [x] 招生公示与 HMAC 文书验真公开接口 - [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭) - [x] 考生业务 -- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置及手工公告写入已原生化) +- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置及通知公告管理已原生化) - [x] 考生志愿填报与招生录取查询 - [ ] Excel、文书和缓存 - [ ] 容器入口切换及 Node.js 后端移除 @@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true' $env:CANDIDATE_NATIVE_ENABLED = 'true' ``` -管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖超级管理员创建和编辑手工公告,包含 HTML 净化、草稿发布状态及事务内审计;管理端公告列表与系统自动发布内容暂时仍由 Node 处理。 +管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。 其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用: @@ -64,10 +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' +$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true' ``` -`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 +旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试: diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index 4e60aef..024359e 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -249,9 +249,23 @@ try { } $registrationSwitchProcess.Dispose() - $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment - $projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj' + $buildProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( + 'build', $projectPath, + '--configuration', 'Release' + ) -Environment @{} + if (-not $buildProcess.WaitForExit(60000)) { + $buildProcess.Kill($true) + throw 'Timed out while building the ASP.NET Core migration host' + } + $buildOutput = $buildProcess.StandardOutput.ReadToEnd() + $buildError = $buildProcess.StandardError.ReadToEnd() + if ($buildProcess.ExitCode -ne 0) { + throw "Failed to build the ASP.NET Core migration host`n$buildOutput`n$buildError" + } + $buildProcess.Dispose() + + $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment $dotnetProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( 'run', '--project', $projectPath, @@ -426,7 +440,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_NOTICE_MANAGEMENT_ENABLED = 'true' ADMIN_NATIVE_ALLOW_MEMORY = 'true' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl @@ -726,6 +740,52 @@ try { throw "Native admin route '$superOnlyRoute' did not preserve the super-admin boundary" } } + $legacyNoticeManagement = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/notices" -WebSession $session + $nativeNoticeManagement = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -WebSession $nativeSession + if ($nativeNoticeManagement.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native notice management list did not use ASP.NET Core' + } + Assert-JsonEquivalent -Expected $legacyNoticeManagement.Content -Actual $nativeNoticeManagement.Content -Label 'Admin notice management list' + $schoolNoticeManagementForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolNoticeManagementForbidden.StatusCode -ne 403 -or $schoolNoticeManagementForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native notice management list did not preserve the super-admin boundary' + } + + $noticeManagementState = $nativeNoticeManagement.Content | ConvertFrom-Json + $managedPlan = @($noticeManagementState.publications | Where-Object { $_.sourceType -eq 'plan' })[0] + if ($null -eq $managedPlan) { + throw 'Seed data did not provide a system plan publication for notice-management smoke testing' + } + $invalidPublicationVisibility = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/publications/plan/$($managedPlan.id)" -Method Patch -ContentType 'application/json' -Body (@{ visible = 'false' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($invalidPublicationVisibility.StatusCode -ne 400 -or $invalidPublicationVisibility.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { + throw 'Native system-publication update accepted a non-boolean visibility value' + } + $missingPublication = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/publications/plan/missing-publication" -Method Patch -ContentType 'application/json' -Body (@{ visible = $false } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck + if ($missingPublication.StatusCode -ne 404) { + throw 'Native system-publication update did not return 404 for a missing record' + } + $schoolPublicationForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/publications/plan/$($managedPlan.id)" -Method Patch -ContentType 'application/json' -Body (@{ visible = $false } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck + if ($schoolPublicationForbidden.StatusCode -ne 403) { + throw 'Native system-publication update did not preserve the super-admin boundary' + } + $hiddenPublicationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/publications/plan/$($managedPlan.id)" -Method Patch -ContentType 'application/json' -Body (@{ visible = $false } | ConvertTo-Json -Compress) -WebSession $nativeSession + $hiddenPublication = ($hiddenPublicationResponse.Content | ConvertFrom-Json).publication + if ($hiddenPublicationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $hiddenPublication.visible -ne $false -or $hiddenPublication.status -ne 'hidden') { + throw 'Native system-publication update did not hide the requested publication' + } + $hiddenAnnouncements = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/public/announcements" + if (@($hiddenAnnouncements.plans | Where-Object { $_.id -eq $managedPlan.id }).Count -ne 0) { + throw 'Native public announcements still exposed a hidden system publication' + } + $restoredPublicationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/publications/plan/$($managedPlan.id)" -Method Patch -ContentType 'application/json' -Body (@{ visible = $true } | ConvertTo-Json -Compress) -WebSession $nativeSession + $restoredPublication = ($restoredPublicationResponse.Content | ConvertFrom-Json).publication + if ($restoredPublication.visible -ne $true -or $restoredPublication.status -ne 'visible') { + throw 'Native system-publication update did not restore the requested publication' + } + $restoredAnnouncements = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/public/announcements" + if (@($restoredAnnouncements.plans | Where-Object { $_.id -eq $managedPlan.id }).Count -ne 1) { + throw 'Native public announcements did not restore a visible system publication' + } $classLoginBody = @{ username = 'class_admin'; password = '12345678' } | ConvertTo-Json -Compress $legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() $nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new() @@ -1082,7 +1142,7 @@ try { NativeAdminOrganizationWrites = 'passed' NativeAdminAccountBatches = 'passed' NativeAdminConfiguration = 'passed' - NativeAdminNoticeWrites = 'passed' + NativeAdminNoticeManagement = 'passed' } | Format-List } finally { diff --git a/src/Eis.Application/Administration/IAdminNoticeService.cs b/src/Eis.Application/Administration/IAdminNoticeService.cs index 29c94be..8f28f0e 100644 --- a/src/Eis.Application/Administration/IAdminNoticeService.cs +++ b/src/Eis.Application/Administration/IAdminNoticeService.cs @@ -4,6 +4,13 @@ namespace Eis.Application.Administration; public interface IAdminNoticeService { + Task ListAsync(string sessionToken, CancellationToken cancellationToken); Task CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken); Task UpdateAsync(string sessionToken, string noticeId, JsonObject body, CancellationToken cancellationToken); + Task UpdatePublicationVisibilityAsync( + string sessionToken, + string sourceType, + string publicationId, + JsonObject body, + CancellationToken cancellationToken); } diff --git a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs index 80cb02e..124ab1a 100644 --- a/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs +++ b/src/Eis.Infrastructure/Administration/AdminMigrationOptions.cs @@ -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 diff --git a/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs index 9d52ba8..7d4738f 100644 --- a/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs +++ b/src/Eis.Infrastructure/Administration/AdminNoticeRepository.cs @@ -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 LoadManagementAsync(CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var notices = new List(); + 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(); + 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 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> ReadNamesAsync( + DbConnection connection, + string sql, + CancellationToken cancellationToken) + { + var names = new Dictionary(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); } diff --git a/src/Eis.Infrastructure/Administration/AdminNoticeService.cs b/src/Eis.Infrastructure/Administration/AdminNoticeService.cs index 2283870..33d0091 100644 --- a/src/Eis.Infrastructure/Administration/AdminNoticeService.cs +++ b/src/Eis.Infrastructure/Administration/AdminNoticeService.cs @@ -13,6 +13,23 @@ internal sealed class AdminNoticeService( AdminNoticeRepository repository, NoticeContentFormatter formatter) : IAdminNoticeService { + public async Task 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 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 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(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() == publicationId && + item["sourceType"]?.GetValue() == sourceType); + var response = new JsonObject { ["ok"] = true }; + if (publication is not null) response["publication"] = publication; + return Success(response); + } + private async Task 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(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 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()); diff --git a/src/Eis.Infrastructure/Administration/AdminPublicationProjector.cs b/src/Eis.Infrastructure/Administration/AdminPublicationProjector.cs new file mode 100644 index 0000000..f06cde1 --- /dev/null +++ b/src/Eis.Infrastructure/Administration/AdminPublicationProjector.cs @@ -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 Notices, + IReadOnlyList Records, + IReadOnlyDictionary ExamNames, + IReadOnlyDictionary SchoolNames); + +internal static class AdminPublicationProjector +{ + private static readonly HashSet AdmissionRoundStatuses = ["reporting", "supplementary", "completed"]; + + public static AdminPublicationRecord? FindSourceRecord( + IEnumerable 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 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(); + + 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())) + .ToArray(); + } + + private static IReadOnlyList BuildRounds(IReadOnlyList 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(out var text) ? text : null; + + private static bool Boolean(JsonObject payload, string name, bool fallback) => + payload[name] is JsonValue value && value.TryGetValue(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(out var number)) return number; + if (value.TryGetValue(out var integer32)) return integer32; + if (value.TryGetValue(out var integer)) return integer; + if (value.TryGetValue(out var decimalNumber)) return (double)decimalNumber; + if (value.TryGetValue(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); +} diff --git a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs index 22a8c80..2dec735 100644 --- a/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs +++ b/src/Eis.Web/Administration/NativeAdminReadEndpoints.cs @@ -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; } diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 7916953..e827803 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -49,7 +49,7 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment( builder.Configuration.GetValue("AdminMigration:NativeOrganizationWritesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeAccountBatchesEnabled"), builder.Configuration.GetValue("AdminMigration:NativeConfigurationEnabled"), - builder.Configuration.GetValue("AdminMigration:NativeNoticeWritesEnabled")); + builder.Configuration.GetValue("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() }, diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index c9c4c4a..3d37d86 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -14,7 +14,7 @@ "NativeOrganizationWritesEnabled": false, "NativeAccountBatchesEnabled": false, "NativeConfigurationEnabled": false, - "NativeNoticeWritesEnabled": false + "NativeNoticeManagementEnabled": false }, "Logging": { "LogLevel": { diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs index 5e06e3d..fc7a1f7 100644 --- a/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminMigrationOptionsTests.cs @@ -123,7 +123,7 @@ public sealed class AdminMigrationOptionsTests } [Fact] - public void EnablesNoticeWritesWithReadsAndSharedSessions() + public void EnablesNoticeManagementWithReadsAndSharedSessions() { WithEnvironment("true", null, null, () => { @@ -133,8 +133,22 @@ public sealed class AdminMigrationOptionsTests sharesLegacySessions: true); Assert.True(options.NativeReadsEnabled); - Assert.True(options.NativeNoticeWritesEnabled); - }, noticeWritesEnabled: "true"); + Assert.True(options.NativeNoticeManagementEnabled); + }, noticeManagementEnabled: "true"); + } + + [Fact] + public void AcceptsLegacyNoticeWritesEnvironmentVariable() + { + WithEnvironment("true", null, null, () => + { + var options = AdminMigrationOptions.FromEnvironment( + configuredNativeReadsEnabled: false, + authenticationNativeEnabled: true, + sharesLegacySessions: true); + + Assert.True(options.NativeNoticeManagementEnabled); + }, legacyNoticeWritesEnabled: "true"); } private static void WithEnvironment( @@ -144,13 +158,15 @@ public sealed class AdminMigrationOptionsTests Action test, string? accountBatchesEnabled = null, string? configurationEnabled = null, - string? noticeWritesEnabled = null) + string? noticeManagementEnabled = null, + string? legacyNoticeWritesEnabled = 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 previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED"); + var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"); var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"); try { @@ -158,7 +174,8 @@ 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_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory); test(); } @@ -168,7 +185,8 @@ 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_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled); + Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled); Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory); } } diff --git a/tests/Eis.Infrastructure.Tests/Administration/AdminPublicationProjectorTests.cs b/tests/Eis.Infrastructure.Tests/Administration/AdminPublicationProjectorTests.cs new file mode 100644 index 0000000..bdee00e --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Administration/AdminPublicationProjectorTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Nodes; +using Eis.Infrastructure.Administration; + +namespace Eis.Infrastructure.Tests.Administration; + +public sealed class AdminPublicationProjectorTests +{ + [Fact] + public void VirtualRoundUsesSettingAsPatchTargetAndSuppressesFinalDuplicate() + { + var setting = Record( + "setting-1", + "setting", + "completed", + new JsonObject + { + ["autoPublish"] = true, + ["publicVisible"] = false, + ["completedAt"] = "2026-06-02T00:00:00.000Z", + ["roundPublishedAt"] = "2026-06-01T00:00:00.000Z" + }); + var placement = Record( + "placement-1", + "placement", + "final", + new JsonObject { ["finalizedRound"] = 2 }); + var snapshot = Snapshot([setting, placement]); + + var publication = Assert.Single(AdminPublicationProjector.Build(snapshot)); + + Assert.Equal("setting-1", publication["id"]?.GetValue()); + Assert.Equal("admission", publication["sourceType"]?.GetValue()); + Assert.Equal("示例考试第 2 轮录取名单公示", publication["title"]?.GetValue()); + Assert.False(publication["visible"]?.GetValue()); + Assert.Equal("hidden", publication["status"]?.GetValue()); + Assert.Same(setting, AdminPublicationProjector.FindSourceRecord(snapshot.Records, "admission", "setting-1")); + } + + [Fact] + public void ReportingPublicationMatchesNodeProjection() + { + var report = new AdminPublicationRecord( + "report-1", + "notification", + "exam-1", + null, + "school-1", + "approved", + new JsonObject + { + ["type"] = "admission_reporting", + ["supplementDecision"] = "supplement", + ["approvedAt"] = "2026-06-03T00:00:00.000Z", + ["statistics"] = new JsonObject + { + ["totalQuota"] = 20, + ["reportedCount"] = 15, + ["reportingRate"] = 75 + } + }, + "2026-06-01T00:00:00.000Z", + "2026-06-03T00:00:00.000Z"); + + var publication = Assert.Single(AdminPublicationProjector.Build(Snapshot([report]))); + + Assert.Equal("reporting", publication["sourceType"]?.GetValue()); + Assert.Equal("示例考试 · 示例学校考生报到情况及补录说明", publication["title"]?.GetValue()); + Assert.Equal("计划 20 人,已报到 15 人,完成率 75%。", publication["summary"]?.GetValue()); + Assert.True(publication["visible"]?.GetValue()); + } + + private static AdminPublicationRecord Record(string id, string kind, string status, JsonObject payload) => new( + id, + kind, + "exam-1", + null, + null, + status, + payload, + "2026-06-01T00:00:00.000Z", + "2026-06-02T00:00:00.000Z"); + + private static AdminNoticeManagementSnapshot Snapshot(IReadOnlyList records) => new( + [], + records, + new Dictionary { ["exam-1"] = "示例考试" }, + new Dictionary { ["school-1"] = "示例学校" }); +}