From b0679a253d826d782efb690a61f065126f48fb85 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 26 Jul 2026 19:12:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E7=BB=A9=E9=80=9A=E7=9F=A5=EF=BC=9A?= =?UTF-8?q?=E4=BB=85=E5=9C=A8=E6=9F=90=E9=97=A8=E8=AF=BE=E7=A8=8B=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E6=AD=A3=E5=BC=8F=E5=8F=91=E5=B8=83=E5=90=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E9=80=81=EF=BC=8C=E6=8C=89=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E5=8D=95=E5=AD=A6=E7=94=9F=E5=90=8D=E5=8D=95=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=B1=95=E7=A4=BA=E5=85=B7=E4=BD=93=E5=88=86?= =?UTF-8?q?=E6=95=B0=EF=BC=9B=E5=8F=91=E5=B8=83=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=90=8C=E4=B8=80=E6=AC=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E3=80=82=20=E6=89=8B=E5=8A=A8=E5=8F=91=E4=BF=A1=EF=BC=9A?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E2=80=9C=E6=88=90=E7=BB=A9=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E2=80=9D=E9=80=89=E9=A1=B9=EF=BC=8C=E5=8F=AA=E8=83=BD=E5=8F=91?= =?UTF-8?q?=E9=80=81=E6=99=AE=E9=80=9A=E9=80=9A=E7=9F=A5=EF=BC=8C=E5=8E=9F?= =?UTF-8?q?=E6=9C=89=E8=A7=92=E8=89=B2=E4=B8=8E=E8=8C=83=E5=9B=B4=E6=9D=83?= =?UTF-8?q?=E9=99=90=E4=BF=9D=E6=8C=81=E4=B8=8D=E5=8F=98=E3=80=82=20?= =?UTF-8?q?=E9=80=89=E8=AF=BE=E9=80=9A=E7=9F=A5=EF=BC=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=85=B3=E9=97=AD=E4=B8=80=E8=BD=AE=E9=80=89=E8=AF=BE?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E8=87=AA=E5=8A=A8=E5=90=91=E6=AF=8F=E4=BD=8D?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E5=AD=A6=E7=94=9F=E5=8F=91=E9=80=81=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E6=B1=87=E6=80=BB=E9=80=9A=E7=9F=A5=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E6=9C=80=E7=BB=88=E9=80=89=E4=B8=AD=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E5=8F=8A=E5=80=99=E8=A1=A5=E6=9C=AA=E6=88=90=E5=8A=9F=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=EF=BC=9B=E5=80=99=E8=A1=A5=E5=A4=B1=E6=95=88=E3=80=81?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E5=85=B3=E9=97=AD=E5=92=8C=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=BF=9D=E6=8C=81=E5=8E=9F=E5=AD=90=E6=80=A7?= =?UTF-8?q?=E3=80=82=20=E5=85=B3=E9=97=AD=E9=80=89=E8=AF=BE=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=BC=9A=E6=98=8E=E7=A1=AE=E6=8F=90=E7=A4=BA=E5=8F=91?= =?UTF-8?q?=E9=80=81=E9=80=9A=E7=9F=A5=EF=BC=8C=E5=B9=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E9=80=9A=E7=9F=A5=E4=BA=BA=E6=95=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ApprovalsController.cs | 61 +- .../Controllers/AttendanceController.cs | 5 +- .../CourseAdjustmentsController.cs | 18 +- .../Controllers/CourseSelectionsController.cs | 95 +- .../Controllers/GradesController.cs | 26 +- .../Controllers/NotificationsController.cs | 447 +- .../Controllers/WarningsController.cs | 4 +- .../Domain/Academic/NotificationEntities.cs | 36 + .../Persistence/AppDbContext.cs | 17 + .../Persistence/DevelopmentSqliteMigrator.cs | 41 + ...726200000_UnifiedMessageCenter.Designer.cs | 4662 +++++++++++++++++ .../20260726200000_UnifiedMessageCenter.cs | 102 + .../MySql/AppDbContextModelSnapshot.cs | 83 + .../Scheduling/WarningCheckWorker.cs | 4 +- .../CourseSelectionsControllerTests.cs | 29 +- .../NotificationsControllerTests.cs | 381 ++ web/src/layouts/AdminLayout.vue | 2 +- web/src/views/CourseAdjustmentsView.vue | 57 +- web/src/views/CourseSelectionView.vue | 5 +- web/src/views/NotificationCenterView.vue | 939 +++- 20 files changed, 6804 insertions(+), 210 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.cs create mode 100644 tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/ApprovalsController.cs b/src/Jiaowu.Api/Controllers/ApprovalsController.cs index 36c09fe..d263558 100644 --- a/src/Jiaowu.Api/Controllers/ApprovalsController.cs +++ b/src/Jiaowu.Api/Controllers/ApprovalsController.cs @@ -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 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 => "代课", _ => "调停课" }; diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 020499d..9ee60de 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -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(); diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs index c2448be..f8111be 100644 --- a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -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(); } diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index fdbdde1..056ab72 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -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 selectedCourses, + IReadOnlyCollection unsuccessfulCourses) + { + var parts = new List { $"{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 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("当前账号未关联有效学生档案,请联系教务管理员。"); diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index d2b6ee6..249d5d5 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -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 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(); } diff --git a/src/Jiaowu.Api/Controllers/NotificationsController.cs b/src/Jiaowu.Api/Controllers/NotificationsController.cs index 9c2c076..ea94303 100644 --- a/src/Jiaowu.Api/Controllers/NotificationsController.cs +++ b/src/Jiaowu.Api/Controllers/NotificationsController.cs @@ -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 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 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() + }); + } + + 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() + }); + } + + 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 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 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 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 MarkRead(Guid id, CancellationToken cancellationToken) + public async Task 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 MarkAllRead(CancellationToken cancellationToken) + public async Task 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> 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 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); + /// /// Centralized helper to send notifications across the app. /// @@ -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 }); } diff --git a/src/Jiaowu.Api/Controllers/WarningsController.cs b/src/Jiaowu.Api/Controllers/WarningsController.cs index a8ff740..0477410 100644 --- a/src/Jiaowu.Api/Controllers/WarningsController.cs +++ b/src/Jiaowu.Api/Controllers/WarningsController.cs @@ -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); } } } diff --git a/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs b/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs index 57ce40f..764164c 100644 --- a/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs @@ -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 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 } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 1f1bc07..b0a6844 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -77,6 +77,7 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet EvaluationRecords => Set(); public DbSet EvaluationScores => Set(); public DbSet Notifications => Set(); + public DbSet MessageDispatches => Set(); public DbSet StudentStatusChanges => Set(); public DbSet GraduationAuditBatches => @@ -919,7 +920,23 @@ public sealed class AppDbContext(DbContextOptions 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(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(entity => diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index e0fe6f2..476ef0e 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -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( + """ + 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");""" + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.Designer.cs new file mode 100644 index 0000000..70af788 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.Designer.cs @@ -0,0 +1,4662 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726200000_UnifiedMessageCenter")] + partial class UnifiedMessageCenter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ArchivedAt") + .HasColumnType("datetime(6)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsArchived") + .HasColumnType("tinyint(1)"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsArchived"); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CounselorUserId"); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b => + { + b.Property("AttendanceSheetId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("AppealReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("AppealReviewComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AppealReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppealStatus") + .HasColumnType("int"); + + b.Property("AppealSubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInAccuracyMeters") + .HasColumnType("double"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInDistanceMeters") + .HasColumnType("double"); + + b.Property("CheckInLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckedInMethod") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("AttendanceSheetId", "StudentId"); + + b.HasIndex("AppealStatus"); + + b.HasIndex("StudentId"); + + b.ToTable("AttendanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AttendanceDate") + .HasColumnType("datetime(6)"); + + b.Property("CheckInEndsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInMethod") + .HasColumnType("int"); + + b.Property("CheckInStartsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInToken") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LocationRadiusMeters") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TargetLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CheckInToken") + .IsUnique(); + + b.HasIndex("TeachingTaskId", "AttendanceDate"); + + b.ToTable("AttendanceSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CourseCategoryId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CourseCategoryId"); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CancelDate") + .HasColumnType("date"); + + b.Property("CancelWeek") + .HasColumnType("int"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteTeacherId") + .HasColumnType("char(36)"); + + b.Property("TargetDate") + .HasColumnType("date"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantUserId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("SubstituteTeacherId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("TeachingTaskId", "Status"); + + b.ToTable("CourseAdjustments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("CourseCategories"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentType") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WaitlistedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt") + .HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseExemptions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCourseCount") + .HasColumnType("int"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Grade"); + + b.HasIndex("CourseSelectionRoundId", "Grade") + .IsUnique(); + + b.ToTable("CourseSelectionRoundGrades"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalCourseId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteCourseId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OriginalCourseId"); + + b.HasIndex("SubstituteCourseId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "OriginalCourseId") + .IsUnique(); + + b.ToTable("CourseSubstitutions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("DeferredExams"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("MinimumGradePoint") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("DegreeAwardBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AverageGradePoint") + .HasPrecision(4, 2) + .HasColumnType("decimal(4,2)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeAwardBatchId") + .HasColumnType("char(36)"); + + b.Property("ExceptionReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("GraduationAuditResultId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationAuditResultId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("DegreeAwardBatchId", "StudentId") + .IsUnique(); + + b.ToTable("DegreeAwardResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("MaxScore") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationSetupId", "SortOrder"); + + b.ToTable("EvaluationDimensions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("EvaluationSetupId", "StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("EvaluationRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.Property("EvaluationRecordId") + .HasColumnType("char(36)"); + + b.Property("EvaluationDimensionId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("EvaluationRecordId", "EvaluationDimensionId"); + + b.HasIndex("EvaluationDimensionId"); + + b.ToTable("EvaluationScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("EvaluationSetups"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "ExamDate"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Weight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "SortOrder"); + + b.ToTable("GradeItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("GradeItemId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("GradeRecordId", "GradeItemId"); + + b.HasIndex("GradeItemId"); + + b.ToTable("GradeItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CollegeReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollegeReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("FinalReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestedScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GradeModifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "TotalScore"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("GraduationYear", "EnrollmentYear"); + + b.ToTable("GraduationAuditBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("EarnedCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("FailedCourseCount") + .HasColumnType("int"); + + b.Property("GraduationAuditBatchId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("MissingCourseNames") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("PassedRequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("StudentStatusSnapshot") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("GraduationAuditBatchId", "StudentId") + .IsUnique(); + + b.ToTable("GraduationAuditResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("GraduationClearanceBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceBatchId") + .HasColumnType("char(36)"); + + b.Property("IsRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ResponsibleRole") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ResponsibleUnit") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceBatchId", "Code") + .IsUnique(); + + b.ToTable("GraduationClearanceItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceItemId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceItemId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("GraduationClearanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedSessions") + .HasColumnType("int"); + + b.Property("EnrolledStudents") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedCourses") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCourses") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MakeupExamPlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("MakeupExamAutoJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("MakeupScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("SourceDeferredExamId") + .HasColumnType("char(36)"); + + b.Property("SourceGradeRecordId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "StudentId"); + + b.HasIndex("SourceDeferredExamId"); + + b.HasIndex("SourceGradeRecordId"); + + b.HasIndex("StudentId", "MakeupExamSessionId"); + + b.ToTable("MakeupExamEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("MakeupExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("MakeupExamPlanId", "ExamDate"); + + b.HasIndex("MakeupExamPlanId", "StartsAt"); + + b.ToTable("MakeupExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("MakeupExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AudienceId") + .HasColumnType("char(36)"); + + b.Property("AudienceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RecipientCount") + .HasColumnType("int"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SenderUserId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SenderUserId", "CreatedAt"); + + b.ToTable("MessageDispatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MessageDispatchId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("MessageDispatchId"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "Category", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvalidatedAt") + .HasColumnType("datetime(6)"); + + b.Property("InvalidatedByUserId") + .HasColumnType("char(36)"); + + b.Property("InvalidationReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.Property("IssuedByUserId") + .HasColumnType("char(36)"); + + b.Property("PdfContent") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("PdfSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReissuedFromDocumentId") + .HasColumnType("char(36)"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerificationCodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentNumber") + .IsUnique(); + + b.HasIndex("InvalidatedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("ReissuedFromDocumentId") + .IsUnique(); + + b.HasIndex("VerificationCodeHash") + .IsUnique(); + + b.HasIndex("Status", "IssuedAt"); + + b.HasIndex("StudentId", "IssuedAt"); + + b.ToTable("OfficialDocuments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DownloadedByUserId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OfficialDocumentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("DownloadedByUserId", "CreatedAt"); + + b.HasIndex("OfficialDocumentId", "CreatedAt"); + + b.ToTable("OfficialDocumentDownloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "PeriodNumber") + .IsUnique(); + + b.ToTable("ScheduleTimeSlots"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("SchedulingMode") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AcknowledgeComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TriggerValue") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StudentId", "AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AutoCheckEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CheckDayOfWeek") + .HasColumnType("int"); + + b.Property("CheckHour") + .HasColumnType("int"); + + b.Property("CheckMinute") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastCheckAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotifyCounselor") + .HasColumnType("tinyint(1)"); + + b.Property("NotifyStudent") + .HasColumnType("tinyint(1)"); + + b.Property("Threshold") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CalendarSubscriptionCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CalendarSubscriptionStamp") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet") + .WithMany("Records") + .HasForeignKey("AttendanceSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory") + .WithMany() + .HasForeignKey("CourseCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("College"); + + b.Navigation("CourseCategory"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "SubstituteTeacher") + .WithMany() + .HasForeignKey("SubstituteTeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SubstituteTeacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("EligibleGrades") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse") + .WithMany() + .HasForeignKey("OriginalCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "SubstituteCourse") + .WithMany() + .HasForeignKey("SubstituteCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OriginalCourse"); + + b.Navigation("Student"); + + b.Navigation("SubstituteCourse"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch") + .WithMany("Results") + .HasForeignKey("DegreeAwardBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult") + .WithMany() + .HasForeignKey("GraduationAuditResultId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DegreeAwardBatch"); + + b.Navigation("GraduationAuditResult"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Dimensions") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Records") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationDimension", "EvaluationDimension") + .WithMany("Scores") + .HasForeignKey("EvaluationDimensionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationRecord", "EvaluationRecord") + .WithMany("Scores") + .HasForeignKey("EvaluationRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationDimension"); + + b.Navigation("EvaluationRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Items") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeSheet"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeItem", "GradeItem") + .WithMany("Scores") + .HasForeignKey("GradeItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany("ItemScores") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeItem"); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany() + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Records") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany() + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch") + .WithMany("Results") + .HasForeignKey("GraduationAuditBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + + b.Navigation("GraduationAuditBatch"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch") + .WithMany("Items") + .HasForeignKey("GraduationClearanceBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GraduationClearanceBatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem") + .WithMany("Records") + .HasForeignKey("GraduationClearanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GraduationClearanceItem"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany() + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MakeupExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Enrollments") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.DeferredExam", "SourceDeferredExam") + .WithMany() + .HasForeignKey("SourceDeferredExamId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "SourceGradeRecord") + .WithMany() + .HasForeignKey("SourceGradeRecordId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("SourceDeferredExam"); + + b.Navigation("SourceGradeRecord"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany("Sessions") + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("MakeupExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Invigilators") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + 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") + .WithMany() + .HasForeignKey("InvalidatedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument") + .WithMany() + .HasForeignKey("ReissuedFromDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvalidatedByUser"); + + b.Navigation("IssuedByUser"); + + b.Navigation("ReissuedFromDocument"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser") + .WithMany() + .HasForeignKey("DownloadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument") + .WithMany("Downloads") + .HasForeignKey("OfficialDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DownloadedByUser"); + + b.Navigation("OfficialDocument"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("EligibleGrades"); + + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Navigation("Dimensions"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Navigation("ItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Items"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Navigation("Enrollments"); + + 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"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.cs new file mode 100644 index 0000000..aaccc0e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726200000_UnifiedMessageCenter.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class UnifiedMessageCenter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Category", + table: "Notifications", + type: "int", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "MessageDispatchId", + table: "Notifications", + type: "char(36)", + nullable: true); + + migrationBuilder.CreateTable( + name: "MessageDispatches", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + SenderUserId = table.Column(type: "char(36)", nullable: false), + SenderName = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + Title = table.Column(type: "varchar(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false), + Category = table.Column(type: "int", nullable: false), + AudienceType = table.Column(type: "int", nullable: false), + AudienceId = table.Column(type: "char(36)", nullable: true), + AudienceName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false), + RecipientCount = table.Column(type: "int", nullable: false), + LinkUrl = table.Column(type: "varchar(300)", maxLength: 300, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index d41640b..44067a6 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -2210,12 +2210,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("MakeupExamSessionInvigilators"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AudienceId") + .HasColumnType("char(36)"); + + b.Property("AudienceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RecipientCount") + .HasColumnType("int"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SenderUserId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SenderUserId", "CreatedAt"); + + b.ToTable("MessageDispatches"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); + b.Property("Category") + .HasColumnType("int"); + b.Property("Content") .IsRequired() .HasMaxLength(1000) @@ -2231,6 +2292,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql .HasMaxLength(300) .HasColumnType("varchar(300)"); + b.Property("MessageDispatchId") + .HasColumnType("char(36)"); + b.Property("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"); diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs index f304bbf..ad69b03 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/WarningCheckWorker.cs @@ -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); } } } diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs index 6d21744..bff206d 100644 --- a/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs @@ -95,18 +95,31 @@ public sealed class CourseSelectionsControllerTests var adminController = new CourseSelectionsController( db, new AdminDataScope()); - Assert.IsType(await adminController.CloseRound( + var closeResult = Assert.IsType(await adminController.CloseRound( data.RoundId, CancellationToken.None)); + Assert.Equal(2, ReadIntProperty(closeResult.Value, "NotifiedStudentCount")); db.ChangeTracker.Clear(); var waitlist = await db.CourseEnrollments.SingleAsync( x => x.StudentId == data.FirstWaiterStudentId); Assert.Equal(CourseEnrollmentStatus.Expired, waitlist.Status); Assert.NotNull(waitlist.WithdrawnAt); - Assert.True(await db.Notifications.AnyAsync(x => - x.UserId == data.FirstWaiterUserId && - x.Title == "课程候补已结束")); + var resultNotifications = await db.Notifications + .Where(x => x.Title == "第一轮选课结果") + .OrderBy(x => x.UserId) + .ToListAsync(); + Assert.Equal(2, resultNotifications.Count); + var selectedNotification = Assert.Single(resultNotifications + .Where(x => x.UserId == data.EnrolledUserId)); + Assert.Contains("最终选中 1 门", selectedNotification.Content); + Assert.Contains("《程序设计基础》", selectedNotification.Content); + var waitlistNotification = Assert.Single(resultNotifications + .Where(x => x.UserId == data.FirstWaiterUserId)); + Assert.Contains("本轮未选中课程", waitlistNotification.Content); + Assert.Contains("候补未成功 1 门", waitlistNotification.Content); + Assert.Equal(NotificationCategory.CourseSelection, waitlistNotification.Category); + Assert.Equal("/course-selections", waitlistNotification.LinkUrl); } [Fact] @@ -337,6 +350,14 @@ public sealed class CourseSelectionsControllerTests DisplayName = displayName }; + private static int ReadIntProperty(object? value, string propertyName) + { + Assert.NotNull(value); + var property = value.GetType().GetProperty(propertyName); + Assert.NotNull(property); + return Assert.IsType(property.GetValue(value)); + } + private static Student CreateStudent( string number, string name, diff --git a/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs b/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs new file mode 100644 index 0000000..f634d45 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs @@ -0,0 +1,381 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class NotificationsControllerTests +{ + [Fact] + public async Task College_administrator_can_only_send_to_enabled_users_in_own_college() + { + await using var fixture = await NotificationFixture.CreateAsync(); + var controller = new NotificationsController( + fixture.Db, + new TestDataScope( + fixture.Sender.Id, + fixture.FirstCollege.Id, + SystemRoles.CollegeAdmin)); + + var result = await controller.Send( + new SendMessageRequest( + "学院通知", + "请按时完成教学材料提交。"), + CancellationToken.None); + + Assert.IsType(result); + var notifications = await fixture.Db.Notifications + .Include(x => x.MessageDispatch) + .ToListAsync(); + Assert.Equal( + new[] + { + fixture.ClassStudentUser.Id, + fixture.CollegeRecipient.Id, + fixture.TeacherUser.Id + }.Order(), + notifications.Select(x => x.UserId).Order()); + var dispatch = Assert.Single( + notifications.Select(x => x.MessageDispatch).Distinct()); + Assert.Equal("第一学院全院成员", dispatch!.AudienceName); + Assert.Equal(3, dispatch.RecipientCount); + } + + [Fact] + public async Task Teacher_can_send_notice_only_to_own_teaching_class() + { + await using var fixture = await NotificationFixture.CreateAsync(); + var controller = new NotificationsController( + fixture.Db, + new TestDataScope( + fixture.TeacherUser.Id, + fixture.FirstCollege.Id, + SystemRoles.Teacher)); + + var result = await controller.Send( + new SendMessageRequest( + "课程资料提醒", + "请在规定时间内完成课程资料提交。", + fixture.OwnTask.Id), + CancellationToken.None); + + Assert.IsType(result); + var notification = Assert.Single(await fixture.Db.Notifications + .Include(x => x.MessageDispatch) + .ToListAsync()); + Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId); + Assert.Equal(NotificationCategory.General, notification.Category); + Assert.Null(notification.LinkUrl); + Assert.Equal( + MessageAudienceType.TeachingTask, + notification.MessageDispatch!.AudienceType); + + fixture.Db.Notifications.Remove(notification); + fixture.Db.MessageDispatches.Remove(notification.MessageDispatch); + await fixture.Db.SaveChangesAsync(); + + var forbiddenTask = await controller.Send( + new SendMessageRequest( + "越权消息", + "不应发送。", + fixture.OtherTask.Id), + CancellationToken.None); + + Assert.IsType(forbiddenTask); + Assert.Empty(await fixture.Db.Notifications.ToListAsync()); + } + + [Fact] + public async Task School_administrator_reaches_all_enabled_accounts_except_self() + { + await using var fixture = await NotificationFixture.CreateAsync(); + var controller = new NotificationsController( + fixture.Db, + new TestDataScope( + fixture.Sender.Id, + fixture.FirstCollege.Id, + SystemRoles.AcademicAdmin)); + + var result = await controller.Send( + new SendMessageRequest( + "全校通知", + "本周五进行系统维护。"), + CancellationToken.None); + + Assert.IsType(result); + var recipientIds = await fixture.Db.Notifications + .Select(x => x.UserId) + .ToListAsync(); + Assert.DoesNotContain(fixture.Sender.Id, recipientIds); + Assert.DoesNotContain(fixture.DisabledRecipient.Id, recipientIds); + Assert.Contains(fixture.CollegeRecipient.Id, recipientIds); + Assert.Contains(fixture.OutsideRecipient.Id, recipientIds); + Assert.Contains(fixture.TeacherUser.Id, recipientIds); + Assert.Contains(fixture.ClassStudentUser.Id, recipientIds); + } + + [Fact] + public async Task Publishing_course_grades_automatically_notifies_roster_students() + { + await using var fixture = await NotificationFixture.CreateAsync(); + var studentId = await fixture.Db.Students + .Where(x => x.UserId == fixture.ClassStudentUser.Id) + .Select(x => x.Id) + .SingleAsync(); + var sheet = new GradeSheet + { + TeachingTaskId = fixture.OwnTask.Id, + Status = GradeSheetStatus.Approved, + Records = + [ + new GradeRecord + { + StudentId = studentId, + RegularScore = 88, + FinalScore = 92, + TotalScore = 90.8m, + GradePoint = 4 + } + ] + }; + fixture.Db.GradeSheets.Add(sheet); + await fixture.Db.SaveChangesAsync(); + fixture.Db.ChangeTracker.Clear(); + + var controller = new GradesController( + fixture.Db, + new TestDataScope( + fixture.Sender.Id, + fixture.FirstCollege.Id, + SystemRoles.AcademicAdmin)); + + Assert.IsType(await controller.Publish( + sheet.Id, + CancellationToken.None)); + + var notification = Assert.Single(await fixture.Db.Notifications + .Where(x => x.UserId == fixture.ClassStudentUser.Id) + .ToListAsync()); + Assert.Equal("成绩已发布", notification.Title); + Assert.Contains("《测试课程》", notification.Content); + Assert.DoesNotContain("90.8", notification.Content); + Assert.Equal(NotificationCategory.Grade, notification.Category); + Assert.Equal("/grades", notification.LinkUrl); + } + + private sealed class TestDataScope( + Guid userId, + Guid? collegeId, + params string[] roles) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + userId, + "测试发送人", + collegeId, + EffectiveDataScopeResolver.Resolve(roles), + roles.ToHashSet(StringComparer.OrdinalIgnoreCase)); + } + + private sealed class NotificationFixture : IAsyncDisposable + { + private readonly SqliteConnection connection; + + private NotificationFixture( + SqliteConnection connection, + AppDbContext db, + College firstCollege, + ApplicationUser sender, + ApplicationUser collegeRecipient, + ApplicationUser outsideRecipient, + ApplicationUser disabledRecipient, + ApplicationUser teacherUser, + ApplicationUser classStudentUser, + TeachingTask ownTask, + TeachingTask otherTask) + { + this.connection = connection; + Db = db; + FirstCollege = firstCollege; + Sender = sender; + CollegeRecipient = collegeRecipient; + OutsideRecipient = outsideRecipient; + DisabledRecipient = disabledRecipient; + TeacherUser = teacherUser; + ClassStudentUser = classStudentUser; + OwnTask = ownTask; + OtherTask = otherTask; + } + + public AppDbContext Db { get; } + public College FirstCollege { get; } + public ApplicationUser Sender { get; } + public ApplicationUser CollegeRecipient { get; } + public ApplicationUser OutsideRecipient { get; } + public ApplicationUser DisabledRecipient { get; } + public ApplicationUser TeacherUser { get; } + public ApplicationUser ClassStudentUser { get; } + public TeachingTask OwnTask { get; } + public TeachingTask OtherTask { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var firstCollege = new College + { + Code = "C01", + Name = "第一学院" + }; + var secondCollege = new College + { + Code = "C02", + Name = "第二学院" + }; + var users = Enumerable.Range(1, 6) + .Select(index => new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = $"user{index}", + NormalizedUserName = $"USER{index}", + DisplayName = $"用户{index}", + CollegeId = index == 3 ? secondCollege.Id : firstCollege.Id, + IsEnabled = index != 4 + }) + .ToArray(); + var sender = users[0]; + var collegeRecipient = users[1]; + var outsideRecipient = users[2]; + var disabledRecipient = users[3]; + var teacherUser = users[4]; + var classStudentUser = users[5]; + + var major = new Major + { + Code = "M01", + Name = "测试专业", + CollegeId = firstCollege.Id, + DegreeType = "本科", + SchoolingYears = 4 + }; + var administrativeClass = new AdministrativeClass + { + Code = "CL01", + Name = "测试行政班", + MajorId = major.Id, + Grade = 2025 + }; + var student = new Student + { + StudentNumber = "20250001", + Name = "测试学生", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2025, + EnrollmentDate = new DateOnly(2025, 9, 1), + UserId = classStudentUser.Id + }; + var teacher = new Teacher + { + TeacherNumber = "T001", + Name = "测试教师", + CollegeId = firstCollege.Id, + UserId = teacherUser.Id + }; + var term = new AcademicTerm + { + Code = "2025-A", + Name = "2025 秋季学期", + AcademicYear = "2025-2026", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2025, 9, 1), + EndDate = new DateOnly(2026, 1, 20), + IsCurrent = true + }; + var course = new Course + { + Code = "COURSE-1", + Name = "测试课程", + CollegeId = firstCollege.Id, + Credits = 2, + TotalHours = 32, + LectureHours = 32, + PracticeHours = 0, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }; + var ownTask = new TeachingTask + { + TaskNumber = "TASK-1", + Name = "测试课程教学班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 30, + Status = TeachingTaskStatus.Published + }; + ownTask.Teachers.Add(new TeachingTaskTeacher + { + TeachingTaskId = ownTask.Id, + TeacherId = teacher.Id, + IsPrimary = true + }); + ownTask.Classes.Add(new TeachingTaskClass + { + TeachingTaskId = ownTask.Id, + AdministrativeClassId = administrativeClass.Id + }); + var otherTask = new TeachingTask + { + TaskNumber = "TASK-2", + Name = "其他教学班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 30, + Status = TeachingTaskStatus.Published + }; + + db.AddRange(firstCollege, secondCollege, term); + db.Users.AddRange(users); + await db.SaveChangesAsync(); + db.Add(major); + await db.SaveChangesAsync(); + db.Add(course); + await db.SaveChangesAsync(); + db.Add(teacher); + await db.SaveChangesAsync(); + db.Add(administrativeClass); + await db.SaveChangesAsync(); + db.AddRange(student, ownTask, otherTask); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + return new NotificationFixture( + connection, + db, + firstCollege, + sender, + collegeRecipient, + outsideRecipient, + disabledRecipient, + teacherUser, + classStudentUser, + ownTask, + otherTask); + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 7f6c122..438a2e8 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -282,7 +282,7 @@ onMounted(() => {
- +
{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}
diff --git a/web/src/views/CourseAdjustmentsView.vue b/web/src/views/CourseAdjustmentsView.vue index 29d9ada..95e032e 100644 --- a/web/src/views/CourseAdjustmentsView.vue +++ b/web/src/views/CourseAdjustmentsView.vue @@ -1,6 +1,6 @@