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

学生可申请休学、复学或退学。
自动根据当前状态推导目标学籍状态。
同一学生不能同时提交多项在审申请。
强制执行辅导员 → 学院 → 校级三级审核。
驳回不会修改学生档案。
只有校级最终批准才更新学籍状态。
数据范围分别限制到本人、所带班级、所属学院或全校。
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
@@ -0,0 +1,139 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/student-status-changes")]
public sealed class StudentStatusChangesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult> Get(CancellationToken token)
{
var source = ScopedChanges().AsNoTracking();
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
CollegeName = x.Student.AdministrativeClass.Major!.College!.Name,
x.Type, x.OriginalStatus, x.TargetStatus, x.Reason, x.State,
x.ReviewComment, x.SubmittedAt, x.ReviewedAt, x.ApprovedAt
}).ToListAsync(token));
}
[HttpPost]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Create(
StudentStatusChangeRequest request,
CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.FirstOrDefaultAsync(x => x.UserId == userId, token);
if (student is null) return ConflictProblem("当前账号未关联学生档案。");
var target = request.Type switch
{
StudentStatusChangeType.Suspension when student.Status == StudentStatus.Active =>
StudentStatus.Suspended,
StudentStatusChangeType.Resumption when student.Status == StudentStatus.Suspended =>
StudentStatus.Active,
StudentStatusChangeType.Withdrawal when student.Status is StudentStatus.Active or StudentStatus.Suspended =>
StudentStatus.Withdrawn,
_ => (StudentStatus?)null
};
if (!target.HasValue) return ConflictProblem("当前学籍状态不能申请该类异动。");
if (await db.StudentStatusChanges.AnyAsync(x => x.StudentId == student.Id &&
x.State != StudentStatusChangeState.Approved &&
x.State != StudentStatusChangeState.Rejected &&
x.State != StudentStatusChangeState.Cancelled, token))
return ConflictProblem("已有一项学籍异动正在审核中。");
var change = new StudentStatusChange
{
StudentId = student.Id, Type = request.Type,
OriginalStatus = student.Status, TargetStatus = target.Value,
Reason = request.Reason.Trim()
};
db.StudentStatusChanges.Add(change);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { change.Id });
}
[HttpPost("{id:guid}/review")]
public async Task<ActionResult> Review(
Guid id,
StudentStatusReviewRequest request,
CancellationToken token)
{
var change = await ScopedChanges().Include(x => x.Student)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (change is null) return NotFound();
var scope = currentUserDataScope.Current;
if (!request.Approved)
{
if (change.State is StudentStatusChangeState.Approved or
StudentStatusChangeState.Rejected or StudentStatusChangeState.Cancelled)
return ConflictProblem("该申请已经结束。");
change.State = StudentStatusChangeState.Rejected;
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
}
else if (scope.IsInRole(SystemRoles.Counselor) &&
change.State == StudentStatusChangeState.Submitted)
change.State = StudentStatusChangeState.CounselorApproved;
else if (scope.IsInRole(SystemRoles.CollegeAdmin) &&
change.State == StudentStatusChangeState.CounselorApproved)
change.State = StudentStatusChangeState.CollegeApproved;
else if ((scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.SuperAdmin)) &&
change.State == StudentStatusChangeState.CollegeApproved)
{
change.State = StudentStatusChangeState.Approved;
change.Student!.Status = change.TargetStatus;
change.ApprovedAt = DateTime.UtcNow;
}
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
private IQueryable<StudentStatusChange> ScopedChanges()
{
var scope = currentUserDataScope.Current;
var source = db.StudentStatusChanges.AsQueryable();
if (scope.IsInRole(SystemRoles.Student))
return source.Where(x => x.Student!.UserId == scope.UserId);
if (scope.IsInRole(SystemRoles.Counselor))
return source.Where(x =>
x.Student!.AdministrativeClass!.CounselorUserId == scope.UserId);
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == scope.CollegeId);
if (scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.SuperAdmin)) return source;
return source.Where(_ => false);
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学籍异动操作", Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record StudentStatusChangeRequest(
StudentStatusChangeType Type,
[Required, MinLength(10), MaxLength(1000)] string Reason);
public sealed record StudentStatusReviewRequest(
bool Approved,
[MaxLength(500)] string? Comment);
@@ -0,0 +1,36 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class StudentStatusChange : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public StudentStatusChangeType Type { get; set; }
public StudentStatus OriginalStatus { get; set; }
public StudentStatus TargetStatus { get; set; }
public required string Reason { get; set; }
public StudentStatusChangeState State { get; set; } =
StudentStatusChangeState.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public DateTime? ApprovedAt { get; set; }
}
public enum StudentStatusChangeType
{
Suspension = 1,
Resumption = 2,
Withdrawal = 3
}
public enum StudentStatusChangeState
{
Submitted = 1,
CounselorApproved = 2,
CollegeApproved = 3,
Approved = 4,
Rejected = 5,
Cancelled = 6
}
@@ -39,6 +39,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -386,6 +388,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany() entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); .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 => builder.Entity<AuditLog>(entity =>
{ {
@@ -14,6 +14,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string CourseSelectionMigration = "20260724_06_course_selection"; private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades"; private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams"; private const string ExamsMigration = "20260724_08_exams";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -70,6 +71,10 @@ public sealed class DevelopmentSqliteMigrator(
GradesStatements, GradesStatements,
cancellationToken); cancellationToken);
await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken); await ApplyMigrationAsync(ExamsMigration, ExamsStatements, cancellationToken);
await ApplyMigrationAsync(
StudentStatusChangesMigration,
StudentStatusChangesStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -614,4 +619,22 @@ public sealed class DevelopmentSqliteMigrator(
""", """,
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");""" """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"); 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 => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1784,6 +1836,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AdministrativeClass"); 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 => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")