From 17b9d89bf3dc62ac8c6ed84830e18a09b8515114 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 11 Aug 2026 11:11:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=85=B6=E4=BB=96=E8=80=83?= =?UTF-8?q?=E8=AF=95=E6=88=90=E7=BB=A9=E6=9B=BF=E6=8D=A2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/OtherExamsController.cs | 26 +++-- .../OtherExamsControllerTests.cs | 102 ++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/OtherExamsController.cs b/src/Jiaowu.Api/Controllers/OtherExamsController.cs index 1dfcc91..84f8ca9 100644 --- a/src/Jiaowu.Api/Controllers/OtherExamsController.cs +++ b/src/Jiaowu.Api/Controllers/OtherExamsController.cs @@ -74,7 +74,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope 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; + return result is null ? Ok(new { updated = request.Results.Count }) : result; } [HttpGet("batches/{id:guid}/template")] @@ -153,7 +153,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。"); foreach (var item in inputs) { - var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed); + var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes); if (error is not null) return ValidationProblem(error); } var studentIds = students.Values.Select(x => x.Id).ToList(); @@ -166,7 +166,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope batch.Status = OtherExamBatchStatus.Draft; await db.SaveChangesAsync(ct); - batch.Results = inputs.Select(x => + var replacementResults = inputs.Select(x => { var student = students[x.StudentNumber.Trim()]; return new OtherExamResult @@ -180,6 +180,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope Notes = Normalize(x.Notes) }; }).ToList(); + db.OtherExamResults.AddRange(replacementResults); await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); return null; @@ -209,13 +210,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。", _ => null }; - private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch + private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes) { - OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。", - OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。", - OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。", - _ => null - }; + if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。"; + return b.MetricKind switch + { + OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。", + OtherExamMetricKind.Score when decimal.Round(score.Value, 2) != score.Value => "分数最多保留两位小数。", + OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。", + OtherExamMetricKind.Level when Normalize(level)!.Length > 50 => "等级不能超过 50 个字符。", + OtherExamMetricKind.Level when !(b.LevelOptions ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Contains(Normalize(level)!, StringComparer.Ordinal) => "等级必须从该考试场次配置的等级选项中选择。", + 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); diff --git a/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs new file mode 100644 index 0000000..1eea5fe --- /dev/null +++ b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs @@ -0,0 +1,102 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class OtherExamsControllerTests +{ + [Fact] + public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" }; + var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 }; + var student = new Student + { + StudentNumber = "202600001", + Name = "测试学生", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1), + Status = StudentStatus.Active + }; + var batch = new OtherExamBatch + { + ExamCode = "CET4", + Name = "大学英语四级", + ExamDate = new DateOnly(2026, 6, 1), + MetricKind = OtherExamMetricKind.Score, + MaxScore = 710 + }; + var oldResult = new OtherExamResult + { + OtherExamBatchId = batch.Id, + StudentId = student.Id, + Score = 500, + AttemptNumber = 1 + }; + db.AddRange(college, major, administrativeClass, student, batch, oldResult); + await db.SaveChangesAsync(); + + var controller = new OtherExamsController(db, new TestDataScope(college.Id)); + var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([ + new OtherExamResultRequest(student.StudentNumber, 610.5m, null, null, "已复核") + ]), CancellationToken.None); + + Assert.IsType(result); + db.ChangeTracker.Clear(); + var saved = await db.OtherExamResults.SingleAsync(x => x.OtherExamBatchId == batch.Id); + Assert.Equal(610.5m, saved.Score); + Assert.Equal("已复核", saved.Notes); + Assert.Equal(1, saved.AttemptNumber); + } + + [Fact] + public async Task ReplaceResults_RejectsOverlongNotesBeforeDatabaseSave() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" }; + var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 }; + var student = new Student { StudentNumber = "202600001", Name = "测试学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active }; + var batch = new OtherExamBatch { ExamCode = "CET4", Name = "大学英语四级", ExamDate = new DateOnly(2026, 6, 1), MetricKind = OtherExamMetricKind.Score, MaxScore = 710 }; + db.AddRange(college, major, administrativeClass, student, batch); + await db.SaveChangesAsync(); + + var controller = new OtherExamsController(db, new TestDataScope(college.Id)); + var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([ + new OtherExamResultRequest(student.StudentNumber, 610, null, null, new string('注', 501)) + ]), CancellationToken.None); + + var validation = Assert.IsType(result); + Assert.Contains("备注不能超过 500 个字符", Assert.IsType(validation.Value).Detail); + Assert.Empty(await db.OtherExamResults.ToListAsync()); + } + + private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + } +}