“教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。 后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。 扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class CourseGradeStatisticsRefreshSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnqueueDueAsync_UsesPersistentScheduleAndHonorsInterval()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||
db,
|
||||
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||
var now = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await scheduler.EnqueueDueAsync(now, CancellationToken.None);
|
||||
var setting = Assert.Single(await db.CourseGradeStatisticsRefreshSettings.ToListAsync());
|
||||
Assert.True(setting.IsEnabled);
|
||||
Assert.Equal(now, setting.LastRunAt);
|
||||
|
||||
await scheduler.EnqueueDueAsync(now.AddMinutes(1), CancellationToken.None);
|
||||
Assert.Equal(now, setting.LastRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueStaleAsync_GroupsByCourseTerm_AndSkipsActiveTarget()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var college = new College { Code = "SCHEDULE", Name = "定时任务学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "SCHEDULE-M",
|
||||
Name = "定时任务专业",
|
||||
College = college,
|
||||
DegreeType = "本科"
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "SCHEDULE-C",
|
||||
Name = "定时任务一班",
|
||||
Major = major,
|
||||
Grade = 2026
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "SCHEDULE-001",
|
||||
Name = "定时任务学生",
|
||||
AdministrativeClass = administrativeClass,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "SCHEDULE-COURSE",
|
||||
Name = "定时任务课程",
|
||||
College = college,
|
||||
Credits = 2,
|
||||
TotalHours = 32,
|
||||
LectureHours = 32
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-A",
|
||||
Name = "2026-2027 学年第一学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var firstTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "SCHEDULE-T1",
|
||||
Name = "定时任务教学班一",
|
||||
Course = course,
|
||||
AcademicTerm = term,
|
||||
Capacity = 30
|
||||
};
|
||||
var secondTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "SCHEDULE-T2",
|
||||
Name = "定时任务教学班二",
|
||||
Course = course,
|
||||
AcademicTerm = term,
|
||||
Capacity = 30
|
||||
};
|
||||
var firstSheet = PublishedSheet(firstTask, student, 80m);
|
||||
var secondSheet = PublishedSheet(secondTask, student, 90m);
|
||||
db.GradeSheets.AddRange(firstSheet, secondSheet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var scheduler = new CourseGradeStatisticsRefreshScheduler(
|
||||
db,
|
||||
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
|
||||
|
||||
var queued = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||
var queuedAgain = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, queued);
|
||||
Assert.Equal(0, queuedAgain);
|
||||
var job = Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||
var outbox = Assert.Single(await db.BackgroundJobOutboxMessages.ToListAsync());
|
||||
Assert.Equal(BackgroundJobKind.CourseGradeStatisticsRefresh, outbox.JobKind);
|
||||
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||
db.TeachingTaskGradeStatistics.AddRange(
|
||||
FreshStatistic(firstSheet, course, term),
|
||||
FreshStatistic(secondSheet, course, term));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var queuedAfterFreshStatistics = await scheduler.EnqueueStaleAsync(
|
||||
100,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, queuedAfterFreshStatistics);
|
||||
Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
|
||||
}
|
||||
|
||||
private static GradeSheet PublishedSheet(
|
||||
TeachingTask task,
|
||||
Student student,
|
||||
decimal score)
|
||||
{
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTask = task,
|
||||
Status = GradeSheetStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow
|
||||
};
|
||||
sheet.Records.Add(new GradeRecord
|
||||
{
|
||||
GradeSheet = sheet,
|
||||
Student = student,
|
||||
TotalScore = score,
|
||||
GradePoint = 3m
|
||||
});
|
||||
return sheet;
|
||||
}
|
||||
|
||||
private static TeachingTaskGradeStatistic FreshStatistic(
|
||||
GradeSheet sheet,
|
||||
Course course,
|
||||
AcademicTerm term) => new()
|
||||
{
|
||||
GradeSheetId = sheet.Id,
|
||||
TeachingTaskId = sheet.TeachingTaskId,
|
||||
CourseId = course.Id,
|
||||
AcademicTermId = term.Id,
|
||||
StudentCount = 1,
|
||||
PassedCount = 1,
|
||||
HighestScore = 80m,
|
||||
AverageScore = 80m,
|
||||
MedianScore = 80m,
|
||||
LowestScore = 80m,
|
||||
PassRate = 100m,
|
||||
CalculatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections;
|
||||
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 WarningRulePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveRules_PersistsAutoCheck_AndReturnsNumericType()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "WARN-TERM",
|
||||
Name = "预警测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
db.AcademicTerms.Add(term);
|
||||
await db.SaveChangesAsync();
|
||||
var controller = new WarningsController(db, new AllDataScope());
|
||||
|
||||
var saveResult = await controller.SaveRules(term.Id,
|
||||
[
|
||||
new WarningRuleDto(
|
||||
WarningType.FailedCredits,
|
||||
"不及格学分",
|
||||
2m,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
true,
|
||||
1,
|
||||
9,
|
||||
30)
|
||||
], CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(saveResult);
|
||||
db.ChangeTracker.Clear();
|
||||
var persisted = Assert.Single(await db.WarningRules.AsNoTracking().ToListAsync());
|
||||
Assert.True(persisted.AutoCheckEnabled);
|
||||
Assert.Equal(1, persisted.CheckDayOfWeek);
|
||||
Assert.Equal(9, persisted.CheckHour);
|
||||
Assert.Equal(30, persisted.CheckMinute);
|
||||
|
||||
var getResult = await controller.GetRules(term.Id, CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(getResult);
|
||||
var row = Assert.Single(Assert.IsAssignableFrom<IEnumerable>(ok.Value).Cast<object>());
|
||||
var type = row.GetType().GetProperty("Type")?.GetValue(row);
|
||||
Assert.Equal(1, Assert.IsType<int>(type));
|
||||
}
|
||||
|
||||
private sealed class AllDataScope : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
Guid.NewGuid(),
|
||||
"测试管理员",
|
||||
null,
|
||||
DataScope.All,
|
||||
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user