Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs
T
biss bcc4d33bd5 多角色账号按 All > College > Class > Self 自动取最高权限。
学院管理员限制在所属学院。
辅导员通过稳定账号 ID 绑定行政班,避免重名串班。
教师只能访问本人档案、授课课程和所授课学生。
学生只能访问本人档案及所在班级课程。
教师/学生角色会自动校验并绑定工号或学号档案。
超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
2026-07-24 14:54:30 +08:00

516 lines
20 KiB
C#

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 = ReadRoles)]
[Route("api/curriculum-plans")]
public sealed class CurriculumPlansController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet]
public async Task<ActionResult<PagedResult<object>>> 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<object>(items, total, page, pageSize));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<CurriculumPlan> ScopedPlans()
{
var source = db.CurriculumPlans.AsQueryable();
var collegeId = ScopedCollegeId();
return collegeId.HasValue
? source.Where(x => x.Major!.CollegeId == collegeId.Value)
: source;
}
private async Task<Major?> 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<CurriculumPlan?> EditablePlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedPlans().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private async Task<CurriculumPlan?> DraftPlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedPlans()
.Include(x => x.Major)
.FirstOrDefaultAsync(
x => x.Id == id && x.Status == CurriculumPlanStatus.Draft,
cancellationToken);
private Guid? ScopedCollegeId()
=> currentUserDataScope.Current.RestrictedCollegeId;
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("版本、模块编码或课程配置重复。");
}
}
private async Task<ActionResult> 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);