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

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