Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/EvaluationsController.cs
T
2026-08-11 12:04:22 +08:00

630 lines
24 KiB
C#

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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>(), Tasks = Array.Empty<object>() });
// 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>() });
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<TeacherEvaluationDimensionResult>)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<TeacherEvaluationDimensionResult>())))
.ToArray();
return Ok(new { Tasks = result });
}
[HttpGet("setups/{id:guid}/results")]
[Authorize(Roles = StaffRoles)]
public async Task<ActionResult> 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<EvaluationDimensionRequest> Dimensions);
public sealed record EvaluationDimensionRequest(
[MaxLength(60)] string Name,
int MaxScore);
public sealed record SubmitEvaluationRequest(
Guid SetupId,
IReadOnlyCollection<EvaluationScoreRequest> Scores);
public sealed record EvaluationScoreRequest(
Guid DimensionId,
int Score);
public sealed record TeacherEvaluationTaskResult(
Guid SetupId,
string SetupName,
TeacherEvaluationTask Task,
int StudentCount,
double AverageTotal,
IReadOnlyCollection<TeacherEvaluationDimensionResult> 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);