成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。 选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。 关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
@@ -206,7 +206,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
|
||||
.FirstAsync(ct);
|
||||
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
|
||||
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return Created("", new { gm.Id });
|
||||
}
|
||||
|
||||
@@ -219,7 +221,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
|
||||
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
|
||||
await NotificationService.SendToRoleAsync(
|
||||
db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
"成绩修改待校级审核",
|
||||
$"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》",
|
||||
linkUrl: "/approvals",
|
||||
cancellationToken: ct,
|
||||
category: NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -227,7 +236,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
||||
{
|
||||
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
var gm = await db.GradeModifications
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.Student)
|
||||
.Include(x => x.GradeRecord)
|
||||
.ThenInclude(x => x!.GradeSheet)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (gm is null) return NotFound();
|
||||
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
|
||||
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
|
||||
@@ -235,7 +251,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已通过",
|
||||
"您的成绩修改申请已通过三级审批并生效。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
var studentUserId = gm.GradeRecord.Student?.UserId;
|
||||
if (studentUserId.HasValue &&
|
||||
studentUserId.Value != gm.ApplicantUserId)
|
||||
{
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
studentUserId.Value,
|
||||
"成绩已更新",
|
||||
$"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。",
|
||||
"/grades",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -249,7 +285,14 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
|
||||
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
gm.ApplicantUserId,
|
||||
"成绩修改已驳回",
|
||||
gm.ReviewComment ?? "审核未通过。",
|
||||
"/approvals",
|
||||
ct,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -513,10 +556,10 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
ct);
|
||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); }
|
||||
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); }
|
||||
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); }
|
||||
|
||||
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
|
||||
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
|
||||
|
||||
@@ -793,7 +793,8 @@ public sealed class AttendanceController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"考勤申诉待处理",
|
||||
$"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
|
||||
"/teacher-attendance", cancellationToken);
|
||||
"/teacher-attendance", cancellationToken,
|
||||
NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -905,7 +906,7 @@ public sealed class AttendanceController(
|
||||
request.Comment is not null
|
||||
? $"您的考勤申诉{result}。意见:{request.Comment}"
|
||||
: $"您的考勤申诉{result}。",
|
||||
null, cancellationToken);
|
||||
null, cancellationToken, NotificationCategory.Attendance);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
|
||||
@@ -138,7 +138,8 @@ public sealed class CourseAdjustmentsController(
|
||||
SystemRoles.CollegeAdmin,
|
||||
$"新的{tl}申请",
|
||||
$"《{taskInfo.Name}》提交了{tl}申请,请及时审核。",
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken);
|
||||
taskInfo.CollegeId, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +172,14 @@ public sealed class CourseAdjustmentsController(
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。",
|
||||
adj.TeachingTask.Course.CollegeId,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
await NotificationService.SendToRoleAsync(db,
|
||||
SystemRoles.AcademicAdmin,
|
||||
$"新的{TypeLabel(adj.Type)}申请",
|
||||
$"《{courseName}》提交了{TypeLabel(adj.Type)}申请。",
|
||||
null, "/course-adjustments", cancellationToken);
|
||||
null, "/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -213,7 +216,8 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
"调停课申请已通过",
|
||||
$"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。",
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
|
||||
// Notify affected students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
@@ -230,7 +234,8 @@ public sealed class CourseAdjustmentsController(
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"课程变动通知",
|
||||
$"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。",
|
||||
"/my-timetable", cancellationToken);
|
||||
"/my-timetable", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -383,7 +388,8 @@ public sealed class CourseAdjustmentsController(
|
||||
: $"您的{TypeLabel(adj.Type)}申请已退回。";
|
||||
await NotificationService.SendAsync(db, adj.ApplicantUserId,
|
||||
$"{TypeLabel(adj.Type)}申请已退回", msg,
|
||||
"/course-adjustments", cancellationToken);
|
||||
"/course-adjustments", cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -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("当前账号未关联有效学生档案,请联系教务管理员。");
|
||||
|
||||
|
||||
@@ -411,7 +411,8 @@ public sealed class GradesController(
|
||||
$"《{courseName}》成绩已提交,请及时审核。",
|
||||
collegeId,
|
||||
"/grades",
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -452,7 +453,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩审核通过",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -497,7 +498,7 @@ public sealed class GradesController(
|
||||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||||
"成绩被退回",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||||
"/grades", cancellationToken);
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -505,7 +506,10 @@ public sealed class GradesController(
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
var sheet = await AccessibleSheets()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
@@ -514,13 +518,11 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify enrolled students
|
||||
var studentUserIds = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOffering!.TeachingTaskId == sheet.TeachingTaskId &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
var studentUserIds = await db.GradeRecords
|
||||
.Where(x => x.GradeSheetId == sheet.Id)
|
||||
.Select(x => x.Student!.UserId)
|
||||
.Where(id => id != null)
|
||||
.Select(id => id!.Value)
|
||||
@@ -528,8 +530,8 @@ public sealed class GradesController(
|
||||
.ToListAsync(cancellationToken);
|
||||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||||
"成绩已发布",
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已发布,请查看。",
|
||||
"/grades", cancellationToken);
|
||||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
|
||||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,12 +92,12 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||
var r = rules.First(r => r.Type == w.Type);
|
||||
if (r.NotifyStudent && si.UserId.HasValue)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||
if (r.NotifyCounselor)
|
||||
{
|
||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||
if (counselorId != default)
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,42 @@ public sealed class Notification : EntityBase
|
||||
public Guid UserId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public bool IsRead { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public Guid? MessageDispatchId { get; set; }
|
||||
public MessageDispatch? MessageDispatch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MessageDispatch : EntityBase
|
||||
{
|
||||
public Guid SenderUserId { get; set; }
|
||||
public required string SenderName { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public NotificationCategory Category { get; set; } = NotificationCategory.General;
|
||||
public MessageAudienceType AudienceType { get; set; }
|
||||
public Guid? AudienceId { get; set; }
|
||||
public required string AudienceName { get; set; }
|
||||
public int RecipientCount { get; set; }
|
||||
public string? LinkUrl { get; set; }
|
||||
public ICollection<Notification> Notifications { get; set; } = [];
|
||||
}
|
||||
|
||||
public enum NotificationCategory
|
||||
{
|
||||
General = 1,
|
||||
Approval = 2,
|
||||
Schedule = 3,
|
||||
Grade = 4,
|
||||
Attendance = 5,
|
||||
CourseSelection = 6,
|
||||
Warning = 7
|
||||
}
|
||||
|
||||
public enum MessageAudienceType
|
||||
{
|
||||
School = 1,
|
||||
College = 2,
|
||||
TeachingTask = 3
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
|
||||
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
|
||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||
Set<StudentStatusChange>();
|
||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||
@@ -919,7 +920,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
entity.HasIndex(x => x.MessageDispatchId);
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
.WithMany(x => x.Notifications)
|
||||
.HasForeignKey(x => x.MessageDispatchId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessageDispatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.SenderName).HasMaxLength(100);
|
||||
entity.Property(x => x.Title).HasMaxLength(200);
|
||||
entity.Property(x => x.Content).HasMaxLength(1000);
|
||||
entity.Property(x => x.AudienceName).HasMaxLength(200);
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
|
||||
});
|
||||
|
||||
builder.Entity<AuditLog>(entity =>
|
||||
|
||||
@@ -54,6 +54,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_29_personal_calendar_subscription";
|
||||
private const string OfficialDocumentsMigration =
|
||||
"20260726_30_official_documents";
|
||||
private const string UnifiedMessageCenterMigration =
|
||||
"20260726_31_unified_message_center";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -391,6 +393,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
OfficialDocumentsMigration,
|
||||
officialDocumentsExist ? [] : OfficialDocumentStatements,
|
||||
cancellationToken);
|
||||
|
||||
var messageDispatchesExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'MessageDispatches'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
UnifiedMessageCenterMigration,
|
||||
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1876,4 +1891,30 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
|
||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||
];
|
||||
|
||||
private static readonly string[] UnifiedMessageCenterStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "MessageDispatches" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageDispatches" PRIMARY KEY,
|
||||
"SenderUserId" TEXT NOT NULL,
|
||||
"SenderName" TEXT NOT NULL,
|
||||
"Title" TEXT NOT NULL,
|
||||
"Content" TEXT NOT NULL,
|
||||
"Category" INTEGER NOT NULL,
|
||||
"AudienceType" INTEGER NOT NULL,
|
||||
"AudienceId" TEXT NULL,
|
||||
"AudienceName" TEXT NOT NULL,
|
||||
"RecipientCount" INTEGER NOT NULL,
|
||||
"LinkUrl" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""CREATE INDEX "IX_MessageDispatches_SenderUserId_CreatedAt" ON "MessageDispatches" ("SenderUserId", "CreatedAt");""",
|
||||
"""ALTER TABLE "Notifications" ADD COLUMN "Category" INTEGER NOT NULL DEFAULT 1;""",
|
||||
"""ALTER TABLE "Notifications" ADD COLUMN "MessageDispatchId" TEXT NULL REFERENCES "MessageDispatches" ("Id") ON DELETE CASCADE;""",
|
||||
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
|
||||
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
|
||||
];
|
||||
}
|
||||
|
||||
+4662
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UnifiedMessageCenter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Category",
|
||||
table: "Notifications",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "MessageDispatchId",
|
||||
table: "Notifications",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MessageDispatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SenderUserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SenderName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
|
||||
Category = table.Column<int>(type: "int", nullable: false),
|
||||
AudienceType = table.Column<int>(type: "int", nullable: false),
|
||||
AudienceId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
AudienceName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
|
||||
RecipientCount = table.Column<int>(type: "int", nullable: false),
|
||||
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MessageDispatches", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_MessageDispatchId",
|
||||
table: "Notifications",
|
||||
column: "MessageDispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||
table: "Notifications",
|
||||
columns: new[] { "UserId", "Category", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MessageDispatches_SenderUserId_CreatedAt",
|
||||
table: "MessageDispatches",
|
||||
columns: new[] { "SenderUserId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||
table: "Notifications",
|
||||
column: "MessageDispatchId",
|
||||
principalTable: "MessageDispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MessageDispatches");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Notifications_MessageDispatchId",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Notifications_UserId_Category_CreatedAt",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Category",
|
||||
table: "Notifications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MessageDispatchId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -2210,12 +2210,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("MakeupExamSessionInvigilators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("AudienceId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("AudienceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<int>("AudienceType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("LinkUrl")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<int>("RecipientCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SenderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("varchar(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SenderUserId", "CreatedAt");
|
||||
|
||||
b.ToTable("MessageDispatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
@@ -2231,6 +2292,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<Guid?>("MessageDispatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -2246,8 +2310,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("MessageDispatchId");
|
||||
|
||||
b.HasIndex("UserId", "IsRead");
|
||||
|
||||
b.HasIndex("UserId", "Category", "CreatedAt");
|
||||
|
||||
b.ToTable("Notifications");
|
||||
});
|
||||
|
||||
@@ -4076,6 +4144,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Teacher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch")
|
||||
.WithMany("Notifications")
|
||||
.HasForeignKey("MessageDispatchId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("MessageDispatch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser")
|
||||
@@ -4549,6 +4627,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Invigilators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
|
||||
{
|
||||
b.Navigation("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
|
||||
{
|
||||
b.Navigation("Downloads");
|
||||
|
||||
@@ -75,11 +75,11 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
||||
{
|
||||
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
|
||||
if (rule.NotifyStudent && si.UserId.HasValue)
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
|
||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||
if (rule.NotifyCounselor)
|
||||
{
|
||||
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
||||
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", cancellationToken: ct);
|
||||
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user