发信改为事务内每 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
@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Net;
using System.Text.RegularExpressions;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -14,8 +17,11 @@ namespace Jiaowu.Api.Controllers;
[Route("api/notifications")] [Route("api/notifications")]
public sealed class NotificationsController( public sealed class NotificationsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
ILogger<NotificationsController>? logger = null) : ControllerBase
{ {
private const int MaximumSelectedRecipients = 500;
private const int NotificationBatchSize = 300;
private const string Senders = private const string Senders =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," + SystemRoles.AcademicAdmin + "," +
@@ -106,17 +112,12 @@ public sealed class NotificationsController(
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope)) if (IsSchoolAdministrator(scope))
{ {
var recipientCount = await db.Users.AsNoTracking() return Ok(await BuildAdministratorComposerAsync(
.CountAsync( scope,
x => x.IsEnabled && x.Id != scope.UserId, null,
cancellationToken); MessageAudienceType.School,
return Ok(new "全校已启用账号",
{ cancellationToken));
AudienceType = MessageAudienceType.School,
AudienceName = "全校已启用账号",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
} }
if (scope.IsInRole(SystemRoles.CollegeAdmin) || if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
@@ -138,19 +139,12 @@ public sealed class NotificationsController(
if (college is null) if (college is null)
return ConflictProblem("当前账号关联的学院不存在。"); return ConflictProblem("当前账号关联的学院不存在。");
var recipientCount = await db.Users.AsNoTracking() return Ok(await BuildAdministratorComposerAsync(
.CountAsync( scope,
x => x.IsEnabled && collegeId.Value,
x.Id != scope.UserId && MessageAudienceType.College,
x.CollegeId == collegeId.Value, $"{college}全院成员",
cancellationToken); cancellationToken));
return Ok(new
{
AudienceType = MessageAudienceType.College,
AudienceName = $"{college}全院成员",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
} }
if (scope.IsInRole(SystemRoles.Teacher)) if (scope.IsInRole(SystemRoles.Teacher))
@@ -195,6 +189,114 @@ public sealed class NotificationsController(
return Forbid(); return Forbid();
} }
[HttpGet("recipients")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetRecipients(
Guid? collegeId,
string? role,
Guid? administrativeClassId,
Guid? teachingTaskId,
string? keyword,
int page = 1,
int pageSize = 30,
CancellationToken cancellationToken = default)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.Teacher) &&
!IsSchoolAdministrator(scope) &&
!scope.IsInRole(SystemRoles.CollegeAdmin) &&
!scope.IsInRole(SystemRoles.Counselor))
{
return Forbid();
}
var resolvedCollegeId = IsSchoolAdministrator(scope)
? null
: await ResolveSenderCollegeIdAsync(scope, cancellationToken);
if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue)
return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。");
var filter = new MessageRecipientFilter(
collegeId,
Normalize(role),
administrativeClassId,
teachingTaskId,
Normalize(keyword));
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
return ValidationProblem("所选身份类型无效。");
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = ApplyRecipientFilter(
ScopedRecipientQuery(scope, resolvedCollegeId),
filter);
var total = await source.CountAsync(cancellationToken);
var users = await source
.OrderBy(x => x.DisplayName)
.ThenBy(x => x.UserName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
user.StaffNumber,
CollegeName = db.Colleges
.Where(college => college.Id == user.CollegeId)
.Select(college => college.Name)
.FirstOrDefault(),
StudentNumber = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.StudentNumber)
.FirstOrDefault(),
ClassName = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.AdministrativeClass!.Name)
.FirstOrDefault(),
TeacherNumber = db.Teachers
.Where(teacher => teacher.UserId == user.Id)
.Select(teacher => teacher.TeacherNumber)
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
var userIds = users.Select(x => x.Id).ToArray();
var roleRows = await db.UserRoles.AsNoTracking()
.Where(x => userIds.Contains(x.UserId))
.Join(
db.Roles.AsNoTracking(),
userRole => userRole.RoleId,
roleEntity => roleEntity.Id,
(userRole, roleEntity) => new
{
userRole.UserId,
RoleName = roleEntity.Name!
})
.ToListAsync(cancellationToken);
var rolesByUser = roleRows
.GroupBy(x => x.UserId)
.ToDictionary(
group => group.Key,
group => group.Select(x => x.RoleName).Distinct().ToArray());
return Ok(new
{
Items = users.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber,
user.CollegeName,
user.ClassName,
Roles = rolesByUser.GetValueOrDefault(user.Id, [])
}),
Total = total,
Page = page,
PageSize = pageSize
});
}
[HttpPost("send")] [HttpPost("send")]
[Authorize(Roles = Senders)] [Authorize(Roles = Senders)]
public async Task<ActionResult> Send( public async Task<ActionResult> Send(
@@ -205,8 +307,10 @@ public sealed class NotificationsController(
var content = request.Content.Trim(); var content = request.Content.Trim();
if (title.Length == 0) if (title.Length == 0)
return ValidationProblem("请填写消息标题。"); return ValidationProblem("请填写消息标题。");
if (content.Length == 0) if (PlainText(content).Length == 0)
return ValidationProblem("请填写消息正文。"); return ValidationProblem("请填写消息正文。");
if (content.Length > 20000)
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
MessageAudienceType audienceType; MessageAudienceType audienceType;
Guid? audienceId = null; Guid? audienceId = null;
@@ -215,12 +319,16 @@ public sealed class NotificationsController(
if (IsSchoolAdministrator(scope)) if (IsSchoolAdministrator(scope))
{ {
audienceType = MessageAudienceType.School; var result = await ResolveAdministratorRecipientsAsync(
audienceName = "全校已启用账号"; scope,
recipientIds = await db.Users.AsNoTracking() null,
.Where(x => x.IsEnabled && x.Id != scope.UserId) request,
.Select(x => x.Id) "全校已启用账号",
.ToListAsync(cancellationToken); cancellationToken);
if (result.Error is not null) return result.Error;
audienceType = result.AudienceType;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
} }
else if (scope.IsInRole(SystemRoles.CollegeAdmin) || else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor)) scope.IsInRole(SystemRoles.Counselor))
@@ -241,16 +349,19 @@ public sealed class NotificationsController(
if (collegeName is null) if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。"); return ConflictProblem("当前账号关联的学院不存在。");
audienceType = MessageAudienceType.College; var result = await ResolveAdministratorRecipientsAsync(
audienceId = collegeId.Value; scope,
audienceName = $"{collegeName}全院成员"; collegeId.Value,
recipientIds = await db.Users.AsNoTracking() request,
.Where(x => $"{collegeName}全院成员",
x.IsEnabled && cancellationToken);
x.Id != scope.UserId && if (result.Error is not null) return result.Error;
x.CollegeId == collegeId.Value) audienceType = result.AudienceType;
.Select(x => x.Id) audienceId = audienceType == MessageAudienceType.College
.ToListAsync(cancellationToken); ? collegeId.Value
: null;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
} }
else if (scope.IsInRole(SystemRoles.Teacher)) else if (scope.IsInRole(SystemRoles.Teacher))
{ {
@@ -305,17 +416,31 @@ public sealed class NotificationsController(
RecipientCount = recipientIds.Count, RecipientCount = recipientIds.Count,
LinkUrl = null LinkUrl = null
}; };
dispatch.Notifications = recipientIds.Select(userId => new Notification try
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
{
db.Notifications.AddRange(batch.Select(userId => new Notification
{ {
UserId = userId, UserId = userId,
Title = title, Title = title,
Content = content, Content = content,
Category = NotificationCategory.General, Category = NotificationCategory.General,
LinkUrl = null LinkUrl = null,
}).ToList(); MessageDispatchId = dispatch.Id
}));
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
}
await transaction.CommitAsync(cancellationToken);
return Ok(new return Ok(new
{ {
dispatch.Id, dispatch.Id,
@@ -323,6 +448,22 @@ public sealed class NotificationsController(
dispatch.RecipientCount, dispatch.RecipientCount,
dispatch.CreatedAt dispatch.CreatedAt
}); });
},
cancellationToken,
IsolationLevel.ReadCommitted);
}
catch (DbUpdateException exception)
{
logger?.LogError(
exception,
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
dispatch.Id,
recipientIds.Count);
return Problem(
title: "消息未能发送",
detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
} }
[HttpGet("sent")] [HttpGet("sent")]
@@ -433,6 +574,286 @@ public sealed class NotificationsController(
scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin); scope.IsInRole(SystemRoles.AcademicAdmin);
private async Task<object> BuildAdministratorComposerAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
MessageAudienceType audienceType,
string audienceName,
CancellationToken cancellationToken)
{
var colleges = await db.Colleges.AsNoTracking()
.Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
var administrativeClasses = await db.AdministrativeClasses.AsNoTracking()
.Where(x =>
x.IsEnabled &&
(!fixedCollegeId.HasValue ||
x.Major!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Grade,
CollegeId = x.Major!.CollegeId
})
.ToListAsync(cancellationToken);
var teachingTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Status != TeachingTaskStatus.Draft &&
(!fixedCollegeId.HasValue ||
x.Course!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
.ThenByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name,
TermName = x.AcademicTerm!.Name,
CollegeId = x.Course.CollegeId
})
.ToListAsync(cancellationToken);
var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId)
.CountAsync(cancellationToken);
return new
{
AudienceType = audienceType,
AudienceName = audienceName,
RecipientCount = recipientCount,
CanFilterRecipients = true,
MaximumSelectedRecipients,
Colleges = colleges,
Roles = RoleOptions,
AdministrativeClasses = administrativeClasses,
TeachingTasks = teachingTasks
};
}
private IQueryable<ApplicationUser> ScopedRecipientQuery(
CurrentUserScope scope,
Guid? fixedCollegeId)
{
var source = db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId);
if (IsSchoolAdministrator(scope)) return source;
if (!fixedCollegeId.HasValue) return source.Where(_ => false);
var collegeId = fixedCollegeId.Value;
return source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
private IQueryable<ApplicationUser> ApplyRecipientFilter(
IQueryable<ApplicationUser> source,
MessageRecipientFilter? filter)
{
if (filter is null) return source;
if (filter.CollegeId.HasValue)
{
var collegeId = filter.CollegeId.Value;
source = source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
if (!string.IsNullOrWhiteSpace(filter.Role))
{
var role = filter.Role;
source = source.Where(user =>
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(roleEntity =>
roleEntity.Id == userRole.RoleId &&
roleEntity.Name == role)));
}
if (filter.AdministrativeClassId.HasValue)
{
var classId = filter.AdministrativeClassId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClassId == classId));
}
if (filter.TeachingTaskId.HasValue)
{
var taskId = filter.TeachingTaskId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == taskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
taskId))));
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword;
source = source.Where(user =>
user.DisplayName.Contains(keyword) ||
(user.UserName != null && user.UserName.Contains(keyword)) ||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
db.Students.Any(student =>
student.UserId == user.Id &&
(student.Name.Contains(keyword) ||
student.StudentNumber.Contains(keyword))) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
(teacher.Name.Contains(keyword) ||
teacher.TeacherNumber.Contains(keyword))));
}
return source;
}
private async Task<RecipientResolution> ResolveAdministratorRecipientsAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
SendMessageRequest request,
string defaultAudienceName,
CancellationToken cancellationToken)
{
var source = ScopedRecipientQuery(scope, fixedCollegeId);
if (request.RecipientMode == MessageRecipientMode.Scope)
{
return new RecipientResolution(
fixedCollegeId.HasValue
? MessageAudienceType.College
: MessageAudienceType.School,
defaultAudienceName,
await source.Select(x => x.Id).ToListAsync(cancellationToken),
null);
}
if (request.RecipientMode == MessageRecipientMode.Filtered)
{
if (request.RecipientFilter?.Role is not null &&
!SystemRoles.All.Contains(request.RecipientFilter.Role))
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选身份类型无效。"));
}
var recipientIds = await ApplyRecipientFilter(
source,
request.RecipientFilter)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
return new RecipientResolution(
MessageAudienceType.Custom,
BuildFilteredAudienceName(request.RecipientFilter),
recipientIds,
null);
}
if (request.RecipientMode == MessageRecipientMode.Selected)
{
var requestedIds = request.RecipientUserIds?
.Distinct()
.ToArray() ?? [];
if (requestedIds.Length == 0)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请至少选择一名收件人。"));
}
if (requestedIds.Length > MaximumSelectedRecipients)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem(
$"单次最多指定 {MaximumSelectedRecipients} 名收件人。"));
}
var authorizedIds = await source
.Where(x => requestedIds.Contains(x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (authorizedIds.Count != requestedIds.Length)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选收件人包含无权限访问或已停用的账号。"));
}
return new RecipientResolution(
MessageAudienceType.Custom,
$"指定收件人({authorizedIds.Count} 人)",
authorizedIds,
null);
}
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请选择有效的收件方式。"));
}
private static string BuildFilteredAudienceName(MessageRecipientFilter? filter)
{
if (filter is null) return "当前权限范围内全部账号";
var parts = new List<string>();
if (filter.CollegeId.HasValue) parts.Add("指定学院");
if (filter.Role is not null)
parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ??
filter.Role);
if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班");
if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班");
if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”");
return parts.Count == 0
? "当前权限范围内全部账号"
: string.Join(" · ", parts);
}
private static string PlainText(string html)
{
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
return WebUtility.HtmlDecode(withoutTags).Trim();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static readonly RoleOption[] RoleOptions =
[
new(SystemRoles.Student, "学生"),
new(SystemRoles.Teacher, "任课教师"),
new(SystemRoles.Counselor, "辅导员"),
new(SystemRoles.CollegeAdmin, "学院管理员"),
new(SystemRoles.AcademicAdmin, "校级教务管理员"),
new(SystemRoles.Leader, "校领导"),
new(SystemRoles.SuperAdmin, "超级管理员")
];
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -452,8 +873,33 @@ public sealed class NotificationsController(
public sealed record SendMessageRequest( public sealed record SendMessageRequest(
[property: Required, StringLength(200)] string Title, [property: Required, StringLength(200)] string Title,
[property: Required, StringLength(1000)] string Content, [property: Required, StringLength(20000)] string Content,
Guid? TeachingTaskId = null); Guid? TeachingTaskId = null,
MessageRecipientMode RecipientMode = MessageRecipientMode.Scope,
MessageRecipientFilter? RecipientFilter = null,
IReadOnlyCollection<Guid>? RecipientUserIds = null);
public sealed record MessageRecipientFilter(
Guid? CollegeId = null,
string? Role = null,
Guid? AdministrativeClassId = null,
Guid? TeachingTaskId = null,
string? Keyword = null);
public enum MessageRecipientMode
{
Scope = 1,
Filtered = 2,
Selected = 3
}
public sealed record RoleOption(string Value, string Label);
internal sealed record RecipientResolution(
MessageAudienceType AudienceType,
string? AudienceName,
List<Guid>? RecipientIds,
ActionResult? Error);
/// <summary> /// <summary>
/// Centralized helper to send notifications across the app. /// Centralized helper to send notifications across the app.
@@ -44,5 +44,6 @@ public enum MessageAudienceType
{ {
School = 1, School = 1,
College = 2, College = 2,
TeachingTask = 3 TeachingTask = 3,
Custom = 4
} }
@@ -980,7 +980,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<Notification>(entity => builder.Entity<Notification>(entity =>
{ {
entity.Property(x => x.Title).HasMaxLength(200); entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000); entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.LinkUrl).HasMaxLength(300); entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead }); entity.HasIndex(x => new { x.UserId, x.IsRead });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt }); entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
@@ -996,7 +996,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.SenderName).HasMaxLength(100); entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200); entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000); entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.AudienceName).HasMaxLength(200); entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300); entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt }); entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class MessageRichContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
}
}
}
@@ -2366,8 +2366,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<string>("Content") b.Property<string>("Content")
.IsRequired() .IsRequired()
.HasMaxLength(1000) .HasColumnType("longtext");
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -2413,8 +2412,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<string>("Content") b.Property<string>("Content")
.IsRequired() .IsRequired()
.HasMaxLength(1000) .HasColumnType("longtext");
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -119,6 +119,85 @@ public sealed class NotificationsControllerTests
Assert.Contains(fixture.ClassStudentUser.Id, recipientIds); 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] [Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students() public async Task Publishing_course_grades_automatically_notifies_roster_students()
{ {
@@ -189,6 +268,7 @@ public sealed class NotificationsControllerTests
SqliteConnection connection, SqliteConnection connection,
AppDbContext db, AppDbContext db,
College firstCollege, College firstCollege,
College secondCollege,
ApplicationUser sender, ApplicationUser sender,
ApplicationUser collegeRecipient, ApplicationUser collegeRecipient,
ApplicationUser outsideRecipient, ApplicationUser outsideRecipient,
@@ -201,6 +281,7 @@ public sealed class NotificationsControllerTests
this.connection = connection; this.connection = connection;
Db = db; Db = db;
FirstCollege = firstCollege; FirstCollege = firstCollege;
SecondCollege = secondCollege;
Sender = sender; Sender = sender;
CollegeRecipient = collegeRecipient; CollegeRecipient = collegeRecipient;
OutsideRecipient = outsideRecipient; OutsideRecipient = outsideRecipient;
@@ -213,6 +294,7 @@ public sealed class NotificationsControllerTests
public AppDbContext Db { get; } public AppDbContext Db { get; }
public College FirstCollege { get; } public College FirstCollege { get; }
public College SecondCollege { get; }
public ApplicationUser Sender { get; } public ApplicationUser Sender { get; }
public ApplicationUser CollegeRecipient { get; } public ApplicationUser CollegeRecipient { get; }
public ApplicationUser OutsideRecipient { get; } public ApplicationUser OutsideRecipient { get; }
@@ -362,6 +444,7 @@ public sealed class NotificationsControllerTests
connection, connection,
db, db,
firstCollege, firstCollege,
secondCollege,
sender, sender,
collegeRecipient, collegeRecipient,
outsideRecipient, outsideRecipient,
+2
View File
@@ -1 +1,3 @@
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
# 自托管 CKEditor 5GPL 兼容项目可保留 GPL;商业部署请在构建时填写正式许可证键。
VITE_CKEDITOR_LICENSE_KEY=GPL
+2565 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -9,8 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ckeditor/ckeditor5-vue": "^8.2.0",
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1", "axios": "^1.18.1",
"ckeditor5": "^48.3.1",
"dompurify": "^3.4.12",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
+1
View File
@@ -49,6 +49,7 @@ declare module 'vue' {
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
rich?: boolean
}>()
const safeHtml = computed(() => DOMPurify.sanitize(props.content, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's',
'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a',
],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
}))
</script>
<template>
<div v-if="rich" class="rich-message-content" v-html="safeHtml" />
<p v-else class="plain-message-content">{{ content }}</p>
</template>
<style scoped>
.plain-message-content {
margin: 0;
white-space: pre-wrap;
}
.rich-message-content {
overflow-wrap: anywhere;
color: inherit;
font-size: inherit;
line-height: 1.75;
}
.rich-message-content :deep(p),
.rich-message-content :deep(ul),
.rich-message-content :deep(ol),
.rich-message-content :deep(blockquote) {
margin: 0 0 .65em;
}
.rich-message-content :deep(p:last-child),
.rich-message-content :deep(ul:last-child),
.rich-message-content :deep(ol:last-child),
.rich-message-content :deep(blockquote:last-child) {
margin-bottom: 0;
}
.rich-message-content :deep(h2),
.rich-message-content :deep(h3),
.rich-message-content :deep(h4) {
margin: .8em 0 .35em;
color: var(--ink);
font-family: inherit;
}
.rich-message-content :deep(h2) { font-size: 1.25em; }
.rich-message-content :deep(h3) { font-size: 1.12em; }
.rich-message-content :deep(h4) { font-size: 1em; }
.rich-message-content :deep(ul),
.rich-message-content :deep(ol) {
padding-left: 1.5em;
}
.rich-message-content :deep(blockquote) {
padding: .55em .9em;
border-left: 3px solid var(--teal);
background: #f3f8f7;
}
.rich-message-content :deep(a) {
color: var(--indigo);
text-decoration: underline;
text-underline-offset: 2px;
}
</style>
File diff suppressed because it is too large Load Diff