Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/CoursesController.cs
T
biss fe508054d0 教学任务按 授课周数 × 周学时 = 课程总学时 双端校验,不匹配会显示具体差额并阻止保存;公共课批量生成同样校验。
排课约束增加课程、教师、学院、授课方式、场地要求、约束状态筛选,并支持批量修改当前筛选结果。
新增“非排时课程”授课方式:不进入自动排课
不占星期、节次和教室
不阻塞课表发布
允许正常选课
在班级课表和学生个人课表中单独展示
2026-07-25 08:18:25 +08:00

308 lines
12 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]
[Route("api/courses")]
public sealed class CoursesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string WriteRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet]
public async Task<ActionResult<PagedResult<object>>> Get(
int page = 1,
int pageSize = 20,
string? keyword = null,
Guid? collegeId = null,
Guid? categoryId = null,
CourseNature? nature = null,
bool? isEnabled = null,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
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)));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.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,
x.IsEnabled,
x.SortOrder,
x.CreatedAt,
CanManage = canManageAll ||
canManageCollegeCourses &&
x.CollegeId == managedCollegeId &&
(x.Nature == CourseNature.MajorRequired ||
x.Nature == CourseNature.MajorElective ||
x.Nature == CourseNature.Practice)
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpGet("options")]
public async Task<ActionResult<object>> GetOptions(
Guid? collegeId = null,
CourseNature? nature = null,
string? keyword = null,
CancellationToken cancellationToken = default)
{
var source = ScopedCourses().AsNoTracking().Where(x => x.IsEnabled);
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.Nature,
x.AssessmentMethod
})
.ToListAsync(cancellationToken));
}
[HttpPost]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> Create(
CourseRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
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
};
db.Courses.Add(entity);
return await SaveAsync(entity.Id, true, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Courses.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
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;
return await SaveAsync(entity.Id, false, cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = WriteRoles)]
public async Task<ActionResult> 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();
db.Courses.Remove(entity);
return await SaveAsync(id, false, cancellationToken);
}
private async Task<ActionResult?> ValidateAsync(
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("讲授学时与实践学时之和不能超过总学时。");
return null;
}
private IQueryable<Course> 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.Nature == CourseNature.GeneralRequired ||
x.Nature == CourseNature.GeneralElective ||
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))));
}
private bool CanManage(Guid collegeId, CourseNature nature)
=> CourseMaintenancePolicy.CanManage(
currentUserDataScope.Current,
collegeId,
nature);
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(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);