已继续完成“学籍异动”后端基础闭环:

学生可申请休学、复学或退学。
自动根据当前状态推导目标学籍状态。
同一学生不能同时提交多项在审申请。
强制执行辅导员 → 学院 → 校级三级审核。
驳回不会修改学生档案。
只有校级最终批准才更新学籍状态。
数据范围分别限制到本人、所带班级、所属学院或全校。
SQLite 增量升级和 MySQL 迁移已生成。
当前 28 项测试继续全部通过。
This commit is contained in:
2026-07-24 15:46:49 +08:00 Unverified
parent 7f3e37de20
commit 7493ab4a60
7 changed files with 2359 additions and 0 deletions
@@ -39,6 +39,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -386,6 +388,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<StudentStatusChange>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(1000);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.StudentId, x.State });
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
@@ -14,6 +14,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -70,6 +71,10 @@ public sealed class DevelopmentSqliteMigrator(
GradesStatements,
cancellationToken);
await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken);
await ApplyMigrationAsync(
StudentStatusChangesMigration,
StudentStatusChangesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -614,4 +619,22 @@ public sealed class DevelopmentSqliteMigrator(
""",
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");"""
];
private static readonly string[] StudentStatusChangesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "StudentStatusChanges" (
"Id" TEXT NOT NULL CONSTRAINT "PK_StudentStatusChanges" PRIMARY KEY,
"StudentId" TEXT NOT NULL, "Type" INTEGER NOT NULL,
"OriginalStatus" INTEGER NOT NULL, "TargetStatus" INTEGER NOT NULL,
"Reason" TEXT NOT NULL, "State" INTEGER NOT NULL,
"ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL,
"ReviewedAt" TEXT NULL, "ApprovedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_StudentStatusChanges_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX IF NOT EXISTS "IX_StudentStatusChanges_StudentId_State" ON "StudentStatusChanges" ("StudentId", "State");"""
];
}
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class StudentStatusChanges : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StudentStatusChanges",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
OriginalStatus = table.Column<int>(type: "int", nullable: false),
TargetStatus = table.Column<int>(type: "int", nullable: false),
Reason = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
State = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ApprovedAt = 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_StudentStatusChanges", x => x.Id);
table.ForeignKey(
name: "FK_StudentStatusChanges_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_StudentStatusChanges_StudentId_State",
table: "StudentStatusChanges",
columns: new[] { "StudentId", "State" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StudentStatusChanges");
}
}
}
@@ -1054,6 +1054,58 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("OriginalStatus")
.HasColumnType("int");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<int>("TargetStatus")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("StudentId", "State");
b.ToTable("StudentStatusChanges");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{
b.Property<Guid>("Id")
@@ -1784,6 +1836,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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")