学院管理员限制在所属学院。 辅导员通过稳定账号 ID 绑定行政班,避免重名串班。 教师只能访问本人档案、授课课程和所授课学生。 学生只能访问本人档案及所在班级课程。 教师/学生角色会自动校验并绑定工号或学号档案。 超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
362 lines
14 KiB
C#
362 lines
14 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 = ManagementRoles)]
|
|
[Route("api/teaching-tasks")]
|
|
public sealed class TeachingTasksController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
|
{
|
|
private const string ManagementRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin;
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<PagedResult<object>>> Get(
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
string? keyword = null,
|
|
Guid? academicTermId = null,
|
|
Guid? collegeId = null,
|
|
TeachingTaskStatus? status = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
|
var source = ScopedTasks().AsNoTracking();
|
|
if (academicTermId.HasValue)
|
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
|
if (collegeId.HasValue)
|
|
source = source.Where(x => x.Course!.CollegeId == collegeId);
|
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
|
if (!string.IsNullOrWhiteSpace(keyword))
|
|
{
|
|
keyword = keyword.Trim();
|
|
source = source.Where(x =>
|
|
x.TaskNumber.Contains(keyword) ||
|
|
x.Name.Contains(keyword) ||
|
|
x.Course!.Code.Contains(keyword) ||
|
|
x.Course.Name.Contains(keyword));
|
|
}
|
|
|
|
var total = await source.CountAsync(cancellationToken);
|
|
var items = await source
|
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
|
.ThenBy(x => x.TaskNumber)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
TermName = x.AcademicTerm!.Name,
|
|
x.CourseId,
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
CollegeId = x.Course.CollegeId,
|
|
CollegeName = x.Course.College!.Name,
|
|
x.Capacity,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.Status,
|
|
TeacherNames = x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.Select(item => item.Teacher!.Name),
|
|
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
|
|
StudentCount = x.Classes.Sum(item =>
|
|
item.AdministrativeClass!.Students.Count(student =>
|
|
student.Status == StudentStatus.Active)),
|
|
x.UpdatedAt
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.AsNoTracking()
|
|
.Where(x => x.Id == id)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.TaskNumber,
|
|
x.Name,
|
|
x.AcademicTermId,
|
|
TermName = x.AcademicTerm!.Name,
|
|
x.CourseId,
|
|
CourseCode = x.Course!.Code,
|
|
CourseName = x.Course.Name,
|
|
CollegeId = x.Course.CollegeId,
|
|
CollegeName = x.Course.College!.Name,
|
|
x.Capacity,
|
|
x.StartWeek,
|
|
x.EndWeek,
|
|
x.WeeklyHours,
|
|
x.Status,
|
|
x.Notes,
|
|
x.PublishedAt,
|
|
TeacherIds = x.Teachers.Select(item => item.TeacherId),
|
|
PrimaryTeacherId = x.Teachers
|
|
.Where(item => item.IsPrimary)
|
|
.Select(item => (Guid?)item.TeacherId)
|
|
.FirstOrDefault(),
|
|
Teachers = x.Teachers
|
|
.OrderByDescending(item => item.IsPrimary)
|
|
.ThenBy(item => item.Teacher!.TeacherNumber)
|
|
.Select(item => new
|
|
{
|
|
item.TeacherId,
|
|
item.Teacher!.TeacherNumber,
|
|
item.Teacher.Name,
|
|
item.Teacher.Title,
|
|
item.IsPrimary
|
|
}),
|
|
ClassIds = x.Classes.Select(item => item.AdministrativeClassId),
|
|
Classes = x.Classes
|
|
.OrderBy(item => item.AdministrativeClass!.Code)
|
|
.Select(item => new
|
|
{
|
|
item.AdministrativeClassId,
|
|
item.AdministrativeClass!.Code,
|
|
item.AdministrativeClass.Name,
|
|
item.AdministrativeClass.Grade,
|
|
StudentCount = item.AdministrativeClass.Students.Count(student =>
|
|
student.Status == StudentStatus.Active)
|
|
}),
|
|
x.CreatedAt,
|
|
x.UpdatedAt
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
return task is null ? NotFound() : Ok(task);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult> Create(
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = request.TaskNumber.Trim(),
|
|
Name = request.Name.Trim(),
|
|
AcademicTermId = request.AcademicTermId,
|
|
CourseId = request.CourseId,
|
|
Capacity = request.Capacity,
|
|
StartWeek = request.StartWeek,
|
|
EndWeek = request.EndWeek,
|
|
WeeklyHours = request.WeeklyHours,
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
SetAssignments(task, request);
|
|
db.TeachingTasks.Add(task);
|
|
return await SaveAsync(task.Id, true, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("{id:guid}")]
|
|
public async Task<ActionResult> Update(
|
|
Guid id,
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Teachers)
|
|
.Include(x => x.Classes)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (task.Status != TeachingTaskStatus.Draft)
|
|
return ConflictProblem("已发布或已结课的教学任务不可直接修改。");
|
|
var validation = await ValidateRequestAsync(request, cancellationToken);
|
|
if (validation is not null) return validation;
|
|
|
|
task.TaskNumber = request.TaskNumber.Trim();
|
|
task.Name = request.Name.Trim();
|
|
task.AcademicTermId = request.AcademicTermId;
|
|
task.CourseId = request.CourseId;
|
|
task.Capacity = request.Capacity;
|
|
task.StartWeek = request.StartWeek;
|
|
task.EndWeek = request.EndWeek;
|
|
task.WeeklyHours = request.WeeklyHours;
|
|
task.Notes = Normalize(request.Notes);
|
|
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
|
|
db.TeachingTaskClasses.RemoveRange(task.Classes);
|
|
task.Teachers = [];
|
|
task.Classes = [];
|
|
SetAssignments(task, request);
|
|
return await SaveAsync(task.Id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{id:guid}")]
|
|
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (task.Status != TeachingTaskStatus.Draft)
|
|
return ConflictProblem("仅草稿教学任务可以删除。");
|
|
db.TeachingTasks.Remove(task);
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/publish")]
|
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks()
|
|
.Include(x => x.Teachers)
|
|
.Include(x => x.Classes)
|
|
.ThenInclude(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Students)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (task.Status != TeachingTaskStatus.Draft)
|
|
return ConflictProblem("只有草稿教学任务可以发布。");
|
|
if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1)
|
|
return ConflictProblem("发布前必须指定且只能指定一名主讲教师。");
|
|
if (task.Classes.Count == 0)
|
|
return ConflictProblem("发布前至少需要关联一个行政班。");
|
|
var studentCount = task.Classes.Sum(x =>
|
|
x.AdministrativeClass!.Students.Count(student =>
|
|
student.Status == StudentStatus.Active));
|
|
if (studentCount > task.Capacity)
|
|
return ConflictProblem($"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
|
|
|
|
task.Status = TeachingTaskStatus.Published;
|
|
task.PublishedAt = DateTime.UtcNow;
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/close")]
|
|
public async Task<ActionResult> Close(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (task is null) return NotFound();
|
|
if (task.Status != TeachingTaskStatus.Published)
|
|
return ConflictProblem("只有已发布的教学任务可以结课。");
|
|
task.Status = TeachingTaskStatus.Closed;
|
|
return await SaveAsync(id, false, cancellationToken);
|
|
}
|
|
|
|
private IQueryable<TeachingTask> ScopedTasks()
|
|
{
|
|
var source = db.TeachingTasks.AsQueryable();
|
|
var collegeId = ScopedCollegeId();
|
|
return collegeId.HasValue
|
|
? source.Where(x => x.Course!.CollegeId == collegeId.Value)
|
|
: source;
|
|
}
|
|
|
|
private async Task<ActionResult?> ValidateRequestAsync(
|
|
TeachingTaskRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request.StartWeek > request.EndWeek)
|
|
return ValidationProblem("开始周不能晚于结束周。");
|
|
var course = await db.Courses.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
|
|
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
|
|
var collegeId = ScopedCollegeId();
|
|
if (collegeId.HasValue && course.CollegeId != collegeId.Value) return Forbid();
|
|
if (!await db.AcademicTerms.AnyAsync(
|
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("所选学期不存在或已停用。");
|
|
|
|
var teacherIds = request.TeacherIds.Distinct().ToArray();
|
|
if (request.PrimaryTeacherId.HasValue &&
|
|
!teacherIds.Contains(request.PrimaryTeacherId.Value))
|
|
return ValidationProblem("主讲教师必须包含在授课教师中。");
|
|
if (await db.Teachers.CountAsync(
|
|
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
|
cancellationToken) != teacherIds.Length)
|
|
return ValidationProblem("存在无效或非在职授课教师。");
|
|
|
|
var classIds = request.ClassIds.Distinct().ToArray();
|
|
var classes = db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x => classIds.Contains(x.Id));
|
|
if (collegeId.HasValue)
|
|
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
|
|
if (await classes.CountAsync(cancellationToken) != classIds.Length)
|
|
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
|
return null;
|
|
}
|
|
|
|
private static void SetAssignments(TeachingTask task, TeachingTaskRequest request)
|
|
{
|
|
foreach (var teacherId in request.TeacherIds.Distinct())
|
|
{
|
|
task.Teachers.Add(new TeachingTaskTeacher
|
|
{
|
|
TeacherId = teacherId,
|
|
IsPrimary = teacherId == request.PrimaryTeacherId
|
|
});
|
|
}
|
|
foreach (var classId in request.ClassIds.Distinct())
|
|
{
|
|
task.Classes.Add(new TeachingTaskClass
|
|
{
|
|
AdministrativeClassId = classId
|
|
});
|
|
}
|
|
}
|
|
|
|
private Guid? ScopedCollegeId()
|
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
|
|
|
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 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 TeachingTaskRequest(
|
|
[Required, MaxLength(40)] string TaskNumber,
|
|
[Required, MaxLength(120)] string Name,
|
|
Guid AcademicTermId,
|
|
Guid CourseId,
|
|
[Range(1, 10000)] int Capacity,
|
|
[Range(1, 30)] int StartWeek,
|
|
[Range(1, 30)] int EndWeek,
|
|
[Range(1, 40)] int WeeklyHours,
|
|
IReadOnlyCollection<Guid> TeacherIds,
|
|
Guid? PrimaryTeacherId,
|
|
IReadOnlyCollection<Guid> ClassIds,
|
|
[MaxLength(500)] string? Notes);
|