学籍异动:休学、复学、退学申请及辅导员→学院→教务处分级审批。

毕业审核:批次计算、缺失课程检查、人工复核、结果发布。
学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。
毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
This commit is contained in:
2026-07-24 17:30:05 +08:00 Unverified
parent 7493ab4a60
commit b0eaa20da6
34 changed files with 11137 additions and 8 deletions
@@ -51,6 +51,31 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
StudentStatusChanges = await db.StudentStatusChanges
.CountAsync(cancellationToken),
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved,
cancellationToken),
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,278 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/degree-awards")]
public sealed class DegreeAwardsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers = Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.DegreeAwardBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.DegreeName,
x.MinimumGradePoint, x.Status, x.Notes,
x.CalculatedAt, x.PublishedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
GrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.Granted),
NotGrantedCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == DegreeAwardConclusion.NotGranted),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var source = db.DegreeAwardResults.AsNoTracking()
.Where(x => x.DegreeAwardBatchId == id);
if (collegeId.HasValue)
source = source.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
batch.MinimumGradePoint, batch.Status, batch.Notes,
batch.CalculatedAt, batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
x.ExceptionReason, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
DegreeAwardBatchRequest request,
CancellationToken token)
{
var batch = new DegreeAwardBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
DegreeName = request.DegreeName.Trim(),
MinimumGradePoint = request.MinimumGradePoint,
Notes = request.Notes?.Trim()
};
db.DegreeAwardBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.ToListAsync(token);
var audits = auditCandidates
.GroupBy(x => x.StudentId)
.Select(x => x.First())
.ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.Select(x => new
{
x.StudentId,
GradePoint = x.GradePoint!.Value,
x.GradeSheet!.TeachingTask!.Course!.Credits
}).ToListAsync(token);
var oldResults = await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
db.DegreeAwardResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var audit in audits)
{
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
var credits = grades.Sum(x => x.Credits);
var average = credits == 0
? 0
: Math.Round(
grades.Sum(x => x.GradePoint * x.Credits) / credits,
2,
MidpointRounding.AwayFromZero);
var conclusion = DegreeAwardRules.Evaluate(
true, audit.Student!.Status, average, batch.MinimumGradePoint);
var reason = audit.Student.Status != StudentStatus.Graduated
? "学籍状态尚未转为毕业"
: average < batch.MinimumGradePoint
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
: string.Empty;
db.DegreeAwardResults.Add(new DegreeAwardResult
{
DegreeAwardBatchId = batch.Id,
StudentId = audit.StudentId,
GraduationAuditResultId = audit.Id,
AverageGradePoint = average,
CalculatedConclusion = conclusion,
Conclusion = conclusion,
ExceptionReason = reason
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = audits.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Review(
Guid id,
DegreeAwardDecisionRequest request,
CancellationToken token)
{
var result = await db.DegreeAwardResults
.Include(x => x.DegreeAwardBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.DegreeAwardBatch!.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("已发布的授予结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.DegreeAwardBatches.Include(x => x.Results)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != DegreeAwardBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("没有可发布的学位授予审核结果。");
batch.Status = DegreeAwardBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.DegreeAwardResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.DegreeAwardBatch!.Status == DegreeAwardBatchStatus.Published)
.OrderByDescending(x => x.DegreeAwardBatch!.GraduationYear)
.ThenByDescending(x => x.DegreeAwardBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.DegreeAwardBatch!.Name,
x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
x.IsOverridden, x.ReviewComment,
x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学位授予操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record DegreeAwardBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Required, MinLength(2), MaxLength(80)] string DegreeName,
[Range(typeof(decimal), "0", "5")] decimal MinimumGradePoint,
[MaxLength(500)] string? Notes);
public sealed record DegreeAwardDecisionRequest(
DegreeAwardConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,332 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-audits")]
public sealed class GraduationAuditsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Reviewers =
Managers + "," + SystemRoles.CollegeAdmin;
[HttpGet("batches")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var collegeId = RestrictedCollegeId();
return Ok(await db.GraduationAuditBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
ResultCount = x.Results.Count(result =>
!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
EligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Eligible),
IneligibleCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.Conclusion == GraduationAuditConclusion.Ineligible),
OverrideCount = x.Results.Count(result =>
(!collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
result.IsOverridden)
}).ToListAsync(token));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var collegeId = RestrictedCollegeId();
var results = db.GraduationAuditResults.AsNoTracking()
.Where(x => x.GraduationAuditBatchId == id);
if (collegeId.HasValue)
results = results.Where(x =>
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
Results = await results
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.CalculatedConclusion, x.Conclusion, x.IsOverridden,
x.ReviewComment, x.ReviewedAt
}).ToListAsync(token)
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateBatch(
GraduationAuditBatchRequest request,
CancellationToken token)
{
if (request.EnrollmentYear > request.GraduationYear)
return ValidationProblem("入学年级不能晚于毕业年份。");
var batch = new GraduationAuditBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
EnrollmentYear = request.EnrollmentYear,
Notes = request.Notes?.Trim()
};
db.GraduationAuditBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/calculate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布批次不能重新计算。");
var students = await db.Students.AsNoTracking()
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.Where(x => x.EnrollmentYear == batch.EnrollmentYear &&
(x.Status == StudentStatus.Active ||
x.Status == StudentStatus.Suspended))
.OrderBy(x => x.StudentNumber)
.ToListAsync(token);
var plans = await db.CurriculumPlans.AsNoTracking()
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new GradeSnapshot(
x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.ExamStatus))
.ToListAsync(token);
var oldResults = await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
db.GraduationAuditResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var student in students)
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var requiredCourses = plan?.Modules
.SelectMany(x => x.Courses)
.Where(x => x.Type == CurriculumCourseType.Required)
.ToList() ?? [];
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
var passedCourseIds = studentGrades
.Where(IsPassed)
.Select(x => x.CourseId)
.Distinct()
.ToHashSet();
var earnedCredits = studentGrades
.Where(IsPassed)
.GroupBy(x => x.CourseId)
.Sum(x => x.Max(item => item.Credits));
var missingCourses = requiredCourses
.Where(x => !passedCourseIds.Contains(x.CourseId))
.Select(x => x.Course!.Name)
.Distinct()
.ToArray();
var missingCourseNames = plan is null
? "未匹配已发布的培养方案"
: string.Join("、", missingCourses);
if (missingCourseNames.Length > 2000)
missingCourseNames = missingCourseNames[..2000];
var failedCourseCount = studentGrades
.GroupBy(x => x.CourseId)
.Count(x => !x.Any(IsPassed));
var conclusion = GraduationAuditRules.Evaluate(
plan is not null,
student.Status,
plan?.TotalCredits ?? 0,
earnedCredits,
requiredCourses.Count,
requiredCourses.Count - missingCourses.Length,
failedCourseCount);
db.GraduationAuditResults.Add(new GraduationAuditResult
{
GraduationAuditBatchId = batch.Id,
StudentId = student.Id,
CurriculumPlanId = plan?.Id,
StudentStatusSnapshot = student.Status,
RequiredCredits = plan?.TotalCredits ?? 0,
EarnedCredits = earnedCredits,
RequiredCourseCount = requiredCourses.Count,
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
FailedCourseCount = failedCourseCount,
MissingCourseNames = missingCourseNames,
CalculatedConclusion = conclusion,
Conclusion = conclusion
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = students.Count });
}
[HttpPut("results/{id:guid}")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> ReviewResult(
Guid id,
GraduationAuditDecisionRequest request,
CancellationToken token)
{
var result = await db.GraduationAuditResults
.Include(x => x.GraduationAuditBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (result is null) return NotFound();
if (result.GraduationAuditBatch!.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("已发布的审核结果不能修改。");
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue &&
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
return Forbid();
result.Conclusion = request.Conclusion;
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
result.ReviewComment = request.Comment.Trim();
result.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
{
var batch = await db.GraduationAuditBatches
.Include(x => x.Results)
.ThenInclude(x => x.Student)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationAuditBatchStatus.Draft)
return ConflictProblem("该批次已经发布。");
if (batch.Results.Count == 0)
return ConflictProblem("请先计算毕业资格,再发布结果。");
batch.Status = GraduationAuditBatchStatus.Published;
batch.PublishedAt = DateTime.UtcNow;
foreach (var result in batch.Results.Where(x =>
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Active))
result.Student!.Status = StudentStatus.Graduated;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-result")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyResult(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var result = await db.GraduationAuditResults.AsNoTracking()
.Where(x => x.Student!.UserId == userId &&
x.GraduationAuditBatch!.Status ==
GraduationAuditBatchStatus.Published)
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
x.Id,
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount,
x.FailedCourseCount, x.MissingCourseNames,
x.Conclusion, x.IsOverridden, x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token);
return Ok(result);
}
private Guid? RestrictedCollegeId()
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.CollegeAdmin)
? scope.CollegeId ?? Guid.Empty
: null;
}
private static bool IsPassed(GradeSnapshot grade) =>
grade.ExamStatus == GradeExamStatus.Exempt ||
grade.TotalScore is decimal score && score >= 60;
private sealed record GradeSnapshot(
Guid StudentId,
Guid CourseId,
string CourseName,
decimal Credits,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业审核操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationAuditBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[Range(2000, 2200)] int EnrollmentYear,
[MaxLength(500)] string? Notes);
public sealed record GraduationAuditDecisionRequest(
GraduationAuditConclusion Conclusion,
[Required, MinLength(5), MaxLength(500)] string Comment);
@@ -0,0 +1,318 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/graduation-clearance")]
public sealed class GraduationClearanceController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string Workers =
Managers + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor;
[HttpGet("batches")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatches(CancellationToken token)
{
var batches = await db.GraduationClearanceBatches.AsNoTracking()
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes,
x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count
}).ToListAsync(token);
var records = await ScopedRecords().AsNoTracking()
.Select(x => new
{
BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId,
x.StudentId, x.Status
}).ToListAsync(token);
return Ok(batches.Select(batch =>
{
var scoped = records.Where(x => x.BatchId == batch.Id).ToList();
return new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount,
StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(),
RecordCount = scoped.Count,
CompletedCount = scoped.Count(x =>
x.Status != GraduationClearanceRecordStatus.Pending)
};
}));
}
[HttpGet("batches/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status,
x.Notes, x.ClosedAt, x.CreatedAt,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id, item.Code, item.Name, item.ResponsibleUnit,
item.ResponsibleRole, item.IsRequired, item.SortOrder
})
}).FirstOrDefaultAsync(token);
if (batch is null) return NotFound();
var records = await ScopedRecords().AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.OrderBy(x => x.Student!.StudentNumber)
.ThenBy(x => x.GraduationClearanceItem!.SortOrder)
.Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
ItemId = x.GraduationClearanceItemId,
ItemName = x.GraduationClearanceItem!.Name,
x.GraduationClearanceItem.ResponsibleUnit,
x.GraduationClearanceItem.ResponsibleRole,
x.GraduationClearanceItem.IsRequired,
x.Status, x.Notes, x.CompletedAt
}).ToListAsync(token);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.Status,
batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items,
Records = records
});
}
[HttpPost("batches")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Create(
GraduationClearanceBatchRequest request,
CancellationToken token)
{
var allowedRoles = new[]
{
SystemRoles.AcademicAdmin,
SystemRoles.CollegeAdmin,
SystemRoles.Counselor
};
if (request.Items.Count == 0)
return ValidationProblem("至少配置一个离校事项。");
if (request.Items.Select(x => x.Code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Items.Count)
return ValidationProblem("离校事项编码不能重复。");
if (request.Items.Any(x => !allowedRoles.Contains(
x.ResponsibleRole, StringComparer.OrdinalIgnoreCase)))
return ValidationProblem("离校事项责任角色无效。");
var batch = new GraduationClearanceBatch
{
Name = request.Name.Trim(),
GraduationYear = request.GraduationYear,
Notes = request.Notes?.Trim(),
Items = request.Items.Select((item, index) =>
new GraduationClearanceItem
{
Code = item.Code.Trim(),
Name = item.Name.Trim(),
ResponsibleUnit = item.ResponsibleUnit.Trim(),
ResponsibleRole = item.ResponsibleRole,
IsRequired = item.IsRequired,
SortOrder = index + 1
}).ToList()
};
db.GraduationClearanceBatches.Add(batch);
await db.SaveChangesAsync(token);
return Created(string.Empty, new { batch.Id });
}
[HttpPost("batches/{id:guid}/generate")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Generate(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("已关闭批次不能重新生成办理记录。");
var eligibleAudits = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
x.Conclusion == GraduationAuditConclusion.Eligible &&
x.Student!.Status == StudentStatus.Graduated)
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new { x.StudentId })
.ToListAsync(token);
var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray();
var existing = await db.GraduationClearanceRecords.AsNoTracking()
.Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id)
.Select(x => new { x.GraduationClearanceItemId, x.StudentId })
.ToListAsync(token);
var existingKeys = existing
.Select(x => (x.GraduationClearanceItemId, x.StudentId))
.ToHashSet();
var created = 0;
foreach (var item in batch.Items)
foreach (var studentId in studentIds)
{
if (existingKeys.Contains((item.Id, studentId))) continue;
db.GraduationClearanceRecords.Add(new GraduationClearanceRecord
{
GraduationClearanceItemId = item.Id,
StudentId = studentId
});
created++;
}
await db.SaveChangesAsync(token);
return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created });
}
[HttpPut("records/{id:guid}")]
[Authorize(Roles = Workers)]
public async Task<ActionResult> UpdateRecord(
Guid id,
GraduationClearanceRecordRequest request,
CancellationToken token)
{
var record = await db.GraduationClearanceRecords
.Include(x => x.GraduationClearanceItem)
.ThenInclude(x => x!.GraduationClearanceBatch)
.Include(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.ThenInclude(x => x!.Major)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (record is null) return NotFound();
if (record.GraduationClearanceItem!.GraduationClearanceBatch!.Status !=
GraduationClearanceBatchStatus.Open)
return ConflictProblem("批次已关闭,不能修改办理记录。");
if (!CanManage(record)) return Forbid();
record.Status = request.Status;
record.Notes = request.Notes?.Trim();
record.CompletedAt = request.Status == GraduationClearanceRecordStatus.Pending
? null
: DateTime.UtcNow;
record.CompletedByUserId =
request.Status == GraduationClearanceRecordStatus.Pending
? null
: currentUserDataScope.Current.UserId;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("batches/{id:guid}/close")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Close(Guid id, CancellationToken token)
{
var batch = await db.GraduationClearanceBatches
.Include(x => x.Items)
.ThenInclude(x => x.Records)
.FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
if (batch.Status != GraduationClearanceBatchStatus.Open)
return ConflictProblem("该批次已经关闭。");
var records = batch.Items.SelectMany(item => item.Records.Select(record =>
(item.IsRequired, record.Status)));
if (!GraduationClearanceRules.CanClose(records))
return ConflictProblem("仍有必办离校事项尚未完成。");
batch.Status = GraduationClearanceBatchStatus.Closed;
batch.ClosedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("my-clearance")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyClearance(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var batch = await db.GraduationClearanceBatches.AsNoTracking()
.Where(x => x.Items.Any(item =>
item.Records.Any(record => record.Student!.UserId == userId)))
.OrderByDescending(x => x.GraduationYear)
.ThenByDescending(x => x.CreatedAt)
.Select(x => new
{
x.Id, x.Name, x.GraduationYear, x.Status, x.Notes, x.ClosedAt,
Items = x.Items.OrderBy(item => item.SortOrder)
.SelectMany(item => item.Records
.Where(record => record.Student!.UserId == userId)
.Select(record => new
{
record.Id, ItemName = item.Name, item.ResponsibleUnit,
item.IsRequired, record.Status,
record.Notes, record.CompletedAt
}))
}).FirstOrDefaultAsync(token);
return Ok(batch);
}
private IQueryable<GraduationClearanceRecord> ScopedRecords()
{
var scope = currentUserDataScope.Current;
var source = db.GraduationClearanceRecords.AsQueryable();
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);
return source;
}
private bool CanManage(GraduationClearanceRecord record)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.SuperAdmin)) return true;
var role = record.GraduationClearanceItem!.ResponsibleRole;
if (role == SystemRoles.AcademicAdmin &&
scope.IsInRole(SystemRoles.AcademicAdmin)) return true;
if (role == SystemRoles.CollegeAdmin &&
scope.IsInRole(SystemRoles.CollegeAdmin))
return record.Student!.AdministrativeClass!.Major!.CollegeId ==
scope.CollegeId;
return role == SystemRoles.Counselor &&
scope.IsInRole(SystemRoles.Counselor) &&
record.Student!.AdministrativeClass!.CounselorUserId == scope.UserId;
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成毕业离校操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record GraduationClearanceBatchRequest(
[Required, MinLength(3), MaxLength(120)] string Name,
[Range(2000, 2200)] int GraduationYear,
[MaxLength(500)] string? Notes,
IReadOnlyList<GraduationClearanceItemRequest> Items);
public sealed record GraduationClearanceItemRequest(
[Required, MinLength(2), MaxLength(30)] string Code,
[Required, MinLength(2), MaxLength(100)] string Name,
[Required, MinLength(2), MaxLength(100)] string ResponsibleUnit,
[Required, MaxLength(30)] string ResponsibleRole,
bool IsRequired);
public sealed record GraduationClearanceRecordRequest(
GraduationClearanceRecordStatus Status,
[MaxLength(500)] string? Notes);
@@ -30,6 +30,37 @@ public sealed class StudentStatusChangesController(
}).ToListAsync(token));
}
[HttpGet("options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetOptions(CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == userId, token);
if (student is null) return ConflictProblem("当前账号未关联学生档案。");
var types = student.Status switch
{
StudentStatus.Active => new[]
{
StudentStatusChangeType.Suspension,
StudentStatusChangeType.Withdrawal
},
StudentStatus.Suspended => new[]
{
StudentStatusChangeType.Resumption,
StudentStatusChangeType.Withdrawal
},
_ => []
};
var hasPending = await db.StudentStatusChanges.AnyAsync(x =>
x.StudentId == student.Id &&
(x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved), token);
return Ok(new { student.Status, Types = types, HasPending = hasPending });
}
[HttpPost]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Create(
@@ -76,14 +107,19 @@ public sealed class StudentStatusChangesController(
.FirstOrDefaultAsync(x => x.Id == id, token);
if (change is null) return NotFound();
var scope = currentUserDataScope.Current;
if (!CanReviewCurrentStage(scope, change.State))
return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
if (!request.Approved)
{
if (change.State is StudentStatusChangeState.Approved or
StudentStatusChangeState.Rejected or StudentStatusChangeState.Cancelled)
return ConflictProblem("该申请已经结束。");
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new ProblemDetails
{
Title = "审核意见不完整",
Detail = "驳回申请时必须填写审核意见。",
Status = StatusCodes.Status400BadRequest
});
change.State = StudentStatusChangeState.Rejected;
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
}
else if (scope.IsInRole(SystemRoles.Counselor) &&
change.State == StudentStatusChangeState.Submitted)
@@ -100,12 +136,30 @@ public sealed class StudentStatusChangesController(
change.ApprovedAt = DateTime.UtcNow;
}
else return ConflictProblem("当前角色或审核阶段不允许执行该操作。");
change.ReviewComment = request.Comment?.Trim();
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpPost("{id:guid}/cancel")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Cancel(Guid id, CancellationToken token)
{
var userId = currentUserDataScope.Current.UserId;
var change = await db.StudentStatusChanges.FirstOrDefaultAsync(
x => x.Id == id && x.Student!.UserId == userId, token);
if (change is null) return NotFound();
if (change.State != StudentStatusChangeState.Submitted)
return ConflictProblem("只有尚未进入审核的申请可以撤回。");
change.State = StudentStatusChangeState.Cancelled;
change.ReviewedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return NoContent();
}
private IQueryable<StudentStatusChange> ScopedChanges()
{
var scope = currentUserDataScope.Current;
@@ -123,6 +177,21 @@ public sealed class StudentStatusChangesController(
return source.Where(_ => false);
}
private static bool CanReviewCurrentStage(
CurrentUserScope scope,
StudentStatusChangeState state) =>
state switch
{
StudentStatusChangeState.Submitted =>
scope.IsInRole(SystemRoles.Counselor),
StudentStatusChangeState.CounselorApproved =>
scope.IsInRole(SystemRoles.CollegeAdmin),
StudentStatusChangeState.CollegeApproved =>
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.SuperAdmin),
_ => false
};
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成学籍异动操作", Detail = detail,