Files
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

441 lines
18 KiB
C#
Raw Permalink 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 Mapster;
using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.Common.Extensions;
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.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;
public class JournalService(BaseRepository<JournalPage> JournalPageRepository,
BaseRepository<JournalPageTask> JournalPageTaskRepository,
BaseRepository<JournalPageTaskAnswer> JournalPageTaskAnswerRepository,
BaseRepository<JournalCatalog> JournalCatalogRepository,
BaseRepository<DotFile> dotFileRepository,
BaseRepository<DotFileDetail> dotFileDetailRepository,
OssService ossService,
IMessagePublishService messagePublishService,
ILogger<JournalService> logger) : BaseRepository<Journal>, IJournalService
{
private const string JournalExchange = "ex.journal";
private const string PublishBookQueue = "mq.journal.publish.book";
private const string PublishBookRoutingKey = "rk.journal.publish.book";
private const string PublishBookPageQueue = "mq.journal.publish.bookpage";
private const string PublishBookPageRoutingKey = "rk.journal.publish.bookpage";
/// <summary>
/// 查询List
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
public async Task<List<JournalDto>> GetListAsync(JournalQueryDto dto)
{
var query = await Queryable()
.WhereIF(!string.IsNullOrWhiteSpace(dto.Name), a => a.Name.Contains(dto.Name))
.WhereIF(dto.Status.HasValue, a => a.Status == (int)dto.Status)
.Select(a => new JournalDto(), true)
.ToListAsync();
return query;
}
/// <summary>
/// 分页查询
/// </summary>
/// <param name="search"></param>
/// <returns></returns>
public async Task<PageListModel<JournalDto>> GetPageListAsync(PageQueryModel<JournalQueryDto> search)
{
RefAsync<int> totalNumber = 0;
var dataList = await Queryable()
//.InnerJoin<IcrJournalOrganization>((a, b) => a.Id == b.JournalId)
.WhereIF(!string.IsNullOrWhiteSpace(search.Params.Name), a => a.Name.Contains(search.Params.Name))
.WhereIF(search.Params.Status.HasValue, a => a.Status == (int)search.Params.Status)
.WhereIF(!string.IsNullOrWhiteSpace(search.Params.Name), a => a.Name.Contains(search.Params.Name))
.OrderByDescending(a => a.CreatedAt)
.Select(a => new JournalDto(), true)
.ToPageListAsync(search.PageIndex, search.PageSize, totalNumber);
return new PageListModel<JournalDto>(dataList, search.PageIndex, search.PageSize, totalNumber);
}
/// <summary>
/// 创建杂志
/// </summary>
/// <param name="input">杂志信息</param>
/// <returns>新杂志ID</returns>
public async Task<BaseResponse<long>> AddAsync(JournalAddDto input)
{
BusinessException.ThrowIf(string.IsNullOrWhiteSpace(input.Name), "书籍名称不能为空", ResultCode.BAD_REQUEST);
var journal = new Journal
{
Name = input.Name.Trim(),
Title = input.Title,
TotalPage = input.TotalPage,
Status = (int)JournalStatusEnum.Created,
PdfUrl = input.PdfUrl?.RemoveDomain(),
Width = input.Width,
Height = input.Height,
Cover = input.Cover?.RemoveDomain(),
BackCover = input.BackCover?.RemoveDomain(),
PdfPreviewUrl = input.PdfPreviewUrl?.RemoveDomain(),
StartTime = input.StartTime,
EndTime = input.EndTime,
CreatedBy = "System",
UpdatedBy = "System",
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now,
IsDeleted = false
};
// Id 已由 YitIdHelper 预生成,先搬运文件到正式目录,再一次性插入
if (!string.IsNullOrWhiteSpace(input.Cover))
{
var coverKey = $"journal/{journal.Id}/cover.{input.Cover.ToExtension()}";
ossService.CopyObject(input.Cover.RemoveDomain(), coverKey);
journal.Cover = coverKey;
}
if (!string.IsNullOrWhiteSpace(input.BackCover))
{
var backCoverKey = $"journal/{journal.Id}/backCover.{input.BackCover.ToExtension()}";
ossService.CopyObject(input.BackCover.RemoveDomain(), backCoverKey);
journal.BackCover = backCoverKey;
}
if (!string.IsNullOrWhiteSpace(input.PdfUrl))
{
var pdfKey = $"journal/{journal.Id}/Journal.{input.PdfUrl.ToExtension()}";
ossService.CopyObject(input.PdfUrl.RemoveDomain(), pdfKey);
journal.PdfUrl = pdfKey;
}
var res = await base.InsertAsync(journal);
if (!res)
{
new BusinessException("创建失败", ResultCode.GLOBAL_ERROR);
}
logger.LogInformation("杂志创建成功ID: {Id}, 名称: {Name}", journal.Id, input.Name);
return BaseResponse<long>.Success(journal.Id);
}
/// <summary>
/// 编辑
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public async Task<BaseResponse<bool>> EditAsync(JournalEditDto input)
{
var Journal = await base.GetByIdAsync(input.Id);
BusinessException.ThrowIf(Journal.IsNull(), "不存在的Id", ResultCode.NOT_FOUND);
BusinessException.ThrowIf(Journal.Status == (int)JournalStatusEnum.Archive, "已归档不可编辑", ResultCode.CONFLICT);
if (string.IsNullOrWhiteSpace(input.Cover))
{
Journal.Cover = null;
}
else if (input.Cover.RemoveDomain() != Journal.Cover)
{
var key = $"journal/{Journal.Id}/cover.{input.Cover.ToExtension()}";
ossService.CopyObject(input.Cover.RemoveDomain(), key);
Journal.Cover = key;
}
if (string.IsNullOrWhiteSpace(input.BackCover))
{
Journal.BackCover = null;
}
else if (input.BackCover.RemoveDomain() != Journal.BackCover)
{
var backCoverkey = $"journal/{Journal.Id}/backCover.{input.BackCover.ToExtension()}";
ossService.CopyObject(input.BackCover.RemoveDomain(), backCoverkey);
Journal.BackCover = backCoverkey;
}
if (string.IsNullOrWhiteSpace(input.PdfUrl))
{
Journal.PdfUrl = null;
}
else if (input.PdfUrl.RemoveDomain() != Journal.PdfUrl)
{
var pdfUrlkey = $"journal/{Journal.Id}/Journal.{input.PdfUrl.ToExtension()}";
ossService.CopyObject(input.PdfUrl.RemoveDomain(), pdfUrlkey);
Journal.PdfUrl = pdfUrlkey;
}
Journal.Width = input.Width;
Journal.Height = input.Height;
Journal.Name = input.Name;
Journal.Title = input.Title;
Journal.StartTime = input.StartTime;
Journal.EndTime = input.EndTime;
Journal.UpdatedAt = DateTime.Now;
var res = await UseTranAsync(async () =>
{
var result = await Context.Updateable(Journal).IgnoreColumns(c => c.Status).ExecuteCommandAsync();
return result > 0;
});
return BaseResponse<bool>.Success(res);
}
public async Task<List<JournalTaskOutput>> Tasks(long id)
{
var data = await JournalPageTaskRepository.Queryable().Where(w => w.JournalId == id)
.Select(x => new JournalTaskOutput()
{
TaskId = x.Id,
TaskNo = x.No,
TaskSubType = x.Type
}).ToListAsync();
return data.OrderBy(x =>
{
var parts = x.TaskNo.Split('-').Select(int.Parse).ToArray();
return (parts[0], parts[1], parts[2], parts[3]); // 元组
}).ToList();
}
public async Task<JournalDto> DetailAsync(long id)
{
var Journal = await base.Queryable().Where(w => w.Id == id).Select<JournalDto>().FirstAsync();
return Journal;
}
public async Task<bool> DeleteAsync(List<long> ids)
{
BusinessException.ThrowIf(!base.Queryable().Any(w => ids.Contains(w.Id)), "ID不存在", ResultCode.NOT_FOUND);
BusinessException.ThrowIf(base.Queryable().Any(w => ids.Contains(w.Id) && w.Status == (int)JournalStatusEnum.Archive), "已归档不可删除", ResultCode.CONFLICT);
var result = await UseTranAsync(async () =>
{
await base.DeleteAsync(d => ids.Contains(d.Id));
await JournalPageRepository.DeleteAsync(d => ids.Contains(d.JournalId));
await JournalCatalogRepository.DeleteAsync(d => ids.Contains(d.JournalId));
// 先查询要删除的 JournalPageTask Id 列表
var taskIds = await JournalPageTaskRepository.Queryable()
.Where(d => ids.Contains(d.JournalId))
.Select(d => d.Id)
.ToListAsync();
// 删除 JournalPageTaskAnswer
if (taskIds.Any())
{
await JournalPageTaskAnswerRepository.DeleteAsync(d => taskIds.Contains(d.JournalPageTaskId));
}
// 再删除 JournalPageTask
await JournalPageTaskRepository.DeleteAsync(d => ids.Contains(d.JournalId));
return true;
});
//删除OSS上的journal/{JournalId}文件夹
if (result)
{
foreach (var JournalId in ids)
{
var prefix = $"journal/{JournalId}/";
var keys = ossService.ListObjects(prefix);
if (keys?.Count > 0)
{
ossService.DeleteObjects(keys);
}
}
}
return result;
}
/// <summary>
///
/// </summary>
/// <param name="id">书id</param>
/// <param name="index">起始页码</param>
/// <returns></returns>
public async Task<bool> StartPageAsync(long id, int index)
{
var oldIndex = await JournalPageRepository.Queryable().Where(w => w.JournalId == id && w.PageNum == 1).Select(x => x.Sort).FirstAsync();
// 如果位置没有变化,直接返回
if (index == oldIndex) return true;
if (index > oldIndex)
{
// 向后移动的情况
var x = index - oldIndex;
await JournalPageRepository.Updateable().SetColumns(s => s.PageNum, 0).Where(w => w.Sort < index).Where(w => w.JournalId == id).ExecuteCommandAsync();
await JournalPageRepository.Updateable().SetColumns(s => s.PageNum == s.PageNum - x).Where(w => w.Sort >= index).Where(w => w.JournalId == id).ExecuteCommandAsync();
}
else
{
// 向前移动的情况
//var x = oldIndex - input.Index;
await JournalPageRepository.Updateable().SetColumns(s => s.PageNum, 0).Where(w => w.Sort < index).Where(w => w.JournalId == id).ExecuteCommandAsync();
// 将目标页面设置为第一页
await JournalPageRepository.Updateable().SetColumns(s => s.PageNum == s.Sort - (index - 1)).Where(w => w.Sort >= index).Where(w => w.JournalId == id).ExecuteCommandAsync();
}
// await JournalPageRepository.Updateable().SetColumns(s => s.PageNum == s.Sort).Where(w => w.JournalId == input.Id).ExecuteCommandAsync();
return true;
}
public async Task<DotMatrixOutput> PrintCodeAsync(long id)
{
var Journal = await base.Queryable().Where(w => w.Id == id).FirstAsync();
BusinessException.ThrowIf(Journal.IsNull(), "不存在书", ResultCode.NOT_FOUND);
BusinessException.ThrowIf(Journal.Status == (int)JournalStatusEnum.Codeing, "正在生成中...", ResultCode.CONFLICT);
var pages = await JournalPageRepository.Queryable().Where(w => w.JournalId == id).ToListAsync();
BusinessException.ThrowIf(pages.IsNull(), "不存在页", ResultCode.NOT_FOUND);
var output = new DotMatrixOutput()
{
Method = "Journal",
Name = Journal.Name,
NoteId = id,
PdfUrl = Journal.PdfUrl
};
return output;
}
public async Task<bool> ResultReportAsync(DotMatrixNoteJournalReportInput input)
{
if (input.FileUrl.IsNull() && input.FileKey.IsNull())
{
return await Updateable().SetColumns(s => s.Status, input.Success ? JournalStatusEnum.CodeSuccess : JournalStatusEnum.CodeFail)
.SetColumns(s => s.PdfPreviewUrl, input.FileKey)
.Where(w => w.Id == input.Id)
.ExecuteCommandAsync() > 0;
}
return await Updateable().SetColumns(s => s.Status, JournalStatusEnum.CodeFail).Where(w => w.Id == input.Id).ExecuteCommandAsync() > 0;
}
public async Task<bool> StatusAsync(long id, JournalStatusEnum status)
{
var book = await base.GetByIdAsync(id);
var tasks = await Context.Queryable<JournalPageTask>().Where(w => w.JournalId == id).ToListAsync();
BusinessException.ThrowIf(tasks.Count == 0 && status == JournalStatusEnum.Archive, "未添加任何题目,无法归档", ResultCode.UNPROCESSABLE_ENTITY);
BusinessException.ThrowIf(tasks.Any(a => string.IsNullOrWhiteSpace(a.TaskUrl)) && status == JournalStatusEnum.Archive, $"{string.Join(',', tasks.Where(a => string.IsNullOrWhiteSpace(a.TaskUrl)).Select(a => a.No).ToList())}未保存,无法归档", ResultCode.UNPROCESSABLE_ENTITY);
var res = await base.Updateable().SetColumns(s => s.Status, status).Where(w => w.Id == id).ExecuteCommandAsync() > 0;
return res;
}
public async Task<bool> PublishAsync(long id)
{
var book = await base.GetByIdAsync(id);
BusinessException.ThrowIf(book == null || book.IsDeleted, "杂志不存在", ResultCode.NOT_FOUND);
var pages = await JournalPageRepository.Queryable()
.Where(x => x.JournalId == id && !x.IsDeleted)
.OrderBy(x => x.PageNum)
.OrderBy(x => x.Sort)
.Select(x => new
{
x.Id,
x.PageNo,
x.Layout,
x.Url
})
.ToListAsync();
BusinessException.ThrowIf(pages.Count == 0, "书籍未添加任何书页,无法发布", ResultCode.UNPROCESSABLE_ENTITY);
BusinessException.ThrowIf(!book.StartTime.HasValue, "发布开始时间不能为空", ResultCode.UNPROCESSABLE_ENTITY);
BusinessException.ThrowIf(!book.EndTime.HasValue, "发布结束时间不能为空", ResultCode.UNPROCESSABLE_ENTITY);
BusinessException.ThrowIf(book.EndTime < book.StartTime, "发布结束时间不能早于开始时间", ResultCode.UNPROCESSABLE_ENTITY);
var pageIds = pages.Select(x => x.Id).ToList();
var pageQuestions = await JournalPageTaskRepository.Queryable()
.Where(x => x.JournalId == id && pageIds.Contains(x.JournalPageId) && !x.IsDeleted)
.Select(x => new
{
x.JournalPageId,
x.Id,
x.No
})
.ToListAsync();
var questionIdsByPageId = pageQuestions
.GroupBy(x => x.JournalPageId)
.ToDictionary(
x => x.Key,
x => string.Join(',', x.OrderBy(q => ParseTaskNo(q.No)).Select(q => q.Id)));
var publishMessages = new List<MessagePublishInput<object>>
{
new()
{
Exchange = JournalExchange,
Queue = PublishBookQueue,
RoutingKey = PublishBookRoutingKey,
Data = new JournalPublishBookMessage
{
BookId = id,
StartTime = book.StartTime.Value,
EndTime = book.EndTime.Value
},
BusinessType = "JournalPublish",
BusinessId = id
}
};
foreach (var page in pages)
{
publishMessages.Add(new MessagePublishInput<object>
{
Exchange = JournalExchange,
Queue = PublishBookPageQueue,
RoutingKey = PublishBookPageRoutingKey,
Data = new JournalPublishBookPageMessage
{
BookId = id,
PageId = page.Id,
PageNo = page.PageNo,
Layout = page.Layout,
Url = DomainHelper.OssFullUrl(page.Url),
QuestionNo = questionIdsByPageId.GetValueOrDefault(page.Id) ?? string.Empty
},
BusinessType = "JournalPublish",
BusinessId = id
});
}
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)
{
var parts = no?.Split('-') ?? Array.Empty<string>();
return (GetNoPart(parts, 0), GetNoPart(parts, 1), GetNoPart(parts, 2), GetNoPart(parts, 3));
}
static int GetNoPart(string[] parts, int index)
{
return parts.Length > index && int.TryParse(parts[index], out var value) ? value : int.MaxValue;
}
}
}