排课计算、课表发布、补考自动编排三类任务统一接入消息系统。
业务任务与 Outbox 消息同一事务落库,避免“数据库成功但消息没发出去”。 RabbitMQ 使用持久化消息、发布确认、手动 ACK、Quorum Queue、死信队列。 增加处理租约、心跳、异常重试、最大重试次数和幂等状态控制。 保留 InMemory 模式,开发环境无需安装 RabbitMQ。 支持多实例竞争消费,后续可以横向扩容。 新增 /health/messaging 消息系统健康检查。
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class BackgroundJobOutboxTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Publisher_moves_pending_message_to_in_memory_transport()
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"jiaowu-outbox-{Guid.NewGuid():N}.sqlite");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
|
||||
var options = new BackgroundJobOptions
|
||||
{
|
||||
PollIntervalMilliseconds = 100
|
||||
};
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||
services.AddSingleton<IBackgroundJobTransport>(provider =>
|
||||
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||
services.AddSingleton<BackgroundJobOutboxPublisher>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
try
|
||||
{
|
||||
Guid jobId;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "RECOVERY",
|
||||
Name = "补投测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTerm = term,
|
||||
Name = "补投测试排课",
|
||||
Version = "V1"
|
||||
};
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
ActiveSchedulePlanId = plan.Id
|
||||
};
|
||||
jobId = job.Id;
|
||||
db.AddRange(term, plan, job);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var publisher = provider.GetRequiredService<BackgroundJobOutboxPublisher>();
|
||||
await publisher.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var published = false;
|
||||
for (var attempt = 0; attempt < 50 && !published; attempt++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
published = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.AnyAsync(x =>
|
||||
x.JobId == jobId &&
|
||||
x.JobKind == BackgroundJobKind.AutomaticSchedule &&
|
||||
x.State == BackgroundJobOutboxState.Published);
|
||||
}
|
||||
|
||||
Assert.True(published);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await publisher.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.DisposeAsync();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery()
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"jiaowu-runner-{Guid.NewGuid():N}.sqlite");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
|
||||
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleGenerator>();
|
||||
services.AddScoped<Jiaowu.Api.Infrastructure.Scheduling.AutomaticScheduleJobProcessor>();
|
||||
services.AddSingleton(new BackgroundJobOptions());
|
||||
services.AddSingleton<InMemoryBackgroundJobTransport>();
|
||||
services.AddSingleton<IBackgroundJobTransport>(provider =>
|
||||
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
|
||||
services.AddSingleton<BackgroundJobRunner>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
try
|
||||
{
|
||||
BackgroundJobEnvelope envelope;
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "OUTBOX",
|
||||
Name = "消息测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTerm = term,
|
||||
Name = "消息测试排课",
|
||||
Version = "V1"
|
||||
};
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
ActiveSchedulePlanId = plan.Id,
|
||||
Status = AutomaticScheduleJobStatus.Queued
|
||||
};
|
||||
var outbox = BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
job.Id);
|
||||
outbox.State = BackgroundJobOutboxState.Published;
|
||||
outbox.ProcessingAttempts = 5;
|
||||
db.AddRange(term, plan, job, outbox);
|
||||
await db.SaveChangesAsync();
|
||||
envelope = new BackgroundJobEnvelope(
|
||||
outbox.Id,
|
||||
outbox.JobKind,
|
||||
outbox.JobId);
|
||||
}
|
||||
|
||||
var runner = provider.GetRequiredService<BackgroundJobRunner>();
|
||||
var first = await runner.RunAsync(envelope, CancellationToken.None);
|
||||
var duplicate = await runner.RunAsync(envelope, CancellationToken.None);
|
||||
|
||||
Assert.Equal(BackgroundJobRunOutcome.Completed, first.Outcome);
|
||||
Assert.Equal(BackgroundJobRunOutcome.Completed, duplicate.Outcome);
|
||||
await using var assertScope = provider.CreateAsyncScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var persistedOutbox =
|
||||
await assertDb.BackgroundJobOutboxMessages.SingleAsync();
|
||||
Assert.Equal(BackgroundJobOutboxState.Completed, persistedOutbox.State);
|
||||
Assert.NotNull(persistedOutbox.CompletedAt);
|
||||
Assert.Null(persistedOutbox.ProcessingToken);
|
||||
Assert.Null(persistedOutbox.LeaseExpiresAt);
|
||||
Assert.Equal(6, persistedOutbox.ProcessingAttempts);
|
||||
var persistedJob = await assertDb.AutomaticScheduleJobs.SingleAsync();
|
||||
Assert.Equal(AutomaticScheduleJobStatus.Failed, persistedJob.Status);
|
||||
Assert.Null(persistedJob.ActiveSchedulePlanId);
|
||||
Assert.Contains("超过 5 次", persistedJob.ErrorMessage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.DisposeAsync();
|
||||
File.Delete(databasePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user