Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 27m29s
实际开发库验证已返回 CS101 程序设计基础。 课程替代改为“修读中/未通过课程 → 已发布及格课程”的双栏选择。 新增搜索、教学班号、学期、教师、学分、课表状态和完整空状态说明。 新增课程替代申请记录。 提交接口增加服务端资格校验,不能伪造课程 ID 绕过前端。
563 lines
34 KiB
C#
563 lines
34 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using Jiaowu.Api.Domain.Academic;
|
||
using Jiaowu.Api.Domain.Identity;
|
||
using Jiaowu.Api.Infrastructure.Auth;
|
||
using Jiaowu.Api.Infrastructure.Grades;
|
||
using Jiaowu.Api.Infrastructure.Persistence;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace Jiaowu.Api.Controllers;
|
||
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/approvals")]
|
||
public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||
{
|
||
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin;
|
||
private const string Teachers = Managers + "," + SystemRoles.Teacher;
|
||
private const string Students = Managers + "," + SystemRoles.Student;
|
||
|
||
// ═══════════════ Aggregated pending ═══════════════
|
||
[HttpGet("pending")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> GetPending(CancellationToken ct)
|
||
{
|
||
var items = new List<ApprovalItem>();
|
||
|
||
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
||
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
||
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
||
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
||
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
||
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
||
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
||
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
||
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||
.ToListAsync(ct));
|
||
|
||
return Ok(items.OrderByDescending(x => x.Time).ToList());
|
||
}
|
||
|
||
// ═══════════════ Course Exemption ═══════════════
|
||
[HttpGet("exemptions/mine")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetMyExemptions(CancellationToken ct)
|
||
{
|
||
var studentId = await GetStudentIdAsync(ct);
|
||
if (studentId is null) return StudentNotFound();
|
||
return Ok(await db.CourseExemptions.AsNoTracking()
|
||
.Where(x => x.StudentId == studentId).OrderByDescending(x => x.CreatedAt)
|
||
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name })
|
||
.ToListAsync(ct));
|
||
}
|
||
|
||
[HttpPost("exemptions")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> ApplyExemption(ExemptionRequest req, CancellationToken ct)
|
||
{
|
||
var studentId = await GetStudentIdAsync(ct);
|
||
if (studentId is null) return StudentNotFound();
|
||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||
studentId.Value, req.TeachingTaskId, ct))
|
||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||
return ConflictProblem("该课程已有免修申请在审核中。");
|
||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||
db.CourseExemptions.Add(ex);
|
||
await db.SaveChangesAsync(ct);
|
||
// Reload to get nav properties for notification
|
||
var info = await db.CourseExemptions.Where(x => x.Id == ex.Id)
|
||
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
|
||
.FirstAsync(ct);
|
||
await NotifyManagers("免修申请", $"{info.StudentName} 申请免修《{info.CourseName}》", ct);
|
||
return Created("", new { ex.Id });
|
||
}
|
||
|
||
[HttpPost("exemptions/{id:guid}/approve")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> ApproveExemption(Guid id, CancellationToken ct)
|
||
{
|
||
var ex = await db.CourseExemptions.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||
if (ex is null) return NotFound();
|
||
ex.Status = ApprovalStatus.Approved; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewedByUserId = scope.Current.UserId;
|
||
// Auto-apply: mark grade record as exempt
|
||
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == ex.TeachingTaskId, ct);
|
||
if (sheet is not null)
|
||
{
|
||
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == ex.StudentId, ct);
|
||
if (record is not null) { record.ExamStatus = GradeExamStatus.Exempt; record.RegularScore = null; record.FinalScore = null; record.TotalScore = null; record.GradePoint = null; }
|
||
}
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(ex.StudentId, "免修申请已通过", $"《{ex.TeachingTask!.Course!.Name}》免修已批准。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("exemptions/{id:guid}/reject")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> RejectExemption(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
|
||
{
|
||
var ex = await db.CourseExemptions.FindAsync([id], ct);
|
||
if (ex is null) return NotFound();
|
||
ex.Status = ApprovalStatus.Rejected; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewComment = body.Comment?.Trim(); ex.ReviewedByUserId = scope.Current.UserId;
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(ex.StudentId, "免修申请已驳回", ex.ReviewComment ?? "审核未通过。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
// ═══════════════ Deferred Exam ═══════════════
|
||
[HttpGet("deferred/mine")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetMyDeferred(CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
return Ok(await db.DeferredExams.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name }).ToListAsync(ct));
|
||
}
|
||
|
||
[HttpPost("deferred")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> ApplyDeferred(ExemptionRequest req, CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||
sid.Value, req.TeachingTaskId, ct))
|
||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||
db.DeferredExams.Add(d);
|
||
await db.SaveChangesAsync(ct);
|
||
var info = await db.DeferredExams.Where(x => x.Id == d.Id)
|
||
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
|
||
.FirstAsync(ct);
|
||
await NotifyManagers("缓考申请", $"{info.StudentName} 申请《{info.CourseName}》缓考", ct);
|
||
return Created("", new { d.Id });
|
||
}
|
||
|
||
[HttpPost("deferred/{id:guid}/approve")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> ApproveDeferred(Guid id, CancellationToken ct)
|
||
{
|
||
var d = await db.DeferredExams.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||
if (d is null) return NotFound();
|
||
d.Status = ApprovalStatus.Approved; d.ReviewedAt = DateTime.UtcNow; d.ReviewedByUserId = scope.Current.UserId;
|
||
// Auto-apply: set grade record to deferred
|
||
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == d.TeachingTaskId, ct);
|
||
if (sheet is not null)
|
||
{
|
||
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == d.StudentId, ct);
|
||
if (record is not null) record.ExamStatus = GradeExamStatus.Deferred;
|
||
}
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(d.StudentId, "缓考申请已通过", $"《{d.TeachingTask!.Course!.Name}》缓考已批准。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("deferred/{id:guid}/reject")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> RejectDeferred(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
|
||
{
|
||
var d = await db.DeferredExams.FindAsync([id], ct);
|
||
if (d is null) return NotFound();
|
||
d.Status = ApprovalStatus.Rejected; d.ReviewedAt = DateTime.UtcNow; d.ReviewComment = body.Comment?.Trim(); d.ReviewedByUserId = scope.Current.UserId;
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(d.StudentId, "缓考申请已驳回", d.ReviewComment ?? "审核未通过。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
// ═══════════════ Grade Modification (3-level) ═══════════════
|
||
[HttpPost("grade-modifications")]
|
||
[Authorize(Roles = Teachers)]
|
||
public async Task<ActionResult> ApplyGradeMod(GradeModRequest req, CancellationToken ct)
|
||
{
|
||
var record = await db.GradeRecords.Include(x => x.GradeSheet).FirstOrDefaultAsync(x => x.Id == req.GradeRecordId, ct);
|
||
if (record is null) return NotFound();
|
||
if (record.GradeSheet!.Status != GradeSheetStatus.Published)
|
||
return ConflictProblem("只能修改已发布的成绩。");
|
||
var gm = new GradeModification { GradeRecordId = req.GradeRecordId, CurrentScore = record.TotalScore ?? 0, RequestedScore = req.RequestedScore, Reason = req.Reason.Trim(), ApplicantUserId = scope.Current.UserId };
|
||
db.GradeModifications.Add(gm);
|
||
await db.SaveChangesAsync(ct);
|
||
var gmInfo = await db.GradeModifications.Where(x => x.Id == gm.Id)
|
||
.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);
|
||
return Created("", new { gm.Id });
|
||
}
|
||
|
||
[HttpPost("grade-modifications/{id:guid}/college-approve")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> CollegeApproveGradeMod(Guid id, CancellationToken ct)
|
||
{
|
||
var gm = await db.GradeModifications.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.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);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("grade-modifications/{id:guid}/approve")]
|
||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
|
||
{
|
||
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||
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;
|
||
// Auto-apply: update grade record
|
||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||
await db.SaveChangesAsync(ct);
|
||
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("grade-modifications/{id:guid}/reject")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> RejectGradeMod(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
|
||
{
|
||
var gm = await db.GradeModifications.FindAsync([id], ct);
|
||
if (gm is null) return NotFound();
|
||
gm.Status = GradeModificationStatus.Rejected; gm.ReviewComment = body.Comment?.Trim();
|
||
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);
|
||
return NoContent();
|
||
}
|
||
|
||
// ═══════════════ Course Substitution ═══════════════
|
||
[HttpGet("substitutions/mine")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||
.Where(x => x.StudentId == sid)
|
||
.OrderByDescending(x => x.CreatedAt)
|
||
.Select(x => new StudentCourseSubstitutionRecord(
|
||
x.Id,
|
||
x.Status,
|
||
x.Reason,
|
||
x.ReviewComment,
|
||
x.SubmittedAt,
|
||
x.OriginalCourse!.Code,
|
||
x.OriginalCourse.Name,
|
||
x.SubstituteCourse!.Code,
|
||
x.SubstituteCourse.Name))
|
||
.ToListAsync(ct));
|
||
}
|
||
|
||
[HttpPost("substitutions")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||
.AnyAsync(task =>
|
||
task.CourseId == req.OriginalCourseId &&
|
||
task.Status == TeachingTaskStatus.Published &&
|
||
!task.AcademicTerm!.IsArchived &&
|
||
(task.AcademicTerm.IsCurrent ||
|
||
db.ScheduleEntries.Any(entry =>
|
||
entry.TeachingTaskId == task.Id &&
|
||
entry.SchedulePlan!.Status ==
|
||
SchedulePlanStatus.Published)) &&
|
||
(task.Classes.Any(item =>
|
||
item.AdministrativeClass!.Students.Any(student =>
|
||
student.Id == sid.Value)) ||
|
||
db.CourseEnrollments.Any(enrollment =>
|
||
enrollment.StudentId == sid.Value &&
|
||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||
task.Id)),
|
||
ct);
|
||
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||
.AnyAsync(record =>
|
||
record.StudentId == sid.Value &&
|
||
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||
record.GradeSheet.TeachingTask!.CourseId ==
|
||
req.OriginalCourseId &&
|
||
record.TotalScore < 60,
|
||
ct);
|
||
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||
if (!canReplaceOriginal)
|
||
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||
.AnyAsync(x =>
|
||
x.StudentId == sid.Value &&
|
||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||
x.TotalScore >= 60,
|
||
ct);
|
||
if (!hasPassedSubstitute)
|
||
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||
return ConflictProblem("该课程已有替代申请在审核中。");
|
||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||
db.CourseSubstitutions.Add(cs);
|
||
await db.SaveChangesAsync(ct);
|
||
var info = await db.CourseSubstitutions.Where(x => x.Id == cs.Id)
|
||
.Select(x => new { StudentName = x.Student!.Name, SubName = x.SubstituteCourse!.Name, OrigName = x.OriginalCourse!.Name })
|
||
.FirstAsync(ct);
|
||
await NotifyManagers("课程替代申请", $"{info.StudentName} 申请用《{info.SubName}》替代《{info.OrigName}》", ct);
|
||
return Created("", new { cs.Id });
|
||
}
|
||
|
||
[HttpPost("substitutions/{id:guid}/approve")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> ApproveSubstitution(Guid id, CancellationToken ct)
|
||
{
|
||
var cs = await db.CourseSubstitutions.Include(x => x.Student).Include(x => x.OriginalCourse).Include(x => x.SubstituteCourse).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||
if (cs is null) return NotFound();
|
||
cs.Status = ApprovalStatus.Approved; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewedByUserId = scope.Current.UserId;
|
||
// Auto-apply: copy passed grade to original course
|
||
var currentTerm = await db.AcademicTerms.FirstOrDefaultAsync(x => x.IsCurrent, ct);
|
||
if (currentTerm is not null)
|
||
{
|
||
var passedRecord = await db.GradeRecords
|
||
.Where(x => x.StudentId == cs.StudentId && x.GradeSheet!.TeachingTask!.CourseId == cs.SubstituteCourseId && x.TotalScore >= 60)
|
||
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||
.FirstOrDefaultAsync(ct);
|
||
if (passedRecord is not null)
|
||
{
|
||
// Find or create grade record for original course
|
||
var origTask = await db.TeachingTasks.FirstOrDefaultAsync(x => x.CourseId == cs.OriginalCourseId && x.AcademicTermId == currentTerm.Id, ct);
|
||
if (origTask is not null)
|
||
{
|
||
var origSheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == origTask.Id, ct);
|
||
if (origSheet is not null)
|
||
{
|
||
var origRecord = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == origSheet.Id && x.StudentId == cs.StudentId, ct);
|
||
if (origRecord is not null)
|
||
{
|
||
origRecord.TotalScore = passedRecord.TotalScore;
|
||
origRecord.GradePoint = passedRecord.GradePoint;
|
||
origRecord.Notes =
|
||
$"课程替代:{cs.SubstituteCourse!.Code} {cs.SubstituteCourse.Name}";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(cs.StudentId, "课程替代申请已通过", $"《{cs.SubstituteCourse!.Name}》替代《{cs.OriginalCourse!.Name}》已生效。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("substitutions/{id:guid}/reject")]
|
||
[Authorize(Roles = Managers)]
|
||
public async Task<ActionResult> RejectSubstitution(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
|
||
{
|
||
var cs = await db.CourseSubstitutions.FindAsync([id], ct);
|
||
if (cs is null) return NotFound();
|
||
cs.Status = ApprovalStatus.Rejected; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewComment = body.Comment?.Trim(); cs.ReviewedByUserId = scope.Current.UserId;
|
||
await db.SaveChangesAsync(ct);
|
||
await NotifyStudent(cs.StudentId, "课程替代申请已驳回", cs.ReviewComment ?? "审核未通过。", ct);
|
||
return NoContent();
|
||
}
|
||
|
||
// ═══════════════ Student course options ═══════════════
|
||
[HttpGet("my-courses")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
var courses = await db.TeachingTasks.AsNoTracking()
|
||
.Where(task =>
|
||
task.Status == TeachingTaskStatus.Published &&
|
||
!task.AcademicTerm!.IsArchived &&
|
||
(task.AcademicTerm.IsCurrent ||
|
||
db.ScheduleEntries.Any(entry =>
|
||
entry.TeachingTaskId == task.Id &&
|
||
entry.SchedulePlan!.Status ==
|
||
SchedulePlanStatus.Published)) &&
|
||
(task.Classes.Any(item =>
|
||
item.AdministrativeClass!.Students.Any(student =>
|
||
student.Id == sid.Value)) ||
|
||
db.CourseEnrollments.Any(enrollment =>
|
||
enrollment.StudentId == sid.Value &&
|
||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||
task.Id)))
|
||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||
.ThenBy(x => x.Course!.Code)
|
||
.ThenBy(x => x.TaskNumber)
|
||
.Select(x => new StudentApprovalCourseOption(
|
||
x.Id,
|
||
x.CourseId,
|
||
x.TaskNumber,
|
||
x.Course!.Code,
|
||
x.Course.Name,
|
||
x.Course.Credits,
|
||
x.AcademicTerm!.Name,
|
||
x.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.ThenBy(item => item.Teacher!.Name)
|
||
.Select(item => item.Teacher!.Name),
|
||
db.ScheduleEntries.Any(entry =>
|
||
entry.TeachingTaskId == x.Id &&
|
||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||
x.SchedulingMode))
|
||
.ToListAsync(ct);
|
||
return Ok(courses);
|
||
}
|
||
|
||
[HttpGet("my-grades")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetMyGrades(CancellationToken ct)
|
||
{
|
||
var sid = await GetStudentIdAsync(ct);
|
||
if (sid is null) return StudentNotFound();
|
||
var grades = await db.GradeRecords.AsNoTracking()
|
||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||
.Select(x => new StudentApprovalGradeOption(
|
||
x.GradeSheet!.TeachingTask!.CourseId,
|
||
x.GradeSheet.TeachingTask.Course!.Code,
|
||
x.GradeSheet.TeachingTask.Course.Name,
|
||
x.GradeSheet.TeachingTask.Course.Credits,
|
||
x.TotalScore,
|
||
x.GradePoint,
|
||
x.ExamStatus,
|
||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||
x.GradeSheet.TeachingTask.TaskNumber,
|
||
x.GradeSheet.PublishedAt))
|
||
.ToListAsync(ct);
|
||
return Ok(grades);
|
||
}
|
||
|
||
[HttpGet("grade-records")]
|
||
[Authorize(Roles = Teachers)]
|
||
public async Task<ActionResult> GetGradeRecordsForMod(CancellationToken ct)
|
||
{
|
||
var userId = scope.Current.UserId;
|
||
var records = await db.GradeRecords.AsNoTracking()
|
||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradeSheet.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
|
||
.Select(x => new { x.Id, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, CourseName = x.GradeSheet!.TeachingTask!.Course!.Name, x.TotalScore })
|
||
.ToListAsync(ct);
|
||
return Ok(records);
|
||
}
|
||
|
||
// ═══════════════ Helpers ═══════════════
|
||
private IQueryable<T> Scoped<T>(System.Linq.Expressions.Expression<Func<T, bool>> predicate) where T : class
|
||
{
|
||
var q = db.Set<T>().AsNoTracking().Where(predicate);
|
||
if (scope.Current.Scope == DataScope.All) return q;
|
||
var cid = scope.Current.RestrictedCollegeId;
|
||
if (typeof(T) == typeof(CourseExemption)) return (IQueryable<T>)((IQueryable<CourseExemption>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
|
||
if (typeof(T) == typeof(DeferredExam)) return (IQueryable<T>)((IQueryable<DeferredExam>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
|
||
if (typeof(T) == typeof(GradeModification)) return (IQueryable<T>)((IQueryable<GradeModification>)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid);
|
||
if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable<T>)((IQueryable<CourseSubstitution>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
|
||
if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable<T>)((IQueryable<StudentStatusChange>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
|
||
if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable<T>)((IQueryable<CourseAdjustment>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
|
||
if (typeof(T) == typeof(GradeSheet)) return (IQueryable<T>)((IQueryable<GradeSheet>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
|
||
if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable<T>)((IQueryable<AttendanceRecord>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
|
||
return q;
|
||
}
|
||
|
||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||
Guid studentId,
|
||
Guid teachingTaskId,
|
||
CancellationToken ct) =>
|
||
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||
task.Id == teachingTaskId &&
|
||
task.Status == TeachingTaskStatus.Published &&
|
||
!task.AcademicTerm!.IsArchived &&
|
||
(task.AcademicTerm.IsCurrent ||
|
||
db.ScheduleEntries.Any(entry =>
|
||
entry.TeachingTaskId == task.Id &&
|
||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||
(task.Classes.Any(item =>
|
||
item.AdministrativeClass!.Students.Any(student =>
|
||
student.Id == studentId)) ||
|
||
db.CourseEnrollments.Any(enrollment =>
|
||
enrollment.StudentId == studentId &&
|
||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||
task.Id)),
|
||
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 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 => "代课", _ => "调停课" };
|
||
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "无法完成", Detail = d, Status = 409 });
|
||
}
|
||
|
||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||
public sealed record StudentApprovalCourseOption(
|
||
Guid TeachingTaskId,
|
||
Guid CourseId,
|
||
string TaskNumber,
|
||
string CourseCode,
|
||
string CourseName,
|
||
decimal Credits,
|
||
string AcademicTermName,
|
||
IEnumerable<string> TeacherNames,
|
||
bool HasPublishedSchedule,
|
||
TeachingTaskSchedulingMode SchedulingMode);
|
||
public sealed record StudentApprovalGradeOption(
|
||
Guid CourseId,
|
||
string CourseCode,
|
||
string CourseName,
|
||
decimal Credits,
|
||
decimal? TotalScore,
|
||
decimal? GradePoint,
|
||
GradeExamStatus ExamStatus,
|
||
string AcademicTermName,
|
||
string TaskNumber,
|
||
DateTime? PublishedAt);
|
||
public sealed record StudentCourseSubstitutionRecord(
|
||
Guid Id,
|
||
ApprovalStatus Status,
|
||
string Reason,
|
||
string? ReviewComment,
|
||
DateTime SubmittedAt,
|
||
string OriginalCourseCode,
|
||
string OriginalCourseName,
|
||
string SubstituteCourseCode,
|
||
string SubstituteCourseName);
|
||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||
public sealed record ReviewBody([MaxLength(500)] string? Comment);
|