课程库实行分级维护:学院只能维护本学院的专业必修、专业选修和实践课程;通识课程可查看、引用但不可修改。校级账号保留全校统筹权限。 “排课与课表”已从“教学建设”迁移到“教学运行”,原路由保持不变。 权限规则同时落实在前端和 API,并新增专项测试。
254 lines
9.4 KiB
C#
254 lines
9.4 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,
|
|
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 (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.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));
|
|
}
|
|
|
|
[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,
|
|
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.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 (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,
|
|
[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);
|