“教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。 后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。 扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
This commit is contained in:
@@ -251,13 +251,6 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// Auto-apply: update grade record
|
||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
||||
{
|
||||
GradeSheetId = gm.GradeRecord.GradeSheetId
|
||||
};
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
|
||||
@@ -25,6 +25,69 @@ public sealed class GradeAnalyticsController(
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader + "," +
|
||||
SystemRoles.Teacher;
|
||||
private const string ScheduleManagers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("refresh-schedule")]
|
||||
[Authorize(Roles = ScheduleManagers)]
|
||||
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
||||
{
|
||||
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||
cancellationToken);
|
||||
var defaults = new CourseGradeStatisticsRefreshSetting();
|
||||
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
|
||||
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
|
||||
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
|
||||
var lastRunAt = setting?.LastRunAt;
|
||||
return Ok(new
|
||||
{
|
||||
IsEnabled = enabled,
|
||||
IntervalSeconds = intervalSeconds,
|
||||
BatchSize = batchSize,
|
||||
LastRunAt = lastRunAt,
|
||||
NextRunAt = enabled && lastRunAt.HasValue
|
||||
? lastRunAt.Value.AddSeconds(intervalSeconds)
|
||||
: null as DateTime?
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("refresh-schedule")]
|
||||
[Authorize(Roles = ScheduleManagers)]
|
||||
public async Task<ActionResult> SaveRefreshSchedule(
|
||||
SaveGradeStatisticsRefreshScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.IntervalSeconds is < 10 or > 86400)
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "刷新间隔应在 10 秒到 24 小时之间。",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
if (request.BatchSize is < 1 or > 5000)
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "单次刷新批量应在 1 到 5000 之间。",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
|
||||
var setting = await db.CourseGradeStatisticsRefreshSettings
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
||||
cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new CourseGradeStatisticsRefreshSetting();
|
||||
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
||||
}
|
||||
|
||||
setting.IsEnabled = request.IsEnabled;
|
||||
setting.IntervalSeconds = request.IntervalSeconds;
|
||||
setting.BatchSize = request.BatchSize;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes")]
|
||||
public async Task<ActionResult> GetTeachingClasses(
|
||||
@@ -526,3 +589,8 @@ public sealed class GradeAnalyticsController(
|
||||
IReadOnlyList<HistoricalComparison> History,
|
||||
ComparisonDelta? UniversityDelta);
|
||||
}
|
||||
|
||||
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
||||
bool IsEnabled,
|
||||
int IntervalSeconds,
|
||||
int BatchSize);
|
||||
|
||||
@@ -508,7 +508,6 @@ public sealed class GradesController(
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
@@ -668,7 +667,6 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
@@ -918,7 +916,6 @@ public sealed class GradesController(
|
||||
if (errors.Count > 0)
|
||||
return ImportValidationProblem(errors);
|
||||
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { updated, total = rows.Count });
|
||||
}
|
||||
@@ -1161,7 +1158,6 @@ public sealed class GradesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
QueueCourseStatisticsRefresh(id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
@@ -1171,14 +1167,6 @@ public sealed class GradesController(
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||
{
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
|
||||
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
|
||||
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
|
||||
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
||||
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
|
||||
.ToListAsync(ct));
|
||||
|
||||
[HttpPut("rules")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
|
||||
{
|
||||
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
|
||||
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
|
||||
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
|
||||
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
|
||||
|
||||
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
|
||||
db.WarningRules.RemoveRange(existing);
|
||||
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
|
||||
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
|
||||
foreach (var r in rules)
|
||||
{
|
||||
db.WarningRules.Add(new WarningRule
|
||||
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
|
||||
if (entity is null)
|
||||
{
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold,
|
||||
IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent,
|
||||
NotifyCounselor = r.NotifyCounselor,
|
||||
Description = r.Description?.Trim(),
|
||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
||||
CheckHour = r.CheckHour,
|
||||
CheckMinute = r.CheckMinute
|
||||
});
|
||||
entity = new WarningRule
|
||||
{
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim()
|
||||
};
|
||||
db.WarningRules.Add(entity);
|
||||
}
|
||||
|
||||
entity.Name = r.Name.Trim();
|
||||
entity.Threshold = r.Threshold;
|
||||
entity.IsEnabled = r.IsEnabled;
|
||||
entity.NotifyStudent = r.NotifyStudent;
|
||||
entity.NotifyCounselor = r.NotifyCounselor;
|
||||
entity.Description = r.Description?.Trim();
|
||||
entity.AutoCheckEnabled = r.AutoCheckEnabled;
|
||||
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
|
||||
entity.CheckHour = r.CheckHour;
|
||||
entity.CheckMinute = r.CheckMinute;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
}
|
||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
||||
}
|
||||
|
||||
// ═══════════ Student ═══════════
|
||||
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
|
||||
.ToListAsync(ct));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
|
||||
{
|
||||
public const string DefaultKey = "default";
|
||||
|
||||
public string Key { get; set; } = DefaultKey;
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public int IntervalSeconds { get; set; } = 300;
|
||||
public int BatchSize { get; set; } = 100;
|
||||
public DateTime? LastRunAt { get; set; }
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -438,6 +438,9 @@ builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
|
||||
Reference in New Issue
Block a user