using System.ComponentModel.DataAnnotations; 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/evaluations")] public sealed class EvaluationsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string AdminRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; private const string StaffRoles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Teacher + "," + SystemRoles.Leader; // ── Admin: Manage setups ──────────────────────────────────────── [HttpGet("setups")] [Authorize(Roles = AdminRoles)] public async Task GetSetups(CancellationToken cancellationToken) { var setups = await db.EvaluationSetups.AsNoTracking() .Include(x => x.Dimensions.OrderBy(d => d.SortOrder)) .Include(x => x.AcademicTerm) .OrderByDescending(x => x.AcademicTerm!.StartDate) .Select(x => new { x.Id, x.Name, x.Status, x.AcademicTermId, TermName = x.AcademicTerm!.Name, TermIsCurrent = x.AcademicTerm.IsCurrent, TermIsArchived = x.AcademicTerm.IsArchived, x.StartsAt, x.EndsAt, Dimensions = x.Dimensions.Select(d => new { d.Id, d.Name, d.MaxScore, d.SortOrder }), RecordCount = x.Records.Count, x.CreatedAt }) .ToListAsync(cancellationToken); return Ok(setups); } [HttpPost("setups")] [Authorize(Roles = AdminRoles)] public async Task CreateSetup( EvaluationSetupRequest request, CancellationToken cancellationToken) { if (request.Dimensions.Count == 0) return ValidationProblem("至少需要一个评分维度。"); if (request.Dimensions.Sum(d => d.MaxScore) != 100) return ValidationProblem("所有维度的满分之和必须等于 100。"); var termExists = await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId, cancellationToken); if (!termExists) return ValidationProblem("所选学期不存在。"); var existing = await db.EvaluationSetups .AnyAsync(x => x.AcademicTermId == request.AcademicTermId, cancellationToken); if (existing) return ConflictProblem("该学期已有评教方案。"); var setup = new EvaluationSetup { AcademicTermId = request.AcademicTermId, Name = request.Name.Trim(), Status = EvaluationSetupStatus.Draft, StartsAt = request.StartsAt, EndsAt = request.EndsAt, Dimensions = request.Dimensions.Select((d, i) => new EvaluationDimension { Name = d.Name.Trim(), MaxScore = d.MaxScore, SortOrder = i }).ToList() }; db.EvaluationSetups.Add(setup); await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { setup.Id }); } [HttpPut("setups/{id:guid}")] [Authorize(Roles = AdminRoles)] public async Task UpdateSetup( Guid id, EvaluationSetupRequest request, CancellationToken cancellationToken) { if (request.Dimensions.Count == 0) return ValidationProblem("至少需要一个评分维度。"); if (request.Dimensions.Sum(d => d.MaxScore) != 100) return ValidationProblem("所有维度的满分之和必须等于 100。"); var setup = await db.EvaluationSetups .Include(x => x.Dimensions) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (setup is null) return NotFound(); if (setup.Status == EvaluationSetupStatus.Closed) return ConflictProblem("已关闭的评教方案不能修改。"); setup.Name = request.Name.Trim(); setup.StartsAt = request.StartsAt; setup.EndsAt = request.EndsAt; db.EvaluationDimensions.RemoveRange(setup.Dimensions); setup.Dimensions = request.Dimensions.Select((d, i) => new EvaluationDimension { EvaluationSetupId = setup.Id, Name = d.Name.Trim(), MaxScore = d.MaxScore, SortOrder = i }).ToList(); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("setups/{id:guid}/open")] [Authorize(Roles = AdminRoles)] public async Task OpenSetup(Guid id, CancellationToken cancellationToken) { var setup = await db.EvaluationSetups.FindAsync([id], cancellationToken); if (setup is null) return NotFound(); if (setup.Status == EvaluationSetupStatus.Closed) return ConflictProblem("已关闭的方案不能重新开启。"); setup.Status = EvaluationSetupStatus.Open; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("setups/{id:guid}/close")] [Authorize(Roles = AdminRoles)] public async Task CloseSetup(Guid id, CancellationToken cancellationToken) { var setup = await db.EvaluationSetups.FindAsync([id], cancellationToken); if (setup is null) return NotFound(); setup.Status = EvaluationSetupStatus.Closed; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpDelete("setups/{id:guid}")] [Authorize(Roles = AdminRoles)] public async Task DeleteSetup(Guid id, CancellationToken cancellationToken) { var setup = await db.EvaluationSetups .Include(x => x.Records) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (setup is null) return NotFound(); if (setup.Records.Count > 0) return ConflictProblem("已有评教记录的方案不能删除。"); db.EvaluationSetups.Remove(setup); await db.SaveChangesAsync(cancellationToken); return NoContent(); } // ── Student: Evaluate courses ──────────────────────────────────── [HttpGet("my-tasks")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyTasks(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); // Find open evaluation setups var openSetups = await db.EvaluationSetups.AsNoTracking() .Where(x => x.Status == EvaluationSetupStatus.Open) .Select(x => new { x.Id, x.AcademicTermId }) .ToListAsync(cancellationToken); if (openSetups.Count == 0) return Ok(new { Setups = Array.Empty(), Tasks = Array.Empty() }); // Find enrolled teaching tasks for the student in those terms var termIds = openSetups.Select(x => x.AcademicTermId).ToHashSet(); var enrollments = await db.CourseEnrollments.AsNoTracking() .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && e.StudentId == studentId.Value) .WhereIn( termIds, e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId) .Select(e => new { e.CourseSelectionOffering!.TeachingTaskId, e.CourseSelectionOffering.TeachingTask!.AcademicTermId, e.CourseSelectionOffering.TeachingTask.TaskNumber, TaskName = e.CourseSelectionOffering.TeachingTask.Name, CourseCode = e.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = e.CourseSelectionOffering.TeachingTask.Course.Name, TeacherNames = e.CourseSelectionOffering.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name) }) .ToListAsync(cancellationToken); // Check which tasks have already been evaluated var setupIds = openSetups.Select(s => s.Id).ToArray(); var evaluatedIds = await db.EvaluationRecords.AsNoTracking() .Where(r => r.StudentId == studentId.Value) .WhereIn(setupIds, r => r.EvaluationSetupId) .Select(r => r.TeachingTaskId) .ToHashSetAsync(cancellationToken); var tasks = enrollments.Select(e => new { e.TeachingTaskId, e.AcademicTermId, e.TaskNumber, e.TaskName, e.CourseCode, e.CourseName, e.TeacherNames, SetupId = openSetups.First(s => s.AcademicTermId == e.AcademicTermId).Id, IsEvaluated = evaluatedIds.Contains(e.TeachingTaskId) }).ToList(); return Ok(new { Tasks = tasks }); } [HttpGet("tasks/{teachingTaskId:guid}/form")] [Authorize(Roles = SystemRoles.Student)] public async Task GetEvaluationForm( Guid teachingTaskId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var task = await db.TeachingTasks.AsNoTracking() .Where(x => x.Id == teachingTaskId) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, TeacherNames = x.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name) }) .FirstOrDefaultAsync(cancellationToken); if (task is null) return NotFound(); var setup = await db.EvaluationSetups.AsNoTracking() .Include(x => x.Dimensions.OrderBy(d => d.SortOrder)) .Where(x => x.AcademicTermId == task.AcademicTermId && x.Status == EvaluationSetupStatus.Open) .FirstOrDefaultAsync(cancellationToken); if (setup is null) return ConflictProblem("当前学期没有开放的评教活动。"); // Check if already evaluated var exists = await db.EvaluationRecords.AnyAsync( x => x.EvaluationSetupId == setup.Id && x.StudentId == studentId.Value && x.TeachingTaskId == teachingTaskId, cancellationToken); if (exists) return ConflictProblem("您已经对该课程进行过评教。"); return Ok(new { SetupId = setup.Id, setup.Name, Dimensions = setup.Dimensions.Select(d => new { d.Id, d.Name, d.MaxScore, d.SortOrder }), Task = task }); } [HttpPost("tasks/{teachingTaskId:guid}/submit")] [Authorize(Roles = SystemRoles.Student)] public async Task SubmitEvaluation( Guid teachingTaskId, SubmitEvaluationRequest request, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var studentId = await db.Students .Where(s => s.UserId == userId) .Select(s => (Guid?)s.Id) .FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var setup = await db.EvaluationSetups .Include(x => x.Dimensions) .FirstOrDefaultAsync(x => x.Id == request.SetupId, cancellationToken); if (setup is null) return NotFound("评教方案不存在。"); if (setup.Status != EvaluationSetupStatus.Open) return ConflictProblem("该评教活动已结束。"); // Validate against dimensions var dimMap = setup.Dimensions.ToDictionary(d => d.Id); if (request.Scores.Count != dimMap.Count) return ValidationProblem("评分维度数量不匹配。"); var totalScore = 0; foreach (var score in request.Scores) { if (!dimMap.TryGetValue(score.DimensionId, out var dim)) return ValidationProblem($"无效的评分维度。"); if (score.Score < 0 || score.Score > dim.MaxScore) return ValidationProblem($"「{dim.Name}」评分超出范围(0-{dim.MaxScore})。"); totalScore += score.Score; } // Check duplicate var exists = await db.EvaluationRecords.AnyAsync( x => x.EvaluationSetupId == setup.Id && x.StudentId == studentId.Value && x.TeachingTaskId == teachingTaskId, cancellationToken); if (exists) return ConflictProblem("您已经对该课程进行过评教。"); var record = new EvaluationRecord { EvaluationSetupId = setup.Id, StudentId = studentId.Value, TeachingTaskId = teachingTaskId, SubmittedAt = DateTime.UtcNow, Scores = request.Scores.Select(s => new EvaluationScore { EvaluationDimensionId = s.DimensionId, Score = s.Score }).ToList() }; db.EvaluationRecords.Add(record); await db.SaveChangesAsync(cancellationToken); return Ok(new { record.Id, TotalScore = totalScore }); } // ── Teacher / Staff: View results ───────────────────────────────── [HttpGet("my-results")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMyResults(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var teacherId = await db.Teachers .Where(t => t.UserId == userId) .Select(t => (Guid?)t.Id) .FirstOrDefaultAsync(cancellationToken); if (!teacherId.HasValue) return ConflictProblem("当前账号未关联教师档案。"); var taskIds = await db.TeachingTaskTeachers .Where(x => x.TeacherId == teacherId.Value) .Select(x => x.TeachingTaskId) .ToListAsync(cancellationToken); if (taskIds.Count == 0) return Ok(new { Tasks = Array.Empty() }); var taskSummaries = await db.EvaluationRecords.AsNoTracking() .WhereIn(taskIds, record => record.TeachingTaskId) .Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed) .GroupBy(record => new { record.EvaluationSetupId, SetupName = record.EvaluationSetup!.Name, SetupStartDate = record.EvaluationSetup.AcademicTerm!.StartDate, record.TeachingTaskId, TaskNumber = record.TeachingTask!.TaskNumber, TaskName = record.TeachingTask.Name, record.TeachingTask.AcademicTermId, CourseCode = record.TeachingTask.Course!.Code, CourseName = record.TeachingTask.Course.Name }) .Select(group => new { group.Key, StudentCount = group.Count(), AverageTotal = group.Average(record => record.Scores.Sum(score => score.Score)) }) .OrderByDescending(x => x.Key.SetupStartDate) .ThenBy(x => x.Key.CourseCode) .ThenBy(x => x.Key.TaskNumber) .ToListAsync(cancellationToken); var dimensionSummaries = await ( from score in db.EvaluationScores.AsNoTracking() join record in db.EvaluationRecords.AsNoTracking() on score.EvaluationRecordId equals record.Id join dimension in db.EvaluationDimensions.AsNoTracking() on score.EvaluationDimensionId equals dimension.Id where taskIds.Contains(record.TeachingTaskId) && record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed group score by new { record.EvaluationSetupId, record.TeachingTaskId, dimension.Id, dimension.Name, dimension.MaxScore, dimension.SortOrder } into scoreGroup select new { scoreGroup.Key, AverageScore = scoreGroup.Average(score => score.Score), TotalScore = scoreGroup.Sum(score => score.Score), Count = scoreGroup.Count() }) .ToListAsync(cancellationToken); var dimensionsByTask = dimensionSummaries .GroupBy(item => (item.Key.EvaluationSetupId, item.Key.TeachingTaskId)) .ToDictionary( group => group.Key, group => (IReadOnlyCollection)group .OrderBy(item => item.Key.SortOrder) .Select(item => new TeacherEvaluationDimensionResult( item.Key.Id, item.Key.Name, item.Key.MaxScore, Math.Round((double)item.AverageScore, 1), item.TotalScore, item.Count)) .ToArray()); var result = taskSummaries.Select(item => new TeacherEvaluationTaskResult( item.Key.EvaluationSetupId, item.Key.SetupName, new TeacherEvaluationTask( item.Key.TeachingTaskId, item.Key.TaskNumber, item.Key.TaskName, item.Key.AcademicTermId, item.Key.CourseCode, item.Key.CourseName), item.StudentCount, Math.Round((double)item.AverageTotal, 1), dimensionsByTask.GetValueOrDefault( (item.Key.EvaluationSetupId, item.Key.TeachingTaskId), Array.Empty()))) .ToArray(); return Ok(new { Tasks = result }); } [HttpGet("setups/{id:guid}/results")] [Authorize(Roles = StaffRoles)] public async Task GetSetupResults( Guid id, CancellationToken cancellationToken) { var setup = await db.EvaluationSetups.AsNoTracking() .Include(x => x.Dimensions.OrderBy(d => d.SortOrder)) .Include(x => x.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (setup is null) return NotFound(); var records = await db.EvaluationRecords.AsNoTracking() .Where(r => r.EvaluationSetupId == id) .Include(r => r.Scores) .Include(r => r.TeachingTask) .ThenInclude(t => t!.Course) .ThenInclude(c => c!.College) .ToListAsync(cancellationToken); // By teaching task var byTask = records .GroupBy(r => new { r.TeachingTaskId, r.TeachingTask!.TaskNumber, TaskName = r.TeachingTask.Name, CourseName = r.TeachingTask.Course!.Name, CollegeName = r.TeachingTask.Course.College!.Name, TeacherNames = string.Join("、", r.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name)) }) .Select(g => { var dimAvgs = setup.Dimensions.Select(dim => { var scores = g .SelectMany(r => r.Scores) .Where(s => s.EvaluationDimensionId == dim.Id) .Select(s => (double)s.Score) .ToList(); return new { DimensionName = dim.Name, AverageScore = scores.Count > 0 ? Math.Round(scores.Average(), 1) : 0, Count = scores.Count }; }).ToList(); var totals = g.Select(r => (double)r.Scores.Sum(s => s.Score)).ToList(); return new { g.Key.TeachingTaskId, g.Key.TaskNumber, g.Key.TaskName, g.Key.CourseName, g.Key.CollegeName, g.Key.TeacherNames, StudentCount = g.Count(), AverageTotal = totals.Count > 0 ? Math.Round(totals.Average(), 1) : 0, Dimensions = dimAvgs }; }) .OrderByDescending(x => x.AverageTotal) .ToList(); return Ok(new { Setup = new { setup.Id, setup.Name, setup.Status, TermName = setup.AcademicTerm!.Name, setup.StartsAt, setup.EndsAt, Dimensions = setup.Dimensions.Select(d => new { d.Id, d.Name, d.MaxScore, d.SortOrder }) }, ByTask = byTask, TotalRecords = records.Count, DistinctTasks = byTask.Count, DistinctStudents = records.Select(r => r.StudentId).Distinct().Count() }); } // ── Helpers ────────────────────────────────────────────────────── private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成评教操作", Detail = detail, Status = StatusCodes.Status409Conflict }); } public sealed record EvaluationSetupRequest( Guid AcademicTermId, [MaxLength(120)] string Name, DateTime? StartsAt, DateTime? EndsAt, IReadOnlyCollection Dimensions); public sealed record EvaluationDimensionRequest( [MaxLength(60)] string Name, int MaxScore); public sealed record SubmitEvaluationRequest( Guid SetupId, IReadOnlyCollection Scores); public sealed record EvaluationScoreRequest( Guid DimensionId, int Score); public sealed record TeacherEvaluationTaskResult( Guid SetupId, string SetupName, TeacherEvaluationTask Task, int StudentCount, double AverageTotal, IReadOnlyCollection Dimensions); public sealed record TeacherEvaluationTask( Guid Id, string TaskNumber, string Name, Guid AcademicTermId, string CourseCode, string CourseName); public sealed record TeacherEvaluationDimensionResult( Guid DimensionId, string DimensionName, int MaxScore, double AverageScore, decimal TotalScore, int Count);