新增 POST /api/admin/notices、PATCH /api/admin/notices/{id}

保留超级管理员权限边界
支持草稿、发布、置顶、自动摘要
HTML 净化,拦截脚本和危险链接
公告更新与审计日志同事务提交
新增开关 ADMIN_NATIVE_NOTICE_WRITES_ENABLED=false
管理端公告列表和系统自动公告暂时仍由 Node 处理
This commit is contained in:
2026-07-23 08:41:53 +08:00 Unverified
parent 3665aa3aa9
commit 9e21a277f0
14 changed files with 350 additions and 21 deletions
@@ -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 NativeOrganizationWritesEnabled = false,
bool NativeAccountBatchesEnabled = false,
bool NativeConfigurationEnabled = false)
bool NativeConfigurationEnabled = false,
bool NativeNoticeWritesEnabled = false)
{
public static AdminMigrationOptions FromEnvironment(
bool configuredNativeReadsEnabled,
@@ -12,7 +13,8 @@ public sealed record AdminMigrationOptions(
bool sharesLegacySessions,
bool configuredNativeOrganizationWritesEnabled = false,
bool configuredNativeAccountBatchesEnabled = false,
bool configuredNativeConfigurationEnabled = false)
bool configuredNativeConfigurationEnabled = false,
bool configuredNativeNoticeWritesEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
@@ -26,12 +28,15 @@ public sealed record AdminMigrationOptions(
var configurationEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
configuredNativeConfigurationEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled) && !readsEnabled)
var noticeWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
configuredNativeNoticeWritesEnabled);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
}
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled;
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeWritesEnabled;
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
@@ -47,7 +52,7 @@ public sealed record AdminMigrationOptions(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled);
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeWritesEnabled);
}
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
@@ -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<AdminAccountBatchRepository>();
services.AddScoped<AdminConfigurationRepository>();
services.AddScoped<AdminNoticeRepository>();
services.AddScoped<IAdminReadService, AdminReadService>();
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
services.AddSingleton<NoticeContentFormatter>();
services.AddScoped<IPublicQueryService, PublicQueryService>();
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>"));
}
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)
{
foreach (var child in parent.ChildNodes.ToArray())
@@ -176,4 +184,10 @@ internal sealed partial class NoticeContentFormatter
[GeneratedRegex("\\r?\\n{2,}")]
private static partial Regex ParagraphRegex();
[GeneratedRegex("</?(?: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)));
}
if (!options.NativeConfigurationEnabled) return endpoints;
endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken)));
endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
if (options.NativeConfigurationEnabled)
{
endpoints.MapGet("/api/admin/number-rules", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.GetNumberRulesAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admin/number-rules", (HttpContext context, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveNumberRuleAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admin/workflows", (HttpContext context, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.GetWorkflowsAsync(Token(context), cancellationToken)));
endpoints.MapPut("/api/admin/workflows/{businessType}", (HttpContext context, string businessType, JsonObject body, IAdminConfigurationService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveWorkflowAsync(Token(context), businessType, body, cancellationToken)));
}
if (!options.NativeNoticeWritesEnabled) return endpoints;
endpoints.MapPost("/api/admin/notices", (HttpContext context, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.CreateAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admin/notices/{noticeId}", (HttpContext context, string noticeId, JsonObject body, IAdminNoticeService service, CancellationToken cancellationToken) =>
Execute(context, service.UpdateAsync(Token(context), noticeId, body, cancellationToken)));
return endpoints;
}
+6 -1
View File
@@ -48,7 +48,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
authenticationOptions.SharesLegacySessions,
builder.Configuration.GetValue<bool>("AdminMigration:NativeOrganizationWritesEnabled"),
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(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
@@ -106,6 +107,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
nativeOrganizationWritesEnabled = adminMigrationOptions.NativeOrganizationWritesEnabled,
nativeAccountBatchesEnabled = adminMigrationOptions.NativeAccountBatchesEnabled,
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
nativeNoticeWritesEnabled = adminMigrationOptions.NativeNoticeWritesEnabled,
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
: [])
@@ -122,6 +124,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
.Concat(adminMigrationOptions.NativeConfigurationEnabled
? new[] { "GET/POST number-rules", "GET/PUT workflows" }
: [])
.Concat(adminMigrationOptions.NativeNoticeWritesEnabled
? new[] { "POST/PATCH notices" }
: [])
.ToArray()
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
+2 -1
View File
@@ -13,7 +13,8 @@
"NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false
"NativeConfigurationEnabled": false,
"NativeNoticeWritesEnabled": false
},
"Logging": {
"LogLevel": {