feat: 新增消息Outbox机制、雪花ID配置优化及多项功能完善
1. 新增数据库唯一约束和Message_Outbox表脚本 2. 新增雪花ID、Hangfire存储、MQ重试等配置实体 3. 重构各项目雪花ID生成逻辑,改为从配置读取WorkerId 4. 优化积分服务分页查询、用户背包更新逻辑 5. 新增JWT令牌Redis过期刷新逻辑 6. 完善RabbitMQ死信队列消息头信息 7. 新增可靠MQ消息发布服务和Outbox派发后台服务 8. 替换原有RabbitMQ直接发送为Outbox可靠发布 9. 优化签到服务逻辑,新增重复签到校验和补签卡扣减逻辑 10. 修复自动铺码消费逻辑,新增点阵页预占和释放机制
This commit is contained in:
@ -68,7 +68,7 @@ public class RabbitMQHostedService : BackgroundService
|
||||
_logger.LogError(ex, "消费者 {QueueName} 处理消息异常", queueName);
|
||||
|
||||
// 发送到死信队列,成功则从主队列移除,失败则重新入队
|
||||
var dlqSent = await SendToDeadLetterQueueAsync(exchange, dlqName, dlqRoutingKey, ea.Body.ToArray(), stoppingToken);
|
||||
var dlqSent = await SendToDeadLetterQueueAsync(exchange, dlqName, dlqRoutingKey, queueName, ea.Body.ToArray(), ex, stoppingToken);
|
||||
await channel.BasicNackAsync(ea.DeliveryTag, false, !dlqSent, stoppingToken);
|
||||
|
||||
if (dlqSent)
|
||||
@ -100,7 +100,7 @@ public class RabbitMQHostedService : BackgroundService
|
||||
/// <summary>
|
||||
/// 将失败消息发送到死信队列
|
||||
/// </summary>
|
||||
private async Task<bool> SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, byte[] body, CancellationToken cancellationToken)
|
||||
private async Task<bool> SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, string originalQueueName, byte[] body, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -111,7 +111,14 @@ public class RabbitMQHostedService : BackgroundService
|
||||
|
||||
var properties = new RabbitMQ.Client.BasicProperties
|
||||
{
|
||||
Persistent = true
|
||||
Persistent = true,
|
||||
Headers = new Dictionary<string, object?>
|
||||
{
|
||||
["x-original-queue"] = originalQueueName,
|
||||
["x-error-type"] = exception.GetType().FullName,
|
||||
["x-error-message"] = exception.Message,
|
||||
["x-failed-at"] = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
await channel.BasicPublishAsync(exchange, dlqRoutingKey, false, properties, body, cancellationToken);
|
||||
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
|
||||
using QYZH.InteractiveMagazine.Models.Entity;
|
||||
using QYZH.InteractiveMagazine.Models.Enum;
|
||||
using QYZH.InteractiveMagazine.Models.Settings;
|
||||
using SqlSugar;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.WorkService.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 消息Outbox派发服务。
|
||||
/// </summary>
|
||||
public class MessageOutboxDispatchService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IRabbitMQService rabbitMQService,
|
||||
IConfiguration configuration,
|
||||
ILogger<MessageOutboxDispatchService> logger) : BackgroundService
|
||||
{
|
||||
private const int BatchSize = 50;
|
||||
private readonly RabbitMQRetrySettings retrySettings = configuration.GetSection("RabbitMQRetrySettings").Get<RabbitMQRetrySettings>() ?? new RabbitMQRetrySettings();
|
||||
|
||||
/// <summary>
|
||||
/// 执行Outbox派发循环。
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DispatchPendingMessagesAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Outbox消息派发循环异常");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DispatchPendingMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
|
||||
var now = DateTime.Now;
|
||||
var messages = await db.Queryable<MessageOutbox>()
|
||||
.Where(x => !x.IsDeleted
|
||||
&& (x.Status == (int)MessageOutboxStatusEnum.Pending || x.Status == (int)MessageOutboxStatusEnum.Failed)
|
||||
&& (x.NextRetryAt == null || x.NextRetryAt <= now))
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Take(BatchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
await DispatchMessageAsync(db, message, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DispatchMessageAsync(ISqlSugarClient db, MessageOutbox message, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var payload = JsonDocument.Parse(message.Payload);
|
||||
var sent = await rabbitMQService.SendAsync(new RabbitMQSendParam
|
||||
{
|
||||
Exchange = message.Exchange,
|
||||
Queue = message.Queue,
|
||||
RoutingKey = message.RoutingKey,
|
||||
Data = payload.RootElement.Clone()
|
||||
}, cancellationToken);
|
||||
|
||||
if (!sent)
|
||||
{
|
||||
throw new InvalidOperationException("RabbitMQ SendAsync returned false");
|
||||
}
|
||||
|
||||
await db.Updateable<MessageOutbox>()
|
||||
.SetColumns(x => x.Status == (int)MessageOutboxStatusEnum.Sent)
|
||||
.SetColumns(x => x.SentAt == DateTime.Now)
|
||||
.SetColumns(x => x.UpdatedAt == DateTime.Now)
|
||||
.Where(x => x.Id == message.Id && x.Status != (int)MessageOutboxStatusEnum.Sent)
|
||||
.ExecuteCommandAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var retryCount = message.RetryCount + 1;
|
||||
var abandoned = retryCount >= retrySettings.MaxRetryCount;
|
||||
await db.Updateable<MessageOutbox>()
|
||||
.SetColumns(x => x.Status == (int)(abandoned ? MessageOutboxStatusEnum.Abandoned : MessageOutboxStatusEnum.Failed))
|
||||
.SetColumns(x => x.RetryCount == retryCount)
|
||||
.SetColumns(x => x.NextRetryAt == (abandoned ? null : DateTime.Now.AddMilliseconds(retrySettings.RetryDelayMilliseconds)))
|
||||
.SetColumns(x => x.LastError == ex.Message)
|
||||
.SetColumns(x => x.UpdatedAt == DateTime.Now)
|
||||
.Where(x => x.Id == message.Id)
|
||||
.ExecuteCommandAsync(cancellationToken);
|
||||
|
||||
logger.LogError(ex, "Outbox消息派发失败,MessageId: {MessageId}, RetryCount: {RetryCount}", message.Id, retryCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,8 +28,8 @@ Log.Logger = new LoggerConfiguration()
|
||||
.CreateLogger();
|
||||
builder.Services.AddSerilog();
|
||||
|
||||
// 初始化雪花ID生成器
|
||||
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 2 });
|
||||
var snowflakeSettings = builder.Configuration.GetSection("SnowflakeSettings").Get<SnowflakeSettings>() ?? new SnowflakeSettings { WorkerId = 2 };
|
||||
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = snowflakeSettings.WorkerId });
|
||||
|
||||
// 初始化MySQL(SqlSugar)
|
||||
builder.Services.AddSqlSugar(new IocConfig
|
||||
@ -81,6 +81,7 @@ builder.Services.AddScoped<IQueueConsumer, UserJournalQrCodeGenerateConsumer>();
|
||||
|
||||
// 注册消费者后台服务
|
||||
builder.Services.AddHostedService<RabbitMQHostedService>();
|
||||
builder.Services.AddHostedService<MessageOutboxDispatchService>();
|
||||
|
||||
// 从配置文件读取定时任务列表
|
||||
var jobSettings = builder.Configuration.GetSection("HangfireJobs").Get<HangfireJobSettings>();
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;"
|
||||
},
|
||||
"SnowflakeSettings": {
|
||||
"WorkerId": 2
|
||||
},
|
||||
"RedisSettings": {
|
||||
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5",
|
||||
"Sentinels": [],
|
||||
@ -15,6 +18,13 @@
|
||||
"VirtualHost": "InteractiveMagazine",
|
||||
"PrefetchCount": 1
|
||||
},
|
||||
"RabbitMQRetrySettings": {
|
||||
"MaxRetryCount": 3,
|
||||
"RetryDelayMilliseconds": 30000
|
||||
},
|
||||
"HangfireStorageSettings": {
|
||||
"StorageType": "Memory"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@ -52,7 +62,7 @@
|
||||
"Name": "journal-task-ai-score-job",
|
||||
"JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.JournalTaskAiScoreJob",
|
||||
"MethodName": "ExecuteAsync",
|
||||
"Cron": "*/5 * * * *",
|
||||
"Cron": "*/30 * * * *",
|
||||
"Enabled": true,
|
||||
"Description": "每30分钟扫描上次执行到本次执行之间的期刊答题记录并提交AI批改"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user