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.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/courses")] public sealed class CoursesController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, IAppCache cache) : ControllerBase { private const string WriteRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet] public async Task>> Get( string? cursor = null, int pageSize = 20, string? keyword = null, Guid? collegeId = null, Guid? categoryId = null, CourseNature? nature = null, bool? isEnabled = null, CancellationToken cancellationToken = default, bool? isArchived = null) { pageSize = Math.Clamp(pageSize, 10, 100); if (!CursorToken.TryDecode(cursor, out var after)) return BadRequest("课程游标无效。"); var scope = currentUserDataScope.Current; var canManageAll = scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin); var canManageCollegeCourses = scope.IsInRole(SystemRoles.CollegeAdmin); var managedCollegeId = scope.CollegeId; var source = ScopedCourses().AsNoTracking(); if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value); if (categoryId.HasValue) source = source.Where(x => x.CourseCategoryId == categoryId.Value); if (nature.HasValue) source = source.Where(x => x.Nature == nature.Value); if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword) || (x.EnglishName != null && x.EnglishName.Contains(keyword))); } if (isArchived.HasValue) source = source.Where(x => x.IsArchived == isArchived.Value); var total = await source.CountAsync(cancellationToken); if (after is not null) { source = source.Where(x => x.SortOrder > after.SortOrder || x.SortOrder == after.SortOrder && (x.Code.CompareTo(after.Code) > 0 || x.Code == after.Code && x.Id.CompareTo(after.Id) > 0)); } var rows = await source .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .ThenBy(x => x.Id) .Take(pageSize + 1) .Select(x => new { x.Id, x.Code, x.Name, x.EnglishName, x.CollegeId, CollegeName = x.College!.Name, x.CourseCategoryId, CategoryCode = x.CourseCategory != null ? x.CourseCategory.Code : null, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, x.Credits, x.TotalHours, x.LectureHours, x.PracticeHours, x.Nature, x.AssessmentMethod, x.Description, PrerequisiteCourseIds = x.Prerequisites .OrderBy(item => item.PrerequisiteCourse!.Code) .Select(item => item.PrerequisiteCourseId), Prerequisites = x.Prerequisites .OrderBy(item => item.PrerequisiteCourse!.Code) .Select(item => new { item.PrerequisiteCourseId, item.PrerequisiteCourse!.Code, item.PrerequisiteCourse.Name }), x.IsEnabled, x.SortOrder, x.IsArchived, x.ArchivedAt, x.ArchiveReason, x.CreatedAt, CanManage = canManageAll || canManageCollegeCourses && x.CollegeId == managedCollegeId && (x.Nature == CourseNature.MajorRequired || x.Nature == CourseNature.MajorElective || x.Nature == CourseNature.Practice) }) .ToListAsync(cancellationToken); var hasMore = rows.Count > pageSize; var items = rows.Take(pageSize).ToArray(); var last = items.LastOrDefault(); return Ok(new CursorPagedResult(items.Cast().ToArray(), hasMore && last is not null ? CursorToken.Encode(new CourseCursor(last.SortOrder, last.Code, last.Id)) : null, hasMore, pageSize, total)); } private sealed record CourseCursor(int SortOrder, string Code, Guid Id); [HttpGet("options")] public async Task> GetOptions( Guid? collegeId = null, CourseNature? nature = null, string? keyword = null, CancellationToken cancellationToken = default) { var source = ScopedCourses().AsNoTracking().Where(x => x.IsEnabled && !x.IsArchived); if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value); if (nature.HasValue) source = source.Where(x => x.Nature == nature.Value); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword) || (x.EnglishName != null && x.EnglishName.Contains(keyword))); } return Ok(await source .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId, CollegeName = x.College!.Name, x.CourseCategoryId, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, x.Credits, x.TotalHours, x.LectureHours, x.PracticeHours, x.Nature, x.AssessmentMethod }) .ToListAsync(cancellationToken)); } [HttpPost] [Authorize(Roles = WriteRoles)] public async Task Create( CourseRequest request, CancellationToken cancellationToken) { var entity = new Course { Code = request.Code.Trim(), Name = request.Name.Trim(), EnglishName = Normalize(request.EnglishName), CollegeId = request.CollegeId, CourseCategoryId = request.CourseCategoryId, Credits = request.Credits, TotalHours = request.TotalHours, LectureHours = request.LectureHours, PracticeHours = request.PracticeHours, Nature = request.Nature, AssessmentMethod = request.AssessmentMethod, Description = Normalize(request.Description), IsEnabled = request.IsEnabled, SortOrder = request.SortOrder }; var validation = await ValidateAsync(entity.Id, request, cancellationToken); if (validation is not null) return validation; entity.Prerequisites = NormalizePrerequisiteIds(request) .Select(prerequisiteId => new CoursePrerequisite { CourseId = entity.Id, PrerequisiteCourseId = prerequisiteId }) .ToList(); db.Courses.Add(entity); return await SaveAsync(entity.Id, true, cancellationToken); } [HttpPut("{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task Update( Guid id, CourseRequest request, CancellationToken cancellationToken) { var entity = await db.Courses .Include(x => x.Prerequisites) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return NotFound(); if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid(); var validation = await ValidateAsync(id, request, cancellationToken); if (validation is not null) return validation; if (entity.IsArchived) return Conflict(new ProblemDetails { Detail = "已归档课程请先恢复后再编辑。", Status = 409 }); entity.Code = request.Code.Trim(); entity.Name = request.Name.Trim(); entity.EnglishName = Normalize(request.EnglishName); entity.CollegeId = request.CollegeId; entity.CourseCategoryId = request.CourseCategoryId; entity.Credits = request.Credits; entity.TotalHours = request.TotalHours; entity.LectureHours = request.LectureHours; entity.PracticeHours = request.PracticeHours; entity.Nature = request.Nature; entity.AssessmentMethod = request.AssessmentMethod; entity.Description = Normalize(request.Description); entity.IsEnabled = request.IsEnabled; entity.SortOrder = request.SortOrder; var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet(); db.CoursePrerequisites.RemoveRange( entity.Prerequisites.Where(x => !prerequisiteIds.Contains(x.PrerequisiteCourseId))); foreach (var prerequisiteId in prerequisiteIds.Except( entity.Prerequisites.Select(x => x.PrerequisiteCourseId))) { entity.Prerequisites.Add(new CoursePrerequisite { CourseId = entity.Id, PrerequisiteCourseId = prerequisiteId }); } return await SaveAsync(entity.Id, false, cancellationToken); } [HttpDelete("{id:guid}")] [Authorize(Roles = WriteRoles)] public async Task Delete(Guid id, CancellationToken cancellationToken) { var entity = await db.Courses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid(); if (entity.IsArchived) return Conflict(new ProblemDetails { Detail = "已归档课程不能删除。", Status = 409 }); db.Courses.Remove(entity); return await SaveAsync(id, false, cancellationToken); } private async Task ValidateAsync( Guid courseId, CourseRequest request, CancellationToken cancellationToken) { if (!CanManage(request.CollegeId, request.Nature)) return Forbid(); if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken)) return ValidationProblem("所选学院不存在。"); if (!await db.CourseCategories.AnyAsync( x => x.Id == request.CourseCategoryId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选课程分类不存在或已停用。"); if (request.LectureHours + request.PracticeHours > request.TotalHours) return ValidationProblem("讲授学时与实践学时之和不能超过总学时。"); var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray(); if (prerequisiteIds.Contains(courseId)) return ValidationProblem("课程不能把自身设置为先修课程。"); var accessiblePrerequisiteCount = await ScopedCourses() .CountAsync(x => prerequisiteIds.Contains(x.Id) && x.IsEnabled, cancellationToken); if (accessiblePrerequisiteCount != prerequisiteIds.Length) return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。"); if (await CreatesPrerequisiteCycleAsync( courseId, prerequisiteIds, cancellationToken)) return ValidationProblem("先修关系不能形成循环依赖。"); return null; } private async Task CreatesPrerequisiteCycleAsync( Guid courseId, IReadOnlyCollection prerequisiteIds, CancellationToken cancellationToken) { if (prerequisiteIds.Count == 0) return false; var edges = await db.CoursePrerequisites.AsNoTracking() .Where(x => x.CourseId != courseId) .Select(x => new { x.CourseId, x.PrerequisiteCourseId }) .ToListAsync(cancellationToken); var prerequisitesByCourse = edges .GroupBy(x => x.CourseId) .ToDictionary( group => group.Key, group => group.Select(x => x.PrerequisiteCourseId).ToArray()); foreach (var prerequisiteId in prerequisiteIds) { var pending = new Stack(); var visited = new HashSet(); pending.Push(prerequisiteId); while (pending.TryPop(out var candidate)) { if (candidate == courseId) return true; if (!visited.Add(candidate) || !prerequisitesByCourse.TryGetValue(candidate, out var next)) continue; foreach (var item in next) pending.Push(item); } } return false; } private static IEnumerable NormalizePrerequisiteIds(CourseRequest request) => (request.PrerequisiteCourseIds ?? []).Distinct(); private IQueryable ScopedCourses() { var scope = currentUserDataScope.Current; var source = db.Courses.AsQueryable(); if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) { return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); } if (scope.Scope == DataScope.Class) { return source.Where(course => db.TeachingTasks.Any(task => task.CourseId == course.Id && task.Classes.Any(item => item.AdministrativeClass!.CounselorUserId == scope.UserId))); } var userId = scope.UserId; var isTeacher = scope.IsInRole(SystemRoles.Teacher); var isStudent = scope.IsInRole(SystemRoles.Student); return source.Where(course => isTeacher && db.TeachingTasks.Any(task => task.CourseId == course.Id && task.Teachers.Any(item => item.Teacher!.UserId == userId)) || isStudent && ( db.TeachingTasks.Any(task => task.CourseId == course.Id && task.Classes.Any(item => item.AdministrativeClass!.Students.Any(student => student.UserId == userId))) || db.CourseEnrollments.Any(enrollment => enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.Student!.UserId == userId && enrollment.CourseSelectionOffering!.TeachingTask!.CourseId == course.Id))); } private bool CanManage(Guid collegeId, CourseNature nature) => CourseMaintenancePolicy.CanManage( currentUserDataScope.Current, collegeId, nature); private async Task SaveAsync( Guid id, bool created, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) { return Conflict(new ProblemDetails { Title = "无法完成操作", Detail = "课程编码已存在,或课程已被其他教学业务引用。", Status = StatusCodes.Status409Conflict }); } } private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public sealed record CourseRequest( [Required, MaxLength(40)] string Code, [Required, MaxLength(100)] string Name, [MaxLength(150)] string? EnglishName, Guid CollegeId, Guid CourseCategoryId, [Range(typeof(decimal), "0.1", "99")] decimal Credits, [Range(1, 1000)] int TotalHours, [Range(0, 1000)] int LectureHours, [Range(0, 1000)] int PracticeHours, CourseNature Nature, AssessmentMethod AssessmentMethod, [MaxLength(1000)] string? Description, bool IsEnabled, int SortOrder, IReadOnlyCollection? PrerequisiteCourseIds = null);