主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
@@ -26,6 +26,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
await migrator.MigrateAsync();
|
||||
|
||||
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
||||
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
||||
Assert.True(await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
@@ -112,15 +113,167 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var progressUpdates = new List<AutomaticScheduleProgress>();
|
||||
var result = await new AutomaticScheduleGenerator(db)
|
||||
.GenerateAsync(plan, CancellationToken.None);
|
||||
.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_does_not_attach_duplicate_buildings_from_untracked_classrooms()
|
||||
{
|
||||
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.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AutomaticScheduleJobProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Processor_persists_progress_result_and_schedule_entries()
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"jiaowu-auto-schedule-{Guid.NewGuid():N}.sqlite");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(
|
||||
options => options.UseSqlite(
|
||||
$"Data Source={databasePath};Pooling=False"));
|
||||
services.AddScoped<AutomaticScheduleGenerator>();
|
||||
services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
try
|
||||
{
|
||||
Guid jobId;
|
||||
await using (var seedScope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = seedScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-JOB",
|
||||
Name = "后台排课学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var college = new College { Code = "JOB", Name = "后台任务学院" };
|
||||
var course = new Course
|
||||
{
|
||||
Code = "JOB-01",
|
||||
Name = "后台任务课程",
|
||||
College = college,
|
||||
Credits = 1,
|
||||
TotalHours = 32,
|
||||
LectureHours = 32
|
||||
};
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = "TASK-JOB",
|
||||
Name = "后台任务教学班",
|
||||
AcademicTerm = term,
|
||||
Course = course,
|
||||
Capacity = 100,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeeklyHours = 2,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTerm = term,
|
||||
Name = "后台排课草稿",
|
||||
Version = "V1"
|
||||
};
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
ActiveSchedulePlanId = plan.Id
|
||||
};
|
||||
jobId = job.Id;
|
||||
db.AddRange(term, college, course, task, plan, job);
|
||||
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 = "2",
|
||||
EarliestPeriod = 1,
|
||||
LatestPeriod = 2
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var workerScope = provider.CreateAsyncScope())
|
||||
{
|
||||
var processor = workerScope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var assertScope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var job = await db.AutomaticScheduleJobs.SingleAsync();
|
||||
var entry = await db.ScheduleEntries.SingleAsync();
|
||||
|
||||
Assert.Equal(AutomaticScheduleJobStatus.Succeeded, job.Status);
|
||||
Assert.Null(job.ActiveSchedulePlanId);
|
||||
Assert.Equal(1, job.TotalTasks);
|
||||
Assert.Equal(1, job.ProcessedTasks);
|
||||
Assert.Equal(1, job.CreatedEntries);
|
||||
Assert.Equal(1, job.CompletedTasks);
|
||||
Assert.Equal("[]", job.MessagesJson);
|
||||
Assert.NotNull(job.StartedAt);
|
||||
Assert.NotNull(job.CompletedAt);
|
||||
Assert.Null(job.ErrorMessage);
|
||||
Assert.Equal(2, entry.DayOfWeek);
|
||||
Assert.Equal(2, entry.PeriodCount);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.DisposeAsync();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user