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:
glz
2026-07-10 10:44:00 +08:00
parent 685a8aeaec
commit cbdee5068a
33 changed files with 693 additions and 152 deletions

View File

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS `Message_Outbox` (
`Id` bigint NOT NULL,
`Exchange` varchar(200) NOT NULL,
`Queue` varchar(200) NOT NULL,
`RoutingKey` varchar(200) NOT NULL,
`Payload` json NOT NULL,
`RetryCount` int NOT NULL DEFAULT 0,
`NextRetryAt` datetime NULL,
`SentAt` datetime NULL,
`LastError` varchar(2000) NULL,
`BusinessType` varchar(100) NOT NULL,
`BusinessId` bigint NOT NULL,
`Status` int NOT NULL DEFAULT 0,
`IsDeleted` bit NOT NULL DEFAULT b'0',
`CreatedBy` varchar(100) NOT NULL,
`CreatedAt` datetime NOT NULL,
`UpdatedBy` varchar(100) NULL,
`UpdatedAt` datetime NULL,
PRIMARY KEY (`Id`),
KEY `idx_message_outbox_status_next_retry` (`Status`, `NextRetryAt`, `CreatedAt`),
KEY `idx_message_outbox_business` (`BusinessType`, `BusinessId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,5 @@
ALTER TABLE `CheckIn_Record`
ADD UNIQUE KEY `uk_checkin_record_user_date_type_deleted` (`UserId`, `CheckInDate`, `Type`, `IsDeleted`);
ALTER TABLE `User_Bag`
ADD UNIQUE KEY `uk_user_bag_available_item` (`UserId`, `ItemId`, `Status`, `IsDeleted`);

View File

@ -0,0 +1,25 @@
using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
namespace QYZH.InteractiveMagazine.IService;
/// <summary>
/// MQ消息发布服务。
/// </summary>
public interface IMessagePublishService : IBaseService<MessageOutbox>
{
/// <summary>
/// 可靠发布消息默认写入Outbox。
/// </summary>
Task<MessagePublishResult> PublishAsync<T>(MessagePublishInput<T> input, CancellationToken cancellationToken = default);
/// <summary>
/// 批量可靠发布消息默认写入Outbox。
/// </summary>
Task<MessagePublishResult> PublishBatchAsync<T>(IEnumerable<MessagePublishInput<T>> inputs, CancellationToken cancellationToken = default);
/// <summary>
/// 直接发布消息不写Outbox。
/// </summary>
Task<MessagePublishResult> PublishDirectAsync<T>(MessagePublishInput<T> input, CancellationToken cancellationToken = default);
}

View File

@ -106,6 +106,7 @@ public static class DependencyInjectionExtensions
var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId));
if (adminToken == currentToken)
{
await RefreshRedisTokenExpiryAsync(JwtHelper.BuildAdminTokenKey(userId), currentToken, jwtSettings);
return;
}
@ -116,6 +117,7 @@ public static class DependencyInjectionExtensions
var wechatToken = await RedisHelper.GetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId));
if (wechatToken == currentToken)
{
await RefreshRedisTokenExpiryAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId), currentToken, jwtSettings);
return;
}
@ -124,6 +126,14 @@ public static class DependencyInjectionExtensions
};
}
private static async Task RefreshRedisTokenExpiryAsync(string tokenKey, string currentToken, JwtSettings jwtSettings)
{
if (jwtSettings.ExpiryMinutes > 0)
{
await RedisHelper.SetAsync(tokenKey, currentToken, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
}
}
private static string? GetBearerToken(TokenValidatedContext context)
{
var authHeader = context.HttpContext.Request.Headers.Authorization.FirstOrDefault();

View File

@ -1,99 +1,21 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using QYZH.InteractiveMagazine.Infrastructure.Auth;
using QYZH.InteractiveMagazine.Models.Settings;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
/// <summary>
/// Refreshes the Redis session TTL for the current JWT when it is still the active token.
/// 保留兼容的JWT自动刷新中间件实际刷新在认证成功后执行。
/// </summary>
public class JwtAutoRefreshMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public JwtAutoRefreshMiddleware(RequestDelegate next, IConfiguration configuration)
public JwtAutoRefreshMiddleware(RequestDelegate next)
{
_next = next;
_configuration = configuration;
}
public async Task InvokeAsync(HttpContext context)
{
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
await TryRefreshRedisTokenExpiryAsync(token);
}
await _next(context);
}
private async Task TryRefreshRedisTokenExpiryAsync(string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
if (tokenHandler.ReadToken(token) is not JwtSecurityToken jwtToken)
{
return;
}
if (jwtToken.ValidTo <= DateTime.UtcNow)
{
return;
}
var userId = GetClaimValue(jwtToken, ClaimTypes.NameIdentifier, JwtRegisteredClaimNames.NameId);
if (string.IsNullOrEmpty(userId))
{
return;
}
var jwtSettings = _configuration.GetSection("JwtSettings").Get<JwtSettings>();
if (jwtSettings == null || jwtSettings.ExpiryMinutes <= 0)
{
return;
}
var wxUserId = GetClaimValue(jwtToken, JwtHelper.WxUserIdClaimType);
await RefreshRedisTokenExpiryAsync(wxUserId, userId, token, jwtSettings.ExpiryMinutes);
}
catch
{
// Ignore refresh failures. Authentication middleware will validate the request later.
}
}
private static async Task RefreshRedisTokenExpiryAsync(string? wxUserId, string userId, string currentToken, int expiryMinutes)
{
var adminTokenKey = JwtHelper.BuildAdminTokenKey(userId);
var adminToken = await RedisHelper.GetAsync(adminTokenKey);
if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken))
{
await RedisHelper.SetAsync(adminTokenKey, adminToken, TimeSpan.FromMinutes(expiryMinutes));
return;
}
if (string.IsNullOrEmpty(wxUserId))
{
return;
}
var wechatTokenKey = JwtHelper.BuildWeChatTokenKey(wxUserId, userId);
var wechatToken = await RedisHelper.GetAsync(wechatTokenKey);
if (wechatToken == currentToken)
{
await RedisHelper.SetAsync(wechatTokenKey, currentToken, TimeSpan.FromMinutes(expiryMinutes));
}
}
private static string? GetClaimValue(JwtSecurityToken jwtToken, params string[] claimTypes)
{
return jwtToken.Claims.FirstOrDefault(c => claimTypes.Contains(c.Type))?.Value;
}
}

View File

@ -1,6 +1,7 @@
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
@ -11,14 +12,16 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
{
private readonly IRabbitMQConnection _connection;
private readonly IConfiguration _configuration;
private readonly ILogger<RabbitMQService> _logger;
private readonly JsonSerializerOptions options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration)
public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration, ILogger<RabbitMQService> logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_configuration = configuration;
_logger = logger;
}
@ -55,12 +58,14 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
}
catch (OperationCanceledException ex)
{
Console.WriteLine($"Operation was canceled: {ex.Message}");
_logger.LogWarning(ex, "RabbitMQ消息发送已取消Exchange: {Exchange}, Queue: {Queue}, RoutingKey: {RoutingKey}",
param.Exchange, param.Queue, param.RoutingKey);
return false;
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
_logger.LogError(ex, "RabbitMQ消息发送失败Exchange: {Exchange}, Queue: {Queue}, RoutingKey: {RoutingKey}",
param.Exchange, param.Queue, param.RoutingKey);
return false;
}
}
@ -115,7 +120,7 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
_logger.LogError(ex, "RabbitMQ批量消息发送失败");
// 回滚事务
try { await channel?.TxRollbackAsync(); } catch { /* 忽略回滚异常 */ }
return false;

View File

@ -0,0 +1,38 @@
namespace QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
/// <summary>
/// MQ消息发布入参。
/// </summary>
/// <typeparam name="T">消息数据类型。</typeparam>
public class MessagePublishInput<T>
{
/// <summary>
/// 交换机。
/// </summary>
public string Exchange { get; set; } = string.Empty;
/// <summary>
/// 队列。
/// </summary>
public string Queue { get; set; } = string.Empty;
/// <summary>
/// 路由键。
/// </summary>
public string RoutingKey { get; set; } = string.Empty;
/// <summary>
/// 消息数据。
/// </summary>
public T Data { get; set; } = default!;
/// <summary>
/// 业务类型。
/// </summary>
public string BusinessType { get; set; } = string.Empty;
/// <summary>
/// 业务ID。
/// </summary>
public long BusinessId { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
/// <summary>
/// MQ消息发布结果。
/// </summary>
public class MessagePublishResult
{
/// <summary>
/// 是否成功。
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Outbox消息ID。
/// </summary>
public List<long> OutboxIds { get; set; } = [];
/// <summary>
/// 结果消息。
/// </summary>
public string Message { get; set; } = string.Empty;
}

View File

@ -0,0 +1,60 @@
using SqlSugar;
namespace QYZH.InteractiveMagazine.Models.Entity;
/// <summary>
/// 消息Outbox。
/// </summary>
[SugarTable("Message_Outbox")]
public partial class MessageOutbox : SqlSugarBaseEntity
{
/// <summary>
/// 交换机。
/// </summary>
public string Exchange { get; set; } = string.Empty;
/// <summary>
/// 队列。
/// </summary>
public string Queue { get; set; } = string.Empty;
/// <summary>
/// 路由键。
/// </summary>
public string RoutingKey { get; set; } = string.Empty;
/// <summary>
/// 消息内容JSON。
/// </summary>
public string Payload { get; set; } = string.Empty;
/// <summary>
/// 重试次数。
/// </summary>
public int RetryCount { get; set; }
/// <summary>
/// 下次重试时间。
/// </summary>
public DateTime? NextRetryAt { get; set; }
/// <summary>
/// 发送成功时间。
/// </summary>
public DateTime? SentAt { get; set; }
/// <summary>
/// 最后错误。
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// 业务类型。
/// </summary>
public string BusinessType { get; set; } = string.Empty;
/// <summary>
/// 业务ID。
/// </summary>
public long BusinessId { get; set; }
}

View File

@ -0,0 +1,33 @@
using System.ComponentModel;
namespace QYZH.InteractiveMagazine.Models.Enum;
/// <summary>
/// 消息Outbox状态。
/// </summary>
public enum MessageOutboxStatusEnum
{
/// <summary>
/// 待发送。
/// </summary>
[Description("待发送")]
Pending = 0,
/// <summary>
/// 已发送。
/// </summary>
[Description("已发送")]
Sent = 1,
/// <summary>
/// 发送失败待重试。
/// </summary>
[Description("发送失败待重试")]
Failed = 2,
/// <summary>
/// 已放弃。
/// </summary>
[Description("已放弃")]
Abandoned = 3
}

View File

@ -0,0 +1,12 @@
namespace QYZH.InteractiveMagazine.Models.Settings;
/// <summary>
/// Hangfire存储配置。
/// </summary>
public class HangfireStorageSettings
{
/// <summary>
/// 存储类型,当前默认 Memory可配置为 Redis。
/// </summary>
public string StorageType { get; set; } = "Memory";
}

View File

@ -0,0 +1,17 @@
namespace QYZH.InteractiveMagazine.Models.Settings;
/// <summary>
/// RabbitMQ重试配置。
/// </summary>
public class RabbitMQRetrySettings
{
/// <summary>
/// 最大重试次数。
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 重试延迟毫秒数。
/// </summary>
public int RetryDelayMilliseconds { get; set; } = 30000;
}

View File

@ -0,0 +1,12 @@
namespace QYZH.InteractiveMagazine.Models.Settings;
/// <summary>
/// 雪花ID配置。
/// </summary>
public class SnowflakeSettings
{
/// <summary>
/// WorkerId生产部署时每个写库进程必须唯一。
/// </summary>
public ushort WorkerId { get; set; }
}

View File

@ -48,6 +48,8 @@ public class AutoDotCodeConsumer(
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
JournalPagePrintDto? request = null;
List<long> reservedDotDetailIds = [];
var dotPagesCommitted = false;
try
{
@ -135,6 +137,21 @@ public class AutoDotCodeConsumer(
}
var dotFileDetailPageName = dotFileDetailList.Select(x => x.PageName).OrderBy(x => x).ToArray();
reservedDotDetailIds = dotFileDetailList.Select(x => x.Id).ToList();
var reservedRows = await dbContext.Updateable<DotFileDetail>()
.SetColumns(x => x.IsUse == true)
.SetColumns(x => x.UpdatedAt == DateTime.Now)
.Where(x => reservedDotDetailIds.Contains(x.Id) && !x.IsUse)
.ExecuteCommandAsync(cancellationToken);
if (reservedRows != reservedDotDetailIds.Count)
{
logger.LogError("点阵页码预占失败DotId: {DotId}, Need: {Need}, Reserved: {Reserved}", dotId, reservedDotDetailIds.Count, reservedRows);
await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
reservedDotDetailIds.Clear();
await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
return;
}
var pageStr = "{" + $"[{dotFileDetailList[0].PageName},{pageNumMax}]" + "}";
var arguments = $"-sMode=Generate -sPDF=\"{uploadFilePath}\" -sLIC=\"{xmlPath}\" -pStart=1 -oPDF=\"{downloadFilePath}\" -dPageAddr=1 -dPrint={dPrint} -dDotSize=40 -dType=0 -dOutFile=0 -dControlPageNum={pageStr}";
var printResult = await ExecutePrintToolAsync(exePath, printToolDirectory, arguments, cancellationToken);
@ -142,6 +159,8 @@ public class AutoDotCodeConsumer(
if (printResult.Timeout || printResult.ExitCode != 0)
{
logger.LogError("执行 PrintTool.exe 失败,退出码:{ExitCode},错误信息:{ErrorMessage}", printResult.ExitCode, printResult.Error);
await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
reservedDotDetailIds.Clear();
await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
return;
}
@ -154,6 +173,8 @@ public class AutoDotCodeConsumer(
if (!ValidatePrintOutput(printResult.Output, dotFileDetailPageName, dPrint))
{
await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
reservedDotDetailIds.Clear();
await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
return;
}
@ -189,9 +210,12 @@ public class AutoDotCodeConsumer(
.Where(x => x.Id == dotId)
.ExecuteCommandAsync();
});
dotPagesCommitted = true;
}
else
{
await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
reservedDotDetailIds.Clear();
logger.LogError("回调接口修改书籍状态失败JournalId: {JournalId},接口返回消息:{Message}", statusModel.JournalId, callbackResponse.Message);
}
}
@ -205,6 +229,11 @@ public class AutoDotCodeConsumer(
logger.LogError(ex, "自动铺码处理失败");
if (request != null)
{
if (!dotPagesCommitted && reservedDotDetailIds.Count > 0)
{
await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, CancellationToken.None);
}
await TryCallbackCodeFailAsync(request, CancellationToken.None);
}
}
@ -329,6 +358,20 @@ public class AutoDotCodeConsumer(
}
}
private static async Task ReleaseReservedDotPagesAsync(ISqlSugarClient dbContext, List<long> dotDetailIds, CancellationToken cancellationToken)
{
if (dotDetailIds.Count == 0)
{
return;
}
await dbContext.Updateable<DotFileDetail>()
.SetColumns(x => x.IsUse == false)
.SetColumns(x => x.UpdatedAt == DateTime.Now)
.Where(x => dotDetailIds.Contains(x.Id))
.ExecuteCommandAsync(cancellationToken);
}
private static JournalPagePrintDto BuildFailResponse(JournalPagePrintDto request, string[]? pageNo = null)
{
return new JournalPagePrintDto

View File

@ -55,7 +55,7 @@ public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger<Rab
catch (Exception ex)
{
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);
try
@ -79,7 +79,7 @@ public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger<Rab
}
}
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
{
@ -90,7 +90,14 @@ public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger<Rab
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);
logger.LogInformation("消息已发送到死信队列: {DlqName}", dlqName);

View File

@ -1,6 +1,7 @@
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
using QYZH.InteractiveMagazine.Infrastructure.Redis;
using QYZH.InteractiveMagazine.Infrastructure.SDK;
using QYZH.InteractiveMagazine.Models.Settings;
using QYZH.InteractiveMagazine.PrintWorker.Consumers;
using Serilog;
using SqlSugar;
@ -34,7 +35,8 @@ Log.Logger = new LoggerConfiguration()
builder.Services.AddSerilog();
builder.Services.AddWindowsService(options => options.ServiceName = serviceName);
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 3 });
var snowflakeSettings = builder.Configuration.GetSection("SnowflakeSettings").Get<SnowflakeSettings>() ?? new SnowflakeSettings { WorkerId = 3 };
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = snowflakeSettings.WorkerId });
builder.Services.AddSqlSugar(new IocConfig
{

View File

@ -2,6 +2,9 @@
"ConnectionStrings": {
"DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;"
},
"SnowflakeSettings": {
"WorkerId": 3
},
"RedisSettings": {
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5",
"Sentinels": [],
@ -14,6 +17,10 @@
"Password": "@ss%&*otz%d*pq2S",
"VirtualHost": "InteractiveMagazine"
},
"RabbitMQRetrySettings": {
"MaxRetryCount": 3,
"RetryDelayMilliseconds": 30000
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",

View File

@ -253,9 +253,9 @@ namespace QYZH.InteractiveMagazine.Repository
var total = 0;
page.PageSize = parm.PageSize;
page.PageIndex = parm.PageIndex;
if (string.IsNullOrEmpty(parm.Sort))
if (!string.IsNullOrWhiteSpace(parm.Sort))
{
source.OrderByPropertyName(parm.Sort, parm.SortType.Contains("desc") ? OrderByType.Desc : OrderByType.Asc);
source.OrderByPropertyName(parm.Sort, parm.SortType?.Contains("desc", StringComparison.OrdinalIgnoreCase) == true ? OrderByType.Desc : OrderByType.Asc);
}
page.Result = source
//.OrderByIF(parm.Sort.IsNotEmpty(), $"{parm.Sort.ToSqlFilter()} {(!string.IsNullOrWhiteSpace(parm.SortType) && parm.SortType.Contains("desc") ? "desc" : "asc")}")
@ -278,9 +278,9 @@ namespace QYZH.InteractiveMagazine.Repository
var total = 0;
page.PageSize = parm.PageSize;
page.PageIndex = parm.PageIndex;
if (string.IsNullOrEmpty(parm.Sort))
if (!string.IsNullOrWhiteSpace(parm.Sort))
{
source.OrderByPropertyName(parm.Sort, parm.SortType.Contains("desc") ? OrderByType.Desc : OrderByType.Asc);
source.OrderByPropertyName(parm.Sort, parm.SortType?.Contains("desc", StringComparison.OrdinalIgnoreCase) == true ? OrderByType.Desc : OrderByType.Asc);
}
var result = source
//.OrderByIF(parm.Sort.IsNotEmpty(), $"{parm.Sort.ToSqlFilter()} {(!string.IsNullOrWhiteSpace(parm.SortType) && parm.SortType.Contains("desc") ? "desc" : "asc")}")

View File

@ -74,6 +74,14 @@ public class CheckInService(
await checkInRecordRepository.UseTranAsync(async () =>
{
var duplicate = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= today && r.CheckInDate < today.AddDays(1))
.AnyAsync();
if (duplicate)
{
throw new BusinessException("今日已签到,请明天再来", ResultCode.CONFLICT);
}
// 6a. 创建签到记录
var checkInRecord = new CheckInRecord
{
@ -94,10 +102,16 @@ public class CheckInService(
var newGrowthBalance = user.GrowthPoints + growthReward;
await checkInRecordRepository.Context.Updateable<Users>()
.SetColumns(u => u.GrowthPoints == newGrowthBalance)
.SetColumns(u => u.GrowthPoints == u.GrowthPoints + growthReward)
.SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId && !u.IsDeleted)
.ExecuteCommandAsync();
newGrowthBalance = await checkInRecordRepository.Context.Queryable<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.Select(u => u.GrowthPoints)
.FirstAsync();
// 6c. 通过积分服务增加积分
var pointsResult = await pointsService.AddPointsInTranAsync(new AddPointsInput
{
@ -271,6 +285,14 @@ public class CheckInService(
await checkInRecordRepository.UseTranAsync(async () =>
{
var duplicate = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= targetDate && r.CheckInDate < targetDate.AddDays(1))
.AnyAsync();
if (duplicate)
{
throw new BusinessException($"{targetDate:yyyy-MM-dd} 已签到,无需补签", ResultCode.CONFLICT);
}
// 创建补签记录
var checkInRecord = new CheckInRecord
{
@ -290,14 +312,17 @@ public class CheckInService(
var recordId = await checkInRecordRepository.Insertable(checkInRecord).ExecuteReturnIdentityAsync();
checkInRecord.Id = recordId;
// 更新用户成长值
var newGrowthBalance = user.GrowthPoints + growthReward;
await checkInRecordRepository.Context.Updateable<Users>()
.SetColumns(u => u.GrowthPoints == newGrowthBalance)
.SetColumns(u => u.GrowthPoints == u.GrowthPoints + growthReward)
.SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId && !u.IsDeleted)
.ExecuteCommandAsync();
var newGrowthBalance = await checkInRecordRepository.Context.Queryable<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.Select(u => u.GrowthPoints)
.FirstAsync();
// 通过积分服务增加积分
var pointsResult = await pointsService.AddPointsInTranAsync(new AddPointsInput
{
@ -311,19 +336,20 @@ public class CheckInService(
// 扣减补签卡
if (makeUpCard.Quantity <= 1)
{
var cardAffectedRows = await checkInRecordRepository.Context.Updateable<UserBag>()
.SetColumns(b => b.Quantity == b.Quantity - 1)
.SetColumns(b => b.UpdatedAt == DateTime.Now)
.Where(b => b.Id == makeUpCard.Id && b.UserId == userId && b.Quantity > 0 && b.Status == (int)UserBagStatusEnum.Available && !b.IsDeleted)
.ExecuteCommandAsync();
if (cardAffectedRows <= 0)
{
throw new BusinessException("补签卡不足,无法补签", ResultCode.CONFLICT);
}
await checkInRecordRepository.Context.Updateable<UserBag>()
.SetColumns(b => b.Status == (int)UserBagStatusEnum.Expired)
.SetColumns(b => b.Quantity == 0)
.SetColumns(b => b.UpdatedAt == DateTime.Now)
.Where(b => b.Id == makeUpCard.Id)
.ExecuteCommandAsync();
}
else
{
await checkInRecordRepository.Context.Updateable<UserBag>()
.SetColumns(b => b.Quantity == makeUpCard.Quantity - 1)
.SetColumns(b => b.UpdatedAt == DateTime.Now)
.Where(b => b.Id == makeUpCard.Id)
.Where(b => b.Id == makeUpCard.Id && b.Quantity <= 0 && !b.IsDeleted)
.ExecuteCommandAsync();
}

View File

@ -11,6 +11,7 @@ using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.Journal;
using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
@ -24,7 +25,7 @@ public class JournalPageService(BaseRepository<Journal> JournalRepository,
OssService ossService,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IRabbitMQService rabbitMqService,
IMessagePublishService messagePublishService,
ILogger<AiBasePromptService> logger,
BaseRepository<JournalPage> JournalPageRepository,
BaseRepository<JournalPageTask> JournalPageTaskRepository,
@ -135,18 +136,29 @@ public class JournalPageService(BaseRepository<Journal> JournalRepository,
var isPdfExists = ossService.DoesObjectExist(uploadPdfKey);
BusinessException.ThrowIf(!isPdfExists, "获取期刊上传的PDF文件失败请检查PDF文件是否上传成功", ResultCode.GLOBAL_ERROR);
journalEntity.Status = (int)JournalStatusEnum.Codeing;
await JournalRepository.Updateable(journalEntity).UpdateColumns(x => new { x.Status, x.UpdatedAt }).ExecuteCommandAsync();
var data = new JournalPagePrintDto
{
JournalId = JournalId,
JournalPdfUrl = uploadPdfUrl,
PageNum = [.. journalPageList.Select(x => x.PageNum)]
};
var msRes = await rabbitMqService.SendAsync(new RabbitMQSendParam { Exchange = "ex.journal", Queue = "mq.journal.dotcode.auto", RoutingKey = "rk.journal.dotcode.auto", Data = data });//发生消息
return msRes;
return await UseTranAsync(async () =>
{
journalEntity.Status = (int)JournalStatusEnum.Codeing;
journalEntity.UpdatedAt = DateTime.Now;
await JournalRepository.Updateable(journalEntity).UpdateColumns(x => new { x.Status, x.UpdatedAt }).ExecuteCommandAsync();
await messagePublishService.PublishAsync(new MessagePublishInput<JournalPagePrintDto>
{
Exchange = "ex.journal",
Queue = "mq.journal.dotcode.auto",
RoutingKey = "rk.journal.dotcode.auto",
Data = data,
BusinessType = "JournalDotCode",
BusinessId = JournalId
});
return true;
});
}
/// <summary>
/// 回调接口-自动铺码, 书页铺码后回调接口,更新书页的点阵码

View File

@ -9,10 +9,12 @@ using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.DotMatrix;
using QYZH.InteractiveMagazine.Models.Dto.Journal;
using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
using SqlSugar;
using System.Text.Json;
using Yitter.IdGenerator;
namespace QYZH.InteractiveMagazine.Service;
@ -24,7 +26,7 @@ public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
BaseRepository<DotFile> dotFileRepository,
BaseRepository<DotFileDetail> dotFileDetailRepository,
OssService ossService,
IRabbitMQService rabbitMqService,
IMessagePublishService messagePublishService,
ILogger<JournalService> logger) : BaseRepository<Journal>, IJournalService
{
private const string JournalExchange = "ex.journal";
@ -375,23 +377,27 @@ public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
x => x.Key,
x => string.Join(',', x.OrderBy(q => ParseTaskNo(q.No)).Select(q => q.Id)));
var bookMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
var publishMessages = new List<MessagePublishInput<object>>
{
Exchange = JournalExchange,
Queue = PublishBookQueue,
RoutingKey = PublishBookRoutingKey,
Data = new JournalPublishBookMessage
new()
{
BookId = id,
StartTime = book.StartTime.Value,
EndTime = book.EndTime.Value
Exchange = JournalExchange,
Queue = PublishBookQueue,
RoutingKey = PublishBookRoutingKey,
Data = new JournalPublishBookMessage
{
BookId = id,
StartTime = book.StartTime.Value,
EndTime = book.EndTime.Value
},
BusinessType = "JournalPublish",
BusinessId = id
}
});
BusinessException.ThrowIf(!bookMessageSent, "发布书籍消息发送失败", ResultCode.GLOBAL_ERROR);
};
foreach (var page in pages)
{
var pageMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
publishMessages.Add(new MessagePublishInput<object>
{
Exchange = JournalExchange,
Queue = PublishBookPageQueue,
@ -404,16 +410,21 @@ public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
Layout = page.Layout,
Url = DomainHelper.OssFullUrl(page.Url),
QuestionNo = questionIdsByPageId.GetValueOrDefault(page.Id) ?? string.Empty
}
},
BusinessType = "JournalPublish",
BusinessId = id
});
BusinessException.ThrowIf(!pageMessageSent, $"发布书页消息发送失败PageId: {page.Id}", ResultCode.GLOBAL_ERROR);
}
return await base.Updateable()
.SetColumns(s => s.Status, JournalStatusEnum.Published)
.SetColumns(s => s.UpdatedAt, DateTime.Now)
.Where(w => w.Id == id)
.ExecuteCommandAsync() > 0;
return await UseTranAsync(async () =>
{
await messagePublishService.PublishBatchAsync(publishMessages);
return await base.Updateable()
.SetColumns(s => s.Status, JournalStatusEnum.Published)
.SetColumns(s => s.UpdatedAt, DateTime.Now)
.Where(w => w.Id == id)
.ExecuteCommandAsync() > 0;
});
static (int First, int Second, int Third, int Fourth) ParseTaskNo(string? no)
{

View File

@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
using System.Text.Json;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// MQ消息发布服务。
/// </summary>
public class MessagePublishService(
IRabbitMQService rabbitMQService,
ILogger<MessagePublishService> logger) : BaseRepository<MessageOutbox>, IMessagePublishService
{
/// <summary>
/// 可靠发布消息默认写入Outbox。
/// </summary>
public async Task<MessagePublishResult> PublishAsync<T>(MessagePublishInput<T> input, CancellationToken cancellationToken = default)
{
ValidateInput(input);
var message = BuildOutboxMessage(input);
await Context.Insertable(message).ExecuteCommandAsync(cancellationToken);
return new MessagePublishResult
{
Success = true,
OutboxIds = [message.Id],
Message = "消息已写入Outbox"
};
}
/// <summary>
/// 批量可靠发布消息默认写入Outbox。
/// </summary>
public async Task<MessagePublishResult> PublishBatchAsync<T>(IEnumerable<MessagePublishInput<T>> inputs, CancellationToken cancellationToken = default)
{
var inputList = inputs.ToList();
BusinessException.ThrowIf(inputList.Count == 0, "消息发布列表不能为空", ResultCode.BAD_REQUEST);
inputList.ForEach(ValidateInput);
var messages = inputList.Select(BuildOutboxMessage).ToList();
await Context.Insertable(messages).ExecuteCommandAsync(cancellationToken);
return new MessagePublishResult
{
Success = true,
OutboxIds = messages.Select(x => x.Id).ToList(),
Message = "消息已批量写入Outbox"
};
}
/// <summary>
/// 直接发布消息不写Outbox。
/// </summary>
public async Task<MessagePublishResult> PublishDirectAsync<T>(MessagePublishInput<T> input, CancellationToken cancellationToken = default)
{
ValidateInput(input);
var sent = await rabbitMQService.SendAsync(new RabbitMQSendParam
{
Exchange = input.Exchange,
Queue = input.Queue,
RoutingKey = input.RoutingKey,
Data = input.Data!
}, cancellationToken);
if (!sent)
{
logger.LogError("MQ直接发布失败Exchange: {Exchange}, Queue: {Queue}, RoutingKey: {RoutingKey}, BusinessType: {BusinessType}, BusinessId: {BusinessId}",
input.Exchange, input.Queue, input.RoutingKey, input.BusinessType, input.BusinessId);
throw new BusinessException("MQ消息发送失败", ResultCode.GLOBAL_ERROR);
}
return new MessagePublishResult
{
Success = true,
Message = "消息已直接发送"
};
}
private static MessageOutbox BuildOutboxMessage<T>(MessagePublishInput<T> input)
{
var now = DateTime.Now;
return new MessageOutbox
{
Exchange = input.Exchange,
Queue = input.Queue,
RoutingKey = input.RoutingKey,
Payload = JsonSerializer.Serialize(input.Data),
BusinessType = input.BusinessType,
BusinessId = input.BusinessId,
Status = (int)MessageOutboxStatusEnum.Pending,
CreatedBy = "System",
CreatedAt = now,
UpdatedBy = "System",
UpdatedAt = now
};
}
private static void ValidateInput<T>(MessagePublishInput<T> input)
{
BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Exchange), "MQ交换机不能为空", ResultCode.BAD_REQUEST);
BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Queue), "MQ队列不能为空", ResultCode.BAD_REQUEST);
BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.RoutingKey), "MQ路由键不能为空", ResultCode.BAD_REQUEST);
BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.BusinessType), "MQ业务类型不能为空", ResultCode.BAD_REQUEST);
BusinessException.ThrowIf(input.BusinessId <= 0, "MQ业务ID无效", ResultCode.BAD_REQUEST);
BusinessException.ThrowIf(input.Data == null, "MQ消息数据不能为空", ResultCode.BAD_REQUEST);
}
}

View File

@ -6,6 +6,7 @@ using QYZH.InteractiveMagazine.Models.Dto.Points;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
using SqlSugar;
namespace QYZH.InteractiveMagazine.Service;
@ -265,7 +266,7 @@ public class PointsService(
.WhereIF(!string.IsNullOrEmpty(input.Status), r => r.Status.ToString() == input.Status)
.OrderByDescending(r => r.CreatedAt);
var total = 0;
RefAsync<int> total = 0;
var records = await query
.Select(r => new PointsRecordOutput
{

View File

@ -5,6 +5,8 @@ using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.Journal;
using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
@ -21,7 +23,7 @@ public class UserJournalService(
BaseRepository<Users> usersRepository,
BaseRepository<Journal> journalRepository,
ILogger<UserJournalService> logger,
IRabbitMQService rabbitMqService,
IMessagePublishService messagePublishService,
OssService ossService,
IPetService petService)
: BaseRepository<UserJournal>, IUserJournalService
@ -214,7 +216,7 @@ public class UserJournalService(
}
var recordIds = records.Select(r => r.Id).ToList();
var messageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
var messageSent = (await messagePublishService.PublishAsync(new MessagePublishInput<GenerateUserJournalQrCodeMessage>
{
Exchange = JournalExchange,
Queue = QrCodeGenerateQueue,
@ -223,8 +225,10 @@ public class UserJournalService(
{
RecordIds = recordIds,
OperatorName = operatorName
}
});
},
BusinessType = "UserJournalQrCodeGenerate",
BusinessId = input.JournalId
})).Success;
if (!messageSent)
{
@ -457,7 +461,7 @@ public class UserJournalService(
{
try
{
var messageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
var messageSent = (await messagePublishService.PublishAsync(new MessagePublishInput<BindJournalMessage>
{
Exchange = JournalExchange,
Queue = BindJournalQueue,
@ -469,8 +473,10 @@ public class UserJournalService(
StartTime = journal.StartTime,
EndTime = journal.EndTime,
UploadDomain = user.UploadDomain
}
});
},
BusinessType = "UserJournalBind",
BusinessId = user.Id
})).Success;
if (!messageSent)
{

View File

@ -253,9 +253,9 @@ public class WxMallService(
{
// 已有同类物品,累加数量
await exchangeRecordRepository.Context.Updateable<UserBag>()
.SetColumns(b => b.Quantity == existingBag.Quantity + input.Quantity)
.SetColumns(b => b.Quantity == b.Quantity + input.Quantity)
.SetColumns(b => b.UpdatedAt == DateTime.Now)
.Where(b => b.Id == existingBag.Id)
.Where(b => b.Id == existingBag.Id && !b.IsDeleted && b.Status == (int)UserBagStatusEnum.Available)
.ExecuteCommandAsync();
}
else

View File

@ -26,7 +26,8 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("medal-rule-config.json", optional: false, reloadOnChange: true);
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 1 });
var snowflakeSettings = builder.Configuration.GetSection("SnowflakeSettings").Get<SnowflakeSettings>() ?? new SnowflakeSettings { WorkerId = 1 };
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = snowflakeSettings.WorkerId });
builder.UseAutofac();

View File

@ -9,6 +9,9 @@
"ExpiryMinutes": 120,
"JwtTokenExpiryDays": 30
},
"SnowflakeSettings": {
"WorkerId": 1
},
"RedisSettings": {
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5",
"Sentinels": [],

View File

@ -32,8 +32,8 @@ var builder = WebApplication.CreateBuilder(args);
// 加载勋章规则独立配置文件
builder.Configuration.AddJsonFile("medal-rule-config.json", optional: false, reloadOnChange: true);
// 初始化雪花ID生成器
YitIdHelper.SetIdGenerator(new IdGeneratorOptions() { WorkerId = 1 });
var snowflakeSettings = builder.Configuration.GetSection("SnowflakeSettings").Get<SnowflakeSettings>() ?? new SnowflakeSettings { WorkerId = 1 };
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = snowflakeSettings.WorkerId });
// autofac注入 允许使用autofac作为DI容器
builder.UseAutofac();

View File

@ -9,6 +9,9 @@
"ExpiryMinutes": 120,
"JwtTokenExpiryDays": 30
},
"SnowflakeSettings": {
"WorkerId": 1
},
"RedisSettings": {
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5",
"Sentinels": [],

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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 });
// 初始化MySQLSqlSugar
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>();

View File

@ -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批改"
}