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

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
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
@@ -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("当前账号未关联有效学生档案,请联系教务管理员。");
+14 -12
View File
@@ -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");"""
];
}
@@ -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");
}
}
}
@@ -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);
}
}
}