using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Contracts; 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(Roles = ManagementRoles)] [Route("api/teaching-tasks")] public sealed class TeachingTasksController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet] public async Task>> Get( int page = 1, int pageSize = 20, string? keyword = null, Guid? academicTermId = null, Guid? collegeId = null, TeachingTaskStatus? status = null, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var source = ScopedTasks().AsNoTracking(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (collegeId.HasValue) source = source.Where(x => x.Course!.CollegeId == collegeId); if (status.HasValue) source = source.Where(x => x.Status == status); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.TaskNumber.Contains(keyword) || x.Name.Contains(keyword) || x.Course!.Code.Contains(keyword) || x.Course.Name.Contains(keyword)); } var total = await source.CountAsync(cancellationToken); var items = await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.TaskNumber) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, CollegeId = x.Course.CollegeId, CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, x.EndWeek, x.WeeklyHours, x.Status, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name), StudentCount = x.Classes.Sum(item => item.AdministrativeClass!.Students.Count(student => student.Status == StudentStatus.Active)), x.UpdatedAt }) .ToListAsync(cancellationToken); return Ok(new PagedResult(items, total, page, pageSize)); } [HttpGet("{id:guid}")] public async Task GetOne(Guid id, CancellationToken cancellationToken) { var task = await ScopedTasks() .AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, CollegeId = x.Course.CollegeId, CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, x.EndWeek, x.WeeklyHours, x.Status, x.Notes, x.PublishedAt, TeacherIds = x.Teachers.Select(item => item.TeacherId), PrimaryTeacherId = x.Teachers .Where(item => item.IsPrimary) .Select(item => (Guid?)item.TeacherId) .FirstOrDefault(), Teachers = x.Teachers .OrderByDescending(item => item.IsPrimary) .ThenBy(item => item.Teacher!.TeacherNumber) .Select(item => new { item.TeacherId, item.Teacher!.TeacherNumber, item.Teacher.Name, item.Teacher.Title, item.IsPrimary }), ClassIds = x.Classes.Select(item => item.AdministrativeClassId), Classes = x.Classes .OrderBy(item => item.AdministrativeClass!.Code) .Select(item => new { item.AdministrativeClassId, item.AdministrativeClass!.Code, item.AdministrativeClass.Name, item.AdministrativeClass.Grade, StudentCount = item.AdministrativeClass.Students.Count(student => student.Status == StudentStatus.Active) }), x.CreatedAt, x.UpdatedAt }) .FirstOrDefaultAsync(cancellationToken); return task is null ? NotFound() : Ok(task); } [HttpPost] public async Task Create( TeachingTaskRequest request, CancellationToken cancellationToken) { var validation = await ValidateRequestAsync(request, cancellationToken); if (validation is not null) return validation; var task = new TeachingTask { TaskNumber = request.TaskNumber.Trim(), Name = request.Name.Trim(), AcademicTermId = request.AcademicTermId, CourseId = request.CourseId, Capacity = request.Capacity, StartWeek = request.StartWeek, EndWeek = request.EndWeek, WeeklyHours = request.WeeklyHours, Notes = Normalize(request.Notes) }; SetAssignments(task, request); db.TeachingTasks.Add(task); return await SaveAsync(task.Id, true, cancellationToken); } [HttpPut("{id:guid}")] public async Task Update( Guid id, TeachingTaskRequest request, CancellationToken cancellationToken) { var task = await ScopedTasks() .Include(x => x.Teachers) .Include(x => x.Classes) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("已发布或已结课的教学任务不可直接修改。"); var validation = await ValidateRequestAsync(request, cancellationToken); if (validation is not null) return validation; task.TaskNumber = request.TaskNumber.Trim(); task.Name = request.Name.Trim(); task.AcademicTermId = request.AcademicTermId; task.CourseId = request.CourseId; task.Capacity = request.Capacity; task.StartWeek = request.StartWeek; task.EndWeek = request.EndWeek; task.WeeklyHours = request.WeeklyHours; task.Notes = Normalize(request.Notes); db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskClasses.RemoveRange(task.Classes); task.Teachers = []; task.Classes = []; SetAssignments(task, request); return await SaveAsync(task.Id, false, cancellationToken); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken cancellationToken) { var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("仅草稿教学任务可以删除。"); db.TeachingTasks.Remove(task); return await SaveAsync(id, false, cancellationToken); } [HttpPost("{id:guid}/publish")] public async Task Publish(Guid id, CancellationToken cancellationToken) { var task = await ScopedTasks() .Include(x => x.Teachers) .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); if (task.Status != TeachingTaskStatus.Draft) return ConflictProblem("只有草稿教学任务可以发布。"); if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1) return ConflictProblem("发布前必须指定且只能指定一名主讲教师。"); if (task.Classes.Count == 0) return ConflictProblem("发布前至少需要关联一个行政班。"); var studentCount = task.Classes.Sum(x => x.AdministrativeClass!.Students.Count(student => student.Status == StudentStatus.Active)); if (studentCount > task.Capacity) return ConflictProblem($"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。"); task.Status = TeachingTaskStatus.Published; task.PublishedAt = DateTime.UtcNow; return await SaveAsync(id, false, cancellationToken); } [HttpPost("{id:guid}/close")] public async Task Close(Guid id, CancellationToken cancellationToken) { var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (task is null) return NotFound(); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("只有已发布的教学任务可以结课。"); task.Status = TeachingTaskStatus.Closed; return await SaveAsync(id, false, cancellationToken); } private IQueryable ScopedTasks() { var source = db.TeachingTasks.AsQueryable(); var collegeId = ScopedCollegeId(); return collegeId.HasValue ? source.Where(x => x.Course!.CollegeId == collegeId.Value) : source; } private async Task ValidateRequestAsync( TeachingTaskRequest request, CancellationToken cancellationToken) { if (request.StartWeek > request.EndWeek) return ValidationProblem("开始周不能晚于结束周。"); var course = await db.Courses.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); if (course is null) return ValidationProblem("所选课程不存在或已停用。"); var collegeId = ScopedCollegeId(); if (collegeId.HasValue && course.CollegeId != collegeId.Value) return Forbid(); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); var teacherIds = request.TeacherIds.Distinct().ToArray(); if (request.PrimaryTeacherId.HasValue && !teacherIds.Contains(request.PrimaryTeacherId.Value)) return ValidationProblem("主讲教师必须包含在授课教师中。"); if (await db.Teachers.CountAsync( x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效或非在职授课教师。"); var classIds = request.ClassIds.Distinct().ToArray(); var classes = db.AdministrativeClasses.AsNoTracking() .Where(x => classIds.Contains(x.Id)); if (collegeId.HasValue) classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); if (await classes.CountAsync(cancellationToken) != classIds.Length) return ValidationProblem("存在无效或不在当前数据范围内的行政班。"); return null; } private static void SetAssignments(TeachingTask task, TeachingTaskRequest request) { foreach (var teacherId in request.TeacherIds.Distinct()) { task.Teachers.Add(new TeachingTaskTeacher { TeacherId = teacherId, IsPrimary = teacherId == request.PrimaryTeacherId }); } foreach (var classId in request.ClassIds.Distinct()) { task.Classes.Add(new TeachingTaskClass { AdministrativeClassId = classId }); } } private Guid? ScopedCollegeId() => currentUserDataScope.Current.RestrictedCollegeId; private async Task SaveAsync( Guid id, bool created, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) { return ConflictProblem("教学任务编号重复,或关联数据已失效。"); } } 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 TeachingTaskRequest( [Required, MaxLength(40)] string TaskNumber, [Required, MaxLength(120)] string Name, Guid AcademicTermId, Guid CourseId, [Range(1, 10000)] int Capacity, [Range(1, 30)] int StartWeek, [Range(1, 30)] int EndWeek, [Range(1, 40)] int WeeklyHours, IReadOnlyCollection TeacherIds, Guid? PrimaryTeacherId, IReadOnlyCollection ClassIds, [MaxLength(500)] string? Notes);