using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Common; 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] [Route("api/base-data")] public sealed class BaseDataController(AppDbContext db) : ControllerBase { private const string Administrators = $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; [HttpGet("campuses")] public async Task>> GetCampuses( CancellationToken cancellationToken) => await db.Campuses.AsNoTracking() .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .ToListAsync(cancellationToken); [HttpPost("campuses")] [Authorize(Roles = Administrators)] public async Task> CreateCampus( CatalogRequest request, CancellationToken cancellationToken) { var entity = new Campus { Code = request.Code.Trim(), Name = request.Name.Trim(), Address = request.Description?.Trim(), SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetCampuses", cancellationToken); } [HttpPut("campuses/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateCampus( Guid id, CatalogRequest request, CancellationToken cancellationToken) { var entity = await db.Campuses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); entity.Address = request.Description?.Trim(); await db.SaveChangesAsync(cancellationToken); return entity; } [HttpGet("colleges")] public async Task> GetColleges(CancellationToken cancellationToken) => Ok(await db.Colleges.AsNoTracking() .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.ShortName, x.CampusId, CampusName = x.Campus != null ? x.Campus.Name : null, x.IsEnabled, x.SortOrder }) .ToListAsync(cancellationToken)); [HttpPost("colleges")] [Authorize(Roles = Administrators)] public async Task> CreateCollege( CollegeRequest request, CancellationToken cancellationToken) { if (request.CampusId.HasValue && !await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken)) { return ValidationProblem("所选校区不存在。"); } var entity = new College { Code = request.Code.Trim(), Name = request.Name.Trim(), ShortName = request.ShortName?.Trim(), CampusId = request.CampusId, SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetColleges", cancellationToken); } [HttpPut("colleges/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateCollege( Guid id, CollegeRequest request, CancellationToken cancellationToken) { var entity = await db.Colleges.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); entity.ShortName = request.ShortName?.Trim(); entity.CampusId = request.CampusId; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpGet("majors")] public async Task> GetMajors(CancellationToken cancellationToken) => Ok(await db.Majors.AsNoTracking() .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId, CollegeName = x.College!.Name, x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder }) .ToListAsync(cancellationToken)); [HttpGet("course-categories")] public async Task>> GetCourseCategories( CancellationToken cancellationToken) => await db.CourseCategories.AsNoTracking() .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) .ToListAsync(cancellationToken); [HttpPost("course-categories")] [Authorize(Roles = Administrators)] public async Task> CreateCourseCategory( CatalogRequest request, CancellationToken cancellationToken) { var entity = new CourseCategory { Code = request.Code.Trim(), Name = request.Name.Trim(), SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, nameof(GetCourseCategories), cancellationToken); } [HttpPut("course-categories/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateCourseCategory( Guid id, CatalogRequest request, CancellationToken cancellationToken) { var entity = await db.CourseCategories.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); await db.SaveChangesAsync(cancellationToken); return entity; } [HttpPost("majors")] [Authorize(Roles = Administrators)] public async Task> CreateMajor( MajorRequest request, CancellationToken cancellationToken) { if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken)) return ValidationProblem("所选学院不存在。"); var entity = new Major { Code = request.Code.Trim(), Name = request.Name.Trim(), CollegeId = request.CollegeId, DegreeType = request.DegreeType.Trim(), SchoolingYears = request.SchoolingYears, SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetMajors", cancellationToken); } [HttpPut("majors/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateMajor( Guid id, MajorRequest request, CancellationToken cancellationToken) { var entity = await db.Majors.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); entity.CollegeId = request.CollegeId; entity.DegreeType = request.DegreeType.Trim(); entity.SchoolingYears = request.SchoolingYears; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpGet("classes")] public async Task> GetClasses(CancellationToken cancellationToken) => Ok(await db.AdministrativeClasses.AsNoTracking() .OrderByDescending(x => x.Grade).ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.MajorId, MajorName = x.Major!.Name, CollegeName = x.Major.College!.Name, x.Grade, x.CounselorUserId, x.CounselorName, x.IsEnabled, x.SortOrder }) .ToListAsync(cancellationToken)); [HttpGet("counselors")] [Authorize(Roles = Administrators)] public async Task> GetCounselors(CancellationToken cancellationToken) => Ok(await db.Users.AsNoTracking() .Where(user => user.IsEnabled && db.UserRoles.Any(userRole => userRole.UserId == user.Id && db.Roles.Any(role => role.Id == userRole.RoleId && role.Name == SystemRoles.Counselor))) .OrderBy(x => x.DisplayName) .Select(x => new { x.Id, x.DisplayName, x.StaffNumber, x.CollegeId }) .ToListAsync(cancellationToken)); [HttpPost("classes")] [Authorize(Roles = Administrators)] public async Task> CreateClass( ClassRequest request, CancellationToken cancellationToken) { if (!await db.Majors.AnyAsync(x => x.Id == request.MajorId, cancellationToken)) return ValidationProblem("所选专业不存在。"); var counselorName = await GetCounselorNameAsync( request.CounselorUserId, cancellationToken); if (request.CounselorUserId.HasValue && counselorName is null) return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。"); var entity = new AdministrativeClass { Code = request.Code.Trim(), Name = request.Name.Trim(), MajorId = request.MajorId, Grade = request.Grade, CounselorUserId = request.CounselorUserId, CounselorName = counselorName, SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetClasses", cancellationToken); } [HttpPut("classes/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateClass( Guid id, ClassRequest request, CancellationToken cancellationToken) { var entity = await db.AdministrativeClasses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); var counselorName = await GetCounselorNameAsync( request.CounselorUserId, cancellationToken); if (request.CounselorUserId.HasValue && counselorName is null) return ValidationProblem("所选辅导员账号不存在、已停用或没有辅导员角色。"); ApplyCatalog(entity, request); entity.MajorId = request.MajorId; entity.Grade = request.Grade; entity.CounselorUserId = request.CounselorUserId; entity.CounselorName = counselorName; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpGet("terms")] public async Task>> GetTerms( CancellationToken cancellationToken) => await db.AcademicTerms.AsNoTracking() .OrderByDescending(x => x.IsCurrent) .ThenBy(x => x.IsArchived) .ThenByDescending(x => x.StartDate) .ToListAsync(cancellationToken); [HttpPost("terms")] [Authorize(Roles = Administrators)] public async Task> CreateTerm( TermRequest request, CancellationToken cancellationToken) { if (request.EndDate <= request.StartDate) return ValidationProblem("学期结束日期必须晚于开始日期。"); if (request.IsCurrent && !request.IsEnabled) return ValidationProblem("当前学期必须保持启用。"); var hasCurrentTerm = await db.AcademicTerms .AnyAsync(x => x.IsCurrent, cancellationToken); var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled); if (shouldBeCurrent) { var currentTerms = await db.AcademicTerms .Where(x => x.IsCurrent) .ToListAsync(cancellationToken); foreach (var currentTerm in currentTerms) currentTerm.IsCurrent = false; } var entity = new AcademicTerm { Code = request.Code.Trim(), Name = request.Name.Trim(), AcademicYear = request.AcademicYear.Trim(), Season = request.Season, StartDate = request.StartDate, EndDate = request.EndDate, IsCurrent = shouldBeCurrent, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetTerms", cancellationToken); } [HttpPut("terms/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateTerm( Guid id, TermRequest request, CancellationToken cancellationToken) { var entity = await db.AcademicTerms.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); if (request.EndDate <= request.StartDate) return ValidationProblem("学期结束日期必须晚于开始日期。"); if (entity.IsCurrent && !request.IsCurrent) return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。"); if (request.IsCurrent && entity.IsArchived) return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。"); if (request.IsCurrent && !request.IsEnabled) return ValidationProblem("当前学期必须保持启用。"); if (request.IsCurrent) { var currentTerms = await db.AcademicTerms .Where(x => x.Id != id && x.IsCurrent) .ToListAsync(cancellationToken); foreach (var currentTerm in currentTerms) currentTerm.IsCurrent = false; } ApplyCatalog(entity, request); entity.AcademicYear = request.AcademicYear.Trim(); entity.Season = request.Season; entity.StartDate = request.StartDate; entity.EndDate = request.EndDate; entity.IsCurrent = request.IsCurrent; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpPost("terms/{id:guid}/set-current")] [Authorize(Roles = Administrators)] public async Task> SetCurrentTerm( Guid id, CancellationToken cancellationToken) { var entity = await db.AcademicTerms.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); if (entity.IsArchived) return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。"); if (!entity.IsEnabled) return ConflictProblem("停用学期不能设为当前,请先启用。"); if (entity.IsCurrent) return entity; var currentTerms = await db.AcademicTerms .Where(x => x.Id != id && x.IsCurrent) .ToListAsync(cancellationToken); foreach (var currentTerm in currentTerms) currentTerm.IsCurrent = false; entity.IsCurrent = true; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpPost("terms/{id:guid}/archive")] [Authorize(Roles = Administrators)] public async Task> ArchiveTerm( Guid id, CancellationToken cancellationToken) { var entity = await db.AcademicTerms.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); if (entity.IsCurrent) return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。"); if (entity.IsArchived) return entity; entity.IsArchived = true; entity.ArchivedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpPost("terms/{id:guid}/unarchive")] [Authorize(Roles = Administrators)] public async Task> UnarchiveTerm( Guid id, CancellationToken cancellationToken) { var entity = await db.AcademicTerms.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); if (!entity.IsArchived) return entity; entity.IsArchived = false; entity.ArchivedAt = null; await db.SaveChangesAsync(cancellationToken); return entity; } [HttpGet("classrooms")] public async Task> GetClassrooms(CancellationToken cancellationToken) => Ok(await db.Classrooms.AsNoTracking() .OrderBy(x => x.Building!.Campus!.SortOrder) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.BuildingId, CampusId = x.Building!.CampusId, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name, x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder }) .ToListAsync(cancellationToken)); [HttpGet("buildings")] public async Task> GetBuildings(CancellationToken cancellationToken) => Ok(await db.Buildings.AsNoTracking() .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.CampusId, CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder }) .ToListAsync(cancellationToken)); [HttpPost("buildings")] [Authorize(Roles = Administrators)] public async Task> CreateBuilding( BuildingRequest request, CancellationToken cancellationToken) { if (!await db.Campuses.AnyAsync(x => x.Id == request.CampusId, cancellationToken)) return ValidationProblem("所选校区不存在。"); var entity = new Building { Code = request.Code.Trim(), Name = request.Name.Trim(), CampusId = request.CampusId, SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetBuildings", cancellationToken); } [HttpPost("classrooms")] [Authorize(Roles = Administrators)] public async Task> CreateClassroom( ClassroomRequest request, CancellationToken cancellationToken) { if (!await db.Buildings.AnyAsync(x => x.Id == request.BuildingId, cancellationToken)) return ValidationProblem("所选教学楼不存在。"); var entity = new Classroom { Code = request.Code.Trim(), Name = request.Name.Trim(), BuildingId = request.BuildingId, Capacity = request.Capacity, RoomType = request.RoomType.Trim(), Equipment = request.Equipment?.Trim(), SortOrder = request.SortOrder, IsEnabled = request.IsEnabled }; return await CreateAsync(entity, "GetClassrooms", cancellationToken); } [HttpPut("classrooms/{id:guid}")] [Authorize(Roles = Administrators)] public async Task> UpdateClassroom( Guid id, ClassroomRequest request, CancellationToken cancellationToken) { var entity = await db.Classrooms.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); entity.BuildingId = request.BuildingId; entity.Capacity = request.Capacity; entity.RoomType = request.RoomType.Trim(); entity.Equipment = request.Equipment?.Trim(); await db.SaveChangesAsync(cancellationToken); return entity; } [HttpDelete("{kind}/{id:guid}")] [Authorize(Roles = Administrators)] public async Task Delete( string kind, Guid id, CancellationToken cancellationToken) { object? entity = kind.ToLowerInvariant() switch { "campuses" => await db.Campuses.FindAsync([id], cancellationToken), "colleges" => await db.Colleges.FindAsync([id], cancellationToken), "majors" => await db.Majors.FindAsync([id], cancellationToken), "classes" => await db.AdministrativeClasses.FindAsync([id], cancellationToken), "terms" => await db.AcademicTerms.FindAsync([id], cancellationToken), "buildings" => await db.Buildings.FindAsync([id], cancellationToken), "classrooms" => await db.Classrooms.FindAsync([id], cancellationToken), "course-categories" => await db.CourseCategories.FindAsync([id], cancellationToken), _ => null }; if (entity is null) return NotFound(); if (entity is AcademicTerm { IsCurrent: true }) return ConflictProblem("当前学期不能删除,请先切换到其他学期。"); db.Remove(entity); try { await db.SaveChangesAsync(cancellationToken); return NoContent(); } catch (DbUpdateException) { return Conflict(new ProblemDetails { Title = "无法删除", Detail = "该数据已被其他业务引用,请先停用,或移除关联数据后再删除。", Status = StatusCodes.Status409Conflict }); } } private async Task> CreateAsync( TEntity entity, string action, CancellationToken cancellationToken) where TEntity : EntityBase { db.Add(entity); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { ModelState.AddModelError("code", "编码已存在或关联数据无效。"); return ValidationProblem(ModelState); } return CreatedAtAction(action, new { id = entity.Id }, entity); } private async Task GetCounselorNameAsync( Guid? userId, CancellationToken cancellationToken) { if (!userId.HasValue) return null; return await db.Users.AsNoTracking() .Where(user => user.Id == userId && user.IsEnabled && db.UserRoles.Any(userRole => userRole.UserId == user.Id && db.Roles.Any(role => role.Id == userRole.RoleId && role.Name == SystemRoles.Counselor))) .Select(x => x.DisplayName) .FirstOrDefaultAsync(cancellationToken); } private static void ApplyCatalog(CatalogEntity entity, CatalogRequest request) { entity.Code = request.Code.Trim(); entity.Name = request.Name.Trim(); entity.SortOrder = request.SortOrder; entity.IsEnabled = request.IsEnabled; } private ObjectResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "当前操作无法完成", Detail = detail, Status = StatusCodes.Status409Conflict }); } public record CatalogRequest( [Required, MaxLength(40)] string Code, [Required, MaxLength(100)] string Name, int SortOrder = 0, bool IsEnabled = true, [MaxLength(300)] string? Description = null); public sealed record CollegeRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid? CampusId, [MaxLength(50)] string? ShortName) : CatalogRequest(Code, Name, SortOrder, IsEnabled); public sealed record MajorRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid CollegeId, [Required, MaxLength(30)] string DegreeType, [Range(1, 8)] int SchoolingYears) : CatalogRequest(Code, Name, SortOrder, IsEnabled); public sealed record ClassRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid MajorId, [Range(2000, 2200)] int Grade, Guid? CounselorUserId) : CatalogRequest(Code, Name, SortOrder, IsEnabled); public sealed record TermRequest( string Code, string Name, bool IsEnabled, [Required, MaxLength(20)] string AcademicYear, TermSeason Season, DateOnly StartDate, DateOnly EndDate, bool IsCurrent) : CatalogRequest(Code, Name, 0, IsEnabled); public sealed record BuildingRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid CampusId) : CatalogRequest(Code, Name, SortOrder, IsEnabled); public sealed record ClassroomRequest( string Code, string Name, int SortOrder, bool IsEnabled, Guid BuildingId, [Range(1, 1000)] int Capacity, [Required, MaxLength(40)] string RoomType, [MaxLength(300)] string? Equipment) : CatalogRequest(Code, Name, SortOrder, IsEnabled);