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

学生可申请休学、复学或退学。
自动根据当前状态推导目标学籍状态。
同一学生不能同时提交多项在审申请。
强制执行辅导员 → 学院 → 校级三级审核。
驳回不会修改学生档案。
只有校级最终批准才更新学籍状态。
数据范围分别限制到本人、所带班级、所属学院或全校。
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);