自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。

任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
2026-07-27 21:40:14 +08:00 Unverified
parent 9d9f54a3b0
commit 9e0d4062a4
29 changed files with 6386 additions and 45 deletions
@@ -0,0 +1,205 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class ExamArrangementJobControllerTests
{
[Fact]
public async Task Formal_and_makeup_arrangement_are_enqueued_with_outbox_messages()
{
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-EXAM-JOB",
Name = "考试后台任务测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var formalPlan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
var makeupPlan = new MakeupExamPlan
{
AcademicTerm = term,
Name = "补考草稿"
};
db.AddRange(term, formalPlan, makeupPlan);
await db.SaveChangesAsync();
var scope = new ManagerDataScope();
var exams = new ExamsController(db, scope, NoOpAppCache.Instance);
var makeup = new MakeupExamsController(
db,
scope,
new MakeupExamEligibilityService(db));
var formalResult = await exams.AutoArrange(
formalPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
var makeupResult = await makeup.AutoArrange(
makeupPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
Assert.IsType<AcceptedAtActionResult>(formalResult);
Assert.IsType<AcceptedAtActionResult>(makeupResult);
var jobs = await db.ExamArrangementJobs
.OrderBy(x => x.Kind)
.ToListAsync();
Assert.Equal(2, jobs.Count);
Assert.Equal(ExamArrangementKind.FormalExam, jobs[0].Kind);
Assert.Equal(formalPlan.Id, jobs[0].ActivePlanId);
Assert.Equal(ExamArrangementKind.MakeupExam, jobs[1].Kind);
Assert.Equal(makeupPlan.Id, jobs[1].ActivePlanId);
Assert.All(jobs, job =>
Assert.Equal(ExamArrangementJobStatus.Queued, job.Status));
var outbox = await db.BackgroundJobOutboxMessages.ToListAsync();
Assert.Equal(2, outbox.Count);
Assert.All(outbox, message =>
{
Assert.Equal(BackgroundJobKind.ExamArrangement, message.JobKind);
Assert.Equal(BackgroundJobOutboxState.Pending, message.State);
});
Assert.Equal(
jobs.Select(x => x.Id).Order(),
outbox.Select(x => x.JobId).Order());
}
[Fact]
public async Task Repeated_formal_arrangement_returns_the_existing_active_job()
{
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-EXAM-DUP",
Name = "考试任务去重测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
db.AddRange(term, plan);
await db.SaveChangesAsync();
var controller = new ExamsController(
db,
new ManagerDataScope(),
NoOpAppCache.Instance);
var first = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
var second = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
Assert.Equal(202, first.StatusCode);
Assert.Equal(202, second.StatusCode);
Assert.Equal(1, await db.ExamArrangementJobs.CountAsync());
Assert.Equal(1, await db.BackgroundJobOutboxMessages.CountAsync());
}
[Fact]
public async Task Processor_records_business_failure_and_releases_active_plan()
{
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-EXAM-FAIL",
Name = "考试任务失败测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "缺少节次配置的考试计划"
};
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.FormalExam,
PlanId = plan.Id,
ActivePlanId = plan.Id,
AssignClassrooms = true,
AssignInvigilators = true
};
db.AddRange(term, plan, job);
await db.SaveChangesAsync();
var processor = new ExamArrangementJobProcessor(
db,
new ExamArrangementService(db),
new MakeupExamArrangementService(db),
NoOpAppCache.Instance,
NullLogger<ExamArrangementJobProcessor>.Instance);
await processor.ProcessAsync(job.Id, CancellationToken.None);
db.ChangeTracker.Clear();
var failed = await db.ExamArrangementJobs.SingleAsync();
Assert.Equal(ExamArrangementJobStatus.Failed, failed.Status);
Assert.Null(failed.ActivePlanId);
Assert.Equal("编排失败", failed.CurrentStep);
Assert.False(string.IsNullOrWhiteSpace(failed.ErrorMessage));
Assert.NotNull(failed.CompletedAt);
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string> { SystemRoles.AcademicAdmin });
}
}