实验成绩计算

This commit is contained in:
2026-08-09 22:40:21 +08:00 Unverified
parent d4abdd3db4
commit c25f16ec20
12 changed files with 7653 additions and 290 deletions
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -138,7 +139,7 @@ public sealed class ExperimentGradesController(
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
var results = await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
@@ -157,12 +158,19 @@ public sealed class ExperimentGradesController(
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTaskId,
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.AcademicTermId,
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.TaskNumber,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.ContributionWeight,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
@@ -187,7 +195,40 @@ public sealed class ExperimentGradesController(
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
taskIds.Contains(x.TeachingTaskId))
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var courses = results
.GroupBy(x => x.TeachingTaskId)
.Select(group =>
{
var first = group.First();
aggregates.TryGetValue(group.Key, out var aggregate);
return new
{
TeachingTaskId = group.Key,
first.AcademicTermId,
first.TermName,
first.TaskNumber,
first.CourseCode,
first.CourseName,
ExperimentCourseScore = aggregate?.WeightedAverageScore,
PublishedProjectCount =
aggregate?.PublishedProjectCount ?? group.Count(),
TotalWeight = aggregate?.TotalWeight ??
group.Sum(x => x.ContributionWeight),
RefreshedAt = aggregate?.RefreshedAt,
Projects = group.OrderBy(x => x.ProjectCode).ToList()
};
})
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
.ThenBy(x => x.CourseCode)
.ToList();
return Ok(courses);
}
[HttpPost("sheets")]
@@ -744,7 +785,15 @@ public sealed class ExperimentGradesController(
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await db.ExecuteInRetriableTransactionAsync(async transaction =>
{
await db.SaveChangesAsync(cancellationToken);
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
db,
sheet.ExperimentProject!.TeachingTaskId,
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}, cancellationToken, IsolationLevel.Serializable);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
/// <summary>
/// A student's persisted experiment-part score for one teaching task.
/// The score is the weighted average of every published experiment project.
/// </summary>
public sealed class ExperimentCourseGrade : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? WeightedAverageScore { get; set; }
public decimal TotalWeight { get; set; }
public int PublishedProjectCount { get; set; }
public DateTime RefreshedAt { get; set; }
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
@@ -5,13 +5,85 @@ namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task RefreshTeachingTaskAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var existing = await db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
var studentIds = sheets
.SelectMany(x => x.Scores.Select(score => score.StudentId))
.Distinct()
.ToArray();
var refreshedAt = DateTime.UtcNow;
var totalWeight = sheets.Sum(x => x.ContributionWeight);
foreach (var studentId in studentIds)
{
var score = CalculateStudentScore(sheets, studentId);
if (!existing.Remove(studentId, out var aggregate))
{
aggregate = new Domain.Academic.ExperimentCourseGrade
{
TeachingTaskId = teachingTaskId,
StudentId = studentId
};
db.ExperimentCourseGrades.Add(aggregate);
}
aggregate.WeightedAverageScore = score;
aggregate.TotalWeight = totalWeight;
aggregate.PublishedProjectCount = sheets.Count;
aggregate.RefreshedAt = refreshedAt;
}
db.ExperimentCourseGrades.RemoveRange(existing.Values);
await db.SaveChangesAsync(cancellationToken);
}
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var requestedStudentIds = studentIds.Distinct().ToArray();
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
requestedStudentIds.Contains(x.StudentId))
.ToDictionaryAsync(
x => x.StudentId,
x => x.WeightedAverageScore,
cancellationToken);
var scores = requestedStudentIds.ToDictionary(
studentId => studentId,
studentId => persistedScores.GetValueOrDefault(studentId));
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken) =>
db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
@@ -28,40 +100,27 @@ public static class ExperimentGradeAggregationService
.AsSplitQuery()
.ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>();
foreach (var studentId in studentIds.Distinct())
private static decimal? CalculateStudentScore(
IReadOnlyCollection<PublishedExperimentSheet> sheets,
Guid studentId)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
if (sheets.Count == 0) return null;
foreach (var sheet in sheets)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
var complete = sheets.Count > 0;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore) return null;
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
return totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
private sealed record PublishedExperimentSheet(
@@ -58,6 +58,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
Set<ExperimentCourseGrade>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
@@ -795,6 +797,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentCourseGrade>(entity =>
{
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
.IsUnique();
entity.HasIndex(x => x.StudentId);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
@@ -100,6 +100,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_52_reusable_course_groups";
private const string CourseGradeStatisticsRefreshSettingsMigration =
"20260809_53_course_grade_statistics_refresh_settings";
private const string ExperimentCourseGradesMigration =
"20260809_54_experiment_course_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -721,6 +723,14 @@ public sealed class DevelopmentSqliteMigrator(
ReusableCourseGroupsMigration,
courseGroupsExist ? [] : ReusableCourseGroupsStatements,
cancellationToken);
var experimentCourseGradesExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentCourseGrades'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentCourseGradesMigration,
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2989,6 +2999,80 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
];
private static readonly string[] ExperimentCourseGradesStatements =
[
"""
CREATE TABLE "ExperimentCourseGrades" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentCourseGrades" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"WeightedAverageScore" TEXT NULL,
"TotalWeight" TEXT NOT NULL,
"PublishedProjectCount" INTEGER NOT NULL,
"RefreshedAt" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentCourseGrades_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX "IX_ExperimentCourseGrades_StudentId" ON "ExperimentCourseGrades" ("StudentId");""",
"""CREATE UNIQUE INDEX "IX_ExperimentCourseGrades_TeachingTaskId_StudentId" ON "ExperimentCourseGrades" ("TeachingTaskId", "StudentId");""",
"""
INSERT INTO "ExperimentCourseGrades"
("Id", "TeachingTaskId", "StudentId", "WeightedAverageScore",
"TotalWeight", "PublishedProjectCount", "RefreshedAt",
"CreatedAt", "UpdatedAt")
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))),
project."TeachingTaskId",
record."StudentId",
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId")
AND SUM(CASE WHEN record."TotalScore" IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record."TotalScore" * sheet."ContributionWeight") /
SUM(sheet."ContributionWeight"), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet."ContributionWeight"), 0)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
(
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now')
FROM "ExperimentGradeRecords" AS record
INNER JOIN "ExperimentGradeSheets" AS sheet
ON sheet."Id" = record."ExperimentGradeSheetId"
INNER JOIN "ExperimentProjects" AS project
ON project."Id" = sheet."ExperimentProjectId"
WHERE sheet."Status" = 4
GROUP BY project."TeachingTaskId", record."StudentId";
"""
];
private static readonly string[] ExperimentClassroomConstraintStatements =
[
"""
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PersistExperimentCourseGrades : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExperimentCourseGrades",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
WeightedAverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
TotalWeight = table.Column<decimal>(type: "decimal(8,1)", precision: 8, scale: 1, nullable: false),
PublishedProjectCount = table.Column<int>(type: "int", nullable: false),
RefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentCourseGrades", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_StudentId",
table: "ExperimentCourseGrades",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_TeachingTaskId_StudentId",
table: "ExperimentCourseGrades",
columns: new[] { "TeachingTaskId", "StudentId" },
unique: true);
migrationBuilder.Sql(
"""
INSERT INTO `ExperimentCourseGrades`
(`Id`, `TeachingTaskId`, `StudentId`, `WeightedAverageScore`,
`TotalWeight`, `PublishedProjectCount`, `RefreshedAt`,
`CreatedAt`, `UpdatedAt`)
SELECT
UUID(),
project.`TeachingTaskId`,
record.`StudentId`,
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`)
AND SUM(CASE WHEN record.`TotalScore` IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record.`TotalScore` * sheet.`ContributionWeight`) /
SUM(sheet.`ContributionWeight`), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet.`ContributionWeight`), 0)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
(
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)
FROM `ExperimentGradeRecords` AS record
INNER JOIN `ExperimentGradeSheets` AS sheet
ON sheet.`Id` = record.`ExperimentGradeSheetId`
INNER JOIN `ExperimentProjects` AS project
ON project.`Id` = sheet.`ExperimentProjectId`
WHERE sheet.`Status` = 4
GROUP BY project.`TeachingTaskId`, record.`StudentId`;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentCourseGrades");
}
}
}
@@ -2263,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExperimentBookings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("PublishedProjectCount")
.HasColumnType("int");
b.Property<DateTime>("RefreshedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<decimal>("TotalWeight")
.HasPrecision(8, 1)
.HasColumnType("decimal(8,1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("WeightedAverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("StudentId");
b.HasIndex("TeachingTaskId", "StudentId")
.IsUnique();
b.ToTable("ExperimentCourseGrades");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.Property<Guid>("Id")
@@ -2494,11 +2536,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("ScheduleEntryId");
b.HasIndex("Status", "StartDate", "EndDate");
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
.IsUnique();
b.HasIndex("Status", "StartDate", "EndDate");
b.ToTable("ExperimentProjects");
});
@@ -5803,6 +5845,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Student");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")