自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user