已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。
发布接口立即返回 202 Accepted,页面轮询显示检查进度。 校验改为批量读取,减少重复数据库查询。 检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。 同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。 发布期间锁定编辑、自动排课等冲突操作。 已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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 SchedulePublishJobProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Processor_validates_and_publishes_plan_atomically()
|
||||
{
|
||||
var result = await RunPublishAsync(weeklyHours: 2);
|
||||
|
||||
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||
Assert.Null(result.ActiveAcademicTermId);
|
||||
Assert.Equal(5, result.CompletedSteps);
|
||||
Assert.Equal("课表已发布", result.CurrentStep);
|
||||
Assert.Null(result.ErrorMessage);
|
||||
Assert.NotNull(result.PublishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_keeps_draft_when_validation_fails()
|
||||
{
|
||||
var result = await RunPublishAsync(weeklyHours: 4);
|
||||
|
||||
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||
Assert.Null(result.ActiveAcademicTermId);
|
||||
Assert.Equal("检查未通过", result.CurrentStep);
|
||||
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage);
|
||||
Assert.Null(result.PublishedAt);
|
||||
}
|
||||
|
||||
private static async Task<PublishResult> RunPublishAsync(int weeklyHours)
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"jiaowu-publish-{Guid.NewGuid():N}.sqlite");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(
|
||||
options => options.UseSqlite(
|
||||
$"Data Source={databasePath};Pooling=False"));
|
||||
services.AddScoped<SchedulePlanPublisher>();
|
||||
services.AddScoped<SchedulePublishJobProcessor>();
|
||||
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-PUBLISH-{weeklyHours}",
|
||||
Name = "后台发布学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
||||
var course = new Course
|
||||
{
|
||||
Code = "PUB-01",
|
||||
Name = "发布测试课程",
|
||||
College = college,
|
||||
Credits = 1,
|
||||
TotalHours = 32,
|
||||
LectureHours = 32
|
||||
};
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = $"TASK-PUB-{weeklyHours}",
|
||||
Name = "发布测试教学班",
|
||||
AcademicTerm = term,
|
||||
Course = course,
|
||||
Capacity = 60,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeeklyHours = weeklyHours,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTerm = term,
|
||||
Name = "待发布课表",
|
||||
Version = "V1"
|
||||
};
|
||||
plan.Entries.Add(new ScheduleEntry
|
||||
{
|
||||
TeachingTask = task,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
var job = new SchedulePublishJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
AcademicTermId = term.Id,
|
||||
ActiveAcademicTermId = term.Id,
|
||||
CurrentStep = "等待后台检查"
|
||||
};
|
||||
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
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var workerScope = provider.CreateAsyncScope())
|
||||
{
|
||||
var processor = workerScope.ServiceProvider
|
||||
.GetRequiredService<SchedulePublishJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var assertScope = provider.CreateAsyncScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobResult = await assertDb.SchedulePublishJobs.SingleAsync();
|
||||
var planResult = await assertDb.SchedulePlans.SingleAsync();
|
||||
return new(
|
||||
jobResult.Status,
|
||||
planResult.Status,
|
||||
jobResult.ActiveAcademicTermId,
|
||||
jobResult.CompletedSteps,
|
||||
jobResult.CurrentStep,
|
||||
jobResult.ErrorMessage,
|
||||
planResult.PublishedAt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.DisposeAsync();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PublishResult(
|
||||
SchedulePublishJobStatus JobStatus,
|
||||
SchedulePlanStatus PlanStatus,
|
||||
Guid? ActiveAcademicTermId,
|
||||
int CompletedSteps,
|
||||
string? CurrentStep,
|
||||
string? ErrorMessage,
|
||||
DateTime? PublishedAt);
|
||||
}
|
||||
Reference in New Issue
Block a user