“教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。 后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。 扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
/// <summary>
|
||||
/// Repairs missing or stale materialized grade statistics on a database-
|
||||
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
|
||||
/// worker batches changes made during bulk imports.
|
||||
/// </summary>
|
||||
public sealed class CourseGradeStatisticsRefreshWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Database-configured course grade statistics scheduler started.");
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var scheduler = scope.ServiceProvider
|
||||
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
|
||||
var queued = await scheduler.EnqueueDueAsync(
|
||||
timeProvider.GetUtcNow().UtcDateTime,
|
||||
stoppingToken);
|
||||
if (queued > 0)
|
||||
logger.LogInformation(
|
||||
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
|
||||
queued);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
|
||||
}
|
||||
|
||||
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CourseGradeStatisticsRefreshScheduler(
|
||||
AppDbContext db,
|
||||
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
|
||||
{
|
||||
public async Task<int> EnqueueDueAsync(
|
||||
DateTime utcNow,
|
||||
CancellationToken cancellationToken) =>
|
||||
await ExecuteWithLeaseAsync(async () =>
|
||||
{
|
||||
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||
cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new CourseGradeStatisticsRefreshSetting();
|
||||
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(
|
||||
Math.Clamp(setting.IntervalSeconds, 10, 86400));
|
||||
if (!setting.IsEnabled ||
|
||||
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
|
||||
{
|
||||
if (db.Entry(setting).State == EntityState.Added)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
|
||||
setting.LastRunAt = utcNow;
|
||||
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return queued;
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<int> EnqueueStaleAsync(
|
||||
int batchSize,
|
||||
CancellationToken cancellationToken) =>
|
||||
await ExecuteWithLeaseAsync(async () =>
|
||||
{
|
||||
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
|
||||
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
|
||||
return queued;
|
||||
}, cancellationToken);
|
||||
|
||||
private async Task<int> ExecuteWithLeaseAsync(
|
||||
Func<Task<int>> action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var usesMySqlLease = db.Database.ProviderName?.Contains(
|
||||
"MySql",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
|
||||
{
|
||||
await db.Database.CloseConnectionAsync();
|
||||
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (usesMySqlLease)
|
||||
await ReleaseMySqlLeaseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> EnqueueStaleCoreAsync(
|
||||
int batchSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
batchSize = Math.Clamp(batchSize, 1, 5000);
|
||||
var activeTargets = await (
|
||||
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||
join sheet in db.GradeSheets.AsNoTracking()
|
||||
on job.GradeSheetId equals sheet.Id
|
||||
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
|
||||
select new CourseTermTarget(
|
||||
sheet.TeachingTask!.CourseId,
|
||||
sheet.TeachingTask.AcademicTermId))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var active = activeTargets.ToHashSet();
|
||||
|
||||
var rows = await db.GradeSheets.AsNoTracking()
|
||||
.Where(sheet =>
|
||||
sheet.Status == GradeSheetStatus.Published &&
|
||||
sheet.Records.Any(record => record.TotalScore != null))
|
||||
.Select(sheet => new RefreshCandidate(
|
||||
sheet.Id,
|
||||
sheet.TeachingTask!.CourseId,
|
||||
sheet.TeachingTask.AcademicTermId,
|
||||
sheet.UpdatedAt,
|
||||
sheet.Records
|
||||
.Where(record => record.TotalScore != null)
|
||||
.Max(record => record.UpdatedAt),
|
||||
db.TeachingTaskGradeStatistics
|
||||
.Where(statistic => statistic.GradeSheetId == sheet.Id)
|
||||
.Select(statistic => (DateTime?)statistic.CalculatedAt)
|
||||
.FirstOrDefault()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var stale = rows
|
||||
.Where(row =>
|
||||
row.CalculatedAt is null ||
|
||||
row.SheetUpdatedAt > row.CalculatedAt ||
|
||||
row.RecordsUpdatedAt > row.CalculatedAt)
|
||||
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
|
||||
.Where(group => !active.Contains(group.Key))
|
||||
.Select(group => group
|
||||
.OrderByDescending(row => row.RecordsUpdatedAt)
|
||||
.ThenByDescending(row => row.SheetUpdatedAt)
|
||||
.First())
|
||||
.Take(batchSize)
|
||||
.ToArray();
|
||||
|
||||
foreach (var candidate in stale)
|
||||
{
|
||||
var job = new CourseGradeStatisticsRefreshJob
|
||||
{
|
||||
GradeSheetId = candidate.GradeSheetId
|
||||
};
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||
job.Id));
|
||||
}
|
||||
|
||||
if (stale.Length == 0) return 0;
|
||||
logger.LogDebug(
|
||||
"Queued {Count} stale course grade statistics targets.",
|
||||
stale.Length);
|
||||
return stale.Length;
|
||||
}
|
||||
|
||||
private async Task<bool> TryAcquireMySqlLeaseAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await db.Database.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(result) == 1;
|
||||
}
|
||||
|
||||
private async Task ReleaseMySqlLeaseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
|
||||
await command.ExecuteScalarAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await db.Database.CloseConnectionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RefreshCandidate(
|
||||
Guid GradeSheetId,
|
||||
Guid CourseId,
|
||||
Guid AcademicTermId,
|
||||
DateTime SheetUpdatedAt,
|
||||
DateTime RecordsUpdatedAt,
|
||||
DateTime? CalculatedAt);
|
||||
|
||||
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
|
||||
}
|
||||
@@ -144,6 +144,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||
Set<SystemFeatureSetting>();
|
||||
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
|
||||
Set<CourseGradeStatisticsRefreshSetting>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
@@ -1536,6 +1538,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.Key).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Key).HasMaxLength(50);
|
||||
entity.HasIndex(x => x.Key).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocument>(entity =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
|
||||
@@ -98,6 +98,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260809_51_separate_experiment_classroom_scope";
|
||||
private const string ReusableCourseGroupsMigration =
|
||||
"20260809_52_reusable_course_groups";
|
||||
private const string CourseGradeStatisticsRefreshSettingsMigration =
|
||||
"20260809_53_course_grade_statistics_refresh_settings";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -677,6 +679,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
TeachingTaskGradeAnalyticsMigration,
|
||||
TeachingTaskGradeAnalyticsStatements,
|
||||
cancellationToken);
|
||||
var gradeRefreshSettingsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGradeStatisticsRefreshSettings'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
CourseGradeStatisticsRefreshSettingsMigration,
|
||||
gradeRefreshSettingsExist ? [] : CourseGradeStatisticsRefreshSettingsStatements,
|
||||
cancellationToken);
|
||||
var swaggerSettingsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||
@@ -2973,6 +2983,12 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
||||
];
|
||||
|
||||
private static readonly string[] ExperimentClassroomConstraintStatements =
|
||||
[
|
||||
"""
|
||||
|
||||
+6867
File diff suppressed because it is too large
Load Diff
+48
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CourseGradeStatisticsRefreshSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseGradeStatisticsRefreshSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Key = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
IntervalSeconds = table.Column<int>(type: "int", nullable: false),
|
||||
BatchSize = table.Column<int>(type: "int", nullable: false),
|
||||
LastRunAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CourseGradeStatisticsRefreshSettings", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatisticsRefreshSettings_Key",
|
||||
table: "CourseGradeStatisticsRefreshSettings",
|
||||
column: "Key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseGradeStatisticsRefreshSettings");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -1098,6 +1098,43 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("BatchSize")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("IntervalSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<DateTime?>("LastRunAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CourseGradeStatisticsRefreshSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
Reference in New Issue
Block a user