已完成“考试安排与考场管理”模块:

考试计划草稿、场次配置与正式发布。
教学班、考试时间、考场、监考教师关联。
自动校验考场容量。
阻止考场、监考教师和学生考试时间冲突。
管理员查看考生名单。
教师查看个人监考安排。
学生查看已发布考试日程。
SQLite 增量升级和 MySQL 正式迁移。
This commit is contained in:
2026-07-24 15:43:55 +08:00 Unverified
parent af52e27a3a
commit 7f3e37de20
18 changed files with 3057 additions and 2 deletions
@@ -35,6 +35,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -353,6 +357,36 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm).WithMany()
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSession>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => x.ClassroomId);
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSessionInvigilator>(entity =>
{
entity.HasKey(x => new { x.ExamSessionId, x.TeacherId });
entity.HasOne(x => x.ExamSession).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -423,6 +423,7 @@ public sealed class DatabaseInitializer(
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
await SeedDevelopmentGradesAsync();
await SeedDevelopmentExamsAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
@@ -506,6 +507,38 @@ public sealed class DatabaseInitializer(
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentExamsAsync()
{
if (await db.ExamPlans.AnyAsync()) return;
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var room = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
db.ExamPlans.Add(new ExamPlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期期末考试",
Status = ExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Notes = "本地开发演示考试计划。",
Sessions =
[
new ExamSession
{
TeachingTaskId = task.Id,
ClassroomId = room.Id,
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
Invigilators =
[
new ExamSessionInvigilator { TeacherId = teacher.Id }
]
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
{
var definitions = new[]
@@ -13,6 +13,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -68,6 +69,7 @@ public sealed class DevelopmentSqliteMigrator(
GradesMigration,
GradesStatements,
cancellationToken);
await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -569,4 +571,47 @@ public sealed class DevelopmentSqliteMigrator(
ON "GradeRecords" ("StudentId", "TotalScore");
"""
];
private static readonly string[] ExamsStatements =
[
"""
CREATE TABLE IF NOT EXISTS "ExamPlans" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPlans" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamPlans_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamPlans_AcademicTermId_Status" ON "ExamPlans" ("AcademicTermId", "Status");""",
"""
CREATE TABLE IF NOT EXISTS "ExamSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL,
"Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId" FOREIGN KEY ("ExamPlanId")
REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId")
REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId")
REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ExamPlanId_StartsAt" ON "ExamSessions" ("ExamPlanId", "StartsAt");""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_TeachingTaskId" ON "ExamSessions" ("TeachingTaskId");""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessions_ClassroomId" ON "ExamSessions" ("ClassroomId");""",
"""
CREATE TABLE IF NOT EXISTS "ExamSessionInvigilators" (
"ExamSessionId" TEXT NOT NULL, "TeacherId" TEXT NOT NULL,
CONSTRAINT "PK_ExamSessionInvigilators" PRIMARY KEY ("ExamSessionId", "TeacherId"),
CONSTRAINT "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessionInvigilators_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");"""
];
}
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamPlans",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamPlans", x => x.Id);
table.ForeignKey(
name: "FK_ExamPlans_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSessions", x => x.Id);
table.ForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamSessions_ExamPlans_ExamPlanId",
column: x => x.ExamPlanId,
principalTable: "ExamPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSessions_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSessionInvigilators",
columns: table => new
{
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSessionInvigilators", x => new { x.ExamSessionId, x.TeacherId });
table.ForeignKey(
name: "FK_ExamSessionInvigilators_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSessionInvigilators_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamPlans_AcademicTermId_Status",
table: "ExamPlans",
columns: new[] { "AcademicTermId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessionInvigilators_TeacherId",
table: "ExamSessionInvigilators",
column: "TeacherId");
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ExamPlanId_StartsAt",
table: "ExamSessions",
columns: new[] { "ExamPlanId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_TeachingTaskId",
table: "ExamSessions",
column: "TeachingTaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamSessionInvigilators");
migrationBuilder.DropTable(
name: "ExamSessions");
migrationBuilder.DropTable(
name: "ExamPlans");
}
}
}
@@ -634,6 +634,100 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "Status");
b.ToTable("ExamPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExamPlanId")
.HasColumnType("char(36)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("TeachingTaskId");
b.HasIndex("ExamPlanId", "StartsAt");
b.ToTable("ExamSessions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b =>
{
b.Property<Guid>("ExamSessionId")
.HasColumnType("char(36)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.HasKey("ExamSessionId", "TeacherId");
b.HasIndex("TeacherId");
b.ToTable("ExamSessionInvigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{
b.Property<Guid>("Id")
@@ -1538,6 +1632,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
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.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan")
.WithMany("Sessions")
.HasForeignKey("ExamPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("ExamPlan");
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.GradeRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
@@ -1782,6 +1933,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
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.GradeSheet", b =>
{
b.Navigation("Records");