新增 POST /api/admin/notices、PATCH /api/admin/notices/{id}
保留超级管理员权限边界 支持草稿、发布、置顶、自动摘要 HTML 净化,拦截脚本和危险链接 公告更新与审计日志同事务提交 新增开关 ADMIN_NATIVE_NOTICE_WRITES_ENABLED=false 管理端公告列表和系统自动公告暂时仍由 Node 处理
This commit is contained in:
@@ -33,6 +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
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+5
-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,9 +54,9 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖超级管理员创建和编辑手工公告,包含 HTML 净化、草稿发布状态及事务内审计;管理端公告列表与系统自动发布内容暂时仍由 Node 处理。
|
||||||
|
|
||||||
其余审批流和考务编排接口仍转发给 Node,因此两个管理端开关都要求原生认证和共享 Redis;组织维护开关还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:AUTH_NATIVE_ENABLED = 'true'
|
$env:AUTH_NATIVE_ENABLED = 'true'
|
||||||
@@ -64,9 +64,10 @@ $env:ADMIN_NATIVE_READS_ENABLED = 'true'
|
|||||||
$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
|
$env:ADMIN_NATIVE_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'
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeWritesEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,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_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -953,6 +954,48 @@ try {
|
|||||||
$restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress
|
$restoreSettingBody = @{ enabled = $originalSelfRegistration } | ConvertTo-Json -Compress
|
||||||
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null
|
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/admin/settings/self-registration" -Method Put -ContentType 'application/json' -Body $restoreSettingBody -WebSession $nativeSession | Out-Null
|
||||||
|
|
||||||
|
$noticeCreateForbidden = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '越权公告'; content = '<p>无权发布</p>' } | ConvertTo-Json -Compress) -WebSession $nativeSchoolSession -SkipHttpErrorCheck
|
||||||
|
if ($noticeCreateForbidden.StatusCode -ne 403 -or $noticeCreateForbidden.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native notice creation did not preserve the super-admin boundary'
|
||||||
|
}
|
||||||
|
$invalidNotice = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body (@{ title = '无正文公告'; content = '<script>alert(1)</script>' } | ConvertTo-Json -Compress) -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidNotice.StatusCode -ne 400 -or $invalidNotice.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native notice creation accepted content that became empty after sanitization'
|
||||||
|
}
|
||||||
|
$noticeCreateBody = @{
|
||||||
|
title = '原生迁移公告'
|
||||||
|
summary = ''
|
||||||
|
content = '<p>公告<strong>正文</strong><script>alert(1)</script></p><a href="javascript:alert(1)">危险链接</a>'
|
||||||
|
category = '迁移公告'
|
||||||
|
pinned = $true
|
||||||
|
status = 'draft'
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$noticeCreateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body $noticeCreateBody -WebSession $nativeSession
|
||||||
|
$createdNativeNotice = ($noticeCreateResponse.Content | ConvertFrom-Json).notice
|
||||||
|
if ($noticeCreateResponse.StatusCode -ne 201 -or $noticeCreateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Native notice creation did not use ASP.NET Core'
|
||||||
|
}
|
||||||
|
if ($createdNativeNotice.status -ne 'draft' -or $null -ne $createdNativeNotice.publishAt -or $createdNativeNotice.summary -ne '公告正文 危险链接') {
|
||||||
|
throw 'Native notice creation did not preserve draft state or derive its summary'
|
||||||
|
}
|
||||||
|
if ($createdNativeNotice.content -match '(?i)<script|javascript:') {
|
||||||
|
throw 'Native notice creation returned unsafe HTML'
|
||||||
|
}
|
||||||
|
$noticePublishBody = @{ title = '原生迁移公告(已发布)'; status = 'published' } | ConvertTo-Json -Compress
|
||||||
|
$noticePublishResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/notices/$($createdNativeNotice.id)" -Method Patch -ContentType 'application/json' -Body $noticePublishBody -WebSession $nativeSession
|
||||||
|
$publishedNativeNotice = ($noticePublishResponse.Content | ConvertFrom-Json).notice
|
||||||
|
if ($noticePublishResponse.StatusCode -ne 200 -or $noticePublishResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $publishedNativeNotice.status -ne 'published' -or -not $publishedNativeNotice.publishAt) {
|
||||||
|
throw 'Native notice update did not publish the notice'
|
||||||
|
}
|
||||||
|
$nativePublicNoticeResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/public/notices/$($createdNativeNotice.id)"
|
||||||
|
$nativePublicNotice = ($nativePublicNoticeResponse.Content | ConvertFrom-Json).notice
|
||||||
|
if ($nativePublicNoticeResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or $nativePublicNotice.title -ne '原生迁移公告(已发布)') {
|
||||||
|
throw 'Native public notice endpoint could not read the newly published notice'
|
||||||
|
}
|
||||||
|
if ($nativePublicNotice.content -match '(?i)<script|javascript:') {
|
||||||
|
throw 'Native public notice endpoint exposed unsafe HTML from an administrative write'
|
||||||
|
}
|
||||||
|
|
||||||
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
|
$adminCandidateRoute = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -WebSession $nativeSession -SkipHttpErrorCheck
|
||||||
if ($adminCandidateRoute.StatusCode -ne 403) {
|
if ($adminCandidateRoute.StatusCode -ne 403) {
|
||||||
throw 'Native candidate API did not enforce the candidate role boundary'
|
throw 'Native candidate API did not enforce the candidate role boundary'
|
||||||
@@ -1039,6 +1082,7 @@ try {
|
|||||||
NativeAdminOrganizationWrites = 'passed'
|
NativeAdminOrganizationWrites = 'passed'
|
||||||
NativeAdminAccountBatches = 'passed'
|
NativeAdminAccountBatches = 'passed'
|
||||||
NativeAdminConfiguration = 'passed'
|
NativeAdminConfiguration = 'passed'
|
||||||
|
NativeAdminNoticeWrites = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Eis.Application.Administration;
|
||||||
|
|
||||||
|
public interface IAdminNoticeService
|
||||||
|
{
|
||||||
|
Task<AdminEndpointResult> CreateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> UpdateAsync(string sessionToken, string noticeId, JsonObject body, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool NativeReadsEnabled,
|
bool NativeReadsEnabled,
|
||||||
bool NativeOrganizationWritesEnabled = false,
|
bool NativeOrganizationWritesEnabled = false,
|
||||||
bool NativeAccountBatchesEnabled = false,
|
bool NativeAccountBatchesEnabled = false,
|
||||||
bool NativeConfigurationEnabled = false)
|
bool NativeConfigurationEnabled = false,
|
||||||
|
bool NativeNoticeWritesEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -12,7 +13,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool sharesLegacySessions,
|
bool sharesLegacySessions,
|
||||||
bool configuredNativeOrganizationWritesEnabled = false,
|
bool configuredNativeOrganizationWritesEnabled = false,
|
||||||
bool configuredNativeAccountBatchesEnabled = false,
|
bool configuredNativeAccountBatchesEnabled = false,
|
||||||
bool configuredNativeConfigurationEnabled = false)
|
bool configuredNativeConfigurationEnabled = false,
|
||||||
|
bool configuredNativeNoticeWritesEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -26,12 +28,15 @@ public sealed record AdminMigrationOptions(
|
|||||||
var configurationEnabled = ParseBoolean(
|
var configurationEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
|
||||||
configuredNativeConfigurationEnabled);
|
configuredNativeConfigurationEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled) && !readsEnabled)
|
var noticeWritesEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
|
||||||
|
configuredNativeNoticeWritesEnabled);
|
||||||
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
}
|
}
|
||||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled;
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -47,7 +52,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled);
|
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeWritesEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminNotice(string Id, string Title, string Summary, string Content, string Category, bool Pinned, string Status, string? PublishAt, string? CreatedAt, string Author);
|
||||||
|
|
||||||
|
internal sealed class AdminNoticeRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<AdminNotice?> FindAsync(string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT id, title, summary, content, category, pinned, status, publish_at, created_at, author FROM notices WHERE id = @id LIMIT 1";
|
||||||
|
Add(command, "@id", id);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
return await reader.ReadAsync(cancellationToken) ? Read(reader) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CreateAsync(AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteAsync("""
|
||||||
|
INSERT INTO notices (id, title, summary, content, category, pinned, status, publish_at, created_at, author)
|
||||||
|
VALUES (@id, @title, @summary, @content, @category, @pinned, @status, @publishAt, @createdAt, @author)
|
||||||
|
""", notice, audit, cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateAsync(AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteAsync("""
|
||||||
|
UPDATE notices SET title = @title, summary = @summary, content = @content, category = @category,
|
||||||
|
pinned = @pinned, status = @status, publish_at = @publishAt WHERE id = @id
|
||||||
|
""", notice, audit, cancellationToken);
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(string sql, AdminNotice notice, AdminAuditEntry audit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = sql;
|
||||||
|
Add(command, "@id", notice.Id); Add(command, "@title", notice.Title); Add(command, "@summary", notice.Summary);
|
||||||
|
Add(command, "@content", notice.Content); Add(command, "@category", notice.Category); Add(command, "@pinned", notice.Pinned ? 1 : 0);
|
||||||
|
Add(command, "@status", notice.Status); Add(command, "@publishAt", notice.PublishAt); Add(command, "@createdAt", notice.CreatedAt); Add(command, "@author", notice.Author);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = "INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (@id, @actorId, @action, @detail, @createdAt)";
|
||||||
|
Add(command, "@id", audit.Id); Add(command, "@actorId", audit.ActorId); Add(command, "@action", audit.Action); Add(command, "@detail", audit.Detail); Add(command, "@createdAt", audit.CreatedAt);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminNotice Read(DbDataReader reader) => new(
|
||||||
|
Text(reader, "id"), Text(reader, "title"), Text(reader, "summary"), Text(reader, "content"), Text(reader, "category"),
|
||||||
|
Convert.ToInt64(reader.GetValue(reader.GetOrdinal("pinned")), CultureInfo.InvariantCulture) != 0, Text(reader, "status"),
|
||||||
|
Optional(reader, "publish_at"), Optional(reader, "created_at"), Text(reader, "author"));
|
||||||
|
private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
|
||||||
|
private static string? Optional(DbDataReader reader, string name) { var index = reader.GetOrdinal(name); return reader.IsDBNull(index) ? null : Convert.ToString(reader.GetValue(index), CultureInfo.InvariantCulture); }
|
||||||
|
private static void Add(DbCommand command, string name, object? value) { var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.Value = value ?? DBNull.Value; command.Parameters.Add(parameter); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Administration;
|
||||||
|
using Eis.Infrastructure.Authentication;
|
||||||
|
using Eis.Infrastructure.Public;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed class AdminNoticeService(
|
||||||
|
IAuthenticationStateStore authenticationState,
|
||||||
|
AuthenticationRepository authenticationRepository,
|
||||||
|
AdminNoticeRepository repository,
|
||||||
|
NoticeContentFormatter formatter) : IAdminNoticeService
|
||||||
|
{
|
||||||
|
public async Task<AdminEndpointResult> CreateAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var title = Clean(Text(body["title"]), 120);
|
||||||
|
var content = formatter.Sanitize(Text(body["content"]));
|
||||||
|
var contentText = formatter.PlainText(content);
|
||||||
|
if (title.Length == 0 || contentText.Length == 0) return Error(400, "通知标题和正文不能为空");
|
||||||
|
var summary = Clean(Text(body["summary"]), 260);
|
||||||
|
var category = Clean(Text(body["category"]), 30);
|
||||||
|
var draft = Text(body["status"]) == "draft";
|
||||||
|
var notice = new AdminNotice(
|
||||||
|
Uid("notice"),
|
||||||
|
title,
|
||||||
|
summary.Length == 0 ? contentText[..Math.Min(contentText.Length, 80)] : summary,
|
||||||
|
content,
|
||||||
|
category.Length == 0 ? "通知公告" : category,
|
||||||
|
JsBoolean(body["pinned"]),
|
||||||
|
draft ? "draft" : "published",
|
||||||
|
draft ? null : NowIso(),
|
||||||
|
NowIso(),
|
||||||
|
user.DisplayName);
|
||||||
|
await repository.CreateAsync(
|
||||||
|
notice,
|
||||||
|
Audit(user, draft ? "保存通知草稿" : "发布通知", notice.Title),
|
||||||
|
cancellationToken);
|
||||||
|
return new AdminEndpointResult(201, new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminEndpointResult> UpdateAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string noticeId,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveSuperAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var existing = await repository.FindAsync(noticeId, cancellationToken);
|
||||||
|
if (existing is null) return Error(404, "通知不存在");
|
||||||
|
var notice = existing with
|
||||||
|
{
|
||||||
|
Title = body["title"] is null ? existing.Title : Clean(Text(body["title"]), 260),
|
||||||
|
Summary = body["summary"] is null ? existing.Summary : Clean(Text(body["summary"]), 260),
|
||||||
|
Category = body["category"] is null ? existing.Category : Clean(Text(body["category"]), 260),
|
||||||
|
Pinned = body["pinned"] is null ? existing.Pinned : JsBoolean(body["pinned"])
|
||||||
|
};
|
||||||
|
if (body["content"] is not null)
|
||||||
|
{
|
||||||
|
var content = formatter.Sanitize(Text(body["content"]));
|
||||||
|
if (formatter.PlainText(content).Length == 0) return Error(400, "通知正文不能为空");
|
||||||
|
notice = notice with { Content = content };
|
||||||
|
}
|
||||||
|
var requestedStatus = Text(body["status"]);
|
||||||
|
if (requestedStatus is "draft" or "published")
|
||||||
|
{
|
||||||
|
notice = notice with
|
||||||
|
{
|
||||||
|
Status = requestedStatus,
|
||||||
|
PublishAt = requestedStatus == "published" && notice.PublishAt is null ? NowIso() : notice.PublishAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await repository.UpdateAsync(
|
||||||
|
notice,
|
||||||
|
Audit(user, "更新通知", $"{notice.Title} · {notice.Status}"),
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject { ["ok"] = true, ["notice"] = NoticeJson(notice) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResolvedAdmin> ResolveSuperAsync(string token, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var userId = await authenticationState.GetSessionUserIdAsync(token);
|
||||||
|
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
|
||||||
|
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
if (user.Role != "admin") return ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
|
||||||
|
return (user.AdminLevel ?? "super") == "super"
|
||||||
|
? new ResolvedAdmin(user, null)
|
||||||
|
: ResolvedAdmin.Failed(Error(403, "当前管理员层级无权执行此操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject NoticeJson(AdminNotice item) => new()
|
||||||
|
{
|
||||||
|
["id"] = item.Id,
|
||||||
|
["title"] = item.Title,
|
||||||
|
["summary"] = item.Summary,
|
||||||
|
["content"] = formatter.Sanitize(item.Content),
|
||||||
|
["category"] = item.Category,
|
||||||
|
["pinned"] = item.Pinned,
|
||||||
|
["status"] = item.Status,
|
||||||
|
["publishAt"] = JsonValue.Create(item.PublishAt),
|
||||||
|
["createdAt"] = JsonValue.Create(item.CreatedAt),
|
||||||
|
["author"] = item.Author,
|
||||||
|
["contentHtml"] = formatter.ContentHtml(item.Content)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool JsBoolean(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node is null) return false;
|
||||||
|
if (node is not JsonValue value) return true;
|
||||||
|
if (value.TryGetValue<bool>(out var boolean)) return boolean;
|
||||||
|
if (value.TryGetValue<string>(out var text)) return text.Length > 0;
|
||||||
|
if (value.TryGetValue<double>(out var number)) return number != 0 && !double.IsNaN(number);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(JsonNode? node) =>
|
||||||
|
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
|
||||||
|
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
|
||||||
|
private static string NowIso() => DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
|
||||||
|
private static string Uid(string prefix) => $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
|
||||||
|
private static string ToBase36(long value) { const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; Span<char> buffer = stackalloc char[16]; var position = buffer.Length; do { buffer[--position] = alphabet[(int)(value % 36)]; value /= 36; } while (value > 0); return new(buffer[position..]); }
|
||||||
|
private static AdminAuditEntry Audit(AuthenticationUser user, string action, string detail) => new(Uid("log"), user.Id, action, detail, NowIso());
|
||||||
|
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
|
||||||
|
private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message });
|
||||||
|
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
|
||||||
|
}
|
||||||
@@ -57,10 +57,13 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<AdminAccountBatchSnapshotLoader>();
|
services.AddScoped<AdminAccountBatchSnapshotLoader>();
|
||||||
services.AddScoped<AdminAccountBatchRepository>();
|
services.AddScoped<AdminAccountBatchRepository>();
|
||||||
services.AddScoped<AdminConfigurationRepository>();
|
services.AddScoped<AdminConfigurationRepository>();
|
||||||
|
services.AddScoped<AdminNoticeRepository>();
|
||||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||||
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
|
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
|
||||||
|
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
||||||
|
services.AddSingleton<NoticeContentFormatter>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ internal sealed partial class NoticeContentFormatter
|
|||||||
.Select(paragraph => $"<p>{WebUtility.HtmlEncode(paragraph).Replace("\r\n", "<br>", StringComparison.Ordinal).Replace("\n", "<br>", StringComparison.Ordinal)}</p>"));
|
.Select(paragraph => $"<p>{WebUtility.HtmlEncode(paragraph).Replace("\r\n", "<br>", StringComparison.Ordinal).Replace("\n", "<br>", StringComparison.Ordinal)}</p>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string PlainText(string? value)
|
||||||
|
{
|
||||||
|
var sanitized = BlockTagRegex().Replace(Sanitize(value), " ");
|
||||||
|
var parser = new HtmlParser();
|
||||||
|
var document = parser.ParseDocument($"<!doctype html><html><body>{sanitized}</body></html>");
|
||||||
|
return WhitespaceRegex().Replace(document.Body?.TextContent ?? string.Empty, " ").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
private static void SanitizeChildren(INode parent)
|
private static void SanitizeChildren(INode parent)
|
||||||
{
|
{
|
||||||
foreach (var child in parent.ChildNodes.ToArray())
|
foreach (var child in parent.ChildNodes.ToArray())
|
||||||
@@ -176,4 +184,10 @@ internal sealed partial class NoticeContentFormatter
|
|||||||
|
|
||||||
[GeneratedRegex("\\r?\\n{2,}")]
|
[GeneratedRegex("\\r?\\n{2,}")]
|
||||||
private static partial Regex ParagraphRegex();
|
private static partial Regex ParagraphRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex("</?(?:p|h[2-4]|ul|ol|li|blockquote|br|figure|figcaption|table|thead|tbody|tfoot|tr|th|td)\\b[^>]*>", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex BlockTagRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex("\\s+")]
|
||||||
|
private static partial Regex WhitespaceRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,15 +55,23 @@ public static class NativeAdminReadEndpoints
|
|||||||
Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken)));
|
Execute(context, service.ReviewAsync(Token(context), batchId, body, cancellationToken)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.NativeConfigurationEnabled) return endpoints;
|
if (options.NativeConfigurationEnabled)
|
||||||
endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
{
|
||||||
Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken)));
|
endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
||||||
endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken)));
|
||||||
Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken)));
|
endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
||||||
endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken)));
|
||||||
Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken)));
|
endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
||||||
endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken)));
|
||||||
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
|
endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.NativeNoticeWritesEnabled) return endpoints;
|
||||||
|
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
|
||||||
|
endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken)));
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
authenticationOptions.SharesLegacySessions,
|
authenticationOptions.SharesLegacySessions,
|
||||||
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.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()),
|
||||||
@@ -106,6 +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,
|
||||||
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" }
|
||||||
: [])
|
: [])
|
||||||
@@ -122,6 +124,9 @@ 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
|
||||||
|
? new[] { "POST/PATCH notices" }
|
||||||
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"NativeReadsEnabled": false,
|
"NativeReadsEnabled": false,
|
||||||
"NativeOrganizationWritesEnabled": false,
|
"NativeOrganizationWritesEnabled": false,
|
||||||
"NativeAccountBatchesEnabled": false,
|
"NativeAccountBatchesEnabled": false,
|
||||||
"NativeConfigurationEnabled": false
|
"NativeConfigurationEnabled": false,
|
||||||
|
"NativeNoticeWritesEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -122,18 +122,35 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}, configurationEnabled: "true");
|
}, configurationEnabled: "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnablesNoticeWritesWithReadsAndSharedSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeReadsEnabled);
|
||||||
|
Assert.True(options.NativeNoticeWritesEnabled);
|
||||||
|
}, noticeWritesEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
private static void WithEnvironment(
|
private static void WithEnvironment(
|
||||||
string? nativeReadsEnabled,
|
string? nativeReadsEnabled,
|
||||||
string? nativeOrganizationWritesEnabled,
|
string? nativeOrganizationWritesEnabled,
|
||||||
string? allowMemory,
|
string? allowMemory,
|
||||||
Action test,
|
Action test,
|
||||||
string? accountBatchesEnabled = null,
|
string? accountBatchesEnabled = null,
|
||||||
string? configurationEnabled = null)
|
string? configurationEnabled = null,
|
||||||
|
string? noticeWritesEnabled = 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 previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -141,6 +158,7 @@ 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_ALLOW_MEMORY", allowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -150,6 +168,7 @@ 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_ALLOW_MEMORY", previousAllowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ public sealed class NoticeContentFormatterTests
|
|||||||
Assert.Contains("rel=\"noopener noreferrer\"", result, StringComparison.Ordinal);
|
Assert.Contains("rel=\"noopener noreferrer\"", result, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlainText_PreservesBlockBoundariesAndDecodesEntities()
|
||||||
|
{
|
||||||
|
const string source = "<p>第一段 内容</p><ul><li>第二项</li></ul>";
|
||||||
|
|
||||||
|
var result = _formatter.PlainText(source);
|
||||||
|
|
||||||
|
Assert.Equal("第一段 内容 第二项", result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ContentHtml_ConvertsPlainTextParagraphsWithoutTrustingMarkup()
|
public void ContentHtml_ConvertsPlainTextParagraphsWithoutTrustingMarkup()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user