Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/NotificationsController.cs
T
2026-07-27 16:06:02 +08:00

1013 lines
36 KiB
C#

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;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/notifications")]
public sealed class NotificationsController(
AppDbContext db,
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 + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor + "," +
SystemRoles.Teacher;
[HttpGet]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
NotificationCategory? category,
string? keyword,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
if (category.HasValue)
source = source.Where(x => x.Category == category);
if (!string.IsNullOrWhiteSpace(keyword))
{
var value = keyword.Trim();
source = source.Where(x =>
x.Title.Contains(value) ||
x.Content.Contains(value));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.Title,
x.Content,
x.Category,
x.IsRead,
x.LinkUrl,
SenderName = x.MessageDispatch == null
? "教务系统"
: x.MessageDispatch.SenderName,
AudienceName = x.MessageDispatch == null
? null
: x.MessageDispatch.AudienceName,
IsManual = x.MessageDispatchId != null,
x.CreatedAt
})
.ToListAsync(cancellationToken);
var unreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
var unreadByCategory = await db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId && !x.IsRead)
.GroupBy(x => x.Category)
.Select(x => new { Category = x.Key, Count = x.Count() })
.ToListAsync(cancellationToken);
return Ok(new
{
Items = items,
Total = total,
UnreadCount = unreadCount,
UnreadByCategory = unreadByCategory
});
}
[HttpGet("unread-count")]
public async Task<ActionResult> GetUnreadCount(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var count = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
return Ok(new { Count = count });
}
[HttpGet("composer")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetComposer(CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope))
{
return Ok(await BuildAdministratorComposerAsync(
scope,
null,
MessageAudienceType.School,
"全校已启用账号",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var college = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (college is null)
return ConflictProblem("当前账号关联的学院不存在。");
return Ok(await BuildAdministratorComposerAsync(
scope,
collegeId.Value,
MessageAudienceType.College,
$"{college}全院成员",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status != TeachingTaskStatus.Draft &&
task.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.OrderByDescending(task => task.AcademicTerm!.IsCurrent)
.ThenByDescending(task => task.AcademicTerm!.StartDate)
.ThenBy(task => task.TaskNumber)
.Select(task => new
{
task.Id,
task.TaskNumber,
CourseName = task.Course!.Name,
TermName = task.AcademicTerm!.Name,
RecipientCount = db.Students.Count(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
(task.Classes.Any(item =>
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status ==
CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!
.TeachingTaskId == task.Id)))
})
.ToListAsync(cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.TeachingTask,
AudienceName = "所带教学班",
RecipientCount = 0,
TeachingTasks = tasks
});
}
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(
SendMessageRequest request,
CancellationToken cancellationToken)
{
var title = request.Title.Trim();
var content = request.Content.Trim();
if (title.Length == 0)
return ValidationProblem("请填写消息标题。");
if (PlainText(content).Length == 0)
return ValidationProblem("请填写消息正文。");
if (content.Length > 20000)
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
var scope = currentUserDataScope.Current;
MessageAudienceType audienceType;
Guid? audienceId = null;
string audienceName;
List<Guid> recipientIds;
if (IsSchoolAdministrator(scope))
{
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))
{
var collegeId = await ResolveSenderCollegeIdAsync(
scope,
cancellationToken);
if (!collegeId.HasValue)
{
return ConflictProblem(
"当前账号未关联学院,暂时无法确定发信范围。");
}
var collegeName = await db.Colleges.AsNoTracking()
.Where(x => x.Id == collegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken);
if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。");
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))
{
if (!request.TeachingTaskId.HasValue)
return ValidationProblem("请选择接收消息的教学班。");
var task = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Id == request.TeachingTaskId.Value &&
x.Status != TeachingTaskStatus.Draft &&
x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId))
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null)
{
return ConflictProblem(
"只能向自己所带且已发布的教学班发信。");
}
audienceType = MessageAudienceType.TeachingTask;
audienceId = task.Id;
audienceName = $"{task.TaskNumber} · {task.CourseName}";
recipientIds = await GetTeachingTaskRecipientIdsAsync(
task.Id,
cancellationToken);
}
else
{
return Forbid();
}
recipientIds = recipientIds.Distinct().ToList();
if (recipientIds.Count == 0)
return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。");
var dispatch = new MessageDispatch
{
SenderUserId = scope.UserId,
SenderName = scope.DisplayName ?? "教务用户",
Title = title,
Content = content,
Category = NotificationCategory.General,
AudienceType = audienceType,
AudienceId = audienceId,
AudienceName = audienceName,
RecipientCount = recipientIds.Count,
LinkUrl = null
};
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,
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)
{
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")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetSent(
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var userId = currentUserDataScope.Current.UserId;
var source = db.MessageDispatches.AsNoTracking()
.Where(x => x.SenderUserId == userId);
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.Title,
x.Content,
x.Category,
x.AudienceType,
x.AudienceName,
x.RecipientCount,
x.CreatedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total });
}
[HttpPost("{id:guid}/read")]
public async Task<ActionResult> MarkRead(
Guid id,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var notification = await db.Notifications
.FirstOrDefaultAsync(
x => x.Id == id && x.UserId == userId,
cancellationToken);
if (notification is null) return NotFound();
notification.IsRead = true;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("read-all")]
public async Task<ActionResult> MarkAllRead(
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
await db.Notifications
.Where(x => x.UserId == userId && !x.IsRead)
.ExecuteUpdateAsync(
updates => updates.SetProperty(x => x.IsRead, true),
cancellationToken);
return NoContent();
}
private async Task<List<Guid>> GetTeachingTaskRecipientIdsAsync(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
return await db.Students.AsNoTracking()
.Where(student =>
student.Status == StudentStatus.Active &&
student.UserId != null &&
db.Users.Any(user =>
user.Id == student.UserId &&
user.IsEnabled) &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == teachingTaskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
teachingTaskId)))
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
}
private async Task<Guid?> ResolveSenderCollegeIdAsync(
CurrentUserScope scope,
CancellationToken cancellationToken)
{
if (scope.CollegeId.HasValue)
return scope.CollegeId;
if (!scope.IsInRole(SystemRoles.Counselor))
return null;
var collegeIds = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.CounselorUserId == scope.UserId)
.Select(x => x.Major!.CollegeId)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return collegeIds.Count == 1 ? collegeIds[0] : null;
}
private static bool IsSchoolAdministrator(CurrentUserScope scope) =>
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
{
Title = "无法发送消息",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private ActionResult ValidationProblem(string detail) =>
BadRequest(new ProblemDetails
{
Title = "消息内容不完整",
Detail = detail,
Status = StatusCodes.Status400BadRequest
});
}
public sealed record SendMessageRequest(
[Required, StringLength(200)] string Title,
[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.
/// </summary>
public static class NotificationService
{
public static async Task SendAsync(
AppDbContext db,
Guid userId,
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
await db.SaveChangesAsync(cancellationToken);
}
public static async Task SendToRoleAsync(
AppDbContext db,
string roleName,
string title,
string content,
Guid? collegeId = null,
string? linkUrl = null,
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
var query = db.Users
.Join(
db.UserRoles,
user => user.Id,
userRole => userRole.UserId,
(user, userRole) => new
{
user.Id,
user.IsEnabled,
user.CollegeId,
userRole.RoleId
})
.Join(
db.Roles,
item => item.RoleId,
role => role.Id,
(item, role) => new
{
item.Id,
item.IsEnabled,
item.CollegeId,
RoleName = role.Name!
});
var userIds = query.Where(x =>
x.RoleName == roleName &&
x.IsEnabled);
if (collegeId.HasValue)
{
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
}
var ids = await userIds
.Select(x => x.Id)
.Distinct()
.ToListAsync(cancellationToken);
foreach (var id in ids)
{
db.Notifications.Add(new Notification
{
UserId = id,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
await db.SaveChangesAsync(cancellationToken);
}
public static async Task SendToUserIdsAsync(
AppDbContext db,
IEnumerable<Guid> userIds,
string title,
string content,
string? linkUrl = null,
CancellationToken cancellationToken = default,
NotificationCategory category = NotificationCategory.General)
{
foreach (var userId in userIds.Distinct())
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = category,
LinkUrl = linkUrl
});
}
await db.SaveChangesAsync(cancellationToken);
}
}