发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。
校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。 接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。 富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。 页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
This commit is contained in:
1 parent
33ae991e86
commit
06d40a7bf1
13 files changed
+9180
-221
No files matched your search
@@ -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,24 +416,54 @@ public sealed class NotificationsController(
|
|||||||
RecipientCount = recipientIds.Count,
|
RecipientCount = recipientIds.Count,
|
||||||
LinkUrl = null
|
LinkUrl = null
|
||||||
};
|
};
|
||||||
dispatch.Notifications = recipientIds.Select(userId => new Notification
|
try
|
||||||
{
|
{
|
||||||
UserId = userId,
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
Title = title,
|
async transaction =>
|
||||||
Content = content,
|
{
|
||||||
Category = NotificationCategory.General,
|
db.MessageDispatches.Add(dispatch);
|
||||||
LinkUrl = null
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
}).ToList();
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
db.MessageDispatches.Add(dispatch);
|
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
{
|
||||||
return Ok(new
|
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,
|
logger?.LogError(
|
||||||
dispatch.AudienceName,
|
exception,
|
||||||
dispatch.RecipientCount,
|
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
|
||||||
dispatch.CreatedAt
|
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 });
|
||||||
|
|||||||
Loaded 3 of 13 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user