本轮完成了完整的通知公告管理迁移:
原生 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:
+2
-2
@@ -33,8 +33,8 @@ ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false
|
|||||||
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false
|
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false
|
||||||
# 报名号规则与审批流程定义维护;必须同时启用管理端只读接口。
|
# 报名号规则与审批流程定义维护;必须同时启用管理端只读接口。
|
||||||
ADMIN_NATIVE_CONFIGURATION_ENABLED=false
|
ADMIN_NATIVE_CONFIGURATION_ENABLED=false
|
||||||
# 手工通知公告创建和修改;公告列表及系统公示暂时仍由 Node 提供。
|
# 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。
|
||||||
ADMIN_NATIVE_NOTICE_WRITES_ENABLED=false
|
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+4
-4
@@ -12,7 +12,7 @@
|
|||||||
- [x] 招生公示与 HMAC 文书验真公开接口
|
- [x] 招生公示与 HMAC 文书验真公开接口
|
||||||
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
||||||
- [x] 考生业务
|
- [x] 考生业务
|
||||||
- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置及手工公告写入已原生化)
|
- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置及通知公告管理已原生化)
|
||||||
- [x] 考生志愿填报与招生录取查询
|
- [x] 考生志愿填报与招生录取查询
|
||||||
- [ ] Excel、文书和缓存
|
- [ ] Excel、文书和缓存
|
||||||
- [ ] 容器入口切换及 Node.js 后端移除
|
- [ ] 容器入口切换及 Node.js 后端移除
|
||||||
@@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖超级管理员创建和编辑手工公告,包含 HTML 净化、草稿发布状态及事务内审计;管理端公告列表与系统自动发布内容暂时仍由 Node 处理。
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。
|
||||||
|
|
||||||
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
@@ -64,10 +64,10 @@ $env:ADMIN_NATIVE_READS_ENABLED = 'true'
|
|||||||
$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
|
$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
|
$env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_CONFIGURATION_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 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -249,9 +249,23 @@ try {
|
|||||||
}
|
}
|
||||||
$registrationSwitchProcess.Dispose()
|
$registrationSwitchProcess.Dispose()
|
||||||
|
|
||||||
$nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment
|
|
||||||
|
|
||||||
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
|
$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 @(
|
$dotnetProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
|
||||||
'run',
|
'run',
|
||||||
'--project', $projectPath,
|
'--project', $projectPath,
|
||||||
@@ -426,7 +440,7 @@ try {
|
|||||||
ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
|
ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
|
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_NOTICE_WRITES_ENABLED = 'true'
|
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -726,6 +740,52 @@ try {
|
|||||||
throw "Native admin route '$superOnlyRoute' did not preserve the super-admin boundary"
|
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
|
$classLoginBody = @{ username = 'class_admin'; password = '12345678' } | ConvertTo-Json -Compress
|
||||||
$legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
$legacyClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
$nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
$nativeClassSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
@@ -1082,7 +1142,7 @@ try {
|
|||||||
NativeAdminOrganizationWrites = 'passed'
|
NativeAdminOrganizationWrites = 'passed'
|
||||||
NativeAdminAccountBatches = 'passed'
|
NativeAdminAccountBatches = 'passed'
|
||||||
NativeAdminConfiguration = 'passed'
|
NativeAdminConfiguration = 'passed'
|
||||||
NativeAdminNoticeWrites = 'passed'
|
NativeAdminNoticeManagement = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ namespace Eis.Application.Administration;
|
|||||||
|
|
||||||
public interface IAdminNoticeService
|
public interface IAdminNoticeService
|
||||||
{
|
{
|
||||||
|
Task<AdminEndpointResult> ListAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string noticeId, 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 NativeOrganizationWritesEnabled = false,
|
||||||
bool NativeAccountBatchesEnabled = false,
|
bool NativeAccountBatchesEnabled = false,
|
||||||
bool NativeConfigurationEnabled = false,
|
bool NativeConfigurationEnabled = false,
|
||||||
bool NativeNoticeWritesEnabled = false)
|
bool NativeNoticeManagementEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -14,7 +14,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool configuredNativeOrganizationWritesEnabled = false,
|
bool configuredNativeOrganizationWritesEnabled = false,
|
||||||
bool configuredNativeAccountBatchesEnabled = false,
|
bool configuredNativeAccountBatchesEnabled = false,
|
||||||
bool configuredNativeConfigurationEnabled = false,
|
bool configuredNativeConfigurationEnabled = false,
|
||||||
bool configuredNativeNoticeWritesEnabled = false)
|
bool configuredNativeNoticeManagementEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -28,15 +28,16 @@ public sealed record AdminMigrationOptions(
|
|||||||
var configurationEnabled = ParseBoolean(
|
var configurationEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
|
||||||
configuredNativeConfigurationEnabled);
|
configuredNativeConfigurationEnabled);
|
||||||
var noticeWritesEnabled = ParseBoolean(
|
var noticeManagementEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED") ??
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
|
||||||
configuredNativeNoticeWritesEnabled);
|
configuredNativeNoticeManagementEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled) && !readsEnabled)
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
}
|
}
|
||||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled;
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -52,7 +53,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 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
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using Eis.Infrastructure.Data;
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
namespace Eis.Infrastructure.Administration;
|
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)
|
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)
|
public async Task<AdminNotice?> FindAsync(string id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await connectionFactory.OpenAsync(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
|
pinned = @pinned, status = @status, publish_at = @publishAt WHERE id = @id
|
||||||
""", notice, audit, cancellationToken);
|
""", 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)
|
private async Task ExecuteAsync(string sql, AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await connectionFactory.OpenAsync(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);
|
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 command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
await using (var command = connection.CreateCommand())
|
await InsertAuditAsync(connection, transaction, audit, cancellationToken);
|
||||||
{
|
|
||||||
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);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
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"),
|
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"),
|
Convert.ToInt64(reader.GetValue(reader.GetOrdinal("pinned")), CultureInfo.InvariantCulture) != 0, Text(reader, "status"),
|
||||||
Optional(reader, "publish_at"), Optional(reader, "created_at"), Text(reader, "author"));
|
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 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 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); }
|
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,
|
AdminNoticeRepository repository,
|
||||||
NoticeContentFormatter formatter) : IAdminNoticeService
|
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(
|
public async Task<AdminEndpointResult> CreateAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
JsonObject body,
|
JsonObject body,
|
||||||
@@ -86,6 +103,41 @@ internal sealed class AdminNoticeService(
|
|||||||
return Success(new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) });
|
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)
|
private async Task<ResolvedAdmin> ResolveSuperAsync(string token, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
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() ?? "";
|
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 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 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 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 string ToBase36(long value) { const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; Span<char> buffer = stackalloc char[16]; var position = buffer.Length; do { buffer[--position] = alphabet[(int)(value % 36)]; value /= 36; } while (value > 0); return new(buffer[position..]); }
|
||||||
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso());
|
private static 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)));
|
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) =>
|
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
|
||||||
Execute(context, service.CreateAsync(Token(context), body, 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) =>
|
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)));
|
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;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeWritesEnabled"));
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"));
|
||||||
builder.Services.AddEisInfrastructure(
|
builder.Services.AddEisInfrastructure(
|
||||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
||||||
@@ -107,7 +107,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled,
|
nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled,
|
||||||
nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled,
|
nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled,
|
||||||
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
|
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
|
||||||
nativeNoticeWritesEnabled = adminMigrationOptions.NativeNoticeWritesEnabled,
|
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
|
||||||
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
||||||
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
? 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
|
.Concat(adminMigrationOptions.NativeConfigurationEnabled
|
||||||
? new[] { "GET/POST number-rules", "GET/PUT workflows" }
|
? new[] { "GET/POST number-rules", "GET/PUT workflows" }
|
||||||
: [])
|
: [])
|
||||||
.Concat(adminMigrationOptions.NativeNoticeWritesEnabled
|
.Concat(adminMigrationOptions.NativeNoticeManagementEnabled
|
||||||
? new[] { "POST/PATCH notices" }
|
? new[] { "GET/POST/PATCH notices", "PATCH system publications" }
|
||||||
: [])
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"NativeOrganizationWritesEnabled": false,
|
"NativeOrganizationWritesEnabled": false,
|
||||||
"NativeAccountBatchesEnabled": false,
|
"NativeAccountBatchesEnabled": false,
|
||||||
"NativeConfigurationEnabled": false,
|
"NativeConfigurationEnabled": false,
|
||||||
"NativeNoticeWritesEnabled": false
|
"NativeNoticeManagementEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EnablesNoticeWritesWithReadsAndSharedSessions()
|
public void EnablesNoticeManagementWithReadsAndSharedSessions()
|
||||||
{
|
{
|
||||||
WithEnvironment("true", null, null, () =>
|
WithEnvironment("true", null, null, () =>
|
||||||
{
|
{
|
||||||
@@ -133,8 +133,22 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
sharesLegacySessions: true);
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
Assert.True(options.NativeReadsEnabled);
|
Assert.True(options.NativeReadsEnabled);
|
||||||
Assert.True(options.NativeNoticeWritesEnabled);
|
Assert.True(options.NativeNoticeManagementEnabled);
|
||||||
}, noticeWritesEnabled: "true");
|
}, 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(
|
private static void WithEnvironment(
|
||||||
@@ -144,13 +158,15 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Action test,
|
Action test,
|
||||||
string? accountBatchesEnabled = null,
|
string? accountBatchesEnabled = null,
|
||||||
string? configurationEnabled = null,
|
string? configurationEnabled = null,
|
||||||
string? noticeWritesEnabled = null)
|
string? noticeManagementEnabled = null,
|
||||||
|
string? legacyNoticeWritesEnabled = null)
|
||||||
{
|
{
|
||||||
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
||||||
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
||||||
var previousAccountBatchesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED");
|
var previousAccountBatchesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED");
|
||||||
var previousConfigurationEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_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");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -158,7 +174,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", nativeOrganizationWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", accountBatchesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", accountBatchesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", configurationEnabled);
|
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);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -168,7 +185,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED", previousNativeOrganizationWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", previousAccountBatchesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED", previousAccountBatchesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED", previousConfigurationEnabled);
|
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);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string>());
|
||||||
|
Assert.Equal("admission", publication["sourceType"]?.GetValue<string>());
|
||||||
|
Assert.Equal("示例考试第 2 轮录取名单公示", publication["title"]?.GetValue<string>());
|
||||||
|
Assert.False(publication["visible"]?.GetValue<bool>());
|
||||||
|
Assert.Equal("hidden", publication["status"]?.GetValue<string>());
|
||||||
|
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<string>());
|
||||||
|
Assert.Equal("示例考试 · 示例学校考生报到情况及补录说明", publication["title"]?.GetValue<string>());
|
||||||
|
Assert.Equal("计划 20 人,已报到 15 人,完成率 75%。", publication["summary"]?.GetValue<string>());
|
||||||
|
Assert.True(publication["visible"]?.GetValue<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AdminPublicationRecord> records) => new(
|
||||||
|
[],
|
||||||
|
records,
|
||||||
|
new Dictionary<string, string> { ["exam-1"] = "示例考试" },
|
||||||
|
new Dictionary<string, string> { ["school-1"] = "示例学校" });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user