发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。
校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。 接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。 富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。 页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -14,8 +17,11 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/notifications")]
|
||||
public sealed class NotificationsController(
|
||||
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 =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
@@ -106,17 +112,12 @@ public sealed class NotificationsController(
|
||||
var scope = currentUserDataScope.Current;
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
var recipientCount = await db.Users.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.IsEnabled && x.Id != scope.UserId,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.School,
|
||||
AudienceName = "全校已启用账号",
|
||||
RecipientCount = recipientCount,
|
||||
TeachingTasks = Array.Empty<object>()
|
||||
});
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
null,
|
||||
MessageAudienceType.School,
|
||||
"全校已启用账号",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
@@ -138,19 +139,12 @@ public sealed class NotificationsController(
|
||||
if (college is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
var recipientCount = await db.Users.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.IsEnabled &&
|
||||
x.Id != scope.UserId &&
|
||||
x.CollegeId == collegeId.Value,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
AudienceType = MessageAudienceType.College,
|
||||
AudienceName = $"{college}全院成员",
|
||||
RecipientCount = recipientCount,
|
||||
TeachingTasks = Array.Empty<object>()
|
||||
});
|
||||
return Ok(await BuildAdministratorComposerAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
MessageAudienceType.College,
|
||||
$"{college}全院成员",
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
@@ -195,6 +189,114 @@ public sealed class NotificationsController(
|
||||
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")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> Send(
|
||||
@@ -205,8 +307,10 @@ public sealed class NotificationsController(
|
||||
var content = request.Content.Trim();
|
||||
if (title.Length == 0)
|
||||
return ValidationProblem("请填写消息标题。");
|
||||
if (content.Length == 0)
|
||||
if (PlainText(content).Length == 0)
|
||||
return ValidationProblem("请填写消息正文。");
|
||||
if (content.Length > 20000)
|
||||
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
|
||||
var scope = currentUserDataScope.Current;
|
||||
MessageAudienceType audienceType;
|
||||
Guid? audienceId = null;
|
||||
@@ -215,12 +319,16 @@ public sealed class NotificationsController(
|
||||
|
||||
if (IsSchoolAdministrator(scope))
|
||||
{
|
||||
audienceType = MessageAudienceType.School;
|
||||
audienceName = "全校已启用账号";
|
||||
recipientIds = await db.Users.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Id != scope.UserId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
null,
|
||||
request,
|
||||
"全校已启用账号",
|
||||
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) ||
|
||||
scope.IsInRole(SystemRoles.Counselor))
|
||||
@@ -241,16 +349,19 @@ public sealed class NotificationsController(
|
||||
if (collegeName is null)
|
||||
return ConflictProblem("当前账号关联的学院不存在。");
|
||||
|
||||
audienceType = MessageAudienceType.College;
|
||||
audienceId = collegeId.Value;
|
||||
audienceName = $"{collegeName}全院成员";
|
||||
recipientIds = await db.Users.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Id != scope.UserId &&
|
||||
x.CollegeId == collegeId.Value)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var result = await ResolveAdministratorRecipientsAsync(
|
||||
scope,
|
||||
collegeId.Value,
|
||||
request,
|
||||
$"{collegeName}全院成员",
|
||||
cancellationToken);
|
||||
if (result.Error is not null) return result.Error;
|
||||
audienceType = result.AudienceType;
|
||||
audienceId = audienceType == MessageAudienceType.College
|
||||
? collegeId.Value
|
||||
: null;
|
||||
audienceName = result.AudienceName!;
|
||||
recipientIds = result.RecipientIds!;
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
@@ -305,24 +416,54 @@ public sealed class NotificationsController(
|
||||
RecipientCount = recipientIds.Count,
|
||||
LinkUrl = null
|
||||
};
|
||||
dispatch.Notifications = recipientIds.Select(userId => new Notification
|
||||
try
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
LinkUrl = null
|
||||
}).ToList();
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
db.MessageDispatches.Add(dispatch);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
|
||||
{
|
||||
db.Notifications.AddRange(batch.Select(userId => new Notification
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = NotificationCategory.General,
|
||||
LinkUrl = null,
|
||||
MessageDispatchId = dispatch.Id
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.AudienceName,
|
||||
dispatch.RecipientCount,
|
||||
dispatch.CreatedAt
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.ReadCommitted);
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.AudienceName,
|
||||
dispatch.RecipientCount,
|
||||
dispatch.CreatedAt
|
||||
});
|
||||
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")]
|
||||
@@ -433,6 +574,286 @@ public sealed class NotificationsController(
|
||||
scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
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) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -452,8 +873,33 @@ public sealed class NotificationsController(
|
||||
|
||||
public sealed record SendMessageRequest(
|
||||
[property: Required, StringLength(200)] string Title,
|
||||
[property: Required, StringLength(1000)] string Content,
|
||||
Guid? TeachingTaskId = null);
|
||||
[property: Required, StringLength(20000)] string Content,
|
||||
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>
|
||||
/// Centralized helper to send notifications across the app.
|
||||
|
||||
@@ -44,5 +44,6 @@ public enum MessageAudienceType
|
||||
{
|
||||
School = 1,
|
||||
College = 2,
|
||||
TeachingTask = 3
|
||||
TeachingTask = 3,
|
||||
Custom = 4
|
||||
}
|
||||
|
||||
@@ -980,7 +980,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<Notification>(entity =>
|
||||
{
|
||||
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.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
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.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.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
|
||||
|
||||
+4919
File diff suppressed because it is too large
Load Diff
+54
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -2366,8 +2366,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
@@ -2413,8 +2412,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
# 自托管 CKEditor 5:GPL 兼容项目可保留 GPL;商业部署请在构建时填写正式许可证键。
|
||||
VITE_CKEDITOR_LICENSE_KEY=GPL
|
||||
|
||||
Generated
+2565
-2
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"ckeditor5": "^48.3.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
Vendored
+1
@@ -49,6 +49,7 @@ declare module 'vue' {
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user