239 lines
15 KiB
C#
239 lines
15 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Globalization;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Auth;
|
|
using Jiaowu.Api.Infrastructure.Excel;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/other-exams")]
|
|
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
|
{
|
|
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
|
|
|
[HttpGet("batches")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
|
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
|
.ToListAsync(ct));
|
|
|
|
[HttpPost("batches")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
|
|
{
|
|
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
|
|
var name = Normalize(request.Name);
|
|
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
|
|
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
|
|
if (error is not null) return ValidationProblem(error);
|
|
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
|
|
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
|
|
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
|
|
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
|
|
db.OtherExamBatches.Add(batch);
|
|
await db.SaveChangesAsync(ct);
|
|
return Ok(new { batch.Id });
|
|
}
|
|
|
|
[HttpGet("students/lookup")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
|
|
{
|
|
var number = Normalize(studentNumber);
|
|
if (number is null) return ValidationProblem("请输入学号。");
|
|
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
|
|
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
|
|
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
|
|
}
|
|
|
|
[HttpGet("batches/{id:guid}")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
|
|
{
|
|
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
|
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
|
|
if (batch is null) return NotFound();
|
|
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
|
|
.OrderBy(x => x.Student!.StudentNumber)
|
|
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
|
|
return Ok(new { Batch = batch, Results = results });
|
|
}
|
|
|
|
[HttpPut("batches/{id:guid}/results")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
|
|
{
|
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
if (batch is null) return NotFound();
|
|
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
|
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
|
}
|
|
|
|
[HttpGet("batches/{id:guid}/template")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
|
|
{
|
|
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
if (batch is null) return NotFound();
|
|
var headers = HeadersFor(batch);
|
|
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
|
|
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
|
|
}
|
|
|
|
[HttpPost("batches/{id:guid}/import")]
|
|
[Authorize(Roles = Managers)]
|
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
|
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
|
|
{
|
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
if (batch is null) return NotFound();
|
|
IReadOnlyList<ExcelRow> rows;
|
|
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
|
|
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
|
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
|
var inputs = new List<OtherExamResultRequest>();
|
|
var errors = new List<string>();
|
|
foreach (var row in rows)
|
|
{
|
|
var number = row["学号"].Trim();
|
|
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
|
|
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
|
|
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
|
|
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
|
|
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
|
|
}
|
|
if (errors.Count > 0) return ImportValidationProblem(errors);
|
|
var result = await ReplaceResultsAsync(batch, inputs, ct);
|
|
return result ?? Ok(new { updated = inputs.Count });
|
|
}
|
|
|
|
[HttpPost("batches/{id:guid}/publish")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
|
|
{
|
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
|
if (batch is null) return NotFound();
|
|
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
|
|
batch.Status = OtherExamBatchStatus.Published;
|
|
batch.PublicationCount++;
|
|
batch.PublishedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(ct);
|
|
return Ok(new { batch.PublicationCount, batch.PublishedAt });
|
|
}
|
|
|
|
[HttpGet("mine")]
|
|
[Authorize(Roles = SystemRoles.Student)]
|
|
public async Task<ActionResult> Mine(CancellationToken ct)
|
|
{
|
|
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
|
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
|
|
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
|
|
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
|
|
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
|
|
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
|
|
return Ok(new { Best = best, History = history });
|
|
}
|
|
|
|
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
|
|
{
|
|
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
|
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
|
var studentRows = await db.Students
|
|
.Where(x => numbers.Contains(x.StudentNumber))
|
|
.ToListAsync(ct);
|
|
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
|
|
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
|
foreach (var item in inputs)
|
|
{
|
|
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
|
if (error is not null) return ValidationProblem(error);
|
|
}
|
|
var studentIds = students.Values.Select(x => x.Id).ToList();
|
|
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
|
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
|
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
|
|
{
|
|
db.OtherExamResults.RemoveRange(batch.Results);
|
|
batch.Status = OtherExamBatchStatus.Draft;
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
batch.Results = inputs.Select(x =>
|
|
{
|
|
var student = students[x.StudentNumber.Trim()];
|
|
return new OtherExamResult
|
|
{
|
|
OtherExamBatchId = batch.Id,
|
|
StudentId = student.Id,
|
|
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
|
|
Score = x.Score,
|
|
Level = Normalize(x.Level),
|
|
IsPassed = x.IsPassed,
|
|
Notes = Normalize(x.Notes)
|
|
};
|
|
}).ToList();
|
|
await db.SaveChangesAsync(ct);
|
|
await transaction.CommitAsync(ct);
|
|
return null;
|
|
}, ct);
|
|
}
|
|
|
|
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
|
{
|
|
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
|
|
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
|
|
_ => ["学号", "是否合格", "备注"]
|
|
};
|
|
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
|
|
{
|
|
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
|
|
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
|
|
}
|
|
private static bool? ParsePass(string value, int row, List<string> errors)
|
|
{
|
|
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
|
|
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
|
|
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
|
|
}
|
|
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
|
|
{
|
|
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
|
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
|
_ => null
|
|
};
|
|
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
|
{
|
|
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
|
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
|
_ => null
|
|
};
|
|
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
|
{
|
|
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
|
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
|
|
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
|
var index = Array.IndexOf(levels, level ?? "");
|
|
return index >= 0 ? levels.Length - index : -1;
|
|
}
|
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
|
{
|
|
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
|
return ValidationProblem(ModelState);
|
|
}
|
|
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
|
|
}
|
|
|
|
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
|
|
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
|
|
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
|