成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
2026-07-26 19:12:42 +08:00 Unverified
parent ecc6546f3a
commit b0679a253d
20 changed files with 6804 additions and 210 deletions
@@ -218,38 +218,67 @@ public sealed class CourseSelectionsController(
if (round.Status != CourseSelectionRoundStatus.Open)
return ConflictProblem("只有开放中的选课批次可以关闭。");
var waitlisted = await db.CourseEnrollments
var enrollments = await db.CourseEnrollments
.Include(x => x.Student)
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x =>
x.CourseSelectionOffering!.CourseSelectionRoundId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted)
x.CourseSelectionOffering!.CourseSelectionRoundId == id)
.ToListAsync(cancellationToken);
var waitlisted = enrollments
.Where(x => x.Status == CourseEnrollmentStatus.Waitlisted)
.ToList();
var now = DateTime.UtcNow;
foreach (var enrollment in waitlisted)
{
enrollment.Status = CourseEnrollmentStatus.Expired;
enrollment.WithdrawnAt = now;
if (enrollment.Student!.UserId is Guid userId)
}
var participantGroups = enrollments
.Where(x => x.Student!.UserId.HasValue)
.GroupBy(x => new
{
db.Notifications.Add(new Notification
{
UserId = userId,
Title = "课程候补已结束",
Content =
$"“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" +
"选课批次已关闭,本次候补未获得名额。",
LinkUrl = "/course-selections"
});
}
x.StudentId,
UserId = x.Student!.UserId!.Value
})
.ToList();
foreach (var participant in participantGroups)
{
var selectedCourses = participant
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name)
.Distinct()
.OrderBy(x => x)
.ToArray();
var unsuccessfulCourses = participant
.Where(x => x.Status == CourseEnrollmentStatus.Expired)
.Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name)
.Distinct()
.OrderBy(x => x)
.ToArray();
db.Notifications.Add(new Notification
{
UserId = participant.Key.UserId,
Title = $"{round.Name}结果",
Content = BuildRoundResultContent(
round.Name,
selectedCourses,
unsuccessfulCourses),
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
round.Status = CourseSelectionRoundStatus.Closed;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { ExpiredWaitlistCount = waitlisted.Count });
return Ok(new
{
ExpiredWaitlistCount = waitlisted.Count,
NotifiedStudentCount = participantGroups.Count
});
},
cancellationToken,
IsolationLevel.Serializable);
@@ -940,6 +969,7 @@ public sealed class CourseSelectionsController(
Content =
$"管理员已将你移出“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" +
"候补队列。",
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1835,6 +1865,7 @@ public sealed class CourseSelectionsController(
Content =
$"“{offering.TeachingTask!.Course!.Name}”候补未能递补:" +
eligibility.Error,
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1869,6 +1900,7 @@ public sealed class CourseSelectionsController(
Content =
$"“{offering.TeachingTask.Course!.Name}”已释放名额," +
"你已自动进入正式选课名单。",
Category = NotificationCategory.CourseSelection,
LinkUrl = "/course-selections"
});
}
@@ -1918,6 +1950,39 @@ public sealed class CourseSelectionsController(
}
}
private static string BuildRoundResultContent(
string roundName,
IReadOnlyCollection<string> selectedCourses,
IReadOnlyCollection<string> unsuccessfulCourses)
{
var parts = new List<string> { $"{roundName}已结束。" };
parts.Add(selectedCourses.Count == 0
? "本轮未选中课程。"
: $"最终选中 {selectedCourses.Count} 门:{FormatCourseNames(selectedCourses)}。");
if (unsuccessfulCourses.Count > 0)
{
parts.Add(
$"候补未成功 {unsuccessfulCourses.Count} 门:" +
$"{FormatCourseNames(unsuccessfulCourses)}。");
}
return string.Concat(parts);
}
private static string FormatCourseNames(IReadOnlyCollection<string> courses)
{
const int visibleCount = 8;
const int visibleNameLength = 40;
var names = courses.Take(visibleCount)
.Select(name => name.Length <= visibleNameLength
? $"《{name}》"
: $"《{name[..visibleNameLength]}…》");
var result = string.Join("、", names);
return courses.Count > visibleCount
? $"{result}等 {courses.Count} 门课程"
: result;
}
private ActionResult ProfileNotFound() =>
ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。");