Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/UserJournalService.cs
glz cbdee5068a 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. 修复自动铺码消费逻辑,新增点阵页预占和释放机制
2026-07-10 10:44:00 +08:00

559 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.Common.Helpers;
using QYZH.InteractiveMagazine.Infrastructure.OSS;
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;
using SqlSugar;
using System.Text.Json;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 用户期刊关联服务实现
/// </summary>
public class UserJournalService(
BaseRepository<UserJournal> userJournalRepository,
BaseRepository<Users> usersRepository,
BaseRepository<Journal> journalRepository,
ILogger<UserJournalService> logger,
IMessagePublishService messagePublishService,
OssService ossService,
IPetService petService)
: BaseRepository<UserJournal>, IUserJournalService
{
private const string JournalExchange = "ex.journal";
private const string BindJournalQueue = "mq.journal.bindUser";
private const string BindJournalRoutingKey = "rk.journal.bindUser";
private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate";
private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate";
private const int MaxBatchQrCodeCount = 500;
private const int MaxRandomIdGenerateRetryCount = 5;
/// <summary>
/// 用户绑定期刊(扫码绑定)
/// </summary>
public async Task<BindJournalOutput> BindJournalAsync(long userId, BindJournalInput input)
{
// 校验参数
if (input.JournalId <= 0|| input.Id <= 0)
{
throw new BusinessException("参数错误,未获取到期刊", ResultCode.BAD_REQUEST);
}
// 校验用户是否存在
var activeStatus = (int)UserJournalStatusEnum.Active;
var boundAt = DateTime.Now;
var user = await usersRepository.Queryable()
.Where(u => u.Id == userId && !u.IsDeleted)
.FirstAsync();
if (user == null || user.IsDeleted)
{
logger.LogWarning("绑定期刊失败用户不存在UserId: {UserId}", userId);
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
}
// 校验期刊是否存在
var journal = await journalRepository.GetByIdAsync(input.JournalId);
if (journal == null || journal.IsDeleted)
{
logger.LogWarning("绑定期刊失败期刊不存在JournalId: {JournalId}", input.JournalId);
throw new BusinessException("期刊不存在", ResultCode.NOT_FOUND);
}
// 校验期刊状态
if (journal.Status != (int)JournalStatusEnum.Published)
{
logger.LogWarning("绑定期刊失败期刊未发布JournalId: {JournalId}, Status: {Status}", input.JournalId, journal.Status);
throw new BusinessException("该期刊暂未发布,无法绑定", ResultCode.UNPROCESSABLE_ENTITY);
}
var userJournal = await userJournalRepository.Queryable()
.Where(uj => uj.Id == input.Id && uj.JournalId == input.JournalId && !uj.IsDeleted)
.FirstAsync();
if (userJournal == null)
{
logger.LogWarning("绑定期刊失败二维码记录不存在UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id);
throw new BusinessException("二维码不存在或已失效", ResultCode.NOT_FOUND);
}
if (userJournal.Status != activeStatus)
{
throw new BusinessException("二维码已失效", ResultCode.UNPROCESSABLE_ENTITY);
}
if (userJournal.UserId.HasValue && userJournal.UserId.Value > 0)
{
logger.LogWarning("重复绑定期刊UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id);
throw new BusinessException("该期刊已被绑定", ResultCode.BAD_REQUEST);
}
var isFirstBind = false;
await userJournalRepository.UseTranAsync(async () =>
{
var boundJournalIds = await userJournalRepository.Queryable()
.Where(uj => uj.UserId == userId && !uj.IsDeleted && uj.Status == activeStatus)
.Select(uj => uj.JournalId)
.ToListAsync();
if (boundJournalIds.Contains(input.JournalId))
{
logger.LogWarning("用户重复绑定同一期刊UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, input.Id);
throw new BusinessException("该用户已绑定过该期刊", ResultCode.BAD_REQUEST);
}
isFirstBind = boundJournalIds.Count == 0;
boundAt = DateTime.Now;
// 创建绑定记录
var updateCount = await userJournalRepository.Updateable()
.SetColumns(uj => uj.UserId == userId)
.SetColumns(uj => uj.UpdatedBy == userId.ToString())
.SetColumns(uj => uj.UpdatedAt == boundAt)
.Where(uj => uj.Id == input.Id
&& uj.JournalId == input.JournalId
&& !uj.IsDeleted
&& uj.Status == activeStatus
&& (uj.UserId == null || uj.UserId == 0))
.ExecuteCommandAsync();
if (updateCount <= 0)
{
logger.LogError("绑定期刊失败写入数据库失败UserId: {UserId}, JournalId: {JournalId}", userId, input.JournalId);
throw new BusinessException("绑定期刊失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
});
logger.LogInformation("用户绑定期刊成功UserId: {UserId}, JournalId: {JournalId}, Id: {Id}", userId, input.JournalId, userJournal.Id);
userJournal.UserId = userId;
userJournal.UpdatedBy = userId.ToString();
userJournal.UpdatedAt = boundAt;
await SendBindJournalMessageAsync(user, journal);
// 首次绑定期刊时激活宠物
if (isFirstBind)
{
try
{
await petService.ActivatePetAsync(userId);
}
catch (Exception ex)
{
logger.LogError(ex, "首次绑定期刊激活宠物失败UserId: {UserId}", userId);
// 宠物激活失败不阻断绑定流程
}
}
return new BindJournalOutput
{
Id = userJournal.Id,
UserId = userId,
JournalId = userJournal.JournalId,
Type = userJournal.Type.ToString(),
Status = userJournal.Status.ToString(),
CreatedAt = userJournal.CreatedAt
};
}
/// <summary>
/// 生成期刊二维码记录
/// </summary>
public async Task<CreateUserJournalQrCodeOutput> CreateQrCodesAsync(CreateUserJournalQrCodeInput input, string operatorName)
{
if (input.JournalId <= 0)
{
throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST);
}
if (input.Count <= 0 || input.Count > MaxBatchQrCodeCount)
{
throw new BusinessException($"生成数量必须在1-{MaxBatchQrCodeCount}之间", ResultCode.BAD_REQUEST);
}
var journal = await journalRepository.GetByIdAsync(input.JournalId);
if (journal == null || journal.IsDeleted)
{
throw new BusinessException("期刊不存在", ResultCode.NOT_FOUND);
}
if (journal.Status != (int)JournalStatusEnum.Published)
{
throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY);
}
var now = DateTime.Now;
var randomIds = await GenerateUniqueQrCodeIdsAsync(input.Count);
var records = randomIds
.Select(id => new UserJournal
{
Id = id,
UserId = null,
JournalId = input.JournalId,
Type = 0,
Status = (int)UserJournalStatusEnum.Generating,
IsDeleted = false,
CreatedBy = operatorName,
CreatedAt = now,
UpdatedBy = operatorName,
UpdatedAt = now
})
.ToList();
var insertCount = await userJournalRepository.Context.Insertable(records).ExecuteCommandAsync();
if (insertCount <= 0)
{
throw new BusinessException("提交二维码生成任务失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
var recordIds = records.Select(r => r.Id).ToList();
var messageSent = (await messagePublishService.PublishAsync(new MessagePublishInput<GenerateUserJournalQrCodeMessage>
{
Exchange = JournalExchange,
Queue = QrCodeGenerateQueue,
RoutingKey = QrCodeGenerateRoutingKey,
Data = new GenerateUserJournalQrCodeMessage
{
RecordIds = recordIds,
OperatorName = operatorName
},
BusinessType = "UserJournalQrCodeGenerate",
BusinessId = input.JournalId
})).Success;
if (!messageSent)
{
await userJournalRepository.Updateable()
.SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Failed)
.SetColumns(uj => uj.UpdatedBy == operatorName)
.SetColumns(uj => uj.UpdatedAt == DateTime.Now)
.Where(uj => recordIds.Contains(uj.Id) && !uj.IsDeleted && uj.Status == (int)UserJournalStatusEnum.Generating)
.ExecuteCommandAsync();
logger.LogError("发送期刊二维码生成消息失败RecordIds: {RecordIds}", string.Join(",", recordIds));
throw new BusinessException("二维码生成任务提交失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
return new CreateUserJournalQrCodeOutput
{
JournalId = input.JournalId,
RequestedCount = input.Count,
AcceptedCount = insertCount,
RecordIds = recordIds,
IsAsync = true,
Message = "二维码生成任务已提交,请稍后查询未绑定二维码列表"
};
}
public async Task<PageListModel<UserJournalQrCodeOutput>> GetQrCodePageListAsync(UserJournalQrCodeQueryInput input)
{
if (input.PageIndex <= 0)
{
throw new BusinessException("页码必须大于0", ResultCode.BAD_REQUEST);
}
if (input.PageSize <= 0 || input.PageSize > 100)
{
throw new BusinessException("每页条数必须在1-100之间", ResultCode.BAD_REQUEST);
}
var query = userJournalRepository.Queryable()
.Where(uj => !uj.IsDeleted)
.WhereIF(input.JournalId.HasValue, uj => uj.JournalId == input.JournalId!.Value)
.WhereIF(input.UserId.HasValue, uj => uj.UserId == input.UserId!.Value)
.WhereIF(input.Status.HasValue, uj => uj.Status == input.Status!.Value)
.WhereIF(input.IsBound == true, uj => uj.UserId != null && uj.UserId > 0)
.WhereIF(input.IsBound == false, uj => uj.UserId == null || uj.UserId == 0)
.OrderByDescending(uj => uj.CreatedAt);
RefAsync<int> totalNumber = 0;
var records = await query.ToPageListAsync(input.PageIndex, input.PageSize, totalNumber);
var outputs = await BuildQrCodeOutputsAsync(records);
return new PageListModel<UserJournalQrCodeOutput>(outputs, input.PageIndex, input.PageSize, totalNumber);
}
/// <summary>
/// 获取期刊二维码详情
/// </summary>
public async Task<UserJournalQrCodeOutput> GetQrCodeDetailAsync(long id)
{
var record = await userJournalRepository.GetByIdAsync(id);
if (record == null || record.IsDeleted)
{
throw new BusinessException("二维码记录不存在", ResultCode.NOT_FOUND);
}
var outputs = await BuildQrCodeOutputsAsync([record]);
return outputs.First();
}
/// <summary>
/// 删除未绑定的期刊二维码记录
/// </summary>
public async Task<bool> DeleteQrCodeAsync(DeleteUserJournalQrCodeInput input, string operatorName)
{
if (input.Ids == null || input.Ids.Count == 0)
{
throw new BusinessException("请选择要删除的二维码", ResultCode.BAD_REQUEST);
}
var ids = input.Ids.Distinct().ToList();
var records = await userJournalRepository.Queryable()
.Where(uj => ids.Contains(uj.Id) && !uj.IsDeleted)
.ToListAsync();
if (records.Count != ids.Count)
{
throw new BusinessException("二维码记录不存在", ResultCode.NOT_FOUND);
}
if (records.Any(uj => uj.UserId.HasValue && uj.UserId.Value > 0))
{
throw new BusinessException("已绑定用户的二维码不能删除", ResultCode.UNPROCESSABLE_ENTITY);
}
var updateCount = await userJournalRepository.Updateable()
.SetColumns(uj => uj.IsDeleted == true)
.SetColumns(uj => uj.Status == (int)UserJournalStatusEnum.Inactive)
.SetColumns(uj => uj.UpdatedBy == operatorName)
.SetColumns(uj => uj.UpdatedAt == DateTime.Now)
.Where(uj => ids.Contains(uj.Id) && !uj.IsDeleted && (uj.UserId == null || uj.UserId == 0))
.ExecuteCommandAsync();
return updateCount == ids.Count;
}
public async Task<List<UserJournalQrCodeOutput>> GetUnboundQrCodesByJournalIdAsync(long journalId)
{
if (journalId <= 0)
{
throw new BusinessException("期刊Id不能为空", ResultCode.BAD_REQUEST);
}
var records = await userJournalRepository.Queryable()
.Where(uj => uj.JournalId == journalId && !uj.IsDeleted)
.Where(uj => uj.UserId == null || uj.UserId == 0)
.Where(uj => uj.Status == (int)UserJournalStatusEnum.Active)
.Where(uj => !string.IsNullOrEmpty(uj.QrCodeUrl))
.OrderByDescending(uj => uj.CreatedAt)
.ToListAsync();
return await BuildQrCodeOutputsAsync(records);
}
private async Task<List<UserJournalQrCodeOutput>> BuildQrCodeOutputsAsync(List<UserJournal> records)
{
if (records.Count == 0)
{
return [];
}
var journalIds = records.Select(r => r.JournalId).Distinct().ToList();
var journals = await journalRepository.Queryable()
.Where(j => journalIds.Contains(j.Id))
.ToListAsync();
var journalDict = journals.ToDictionary(j => j.Id);
var userIds = records
.Where(r => r.UserId.HasValue && r.UserId.Value > 0)
.Select(r => r.UserId!.Value)
.Distinct()
.ToList();
List<Users> users = userIds.Count == 0
? []
: await usersRepository.Queryable()
.Where(u => userIds.Contains(u.Id))
.ToListAsync();
var userDict = users.ToDictionary(u => u.Id);
return records.Select(record =>
{
journalDict.TryGetValue(record.JournalId, out var journal);
Users? user = null;
if (record.UserId.HasValue)
{
userDict.TryGetValue(record.UserId.Value, out user);
}
return MapQrCodeOutput(record, journal, user);
}).ToList();
}
private static UserJournalQrCodeOutput MapQrCodeOutput(UserJournal record, Journal? journal, Users? user)
{
return new UserJournalQrCodeOutput
{
Id = record.Id,
JournalId = record.JournalId,
JournalName = journal?.Name ?? journal?.Title ?? string.Empty,
UserId = record.UserId,
UserName = user?.Name,
Type = record.Type.ToString(),
Status = record.Status.ToString(),
IsBound = record.UserId.HasValue && record.UserId.Value > 0,
QrCodeUrl = DomainHelper.OssFullUrl(record.QrCodeUrl ?? string.Empty),
CreatedAt = record.CreatedAt,
BoundAt = record.UserId.HasValue && record.UserId.Value > 0 ? record.UpdatedAt : null
};
}
private static string BuildQrCodeContent(long journalId, long id)
{
return JsonSerializer.Serialize(new { JournalId = journalId.ToString(), Id = id.ToString() });
}
private async Task<long> GenerateUniqueQrCodeIdAsync()
{
for (var i = 0; i < MaxRandomIdGenerateRetryCount; i++)
{
var id = RandomIdHelper.GenerateLongId();
var exists = await userJournalRepository.Queryable()
.AnyAsync(uj => uj.Id == id);
if (!exists)
{
return id;
}
}
throw new BusinessException("生成二维码ID失败请稍后重试", ResultCode.GLOBAL_ERROR);
}
private async Task<List<long>> GenerateUniqueQrCodeIdsAsync(int count)
{
var ids = new HashSet<long>();
for (var i = 0; i < MaxRandomIdGenerateRetryCount && ids.Count < count; i++)
{
while (ids.Count < count)
{
ids.Add(RandomIdHelper.GenerateLongId());
}
var candidateIds = ids.ToList();
var existingIds = await userJournalRepository.Queryable()
.Where(uj => candidateIds.Contains(uj.Id))
.Select(uj => uj.Id)
.ToListAsync();
if (existingIds.Count == 0)
{
return candidateIds;
}
ids.ExceptWith(existingIds);
}
throw new BusinessException("生成二维码ID失败请稍后重试", ResultCode.GLOBAL_ERROR);
}
private async Task SendBindJournalMessageAsync(Users user, Journal journal)
{
try
{
var messageSent = (await messagePublishService.PublishAsync(new MessagePublishInput<BindJournalMessage>
{
Exchange = JournalExchange,
Queue = BindJournalQueue,
RoutingKey = BindJournalRoutingKey,
Data = new BindJournalMessage
{
UserId = user.Id,
JournalId = journal.Id,
StartTime = journal.StartTime,
EndTime = journal.EndTime,
UploadDomain = user.UploadDomain
},
BusinessType = "UserJournalBind",
BusinessId = user.Id
})).Success;
if (!messageSent)
{
logger.LogError("发送绑定期刊消息失败UserId: {UserId}, JournalId: {JournalId}", user.Id, journal.Id);
}
}
catch (Exception ex)
{
logger.LogError(ex, "发送绑定期刊消息异常UserId: {UserId}, JournalId: {JournalId}", user.Id, journal.Id);
}
}
/// <summary>
/// 获取用户的期刊绑定列表
/// </summary>
public async Task<PageListModel<BindJournalOutput>> GetUserJournalsAsync(long userId, UserJournalQueryInput input)
{
logger.LogInformation("查询用户期刊绑定列表UserId: {UserId}, PageIndex: {PageIndex}, PageSize: {PageSize}",
userId, input.PageIndex, input.PageSize);
if (input.PageIndex <= 0)
{
throw new BusinessException("页码必须大于0", ResultCode.BAD_REQUEST);
}
if (input.PageSize <= 0 || input.PageSize > 100)
{
throw new BusinessException("每页条数必须在1-100之间", ResultCode.BAD_REQUEST);
}
RefAsync<int> totalNumber = 0;
var pageResult = await userJournalRepository.Queryable()
.Where(uj => uj.UserId == userId)
.OrderByDescending(uj => uj.CreatedAt)
.Select(uj => new BindJournalOutput
{
Id = uj.Id,
UserId = uj.UserId ?? 0,
JournalId = uj.JournalId,
Type = uj.Type.ToString(),
Status = uj.Status.ToString(),
CreatedAt = uj.CreatedAt
}, true)
.ToPageListAsync(input.PageIndex, input.PageSize, totalNumber);
return new PageListModel<BindJournalOutput>(pageResult, input.PageIndex, input.PageSize, totalNumber);
}
/// <summary>
/// 取消期刊绑定
/// </summary>
public async Task UnbindJournalAsync(long userId, long id)
{
logger.LogInformation("取消期刊绑定UserId: {UserId}, Id: {Id}", userId, id);
var userJournal = await userJournalRepository.GetByIdAsync(id);
if (userJournal == null || userJournal.IsDeleted)
{
logger.LogWarning("取消绑定失败记录不存在Id: {Id}", id);
throw new BusinessException("绑定记录不存在", ResultCode.NOT_FOUND);
}
// 校验归属权:只能取消自己的绑定
if (userJournal.UserId != userId)
{
logger.LogWarning("取消绑定失败无权操作UserId: {UserId}, RecordUserId: {RecordUserId}", userId, userJournal.UserId);
throw new BusinessException("无权取消该绑定", ResultCode.FORBIDDEN);
}
var result = await userJournalRepository.DeleteByIdAsync(id);
if (!result)
{
logger.LogError("取消绑定失败Id: {Id}", id);
throw new BusinessException("取消绑定失败,请稍后重试", ResultCode.GLOBAL_ERROR);
}
logger.LogInformation("取消期刊绑定成功UserId: {UserId}, Id: {Id}", userId, id);
}
}