using System.ComponentModel.DataAnnotations; using System.Security.Claims; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ReadRoles)] [Route("api/curriculum-plans")] public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet] public async Task>> GetPlans( int page = 1, int pageSize = 20, string? keyword = null, Guid? majorId = null, int? effectiveGrade = null, CurriculumPlanStatus? status = null, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var source = ScopedPlans().AsNoTracking(); if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId); if (effectiveGrade.HasValue) source = source.Where(x => x.EffectiveGrade == effectiveGrade); if (status.HasValue) source = source.Where(x => x.Status == status); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.Name.Contains(keyword) || x.Version.Contains(keyword) || x.Major!.Name.Contains(keyword)); } var total = await source.CountAsync(cancellationToken); var items = await source .OrderByDescending(x => x.EffectiveGrade) .ThenByDescending(x => x.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.Name, x.Version, x.MajorId, MajorName = x.Major!.Name, CollegeId = x.Major.CollegeId, CollegeName = x.Major.College!.Name, x.EffectiveGrade, x.TotalCredits, x.Status, x.PublishedAt, ModuleCount = x.Modules.Count, CourseCount = x.Modules.SelectMany(module => module.Courses).Count(), x.UpdatedAt }) .ToListAsync(cancellationToken); return Ok(new PagedResult(items, total, page, pageSize)); } [HttpGet("{id:guid}")] public async Task GetPlan(Guid id, CancellationToken cancellationToken) { var plan = await ScopedPlans() .AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.Name, x.Version, x.MajorId, MajorName = x.Major!.Name, SchoolingYears = x.Major.SchoolingYears, CollegeId = x.Major.CollegeId, CollegeName = x.Major.College!.Name, x.EffectiveGrade, x.TotalCredits, x.Status, x.Description, x.PublishedAt, x.CreatedAt, x.UpdatedAt, Modules = x.Modules .OrderBy(module => module.SortOrder) .ThenBy(module => module.Code) .Select(module => new { module.Id, module.Code, module.Name, module.RequiredCredits, module.SortOrder, AssignedCredits = module.Courses.Sum(course => course.Course!.Credits), Courses = module.Courses .OrderBy(course => course.RecommendedSemester) .ThenBy(course => course.Course!.Code) .Select(course => new { course.Id, course.CourseId, CourseCode = course.Course!.Code, CourseName = course.Course.Name, course.Course.Credits, course.Course.TotalHours, course.Course.Nature, course.RecommendedSemester, course.Type, course.Notes }) }) }) .FirstOrDefaultAsync(cancellationToken); return plan is null ? NotFound() : Ok(plan); } [HttpPost] public async Task Create( CurriculumPlanRequest request, CancellationToken cancellationToken) { var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken); if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。"); var plan = new CurriculumPlan { MajorId = request.MajorId, Name = request.Name.Trim(), Version = request.Version.Trim(), EffectiveGrade = request.EffectiveGrade, TotalCredits = request.TotalCredits, Description = Normalize(request.Description) }; db.CurriculumPlans.Add(plan); return await SaveCreatedAsync(plan.Id, cancellationToken); } [HttpPut("{id:guid}")] public async Task Update( Guid id, CurriculumPlanRequest request, CancellationToken cancellationToken) { var plan = await EditablePlanAsync(id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != CurriculumPlanStatus.Draft) return ConflictProblem("已发布或已归档的培养方案不可直接修改,请复制为新版本。"); var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken); if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。"); plan.MajorId = request.MajorId; plan.Name = request.Name.Trim(); plan.Version = request.Version.Trim(); plan.EffectiveGrade = request.EffectiveGrade; plan.TotalCredits = request.TotalCredits; plan.Description = Normalize(request.Description); return await SaveNoContentAsync(cancellationToken); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken cancellationToken) { var plan = await EditablePlanAsync(id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != CurriculumPlanStatus.Draft) return ConflictProblem("仅草稿方案可以删除。"); db.CurriculumPlans.Remove(plan); return await SaveNoContentAsync(cancellationToken); } [HttpPost("{id:guid}/clone")] public async Task Clone( Guid id, CloneCurriculumPlanRequest request, CancellationToken cancellationToken) { var source = await ScopedPlans() .AsNoTracking() .Include(x => x.Modules) .ThenInclude(x => x.Courses) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (source is null) return NotFound(); var copy = new CurriculumPlan { MajorId = source.MajorId, Name = request.Name.Trim(), Version = request.Version.Trim(), EffectiveGrade = request.EffectiveGrade, TotalCredits = source.TotalCredits, Description = source.Description, Modules = source.Modules.Select(module => new CurriculumModule { Code = module.Code, Name = module.Name, RequiredCredits = module.RequiredCredits, SortOrder = module.SortOrder, Courses = module.Courses.Select(course => new CurriculumCourse { CourseId = course.CourseId, RecommendedSemester = course.RecommendedSemester, Type = course.Type, Notes = course.Notes }).ToList() }).ToList() }; db.CurriculumPlans.Add(copy); return await SaveCreatedAsync(copy.Id, cancellationToken); } [HttpPost("{id:guid}/publish")] public async Task Publish(Guid id, CancellationToken cancellationToken) { var plan = await ScopedPlans() .Include(x => x.Major) .Include(x => x.Modules) .ThenInclude(x => x.Courses) .ThenInclude(x => x.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != CurriculumPlanStatus.Draft) return ConflictProblem("只有草稿方案可以发布。"); if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0)) return ConflictProblem("发布前每个课程模块都必须配置课程。"); if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits) return ConflictProblem("各模块最低学分之和必须等于方案总学分。"); if (plan.Modules.Any(module => module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits)) return ConflictProblem("存在课程学分合计低于最低学分要求的模块。"); if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1)) return ConflictProblem("同一门课程不能重复加入多个模块。"); if (plan.Modules.SelectMany(x => x.Courses).Any(x => x.RecommendedSemester > plan.Major!.SchoolingYears * 2)) return ConflictProblem("建议学期超出了该专业学制。"); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var previousPlans = await ScopedPlans() .Where(x => x.Id != plan.Id && x.MajorId == plan.MajorId && x.EffectiveGrade == plan.EffectiveGrade && x.Status == CurriculumPlanStatus.Published) .ToListAsync(cancellationToken); foreach (var previous in previousPlans) { previous.Status = CurriculumPlanStatus.Archived; } plan.Status = CurriculumPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return NoContent(); } [HttpPost("{planId:guid}/modules")] public async Task CreateModule( Guid planId, CurriculumModuleRequest request, CancellationToken cancellationToken) { var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var module = new CurriculumModule { CurriculumPlanId = plan.Id, Code = request.Code.Trim(), Name = request.Name.Trim(), RequiredCredits = request.RequiredCredits, SortOrder = request.SortOrder }; db.CurriculumModules.Add(module); return await SaveCreatedAsync(module.Id, cancellationToken); } [HttpPut("{planId:guid}/modules/{moduleId:guid}")] public async Task UpdateModule( Guid planId, Guid moduleId, CurriculumModuleRequest request, CancellationToken cancellationToken) { if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); var module = await db.CurriculumModules .FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken); if (module is null) return NotFound(); module.Code = request.Code.Trim(); module.Name = request.Name.Trim(); module.RequiredCredits = request.RequiredCredits; module.SortOrder = request.SortOrder; return await SaveNoContentAsync(cancellationToken); } [HttpDelete("{planId:guid}/modules/{moduleId:guid}")] public async Task DeleteModule( Guid planId, Guid moduleId, CancellationToken cancellationToken) { if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); var module = await db.CurriculumModules .FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken); if (module is null) return NotFound(); db.CurriculumModules.Remove(module); return await SaveNoContentAsync(cancellationToken); } [HttpPost("{planId:guid}/modules/{moduleId:guid}/courses")] public async Task AddCourse( Guid planId, Guid moduleId, CurriculumCourseRequest request, CancellationToken cancellationToken) { var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); if (!await db.CurriculumModules.AnyAsync( x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken)) return NotFound(); if (!await db.Courses.AnyAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选课程不存在或已停用。"); if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2) return ValidationProblem("建议学期超出了该专业学制。"); if (await db.CurriculumCourses.AnyAsync( x => x.CurriculumModule!.CurriculumPlanId == planId && x.CourseId == request.CourseId, cancellationToken)) return ConflictProblem("该课程已存在于本方案中。"); var item = new CurriculumCourse { CurriculumModuleId = moduleId, CourseId = request.CourseId, RecommendedSemester = request.RecommendedSemester, Type = request.Type, Notes = Normalize(request.Notes) }; db.CurriculumCourses.Add(item); return await SaveCreatedAsync(item.Id, cancellationToken); } [HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")] public async Task UpdateCourse( Guid planId, Guid moduleId, Guid itemId, CurriculumCourseRequest request, CancellationToken cancellationToken) { var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var item = await db.CurriculumCourses .FirstOrDefaultAsync(x => x.Id == itemId && x.CurriculumModuleId == moduleId && x.CurriculumModule!.CurriculumPlanId == planId, cancellationToken); if (item is null) return NotFound(); if (!await db.Courses.AnyAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选课程不存在或已停用。"); if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2) return ValidationProblem("建议学期超出了该专业学制。"); if (await db.CurriculumCourses.AnyAsync( x => x.Id != itemId && x.CurriculumModule!.CurriculumPlanId == planId && x.CourseId == request.CourseId, cancellationToken)) return ConflictProblem("该课程已存在于本方案中。"); item.CourseId = request.CourseId; item.RecommendedSemester = request.RecommendedSemester; item.Type = request.Type; item.Notes = Normalize(request.Notes); return await SaveNoContentAsync(cancellationToken); } [HttpDelete("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")] public async Task DeleteCourse( Guid planId, Guid moduleId, Guid itemId, CancellationToken cancellationToken) { if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); var item = await db.CurriculumCourses .FirstOrDefaultAsync(x => x.Id == itemId && x.CurriculumModuleId == moduleId && x.CurriculumModule!.CurriculumPlanId == planId, cancellationToken); if (item is null) return NotFound(); db.CurriculumCourses.Remove(item); return await SaveNoContentAsync(cancellationToken); } private IQueryable ScopedPlans() { var source = db.CurriculumPlans.AsQueryable(); var collegeId = ScopedCollegeId(); return collegeId.HasValue ? source.Where(x => x.Major!.CollegeId == collegeId.Value) : source; } private async Task FindAccessibleMajorAsync(Guid majorId, CancellationToken cancellationToken) { var source = db.Majors.AsQueryable(); var collegeId = ScopedCollegeId(); if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value); return await source.FirstOrDefaultAsync(x => x.Id == majorId, cancellationToken); } private async Task EditablePlanAsync( Guid id, CancellationToken cancellationToken) => await ScopedPlans().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); private async Task DraftPlanAsync( Guid id, CancellationToken cancellationToken) => await ScopedPlans() .Include(x => x.Major) .FirstOrDefaultAsync( x => x.Id == id && x.Status == CurriculumPlanStatus.Draft, cancellationToken); private Guid? ScopedCollegeId() { if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null; return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId) ? collegeId : Guid.Empty; } private async Task SaveCreatedAsync(Guid id, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { id }); } catch (DbUpdateException) { return ConflictProblem("版本、模块编码或课程配置重复。"); } } private async Task SaveNoContentAsync(CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return 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 CurriculumPlanRequest( Guid MajorId, [Required, MaxLength(120)] string Name, [Required, MaxLength(30)] string Version, [Range(2000, 2200)] int EffectiveGrade, [Range(typeof(decimal), "0.1", "999")] decimal TotalCredits, [MaxLength(1000)] string? Description); public sealed record CloneCurriculumPlanRequest( [Required, MaxLength(120)] string Name, [Required, MaxLength(30)] string Version, [Range(2000, 2200)] int EffectiveGrade); public sealed record CurriculumModuleRequest( [Required, MaxLength(30)] string Code, [Required, MaxLength(100)] string Name, [Range(typeof(decimal), "0", "999")] decimal RequiredCredits, [Range(0, 9999)] int SortOrder); public sealed record CurriculumCourseRequest( Guid CourseId, [Range(1, 20)] int RecommendedSemester, CurriculumCourseType Type, [MaxLength(500)] string? Notes);