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/teacher-course-applications")] public sealed class TeacherCourseApplicationsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ReviewRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet("course-options")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetCourseOptions(CancellationToken cancellationToken) { return Ok(await db.Courses.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId, CollegeName = x.College!.Name, x.Credits, x.Nature }) .ToListAsync(cancellationToken)); } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMine( Guid? academicTermId, CancellationToken cancellationToken) { var teacher = await CurrentTeacherAsync(cancellationToken); if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); var source = db.TeacherCourseApplications.AsNoTracking() .Where(x => x.TeacherId == teacher.Id); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.Course!.Code) .Select(x => new { x.Id, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, CollegeName = x.Course.College!.Name, x.Status, x.Statement, x.ReviewComment, x.SubmittedAt, x.ReviewedAt }) .ToListAsync(cancellationToken)); } [HttpPost("mine")] [Authorize(Roles = SystemRoles.Teacher)] public async Task Submit( TeacherCourseApplicationRequest request, CancellationToken cancellationToken) { var teacher = await CurrentTeacherAsync(cancellationToken); if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); if (!await db.Courses.AnyAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选课程不存在或已停用。"); var application = await db.TeacherCourseApplications .FirstOrDefaultAsync(x => x.AcademicTermId == request.AcademicTermId && x.TeacherId == teacher.Id && x.CourseId == request.CourseId, cancellationToken); if (application is not null && application.Status is TeacherCourseApplicationStatus.Pending or TeacherCourseApplicationStatus.Approved) return ConflictProblem("该学期的课程申报已提交或已审核通过。"); if (application is null) { application = new TeacherCourseApplication { AcademicTermId = request.AcademicTermId, TeacherId = teacher.Id, CourseId = request.CourseId }; db.TeacherCourseApplications.Add(application); } application.Status = TeacherCourseApplicationStatus.Pending; application.Statement = Normalize(request.Statement); application.ReviewComment = null; application.SubmittedAt = DateTime.UtcNow; application.ReviewedAt = null; application.ReviewedByUserId = null; await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { application.Id }); } [HttpDelete("mine/{id:guid}")] [Authorize(Roles = SystemRoles.Teacher)] public async Task Withdraw(Guid id, CancellationToken cancellationToken) { var teacher = await CurrentTeacherAsync(cancellationToken); if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); var application = await db.TeacherCourseApplications.FirstOrDefaultAsync( x => x.Id == id && x.TeacherId == teacher.Id, cancellationToken); if (application is null) return NotFound(); if (application.Status != TeacherCourseApplicationStatus.Pending) return ConflictProblem("只有待审核申报可以撤回。"); application.Status = TeacherCourseApplicationStatus.Withdrawn; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpGet("reviews")] [Authorize(Roles = ReviewRoles)] public async Task GetReviews( Guid? academicTermId, TeacherCourseApplicationStatus? status, CancellationToken cancellationToken) { var source = ScopedApplications().AsNoTracking(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (status.HasValue) source = source.Where(x => x.Status == status); return Ok(await source .OrderBy(x => x.Status) .ThenByDescending(x => x.SubmittedAt) .Select(x => new { x.Id, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.TeacherId, x.Teacher!.TeacherNumber, TeacherName = x.Teacher.Name, TeacherCollegeName = x.Teacher.College!.Name, x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, CourseCollegeName = x.Course.College!.Name, x.Status, x.Statement, x.ReviewComment, x.SubmittedAt, x.ReviewedAt }) .ToListAsync(cancellationToken)); } [HttpGet("assignment-options")] [Authorize(Roles = ReviewRoles)] public async Task GetAssignmentOptions( CancellationToken cancellationToken) { var teachers = db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active); var collegeId = currentUserDataScope.Current.RestrictedCollegeId; if (collegeId.HasValue) teachers = teachers.Where(x => x.CollegeId == collegeId.Value); return Ok(new { Teachers = await teachers .OrderBy(x => x.TeacherNumber) .Select(x => new { x.Id, x.TeacherNumber, x.Name, x.Title, x.CollegeId, CollegeName = x.College!.Name }) .ToListAsync(cancellationToken), Courses = await db.Courses.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId, CollegeName = x.College!.Name, x.Credits, x.Nature }) .ToListAsync(cancellationToken) }); } [HttpPost("assign")] [Authorize(Roles = ReviewRoles)] public async Task Assign( TeacherCourseAssignmentRequest request, CancellationToken cancellationToken) { if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); if (!await db.Courses.AnyAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选课程不存在或已停用。"); var teachers = db.Teachers.Where(x => x.Id == request.TeacherId && x.Status == TeacherStatus.Active); var collegeId = currentUserDataScope.Current.RestrictedCollegeId; if (collegeId.HasValue) teachers = teachers.Where(x => x.CollegeId == collegeId.Value); if (!await teachers.AnyAsync(cancellationToken)) return ValidationProblem("所选教师不存在、不在职或不属于当前学院。"); var application = await db.TeacherCourseApplications .FirstOrDefaultAsync(x => x.AcademicTermId == request.AcademicTermId && x.TeacherId == request.TeacherId && x.CourseId == request.CourseId, cancellationToken); if (application?.Status == TeacherCourseApplicationStatus.Approved) return ConflictProblem("该教师在所选学期已具备此课程的授课资格。"); if (application is null) { application = new TeacherCourseApplication { AcademicTermId = request.AcademicTermId, TeacherId = request.TeacherId, CourseId = request.CourseId, SubmittedAt = DateTime.UtcNow }; db.TeacherCourseApplications.Add(application); } application.Status = TeacherCourseApplicationStatus.Approved; application.ReviewComment = Normalize(request.Comment) ?? "学院直接分配"; application.ReviewedAt = DateTime.UtcNow; application.ReviewedByUserId = currentUserDataScope.Current.UserId; await db.SaveChangesAsync(cancellationToken); return Ok(new { application.Id }); } [HttpPost("{id:guid}/review")] [Authorize(Roles = ReviewRoles)] public async Task Review( Guid id, TeacherCourseReviewRequest request, CancellationToken cancellationToken) { if (request.Status is not ( TeacherCourseApplicationStatus.Approved or TeacherCourseApplicationStatus.Rejected)) return ValidationProblem("审核结论只能为通过或驳回。"); var application = await ScopedApplications() .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (application is null) return NotFound(); if (application.Status != TeacherCourseApplicationStatus.Pending) return ConflictProblem("只有待审核申报可以审核。"); application.Status = request.Status; application.ReviewComment = Normalize(request.ReviewComment); application.ReviewedAt = DateTime.UtcNow; application.ReviewedByUserId = currentUserDataScope.Current.UserId; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpGet("eligible")] [Authorize(Roles = ReviewRoles)] public async Task GetEligible( Guid academicTermId, Guid courseId, CancellationToken cancellationToken) { var source = ScopedApplications().AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.CourseId == courseId && x.Status == TeacherCourseApplicationStatus.Approved && x.Teacher!.Status == TeacherStatus.Active); return Ok(await source .OrderBy(x => x.Teacher!.TeacherNumber) .Select(x => new { x.TeacherId, x.Teacher!.TeacherNumber, x.Teacher.Name, x.Teacher.Title, CollegeName = x.Teacher.College!.Name }) .ToListAsync(cancellationToken)); } private IQueryable ScopedApplications() { var source = db.TeacherCourseApplications.AsQueryable(); var collegeId = currentUserDataScope.Current.RestrictedCollegeId; return collegeId.HasValue ? source.Where(x => x.Teacher!.CollegeId == collegeId.Value) : source; } private Task CurrentTeacherAsync(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return db.Teachers.FirstOrDefaultAsync( x => x.UserId == userId && x.Status == TeacherStatus.Active, cancellationToken); } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public sealed record TeacherCourseApplicationRequest( Guid AcademicTermId, Guid CourseId, [MaxLength(500)] string? Statement); public sealed record TeacherCourseReviewRequest( TeacherCourseApplicationStatus Status, [MaxLength(500)] string? ReviewComment); public sealed record TeacherCourseAssignmentRequest( Guid AcademicTermId, Guid TeacherId, Guid CourseId, [MaxLength(500)] string? Comment);