成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。 选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。 关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -16,18 +16,38 @@ 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<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
|
||||
@@ -36,14 +56,38 @@ public sealed class NotificationsController(
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
|
||||
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 });
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Total = total,
|
||||
UnreadCount = unreadCount,
|
||||
UnreadByCategory = unreadByCategory
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("unread-count")]
|
||||
@@ -55,30 +99,362 @@ public sealed class NotificationsController(
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[HttpGet("composer")]
|
||||
[Authorize(Roles = Senders)]
|
||||
public async Task<ActionResult> 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<object>()
|
||||
});
|
||||
}
|
||||
|
||||
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<object>()
|
||||
});
|
||||
}
|
||||
|
||||
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<ActionResult> 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<Guid> 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<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)
|
||||
public async Task<ActionResult> MarkRead(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var n = await db.Notifications
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
||||
if (n is null) return NotFound();
|
||||
n.IsRead = true;
|
||||
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)
|
||||
public async Task<ActionResult> MarkAllRead(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
await db.Notifications
|
||||
.Where(x => x.UserId == userId && !x.IsRead)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true),
|
||||
.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 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);
|
||||
|
||||
/// <summary>
|
||||
/// Centralized helper to send notifications across the app.
|
||||
/// </summary>
|
||||
@@ -90,13 +466,15 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
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);
|
||||
@@ -109,22 +487,46 @@ public static class NotificationService
|
||||
string content,
|
||||
Guid? collegeId = null,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
var query = db.Users
|
||||
.Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId })
|
||||
.Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! });
|
||||
.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);
|
||||
var userIds = query.Where(x =>
|
||||
x.RoleName == roleName &&
|
||||
x.IsEnabled);
|
||||
|
||||
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
|
||||
if (collegeId.HasValue)
|
||||
{
|
||||
userIds = userIds.Where(x =>
|
||||
db.Teachers.Any(t =>
|
||||
t.UserId == x.Id && t.CollegeId == collegeId.Value));
|
||||
userIds = userIds.Where(x => x.CollegeId == collegeId.Value);
|
||||
}
|
||||
|
||||
var ids = await userIds.Select(x => x.Id).Distinct().ToListAsync(cancellationToken);
|
||||
var ids = await userIds
|
||||
.Select(x => x.Id)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
db.Notifications.Add(new Notification
|
||||
@@ -132,6 +534,7 @@ public static class NotificationService
|
||||
UserId = id,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
@@ -144,7 +547,8 @@ public static class NotificationService
|
||||
string title,
|
||||
string content,
|
||||
string? linkUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
NotificationCategory category = NotificationCategory.General)
|
||||
{
|
||||
foreach (var userId in userIds.Distinct())
|
||||
{
|
||||
@@ -153,6 +557,7 @@ public static class NotificationService
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Content = content,
|
||||
Category = category,
|
||||
LinkUrl = linkUrl
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user