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 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 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 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 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 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 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 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 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(); var errors = new List(); 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 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 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 ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList 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(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 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 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 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 Results); public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);