实验成绩计算

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
@@ -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")