Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/OtherExamsController.cs
T
2026-08-08 21:27:52 +08:00

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);