diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index a66dcee..21aaab7 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -76,6 +76,33 @@ public sealed class ExamsController( return await SaveAsync(plan.Id, true, cancellationToken); } + [HttpDelete("plans/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task DeletePlan( + Guid id, + CancellationToken cancellationToken) + { + var plan = await db.ExamPlans + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != ExamPlanStatus.Draft) + return ConflictProblem("只有草稿考试计划可以删除。"); + + db.ExamPlans.Remove(plan); + try + { + await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); + return NoContent(); + } + catch (DbUpdateException) + { + return ConflictProblem("删除考试计划失败,关联数据可能已发生变化。"); + } + } + [HttpGet("plans/{id:guid}")] public async Task GetPlan(Guid id, CancellationToken cancellationToken) { @@ -354,6 +381,48 @@ public sealed class ExamsController( return await SaveAsync(id, false, cancellationToken); } + [HttpPost("plans/{planId:guid}/sessions/batch-remove")] + [Authorize(Roles = Managers)] + public async Task RemoveSessionsBatch( + Guid planId, + RemoveExamSessionsBatchRequest request, + CancellationToken cancellationToken) + { + var sessionIds = request.SessionIds.Distinct().ToArray(); + if (sessionIds.Length == 0) + return ValidationProblem("请至少选择一个考试场次。"); + if (sessionIds.Length > 100) + return ValidationProblem("一次最多移除100个考试场次。"); + + var plan = await db.ExamPlans.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != ExamPlanStatus.Draft) + return ConflictProblem("已发布的考试计划不能调整场次。"); + + var sessions = await db.ExamSessions + .Where(x => x.ExamPlanId == planId) + .WhereIn(sessionIds, x => x.Id) + .ToListAsync(cancellationToken); + if (sessions.Count != sessionIds.Length) + return ConflictProblem( + "所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。"); + + db.ExamSessions.RemoveRange(sessions); + try + { + await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); + return Ok(new { removedCount = sessions.Count }); + } + catch (DbUpdateException) + { + return ConflictProblem("批量移除考试场次失败,关联数据可能已发生变化。"); + } + } + // ═══════════════════════════════════════════ // Auto-arrange // ═══════════════════════════════════════════ @@ -958,6 +1027,9 @@ public sealed record CreateExamSessionsBatchRequest( [Range(1, 10)] int RequiredInvigilatorCount, [MaxLength(500)] string? Notes); +public sealed record RemoveExamSessionsBatchRequest( + [Required] IReadOnlyCollection SessionIds); + public sealed record ExamAutoArrangeRequest( IReadOnlyCollection? SessionIds = null, bool AssignClassrooms = true, diff --git a/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs b/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs new file mode 100644 index 0000000..39800fa --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs @@ -0,0 +1,273 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class ExamDeletionControllerTests +{ + [Fact] + public async Task RemoveSessionsBatch_ValidatesCompleteSelectionBeforeRemoving() + { + await using var fixture = await Fixture.CreateAsync(); + var controller = fixture.CreateController(); + var selectedIds = fixture.DraftPlan.Sessions + .Select(x => x.Id) + .ToArray(); + var otherPlanSessionId = fixture.PublishedPlan.Sessions.Single().Id; + + var invalidResult = await controller.RemoveSessionsBatch( + fixture.DraftPlan.Id, + new RemoveExamSessionsBatchRequest( + [selectedIds[0], otherPlanSessionId]), + CancellationToken.None); + + Assert.IsType(invalidResult); + Assert.Equal(2, await fixture.Db.ExamSessions.CountAsync( + x => x.ExamPlanId == fixture.DraftPlan.Id)); + + var result = Assert.IsType( + await controller.RemoveSessionsBatch( + fixture.DraftPlan.Id, + new RemoveExamSessionsBatchRequest(selectedIds), + CancellationToken.None)); + + Assert.Equal(2, ReadIntProperty(result.Value!, "removedCount")); + Assert.False(await fixture.Db.ExamSessions.AnyAsync( + x => x.ExamPlanId == fixture.DraftPlan.Id)); + Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync( + x => selectedIds.Contains(x.ExamSessionId))); + Assert.True(await fixture.Db.ExamPlans.AnyAsync( + x => x.Id == fixture.DraftPlan.Id)); + } + + [Fact] + public async Task RemoveSessionsBatch_RejectsPublishedPlan() + { + await using var fixture = await Fixture.CreateAsync(); + var controller = fixture.CreateController(); + var sessionId = fixture.PublishedPlan.Sessions.Single().Id; + + var result = await controller.RemoveSessionsBatch( + fixture.PublishedPlan.Id, + new RemoveExamSessionsBatchRequest([sessionId]), + CancellationToken.None); + + Assert.IsType(result); + Assert.True(await fixture.Db.ExamSessions.AnyAsync( + x => x.Id == sessionId)); + } + + [Fact] + public async Task DeletePlan_DeletesDraftWithSessions_AndRejectsPublishedPlan() + { + await using var fixture = await Fixture.CreateAsync(); + var controller = fixture.CreateController(); + var draftPlanId = fixture.DraftPlan.Id; + var publishedPlanId = fixture.PublishedPlan.Id; + var draftSessionIds = fixture.DraftPlan.Sessions + .Select(x => x.Id) + .ToArray(); + + var publishedResult = await controller.DeletePlan( + publishedPlanId, + CancellationToken.None); + + Assert.IsType(publishedResult); + Assert.True(await fixture.Db.ExamPlans.AnyAsync( + x => x.Id == publishedPlanId)); + + var draftResult = await controller.DeletePlan( + draftPlanId, + CancellationToken.None); + + Assert.IsType(draftResult); + Assert.False(await fixture.Db.ExamPlans.AnyAsync( + x => x.Id == draftPlanId)); + Assert.False(await fixture.Db.ExamSessions.AnyAsync( + x => x.ExamPlanId == draftPlanId)); + Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync( + x => draftSessionIds.Contains(x.ExamSessionId))); + Assert.True(await fixture.Db.ExamPlans.AnyAsync( + x => x.Id == publishedPlanId)); + } + + private static int ReadIntProperty(object value, string name) => + (int)value.GetType().GetProperty(name)!.GetValue(value)!; + + private sealed class Fixture : IAsyncDisposable + { + private readonly SqliteConnection connection; + + private Fixture( + SqliteConnection connection, + AppDbContext db, + ExamPlan draftPlan, + ExamPlan publishedPlan) + { + this.connection = connection; + Db = db; + DraftPlan = draftPlan; + PublishedPlan = publishedPlan; + } + + public AppDbContext Db { get; } + public ExamPlan DraftPlan { get; } + public ExamPlan PublishedPlan { 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 college = new College + { + Code = "CS", + Name = "计算机学院" + }; + var term = new AcademicTerm + { + Code = "2026-1", + Name = "2026—2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20) + }; + var course = new Course + { + Code = "CS101", + Name = "程序设计基础", + CollegeId = college.Id, + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }; + var firstTask = CreateTask( + term.Id, + course.Id, + "2026-1-CS101-01"); + var secondTask = CreateTask( + term.Id, + course.Id, + "2026-1-CS101-02"); + var invigilator = new Teacher + { + TeacherNumber = "T001", + Name = "张老师", + CollegeId = college.Id + }; + var firstDraftSession = CreateSession( + firstTask.Id, + new DateOnly(2027, 1, 8)); + firstDraftSession.Invigilators = + [ + new ExamSessionInvigilator + { + TeacherId = invigilator.Id + } + ]; + var draftPlan = new ExamPlan + { + AcademicTermId = term.Id, + Name = "期末考试草稿", + Sessions = + [ + firstDraftSession, + CreateSession(secondTask.Id, new DateOnly(2027, 1, 9)) + ] + }; + var publishedPlan = new ExamPlan + { + AcademicTermId = term.Id, + Name = "已发布期末考试", + Status = ExamPlanStatus.Published, + PublishedAt = DateTime.UtcNow, + Sessions = + [ + CreateSession(firstTask.Id, new DateOnly(2027, 1, 10)) + ] + }; + + db.AddRange( + college, + term, + course, + firstTask, + secondTask, + invigilator, + draftPlan, + publishedPlan); + await db.SaveChangesAsync(); + + return new Fixture( + connection, + db, + draftPlan, + publishedPlan); + } + + public ExamsController CreateController() => new( + Db, + new ManagerDataScope(), + new ExamArrangementService(Db), + NoOpAppCache.Instance); + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + + private static TeachingTask CreateTask( + Guid termId, + Guid courseId, + string number) => new() + { + AcademicTermId = termId, + CourseId = courseId, + TaskNumber = number, + Name = $"教学班 {number}", + Capacity = 60, + Status = TeachingTaskStatus.Published + }; + + private static ExamSession CreateSession( + Guid taskId, + DateOnly examDate) => new() + { + TeachingTaskId = taskId, + ExamDate = examDate, + StartPeriod = 1, + PeriodCount = 2, + StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)), + EndsAt = examDate.ToDateTime(new TimeOnly(9, 50)), + RequiredInvigilatorCount = 2 + }; + } + + private sealed class ManagerDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "考试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.AcademicAdmin])); + } +} diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index b1411e8..3e17215 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -1,6 +1,6 @@