统一通知中心

This commit is contained in:
2026-07-25 15:50:00 +08:00 Unverified
parent 13b61f191a
commit 2c6103d3ae
8 changed files with 552 additions and 144 deletions
@@ -126,7 +126,21 @@ public sealed class CourseAdjustmentsController(
await db.SaveChangesAsync(cancellationToken);
if (request.Submit)
await NotifyReviewersAsync(adj, cancellationToken);
{
var taskInfo = await db.TeachingTasks
.Where(x => x.Id == request.TeachingTaskId)
.Select(x => new { x.Course!.Name, x.Course.CollegeId })
.FirstOrDefaultAsync(cancellationToken);
if (taskInfo is not null)
{
var tl = TypeLabel(request.Type);
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
$"新的{tl}申请",
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
}
}
return Created(string.Empty, new { adj.Id });
}
@@ -150,7 +164,19 @@ public sealed class CourseAdjustmentsController(
adj.Status = CourseAdjustmentStatus.Submitted;
adj.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await NotifyReviewersAsync(adj, cancellationToken);
var courseName = adj.TeachingTask!.Course!.Name;
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
adj.TeachingTask.Course.CollegeId,
"/course-adjustments", cancellationToken);
await NotificationService.SendToRoleAsync(db,
SystemRoles.AcademicAdmin,
$"新的{TypeLabel(adj.Type)}申请",
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
null, "/course-adjustments", cancellationToken);
return NoContent();
}
@@ -163,6 +189,7 @@ public sealed class CourseAdjustmentsController(
var adj = await db.CourseAdjustments
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (adj is null) return NotFound();
@@ -176,11 +203,153 @@ public sealed class CourseAdjustmentsController(
adj.Status = CourseAdjustmentStatus.Approved;
adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
// Apply schedule changes
await ApplyScheduleChangesAsync(adj, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await NotifyApplicantAsync(adj, "已通过", cancellationToken);
// Notify applicant
await NotificationService.SendAsync(db, adj.ApplicantUserId,
"调停课申请已通过",
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
"/course-adjustments", cancellationToken);
// Notify affected students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.Student!.UserId)
.Where(uid => uid != null)
.Select(uid => uid!.Value)
.Distinct()
.ToListAsync(cancellationToken);
if (studentUserIds.Count > 0)
{
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"课程变动通知",
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
"/my-timetable", cancellationToken);
}
return NoContent();
}
private async Task ApplyScheduleChangesAsync(
CourseAdjustment adj, CancellationToken ct)
{
switch (adj.Type)
{
case CourseAdjustmentType.Reschedule:
// Update existing schedule entries for this teaching task
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
.ToListAsync(ct);
foreach (var entry in entries)
{
entry.DayOfWeek = adj.DayOfWeek.Value;
entry.StartPeriod = adj.StartPeriod.Value;
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
}
if (adj.ClassroomId.HasValue)
{
foreach (var entry in entries)
entry.ClassroomId = adj.ClassroomId.Value;
}
}
break;
case CourseAdjustmentType.Cancel:
// Cancel: remove schedule entries for the specified week
if (adj.CancelWeek.HasValue)
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
x.StartWeek <= adj.CancelWeek.Value &&
x.EndWeek >= adj.CancelWeek.Value)
.ToListAsync(ct);
foreach (var entry in entries)
{
// Split the entry to exclude the cancelled week
if (entry.StartWeek == adj.CancelWeek.Value &&
entry.EndWeek == adj.CancelWeek.Value)
{
db.ScheduleEntries.Remove(entry);
}
else if (entry.StartWeek == adj.CancelWeek.Value)
{
entry.StartWeek = adj.CancelWeek.Value + 1;
}
else if (entry.EndWeek == adj.CancelWeek.Value)
{
entry.EndWeek = adj.CancelWeek.Value - 1;
}
}
}
break;
case CourseAdjustmentType.Makeup:
// Makeup: add a temp schedule entry for the makeup date
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue &&
adj.StartPeriod.HasValue)
{
var publishedPlan = await db.SchedulePlans
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.FirstOrDefaultAsync(ct);
if (publishedPlan is not null)
{
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = publishedPlan.Id,
TeachingTaskId = adj.TeachingTaskId,
ClassroomId = adj.ClassroomId,
DayOfWeek = adj.DayOfWeek.Value,
StartPeriod = adj.StartPeriod.Value,
PeriodCount = adj.PeriodCount ?? 2,
StartWeek = 1,
EndWeek = 1,
WeekPattern = WeekPattern.All,
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd}"
});
}
}
break;
case CourseAdjustmentType.Substitute:
// Substitute: add substitute teacher to the teaching task
if (adj.SubstituteTeacherId.HasValue)
{
var alreadyExists = await db.TeachingTaskTeachers
.AnyAsync(x =>
x.TeachingTaskId == adj.TeachingTaskId &&
x.TeacherId == adj.SubstituteTeacherId.Value, ct);
if (!alreadyExists)
{
db.TeachingTaskTeachers.Add(new TeachingTaskTeacher
{
TeachingTaskId = adj.TeachingTaskId,
TeacherId = adj.SubstituteTeacherId.Value,
IsPrimary = false
});
}
}
break;
}
}
private static string TypeLabel(CourseAdjustmentType type) => type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
// ═══════════════ Reject ═══════════════
[HttpPost("{id:guid}/reject")]
@@ -208,134 +377,18 @@ public sealed class CourseAdjustmentsController(
adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(cancellationToken);
await NotifyApplicantAsync(adj,
$"已退回" + (adj.ReviewComment is not null ? $"{adj.ReviewComment}" : ""),
cancellationToken);
return NoContent();
}
// ═══════════════ Notifications ═══════════════
[HttpGet("notifications")]
[Authorize]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
return Ok(new
{
Items = await source.OrderByDescending(x => x.CreatedAt)
.Take(50)
.Select(x => new
{
x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt
})
.ToListAsync(cancellationToken),
UnreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken)
});
}
[HttpPost("notifications/{id:guid}/read")]
[Authorize]
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;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("notifications/read-all")]
[Authorize]
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),
cancellationToken);
var msg = adj.ReviewComment is not null
? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}"
: $"您的{TypeLabel(adj.Type)}申请已退回。";
await NotificationService.SendAsync(db, adj.ApplicantUserId,
$"{TypeLabel(adj.Type)}申请已退回", msg,
"/course-adjustments", cancellationToken);
return NoContent();
}
// ═══════════════ Helpers ═══════════════
private async Task NotifyReviewersAsync(
CourseAdjustment adj, CancellationToken ct)
{
var typeLabel = adj.Type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
var taskInfo = adj.TeachingTask is not null
? $"{adj.TeachingTask.Course!.Name}({adj.TeachingTask.TaskNumber})"
: "";
// Notify CollegeAdmin and AcademicAdmin of the course's college
var collegeId = adj.TeachingTask?.Course?.CollegeId;
if (!collegeId.HasValue) return;
var reviewerUserIds = await 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! })
.Where(x =>
(x.RoleName == SystemRoles.AcademicAdmin) ||
(x.RoleName == SystemRoles.CollegeAdmin &&
db.Teachers.Any(t =>
t.UserId == x.Id && t.CollegeId == collegeId)))
.Select(x => x.Id)
.Distinct()
.ToListAsync(ct);
foreach (var reviewerId in reviewerUserIds)
{
db.Notifications.Add(new Notification
{
UserId = reviewerId,
Title = $"新的{typeLabel}申请",
Content = $"{taskInfo} 提交了{typeLabel}申请,请及时审核。",
LinkUrl = "/course-adjustments"
});
}
await db.SaveChangesAsync(ct);
}
private async Task NotifyApplicantAsync(
CourseAdjustment adj, string result, CancellationToken ct)
{
var typeLabel = adj.Type switch
{
CourseAdjustmentType.Reschedule => "调课",
CourseAdjustmentType.Cancel => "停课",
CourseAdjustmentType.Makeup => "补课",
CourseAdjustmentType.Substitute => "代课",
_ => "调停课"
};
db.Notifications.Add(new Notification
{
UserId = adj.ApplicantUserId,
Title = $"{typeLabel}申请{result}",
Content = adj.ReviewComment is not null
? $"您的{typeLabel}申请{result}。审核意见:{adj.ReviewComment}"
: $"您的{typeLabel}申请{result}。",
LinkUrl = "/course-adjustments"
});
await db.SaveChangesAsync(ct);
}
private async Task<ActionResult?> ValidateRequestAsync(
CourseAdjustmentRequest request,
CancellationToken cancellationToken)
+57 -4
View File
@@ -397,7 +397,19 @@ public sealed class GradesController(
sheet.Status = GradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
return await SaveAsync(id, false, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
// Notify college admins
var courseName = sheet.TeachingTask!.Course!.Name;
var collegeId = sheet.TeachingTask.Course.CollegeId;
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
"成绩单待审核",
$"《{courseName}》成绩已提交,请及时审核。",
collegeId,
"/grades",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
@@ -425,7 +437,20 @@ public sealed class GradesController(
sheet.Status = GradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
return await SaveAsync(id, false, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
// Notify teachers
var teacherUserIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩审核通过",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
"/grades", cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
@@ -458,7 +483,19 @@ public sealed class GradesController(
sheet.Status = GradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
return await SaveAsync(id, false, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
var teacherUserIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩被退回",
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
"/grades", cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
@@ -474,7 +511,23 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
// Notify enrolled students
var studentUserIds = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.Student!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"成绩已发布",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
"/grades", cancellationToken);
return NoContent();
}
[HttpGet("student/transcript")]
@@ -0,0 +1,161 @@
using System.Security.Claims;
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
{
[HttpGet]
public async Task<ActionResult> GetNotifications(
bool? unreadOnly,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var userId = currentUserDataScope.Current.UserId;
var source = db.Notifications.AsNoTracking()
.Where(x => x.UserId == userId);
if (unreadOnly == true)
source = source.Where(x => !x.IsRead);
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.IsRead, x.LinkUrl, x.CreatedAt
})
.ToListAsync(cancellationToken);
var unreadCount = await db.Notifications
.CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken);
return Ok(new { Items = items, Total = total, UnreadCount = unreadCount });
}
[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 });
}
[HttpPost("{id:guid}/read")]
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;
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(s => s.SetProperty(x => x.IsRead, true),
cancellationToken);
return NoContent();
}
}
/// <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)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
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)
{
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! });
var userIds = query.Where(x => x.RoleName == roleName);
if (collegeId.HasValue && roleName == SystemRoles.CollegeAdmin)
{
userIds = userIds.Where(x =>
db.Teachers.Any(t =>
t.UserId == x.Id && t.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,
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)
{
foreach (var userId in userIds.Distinct())
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = title,
Content = content,
LinkUrl = linkUrl
});
}
await db.SaveChangesAsync(cancellationToken);
}
}