RabbitMQ 默认从单一回调通道提升为三类任务独立并行,跨类型并发能力由 1 提升到 3;每类还能独立配置 1–16 个消费者。[RabbitMqBackgroundJobs.cs (line 143)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs:143) InMemory 开发模式同步改为按任务类型隔离队列,避免某类长任务堵塞其他任务。 租约恢复检查从“每发布一条执行一次”改为默认每 60 秒维护一次。 启动恢复改为数据库 NOT EXISTS 查询,不再把全部历史 Outbox 加载进内存。[BackgroundJobOutboxPublisher.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs:9) 完成消息默认保留 14 天,之后按每批 500 条清理,并增加对应组合索引。 /health/messaging 现在返回各状态积压量、过期租约和最老任务等待时间。[BackgroundJobMonitoringService.cs (line 16)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobMonitoringService.cs:16) 新增 Jiaowu.BackgroundJobs 运行时指标,覆盖发布量、处理量、发布耗时、处理耗时和清理量。[BackgroundJobTelemetry.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTelemetry.cs:9) 配置、Compose 和调优建议已更新。[README.md (line 257)](E:/jiaowu/README.md:257)
395 lines
13 KiB
C#
395 lines
13 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Jiaowu.Api.Tests;
|
|
|
|
public sealed class AutomaticScheduleGeneratorTests
|
|
{
|
|
[Fact]
|
|
public async Task Development_migration_chain_accepts_current_sqlite_schema()
|
|
{
|
|
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();
|
|
await db.Database.ExecuteSqlRawAsync(
|
|
"DROP TABLE \"BackgroundJobOutboxMessages\"");
|
|
|
|
var migrator = new DevelopmentSqliteMigrator(
|
|
db,
|
|
NullLogger<DevelopmentSqliteMigrator>.Instance);
|
|
await migrator.MigrateAsync();
|
|
|
|
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
|
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
|
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
|
|
Assert.True(await db.Database
|
|
.SqlQueryRaw<int>(
|
|
"""
|
|
SELECT COUNT(*) AS "Value"
|
|
FROM pragma_index_list('BackgroundJobOutboxMessages')
|
|
WHERE name = 'IX_BackgroundJobOutboxMessages_State_CompletedAt'
|
|
""")
|
|
.AnyAsync(value => value > 0));
|
|
Assert.True(await db.Database
|
|
.SqlQueryRaw<int>(
|
|
"""
|
|
SELECT COUNT(*) AS "Value"
|
|
FROM pragma_table_info('ScheduleEntries')
|
|
WHERE name = 'ClassroomId' AND "notnull" = 0
|
|
""")
|
|
.AnyAsync(value => value > 0));
|
|
Assert.True(await db.Database
|
|
.SqlQueryRaw<int>(
|
|
"""
|
|
SELECT COUNT(*) AS "Value"
|
|
FROM pragma_table_info('TeachingTasks')
|
|
WHERE name = 'SchedulingMode'
|
|
""")
|
|
.AnyAsync(value => value > 0));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generator_skips_flexible_courses()
|
|
{
|
|
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 = "2026-F",
|
|
Name = "2026 秋季",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 15)
|
|
};
|
|
var college = new College { Code = "PUBLIC", Name = "公共教学部" };
|
|
var course = new Course
|
|
{
|
|
Code = "FLEX-01",
|
|
Name = "社会实践",
|
|
College = college,
|
|
Credits = 1,
|
|
TotalHours = 16,
|
|
PracticeHours = 16
|
|
};
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = "TASK-FLEX",
|
|
Name = "社会实践教学班",
|
|
AcademicTerm = term,
|
|
Course = course,
|
|
Capacity = 100,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 1,
|
|
SchedulingMode = TeachingTaskSchedulingMode.Flexible,
|
|
Status = TeachingTaskStatus.Published
|
|
};
|
|
var plan = new SchedulePlan
|
|
{
|
|
AcademicTerm = term,
|
|
Name = "非排时课程测试",
|
|
Version = "V1"
|
|
};
|
|
db.AddRange(term, college, course, task, plan);
|
|
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
|
{
|
|
AcademicTerm = term,
|
|
PeriodNumber = 1,
|
|
Name = "第 1 节",
|
|
StartsAt = new TimeOnly(8, 0),
|
|
EndsAt = new TimeOnly(8, 45)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await new AutomaticScheduleGenerator(db)
|
|
.GenerateAsync(plan, CancellationToken.None);
|
|
|
|
Assert.Equal(0, result.TotalTasks);
|
|
Assert.Equal(0, result.CreatedEntries);
|
|
Assert.Empty(await db.ScheduleEntries.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generator_schedules_roomless_course_within_allowed_time()
|
|
{
|
|
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 = "2026-A",
|
|
Name = "2026 秋季",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 15)
|
|
};
|
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
|
var course = new Course
|
|
{
|
|
Code = "MOOC-01",
|
|
Name = "在线专题",
|
|
College = college,
|
|
Credits = 1,
|
|
TotalHours = 32,
|
|
LectureHours = 32
|
|
};
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = "TASK-MOOC",
|
|
Name = "在线专题教学班",
|
|
AcademicTerm = term,
|
|
Course = course,
|
|
Capacity = 200,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 2,
|
|
Status = TeachingTaskStatus.Published
|
|
};
|
|
var plan = new SchedulePlan
|
|
{
|
|
AcademicTerm = term,
|
|
Name = "自动排课测试",
|
|
Version = "V1"
|
|
};
|
|
db.AddRange(term, college, course, task, plan);
|
|
db.ScheduleTimeSlots.AddRange(
|
|
new ScheduleTimeSlot
|
|
{
|
|
AcademicTerm = term,
|
|
PeriodNumber = 1,
|
|
Name = "第 1 节",
|
|
StartsAt = new TimeOnly(8, 0),
|
|
EndsAt = new TimeOnly(8, 45)
|
|
},
|
|
new ScheduleTimeSlot
|
|
{
|
|
AcademicTerm = term,
|
|
PeriodNumber = 2,
|
|
Name = "第 2 节",
|
|
StartsAt = new TimeOnly(8, 55),
|
|
EndsAt = new TimeOnly(9, 40)
|
|
});
|
|
db.TeachingTaskScheduleConstraints.Add(new TeachingTaskScheduleConstraint
|
|
{
|
|
TeachingTask = task,
|
|
RequiresClassroom = false,
|
|
AllowedDayOfWeeks = "3",
|
|
EarliestPeriod = 1,
|
|
LatestPeriod = 2
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var progressUpdates = new List<AutomaticScheduleProgress>();
|
|
var result = await new AutomaticScheduleGenerator(db)
|
|
.GenerateAsync(
|
|
plan,
|
|
(progress, _) =>
|
|
{
|
|
progressUpdates.Add(progress);
|
|
return Task.CompletedTask;
|
|
},
|
|
true,
|
|
CancellationToken.None);
|
|
var entry = await db.ScheduleEntries.SingleAsync();
|
|
|
|
Assert.Equal(1, result.CreatedEntries);
|
|
Assert.Empty(result.Messages);
|
|
Assert.Collection(
|
|
progressUpdates,
|
|
progress =>
|
|
{
|
|
Assert.Equal(1, progress.TotalTasks);
|
|
Assert.Equal(0, progress.ProcessedTasks);
|
|
},
|
|
progress =>
|
|
{
|
|
Assert.Equal(1, progress.TotalTasks);
|
|
Assert.Equal(1, progress.ProcessedTasks);
|
|
Assert.Equal(1, progress.CreatedEntries);
|
|
});
|
|
Assert.Equal(3, entry.DayOfWeek);
|
|
Assert.Equal(1, entry.StartPeriod);
|
|
Assert.Equal(2, entry.PeriodCount);
|
|
Assert.Null(entry.ClassroomId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generator_uses_scoped_rooms_without_attaching_duplicate_buildings()
|
|
{
|
|
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 = "2026-B",
|
|
Name = "2026 秋季",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 15)
|
|
};
|
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
|
var building = new Building
|
|
{
|
|
Code = "A",
|
|
Name = "教学楼 A",
|
|
Campus = campus
|
|
};
|
|
var smallRoom = new Classroom
|
|
{
|
|
Code = "A101",
|
|
Name = "A101",
|
|
Building = building,
|
|
Capacity = 20
|
|
};
|
|
var largeRoom = new Classroom
|
|
{
|
|
Code = "A102",
|
|
Name = "A102",
|
|
Building = building,
|
|
Capacity = 30
|
|
};
|
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
|
var course = new Course
|
|
{
|
|
Code = "CS-01",
|
|
Name = "程序设计",
|
|
College = college,
|
|
Credits = 2,
|
|
TotalHours = 32,
|
|
LectureHours = 32
|
|
};
|
|
var smallTask = new TeachingTask
|
|
{
|
|
TaskNumber = "TASK-SMALL",
|
|
Name = "小班教学任务",
|
|
AcademicTerm = term,
|
|
Course = course,
|
|
Capacity = 20,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 2,
|
|
Status = TeachingTaskStatus.Published
|
|
};
|
|
var largeTask = new TeachingTask
|
|
{
|
|
TaskNumber = "TASK-LARGE",
|
|
Name = "大班教学任务",
|
|
AcademicTerm = term,
|
|
Course = course,
|
|
Capacity = 30,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 2,
|
|
Status = TeachingTaskStatus.Published
|
|
};
|
|
var plan = new SchedulePlan
|
|
{
|
|
AcademicTerm = term,
|
|
Name = "同楼多教室排课",
|
|
Version = "V1"
|
|
};
|
|
db.AddRange(
|
|
term,
|
|
campus,
|
|
building,
|
|
smallRoom,
|
|
largeRoom,
|
|
college,
|
|
course,
|
|
smallTask,
|
|
largeTask,
|
|
plan);
|
|
db.ScheduleTimeSlots.AddRange(
|
|
new ScheduleTimeSlot
|
|
{
|
|
AcademicTerm = term,
|
|
PeriodNumber = 1,
|
|
Name = "第 1 节",
|
|
StartsAt = new TimeOnly(8, 0),
|
|
EndsAt = new TimeOnly(8, 45)
|
|
},
|
|
new ScheduleTimeSlot
|
|
{
|
|
AcademicTerm = term,
|
|
PeriodNumber = 2,
|
|
Name = "第 2 节",
|
|
StartsAt = new TimeOnly(8, 55),
|
|
EndsAt = new TimeOnly(9, 40)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
db.TeachingTaskScheduleConstraints.AddRange(
|
|
new TeachingTaskScheduleConstraint
|
|
{
|
|
TeachingTaskId = smallTask.Id,
|
|
RequiredCampusId = campus.Id,
|
|
RequiredBuildingId = building.Id,
|
|
AllowedClassrooms =
|
|
[
|
|
new TeachingTaskAllowedClassroom
|
|
{
|
|
ClassroomId = smallRoom.Id
|
|
}
|
|
]
|
|
},
|
|
new TeachingTaskScheduleConstraint
|
|
{
|
|
TeachingTaskId = largeTask.Id,
|
|
RequiredCampusId = campus.Id,
|
|
RequiredBuildingId = building.Id,
|
|
AllowedClassrooms =
|
|
[
|
|
new TeachingTaskAllowedClassroom
|
|
{
|
|
ClassroomId = largeRoom.Id
|
|
}
|
|
]
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
db.ChangeTracker.Clear();
|
|
plan = await db.SchedulePlans.SingleAsync();
|
|
|
|
var result = await new AutomaticScheduleGenerator(db)
|
|
.GenerateAsync(plan, CancellationToken.None);
|
|
var entries = await db.ScheduleEntries
|
|
.OrderBy(x => x.TeachingTaskId)
|
|
.ToListAsync();
|
|
|
|
Assert.Equal(2, result.CreatedEntries);
|
|
Assert.Empty(result.Messages);
|
|
Assert.Equal(2, entries.Count);
|
|
Assert.True(
|
|
new HashSet<Guid> { smallRoom.Id, largeRoom.Id }
|
|
.SetEquals(entries.Select(x => x.ClassroomId!.Value)));
|
|
Assert.Equal(1, await db.Buildings.CountAsync());
|
|
Assert.Equal(2, await db.Classrooms.CountAsync());
|
|
}
|
|
}
|