using System.ComponentModel.DataAnnotations; 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) : ControllerBase { private const string Senders = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor + "," + SystemRoles.Teacher; [HttpGet] public async Task 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 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 GetComposer(CancellationToken cancellationToken) { 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() }); } 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("当前账号关联的学院不存在。"); 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() }); } 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(); } [HttpPost("send")] [Authorize(Roles = Senders)] public async Task Send( SendMessageRequest request, CancellationToken cancellationToken) { var title = request.Title.Trim(); var content = request.Content.Trim(); if (title.Length == 0) return ValidationProblem("请填写消息标题。"); if (content.Length == 0) return ValidationProblem("请填写消息正文。"); var scope = currentUserDataScope.Current; MessageAudienceType audienceType; Guid? audienceId = null; string audienceName; List recipientIds; 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); } 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("当前账号关联的学院不存在。"); 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); } 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 }; dispatch.Notifications = recipientIds.Select(userId => new Notification { UserId = userId, Title = title, Content = content, Category = NotificationCategory.General, LinkUrl = null }).ToList(); db.MessageDispatches.Add(dispatch); await db.SaveChangesAsync(cancellationToken); return Ok(new { dispatch.Id, dispatch.AudienceName, dispatch.RecipientCount, dispatch.CreatedAt }); } [HttpGet("sent")] [Authorize(Roles = Senders)] public async Task 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 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 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> 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 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 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( [property: Required, StringLength(200)] string Title, [property: Required, StringLength(1000)] string Content, Guid? TeachingTaskId = null); /// /// Centralized helper to send notifications across the app. /// 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 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); } }