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

学生可申请休学、复学或退学。
自动根据当前状态推导目标学籍状态。
同一学生不能同时提交多项在审申请。
强制执行辅导员 → 学院 → 校级三级审核。
驳回不会修改学生档案。
只有校级最终批准才更新学籍状态。
数据范围分别限制到本人、所带班级、所属学院或全校。
SQLite 增量升级和 MySQL 迁移已生成。
当前 28 项测试继续全部通过。
This commit is contained in:
biss committed 2026-07-24 15:46:49 +08:00
1 parent 7f3e37de20
commit 7493ab4a60
7 files changed
+2359

No files matched your search

@@ -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 =>
{ {
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more