统一通知中心
This commit is contained in:
1 parent
13b61f191a
commit
2c6103d3ae
8 files changed
+552
-144
No files matched your search
@@ -126,7 +126,21 @@ public sealed class CourseAdjustmentsController(
|
|||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
if (request.Submit)
|
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 });
|
return Created(string.Empty, new { adj.Id });
|
||||||
}
|
}
|
||||||
@@ -150,7 +164,19 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.Status = CourseAdjustmentStatus.Submitted;
|
adj.Status = CourseAdjustmentStatus.Submitted;
|
||||||
adj.SubmittedAt = DateTime.UtcNow;
|
adj.SubmittedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +189,7 @@ public sealed class CourseAdjustmentsController(
|
|||||||
var adj = await db.CourseAdjustments
|
var adj = await db.CourseAdjustments
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (adj is null) return NotFound();
|
if (adj is null) return NotFound();
|
||||||
|
|
||||||
@@ -176,11 +203,153 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.Status = CourseAdjustmentStatus.Approved;
|
adj.Status = CourseAdjustmentStatus.Approved;
|
||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
|
|
||||||
|
// Apply schedule changes
|
||||||
|
await ApplyScheduleChangesAsync(adj, cancellationToken);
|
||||||
|
|
||||||
await db.SaveChangesAsync(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();
|
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 ═══════════════
|
// ═══════════════ Reject ═══════════════
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
@@ -208,134 +377,18 @@ public sealed class CourseAdjustmentsController(
|
|||||||
adj.ReviewedAt = DateTime.UtcNow;
|
adj.ReviewedAt = DateTime.UtcNow;
|
||||||
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await NotifyApplicantAsync(adj,
|
|
||||||
$"已退回" + (adj.ReviewComment is not null ? $":{adj.ReviewComment}" : ""),
|
|
||||||
cancellationToken);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════ Notifications ═══════════════
|
var msg = adj.ReviewComment is not null
|
||||||
|
? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}"
|
||||||
[HttpGet("notifications")]
|
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||||
[Authorize]
|
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||||
public async Task<ActionResult> GetNotifications(
|
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||||
bool? unreadOnly,
|
"/course-adjustments", cancellationToken);
|
||||||
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);
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Helpers ═══════════════
|
// ═══════════════ 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(
|
private async Task<ActionResult?> ValidateRequestAsync(
|
||||||
CourseAdjustmentRequest request,
|
CourseAdjustmentRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -397,7 +397,19 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Submitted;
|
sheet.Status = GradeSheetStatus.Submitted;
|
||||||
sheet.SubmittedAt = DateTime.UtcNow;
|
sheet.SubmittedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = null;
|
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")]
|
[HttpPost("sheets/{id:guid}/approve")]
|
||||||
@@ -425,7 +437,20 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Approved;
|
sheet.Status = GradeSheetStatus.Approved;
|
||||||
sheet.ReviewedAt = DateTime.UtcNow;
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = null;
|
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")]
|
[HttpPost("sheets/{id:guid}/return")]
|
||||||
@@ -458,7 +483,19 @@ public sealed class GradesController(
|
|||||||
sheet.Status = GradeSheetStatus.Returned;
|
sheet.Status = GradeSheetStatus.Returned;
|
||||||
sheet.ReviewedAt = DateTime.UtcNow;
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
sheet.ReviewComment = request.Comment.Trim();
|
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")]
|
[HttpPost("sheets/{id:guid}/publish")]
|
||||||
@@ -474,7 +511,23 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
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")]
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 3 of 8 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user