发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。

校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。
接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。
富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。
页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
This commit is contained in:
2026-07-27 12:53:10 +08:00 Unverified
parent 33ae991e86
commit 06d40a7bf1
13 changed files with 9180 additions and 221 deletions
@@ -119,6 +119,85 @@ public sealed class NotificationsControllerTests
Assert.Contains(fixture.ClassStudentUser.Id, recipientIds);
}
[Fact]
public async Task School_administrator_can_filter_recipients_by_college()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var result = await controller.Send(
new SendMessageRequest(
"第二学院通知",
"<p><strong>仅发送</strong>给第二学院。</p>",
RecipientMode: MessageRecipientMode.Filtered,
RecipientFilter: new MessageRecipientFilter(
CollegeId: fixture.SecondCollege.Id)),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = Assert.Single(
await fixture.Db.Notifications.ToListAsync());
Assert.Equal(fixture.OutsideRecipient.Id, notification.UserId);
var dispatch = await fixture.Db.MessageDispatches.SingleAsync();
Assert.Equal(MessageAudienceType.Custom, dispatch.AudienceType);
Assert.Equal("指定学院", dispatch.AudienceName);
}
[Fact]
public async Task College_administrator_cannot_select_recipient_outside_college()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.CollegeAdmin));
var result = await controller.Send(
new SendMessageRequest(
"越权消息",
"<p>不应发送。</p>",
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.OutsideRecipient.Id]),
CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.Empty(await fixture.Db.Notifications.ToListAsync());
Assert.Empty(await fixture.Db.MessageDispatches.ToListAsync());
}
[Fact]
public async Task Administrator_can_send_rich_content_larger_than_legacy_limit()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var content = $"<h2>教学安排</h2><p>{new string('内', 1500)}</p>";
var result = await controller.Send(
new SendMessageRequest(
"富文本通知",
content,
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.ClassStudentUser.Id]),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = await fixture.Db.Notifications.SingleAsync();
Assert.Equal(content, notification.Content);
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
}
[Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students()
{
@@ -189,6 +268,7 @@ public sealed class NotificationsControllerTests
SqliteConnection connection,
AppDbContext db,
College firstCollege,
College secondCollege,
ApplicationUser sender,
ApplicationUser collegeRecipient,
ApplicationUser outsideRecipient,
@@ -201,6 +281,7 @@ public sealed class NotificationsControllerTests
this.connection = connection;
Db = db;
FirstCollege = firstCollege;
SecondCollege = secondCollege;
Sender = sender;
CollegeRecipient = collegeRecipient;
OutsideRecipient = outsideRecipient;
@@ -213,6 +294,7 @@ public sealed class NotificationsControllerTests
public AppDbContext Db { get; }
public College FirstCollege { get; }
public College SecondCollege { get; }
public ApplicationUser Sender { get; }
public ApplicationUser CollegeRecipient { get; }
public ApplicationUser OutsideRecipient { get; }
@@ -362,6 +444,7 @@ public sealed class NotificationsControllerTests
connection,
db,
firstCollege,
secondCollege,
sender,
collegeRecipient,
outsideRecipient,