using Microsoft.Extensions.Logging; using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto.Points; using QYZH.InteractiveMagazine.Models.Dto.UserAnswerTaskService; using QYZH.InteractiveMagazine.Models.Entity; using QYZH.InteractiveMagazine.Models.Enum; using QYZH.InteractiveMagazine.Repository; namespace QYZH.InteractiveMagazine.Service; /// /// 用户作答任务服务实现 /// public class UserAnswerTaskService( BaseRepository answerRepository, BaseRepository journalRepository, BaseRepository catalogRepository, BaseRepository pageRepository, BaseRepository taskRepository, BaseRepository userMedalRepository, BaseRepository medalRepository, BaseRepository userJournalRepository, BaseRepository usersRepository, BaseRepository checkInRecordRepository, IPointsService pointsService, ILogger logger) : BaseRepository, IUserAnswerTaskService { /// /// ============================================ /// 方法思路: /// 1. 批量预加载策略:为避免N+1查询问题,先按期刊维度批量加载所有关联数据 /// 2. 跨页题目处理核心: /// - 跨页题目定义:JournalPageTask.Id != GroupId(同一个GroupId下有多个任务) /// - 进度计算:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分(取第一个任务的积分) /// 3. 状态判定逻辑: /// - 未开始:无任何作答记录 /// - 进行中:有作答记录但未全部完成 /// - 已完成:所有GroupId都完成 /// ============================================ /// /// 用户ID /// 用户所有期刊的学习进度列表 public async Task> GetJournalProgressAsync(long userId) { // ============================================ // 跨页题目处理核心逻辑说明: // 1. 跨页题目定义:JournalPageTask.Id != GroupId // - Id 是任务的唯一标识 // - GroupId 是跨页题目的分组标识,同一个跨页题目的所有任务共享同一个GroupId // 2. 进度计算:按 GroupId 去重 // - 已完成条件:该 GroupId 下所有任务都完成(Status=1) // 3. 积分计算:按 GroupId 去重,每个 GroupId 只计算一次积分 // 4. 状态判定: // - 未开始:无任何 GroupId 被作答 // - 进行中:有 GroupId 被作答但未全部完成 // - 已完成:所有 GroupId 都完成 // ============================================ // 1. 查询用户的所有作答记录 var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.UserId == userId && !a.IsDeleted) .ToListAsync(); if (userAnswers == null || userAnswers.Count == 0) { return new List(); } // 2. 提取关联的期刊ID(去重) var journalIds = userAnswers.Select(a => a.JournalId).Distinct().ToList(); // 3. 批量预加载关联实体(按期刊维度全量加载,避免N+1查询) var journals = await journalRepository.Context.Queryable() .Where(j => journalIds.Contains(j.Id) && !j.IsDeleted) .ToListAsync(); // 加载这些期刊的一级目录(ParentId == 0 表示一级目录) var allCatalogs = await catalogRepository.Context.Queryable() .Where(c => journalIds.Contains(c.JournalId) && c.ParentId == 0 && !c.IsDeleted) .ToListAsync(); // 加载这些期刊的所有任务 var allTasks = await taskRepository.Context.Queryable() .Where(t => journalIds.Contains(t.JournalId) && !t.IsDeleted) .ToListAsync(); // 加载这些期刊的所有页面(用于计算剩余篇数) var allPages = await pageRepository.Context.Queryable() .Where(p => journalIds.Contains(p.JournalId) && !p.IsDeleted) .ToListAsync(); // 4. 查询用户连续打卡天数 var consecutiveDays = await CalculateConsecutiveDaysAsync(userId); // 5. 提取用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); var userAnsweredPageIds = userAnswers.Select(a => a.JournalPageId).Distinct().ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 6. 按期刊分组聚合 var result = new List(); foreach (var journal in journals) { // 该期刊下所有任务 var journalTasks = allTasks.Where(t => t.JournalId == journal.Id).ToList(); // 该期刊的一级栏目数 var catalogCount = allCatalogs.Count(c => c.JournalId == journal.Id); // 【跨页题目-进度计算】总任务数:按 GroupId 去重,跨页题目只算一个 var totalTaskCount = journalTasks.Select(t => t.GroupId).Distinct().Count(); // 【跨页题目-已完成判断】已完成任务数:按 GroupId 去重 // 关键:必须该 GroupId 下所有任务都完成,才算该 GroupId 完成 var completedTaskCount = journalTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); // 【跨页题目-积分计算】获取已完成的 GroupId 列表 var completedGroupIds = journalTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Select(g => g.Key) .ToList(); // 【跨页题目-积分去重】每个已完成的 GroupId 只取第一个任务的积分 // 原因:同一个跨页题目的多个任务只应该计算一次积分 var distinctCompletedTasks = journalTasks .Where(t => completedGroupIds.Contains(t.GroupId)) .GroupBy(t => t.GroupId) .Select(g => g.First()) .ToList(); // 累计已完成的跨页题目的总成长值和总积分 var totalGrowthPoints = distinctCompletedTasks.Sum(t => t.GrowthPoint ?? 0); var totalPoints = distinctCompletedTasks.Sum(t => t.Points); // 【跨页题目-进度百分比计算】 var totalGroupIds = journalTasks.Select(t => t.GroupId).Distinct().Count(); var completedGroupIdCount = journalTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); var progress = totalGroupIds > 0 ? Math.Round((double)completedGroupIdCount / totalGroupIds * 100, 2) : 0; // 剩余篇数:该期刊总页数 - 用户已作答页数 var journalPages = allPages.Where(p => p.JournalId == journal.Id).ToList(); var answeredPageCount = journalPages.Count(p => userAnsweredPageIds.Contains(p.Id)); var remainingPages = journalPages.Count - answeredPageCount; result.Add(new JournalProgressOutput { JournalId = journal.Id, JournalName = journal.Name ?? string.Empty, CatalogCount = catalogCount, TotalTaskCount = totalTaskCount, CompletedTaskCount = completedTaskCount, TotalGrowthPoints = totalGrowthPoints, TotalPoints = totalPoints, Progress = progress, RemainingPages = remainingPages, ConsecutiveDays = consecutiveDays }); } return result; } /// /// ============================================ /// 方法思路:计算用户连续打卡天数 /// 1. 查询用户最近一次签到记录 /// 2. 判断是否连续:如果最后一次签到是今天或昨天,连续天数延续 /// 3. 否则返回0(中断了) /// ============================================ /// /// 用户ID /// 连续打卡天数 private async Task CalculateConsecutiveDaysAsync(long userId) { var today = DateTime.Now.Date; // 查询最近一次签到记录 var lastRecord = await checkInRecordRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted) .OrderByDescending(r => r.CheckInDate) .FirstAsync(); if (lastRecord == null) { return 0; } var lastDate = lastRecord.CheckInDate.Date; // 如果最后一次签到是今天或昨天,则连续天数延续 if (lastDate == today || lastDate == today.AddDays(-1)) { return lastRecord.ConsecutiveDays; } return 0; } /// /// ============================================ /// 方法思路:获取目录列表(按一级目录分组展示) /// 1. 批量预加载:该期刊下所有目录、页面、任务、用户作答记录 /// 2. 目录结构处理: /// - 一级目录:ParentId=0 且 Type=Unit /// - 二级目录:ParentId=一级目录Id 且 Type=Unit(可选) /// - 文章目录:Type=Page /// 3. 跨页题目处理: /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分 /// - 已作答判断:只要有一个任务作答就算该GroupId已作答(包含进行中和已完成) /// 4. 状态判定: /// - 未开始:无任何GroupId被作答 /// - 进行中:有GroupId被作答但未全部完成 /// - 已完成:所有GroupId都完成 /// ============================================ /// /// 期刊ID /// 用户ID /// 目录列表(按一级目录分组) public async Task> GetCatalogListAsync(long journalId, long userId) { // 1. 查询期刊 var journal = await journalRepository.Context.Queryable() .Where(j => j.Id == journalId && !j.IsDeleted) .FirstAsync(); if (journal == null) { return new List(); } // 2. 批量加载关联数据 var allCatalogs = await catalogRepository.Context.Queryable() .Where(c => c.JournalId == journalId && !c.IsDeleted) .ToListAsync(); var allPages = await pageRepository.Context.Queryable() .Where(p => p.JournalId == journalId && !p.IsDeleted) .ToListAsync(); var allTasks = await taskRepository.Context.Queryable() .Where(t => t.JournalId == journalId && !t.IsDeleted) .ToListAsync(); var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalId == journalId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); // 3. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 4. 按一级目录分组 var firstLevelCatalogs = allCatalogs.Where(c => c.ParentId == 0 && c.Type == JournalCatalogTypeEnum.Unit) .OrderBy(c => c.Sort).ToList(); var result = new List(); foreach (var firstCatalog in firstLevelCatalogs) { // 该一级目录下的 Type=Page 子目录(即文章) var pageCatalogs = allCatalogs .Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Page) .OrderBy(c => c.Sort) .ToList(); // 文章篇数 = Type=Page 的子目录数量 var articleCount = pageCatalogs.Count; var pageCatalogIds = pageCatalogs.Select(c => c.Id).ToHashSet(); // 获取所有文章下的页面ID var allArticlePageIds = allPages .Where(p => pageCatalogIds.Contains(p.JournalCatalogId)) .Select(p => p.Id) .ToHashSet(); // 该一级目录下的二级单元目录(Type=Unit 的子目录) var secondLevelCatalogs = allCatalogs .Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Unit) .OrderBy(c => c.Sort) .ToList(); // 如果没有二级单元,则将 Type=Page 的文章作为二级列表展示 var children = new List(); if (secondLevelCatalogs.Count > 0) { // 有二级单元:按二级单元分组 foreach (var secondCatalog in secondLevelCatalogs) { // 二级单元下的 Type=Page 子目录 var subPageCatalogs = allCatalogs .Where(c => c.ParentId == secondCatalog.Id && c.Type == JournalCatalogTypeEnum.Page) .ToList(); var subPageCatalogIds = subPageCatalogs.Select(c => c.Id).ToHashSet(); // 二级单元下的所有页面 var secondLevelPageIds = allPages .Where(p => subPageCatalogIds.Contains(p.JournalCatalogId)) .Select(p => p.Id) .ToHashSet(); // 二级单元下的所有任务 var secondLevelTasks = allTasks.Where(t => secondLevelPageIds.Contains(t.JournalPageId)).ToList(); // 判定状态(Status=0进行中,Status=1已完成,跨页题目按GroupId去重) // 跨页题目必须所有任务都完成,才算该 GroupId 已完成 string status; if (secondLevelTasks.Count == 0) { status = "未开始"; } else { // 总 GroupId 数(跨页题目只算一个) var totalGroupCount = secondLevelTasks.Select(t => t.GroupId).Distinct().Count(); // 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成) var completedGroupCount = secondLevelTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); // 【关键】已作答 GroupId 数:只要有一个任务作答就算该 GroupId 已作答(包含进行中和已完成) // 这个字段用于判断状态:未开始 / 进行中 / 已完成 var answeredGroupCount = secondLevelTasks .Where(t => userAnsweredTaskIds.Contains(t.Id)) .Select(t => t.GroupId) .Distinct() .Count(); // 状态判定逻辑: // - answeredGroupCount == 0:没有任何 GroupId 被作答 → 未开始 // - completedGroupCount == totalGroupCount:所有 GroupId 都完成 → 已完成 // - 其他:有 GroupId 被作答但未全部完成 → 进行中 if (answeredGroupCount == 0) status = "未开始"; else if (completedGroupCount == totalGroupCount) status = "已完成"; else status = "进行中"; } // 计算该二级目录的总成长值和总积分(跨页题目按GroupId去重) var distinctSecondLevelTasks = secondLevelTasks .GroupBy(t => t.GroupId) .Select(g => g.First()) .ToList(); var totalGrowthPoints = distinctSecondLevelTasks.Sum(t => t.GrowthPoint) ?? 0; var totalPoints = distinctSecondLevelTasks.Sum(t => t.Points); children.Add(new SecondCatalogOutput { CatalogId = secondCatalog.Id, CatalogName = secondCatalog.Name ?? string.Empty, TotalPoints = totalPoints, TotalGrowthPoints = totalGrowthPoints, Status = status }); } } else { // 没有二级单元:直接将文章作为二级列表 foreach (var pageCatalog in pageCatalogs) { // 该文章下的所有页面 var articlePageIds = allPages .Where(p => p.JournalCatalogId == pageCatalog.Id) .Select(p => p.Id) .ToHashSet(); // 该文章下的所有任务 var articleTasks = allTasks.Where(t => articlePageIds.Contains(t.JournalPageId)).ToList(); // 判定状态(Status=0进行中,Status=1已完成,跨页题目按GroupId去重) // 跨页题目必须所有任务都完成,才算该 GroupId 已完成 string status; if (articleTasks.Count == 0) { status = "未开始"; } else { // 总 GroupId 数(跨页题目只算一个) var totalGroupCount = articleTasks.Select(t => t.GroupId).Distinct().Count(); // 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成) var completedGroupCount = articleTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); // 已作答 GroupId 数(只要有一个任务作答就算已作答,包含进行中和已完成) var answeredGroupCount = articleTasks .Where(t => userAnsweredTaskIds.Contains(t.Id)) .Select(t => t.GroupId) .Distinct() .Count(); if (answeredGroupCount == 0) status = "未开始"; else if (completedGroupCount == totalGroupCount) status = "已完成"; else status = "进行中"; } // 计算该文章的总成长值和总积分(跨页题目按GroupId去重) var distinctArticleTasks = articleTasks .GroupBy(t => t.GroupId) .Select(g => g.First()) .ToList(); var totalGrowthPoints = distinctArticleTasks.Sum(t => t.GrowthPoint) ?? 0; var totalPoints = distinctArticleTasks.Sum(t => t.Points); children.Add(new SecondCatalogOutput { CatalogId = pageCatalog.Id, CatalogName = pageCatalog.Name ?? string.Empty, TotalPoints = totalPoints, TotalGrowthPoints = totalGrowthPoints, Status = status }); } } // 计算该一级目录的总成长值和总积分(基于所有文章页面的任务) var firstLevelTasks = allTasks.Where(t => allArticlePageIds.Contains(t.JournalPageId)).ToList(); result.Add(new CatalogListOutput { CatalogId = firstCatalog.Id, CatalogName = firstCatalog.Name ?? string.Empty, JournalName = journal.Name ?? string.Empty, ArticleCount = articleCount, Children = children }); } return result; } /// /// ============================================ /// 方法思路:获取一级目录概览(含二级目录详情和任务完成状态) /// 1. 批量预加载:该期刊下所有目录、页面、任务、用户作答记录 /// 2. 获取该一级目录下的所有文章目录(Type=Page) /// 3. 跨页题目处理: /// - 总任务数:按GroupId去重(跨页题目只算一个) /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 已作答判断:只要有一个任务作答就算该GroupId已作答 /// 4. 状态格式:"已完成数/总数 已完成/进行中/未开始" /// 例如:"2/3 进行中" 表示3个GroupId中已完成2个 /// ============================================ /// /// 一级目录ID /// 用户ID /// 一级目录概览信息 public async Task GetCatalogOverviewAsync(long catalogId, long userId) { // 1. 查询一级目录 var firstCatalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalogId && !c.IsDeleted) .FirstAsync(); if (firstCatalog == null) { return null; } // 2. 批量加载关联数据 var allCatalogs = await catalogRepository.Context.Queryable() .Where(c => c.JournalId == firstCatalog.JournalId && !c.IsDeleted) .ToListAsync(); var allPages = await pageRepository.Context.Queryable() .Where(p => p.JournalId == firstCatalog.JournalId && !p.IsDeleted) .ToListAsync(); var allTasks = await taskRepository.Context.Queryable() .Where(t => t.JournalId == firstCatalog.JournalId && !t.IsDeleted) .ToListAsync(); var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalId == firstCatalog.JournalId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); // 3. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 4. 获取该一级目录下所有 Type=Page 的子目录(即文章/页面目录) var pageCatalogs = allCatalogs .Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Page) .OrderBy(c => c.Sort) .ToList(); // 5. 文章篇数 = Type=Page 的子目录数量 var articleCount = pageCatalogs.Count; // 6. 获取所有文章目录下的页面 var pageCatalogIds = pageCatalogs.Select(c => c.Id).ToHashSet(); var allLevelPages = allPages.Where(p => pageCatalogIds.Contains(p.JournalCatalogId)).ToList(); var allLevelPageIds = allLevelPages.Select(p => p.Id).ToHashSet(); // 7. 总任务数(按 GroupId 去重,跨页题目只算一个) var totalTaskCount = allTasks .Where(t => allLevelPageIds.Contains(t.JournalPageId)) .Select(t => t.GroupId) .Distinct() .Count(); // 8. 构建子目录详情列表 var subCatalogs = new List(); foreach (var pageCatalog in pageCatalogs) { // 该文章下的所有页面 var articlePages = allPages .Where(p => p.JournalCatalogId == pageCatalog.Id) .OrderBy(p => p.PageNum) .ToList(); var articlePageIds = articlePages.Select(p => p.Id).ToHashSet(); // 该文章下的所有任务 var articleTasks = allTasks.Where(t => articlePageIds.Contains(t.JournalPageId)).ToList(); // 总 GroupId 数(按 GroupId 去重,跨页题目只算一个) var totalGroupCount = articleTasks.Select(t => t.GroupId).Distinct().Count(); // 页码范围 var startPage = articlePages.Count > 0 ? articlePages.Min(p => p.PageNum) : 0; var endPage = articlePages.Count > 0 ? articlePages.Max(p => p.PageNum) : 0; // 状态格式:1/2 进行中、2/2 已完成、0/2 未开始(跨页题目按GroupId去重) // 跨页题目必须所有任务都完成,才算该 GroupId 已完成 string status; if (totalGroupCount == 0) { status = $"0/0 未开始"; } else { // 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成) var completedGroupCount = articleTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); // 已作答 GroupId 数(只要有一个任务作答就算已作答,包含进行中和已完成) var answeredGroupCount = articleTasks .Where(t => userAnsweredTaskIds.Contains(t.Id)) .Select(t => t.GroupId) .Distinct() .Count(); if (answeredGroupCount == 0) status = $"0/{totalGroupCount} 未开始"; else if (completedGroupCount == totalGroupCount) status = $"{completedGroupCount}/{totalGroupCount} 已完成"; else status = $"{completedGroupCount}/{totalGroupCount} 进行中"; } subCatalogs.Add(new SubCatalogDetailOutput { CatalogId = pageCatalog.Id, SecondLevelCatalogName = pageCatalog.Name ?? string.Empty, StartPage = startPage, EndPage = endPage, TaskCount = totalGroupCount, Status = status }); } return new CatalogOverviewOutput { CatalogId = firstCatalog.Id, FirstLevelCatalogName = firstCatalog.Name ?? string.Empty, PageCount = articleCount, TotalTaskCount = totalTaskCount, SubCatalogs = subCatalogs }; } /// /// ============================================ /// 方法思路:获取用户答题任务列表(某个二级栏目/文章下的所有任务) /// 1. 查询目录信息:通过catalogId查询二级栏目 /// 2. 查询一级栏目名称:通过ParentId关联查询一级栏目名称 /// 3. 批量预加载:该栏目下的所有页面、该期刊下所有任务、用户作答记录 /// 4. 跨页题目处理: /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分和成长值 /// - 已作答判断:只要有一个任务作答就算该GroupId已作答 /// 5. 徽章统计:统计该期刊下已获得的徽章数量 /// ============================================ /// /// 二级栏目/文章目录ID /// 用户ID /// 用户答题任务列表信息 public async Task GetUserAnswerTaskListAsync(long catalogId, long userId) { // 1. 查询目录(二级栏目) var catalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalogId && !c.IsDeleted) .FirstAsync(); if (catalog == null) { return null; } // ============================================ // 【获取一级栏目名称】 // 通过 ParentId 关联查询一级栏目名称 // 一级栏目:ParentId = 0 // 二级栏目/文章:ParentId = 一级栏目Id,Type = Page // ============================================ string firstLevelCatalogName = string.Empty; if (catalog.ParentId > 0) { var firstLevelCatalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalog.ParentId && !c.IsDeleted) .FirstAsync(); firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty; } // ============================================ // 2. 批量加载关联数据 // ============================================ // 该栏目下的所有页面 var allPages = await pageRepository.Context.Queryable() .Where(p => p.JournalCatalogId == catalogId && !p.IsDeleted) .ToListAsync(); // 该期刊下所有任务(用于跨页题目关联查询) var allTasks = await taskRepository.Context.Queryable() .Where(t => t.JournalId == catalog.JournalId && !t.IsDeleted) .ToListAsync(); // 该用户在该期刊下所有作答记录 var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalId == catalog.JournalId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); // 3. 获取该目录下的所有页面ID var pageIds = allPages.Select(p => p.Id).ToHashSet(); // 4. 该目录下的所有任务 var catalogTasks = allTasks.Where(t => pageIds.Contains(t.JournalPageId)).ToList(); // 5. 任务名称列表(去重) var taskNames = catalogTasks .Where(t => !string.IsNullOrEmpty(t.Task)) .Select(t => t.Task) .Distinct() .ToList(); // 6. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).Distinct().ToHashSet(); // 7. 计算答题时间(分钟) var totalAnswerSeconds = userAnswers .Where(a => pageIds.Contains(a.JournalPageId)) .Sum(a => a.AnswerSeconds); var answerMinutes = Math.Round(totalAnswerSeconds / 60.0, 2); // ============================================ // 【跨页题目-状态判断】 // 状态判定: // - 未开始:无任何 GroupId 被作答 // - 进行中:有 GroupId 被作答但未全部完成 // - 已完成:所有 GroupId 都完成 // 关键:必须该 GroupId 下所有任务都完成,才算该 GroupId 完成 // ============================================ string status; if (catalogTasks.Count == 0) { status = "未开始"; } else { // 总 GroupId 数(跨页题目只算一个) var totalGroupCount = catalogTasks.Select(t => t.GroupId).Distinct().Count(); // 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成) var completedGroupCount = catalogTasks .GroupBy(t => t.GroupId) .Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id))) .Count(); // 已作答 GroupId 数(只要有一个任务作答就算已作答,包含进行中和已完成) var answeredGroupCount = catalogTasks .Where(t => userAnsweredTaskIds.Contains(t.Id)) .Select(t => t.GroupId) .Distinct() .Count(); if (answeredGroupCount == 0) status = "未开始"; else if (completedGroupCount == totalGroupCount) status = "已完成"; else status = "进行中"; } // 9. 查询徽章个数(通过Medal表关联JournalId) var medalCount = await medalRepository.Context.Queryable() .Where(m => m.JournalId == catalog.JournalId && !m.IsDeleted) .Select(m => m.Id) .CountAsync(); var medalIds = await medalRepository.Context.Queryable() .Where(m => m.JournalId == catalog.JournalId && !m.IsDeleted) .Select(m => m.Id).ToListAsync(); var userMedalCount = await userMedalRepository.Context.Queryable() .Where(um => um.UserId == userId && !um.IsDeleted) .Where(um => medalIds.Contains(um.MedalId)) .CountAsync(); // 10. 计算页码范围 var startPage = allPages.Count > 0 ? allPages.Min(p => p.PageNum) : 0; var endPage = allPages.Count > 0 ? allPages.Max(p => p.PageNum) : 0; // 11. 计算总成长值和总积分(跨页题目按GroupId去重,只算一次) var distinctCatalogTasks = catalogTasks .GroupBy(t => t.GroupId) .Select(g => g.First()) .ToList(); var totalGrowthPoints = distinctCatalogTasks.Sum(t => t.GrowthPoint) ?? 0; var totalPoints = distinctCatalogTasks.Sum(t => t.Points); return new UserAnswerTaskListOutput { FirstLevelCatalogName = firstLevelCatalogName, CatalogName = catalog.Name ?? string.Empty, StartPage = startPage, EndPage = endPage, TaskNames = taskNames, Status = status, AnswerMinutes = answerMinutes, MedalCount = userMedalCount, TotalGrowthPoints = totalGrowthPoints, TotalPoints = totalPoints }; } /// /// ============================================ /// 方法思路:获取跨页题目详情 /// 1. 判断是否跨页题目:journalPageTaskId != GroupId 则为跨页题目 /// 2. 跨页题目处理:根据GroupId查询所有相关任务 /// 3. 图片数组获取逻辑: /// - 已答题:使用用户上传的PageAnswerUrl数组 /// - 未答题:使用任务的TaskUrl数组 /// 4. 栏目名称获取: /// - 二级栏目名称:通过页面关联的JournalCatalogId查询 /// - 一级栏目名称:通过二级栏目的ParentId查询 /// ============================================ /// /// 任务ID /// 用户ID /// 跨页题目详情 public async Task GetCrossPageTaskAsync(long journalPageTaskId, long userId) { // 1. 查询任务信息 var task = await taskRepository.Context.Queryable() .Where(t => t.Id == journalPageTaskId && !t.IsDeleted) .FirstAsync(); if (task == null) { return null; } // ============================================ // 【跨页题目判定】 // 判断标准:journalPageTaskId != task.GroupId 则为跨页题目 // 跨页题目有多个任务,但共享同一个 GroupId // ============================================ var isCrossPage = journalPageTaskId != task.GroupId; // ============================================ // 【获取相关任务】 // 跨页题目:根据 GroupId 查询所有相关任务 // 非跨页题目:只返回当前任务 // ============================================ List relatedTasks; if (isCrossPage) { // 跨页题目:根据 GroupId 查询所有数据 relatedTasks = await taskRepository.Context.Queryable() .Where(t => t.GroupId == task.GroupId && t.JournalId == task.JournalId && !t.IsDeleted) .OrderBy(t => t.Id) .ToListAsync(); } else { // 非跨页题目:只返回当前任务 relatedTasks = new List { task }; } // ============================================ // 【获取栏目名称】 // 二级栏目名称:通过页面关联的 JournalCatalogId 查询 // 一级栏目名称:通过二级栏目的 ParentId 查询 // ============================================ var page = await pageRepository.Context.Queryable() .Where(p => p.Id == task.JournalPageId && !p.IsDeleted) .FirstAsync(); string catalogName = string.Empty; string firstLevelCatalogName = string.Empty; if (page != null) { // 获取二级栏目/文章名称 var catalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == page.JournalCatalogId && !c.IsDeleted) .FirstAsync(); catalogName = catalog?.Name ?? string.Empty; // 获取一级栏目名称 if (catalog != null && catalog.ParentId > 0) { var firstLevelCatalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalog.ParentId && !c.IsDeleted) .FirstAsync(); firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty; } } // ============================================ // 【获取图片数组】 // 已答题:使用用户上传的 PageAnswerUrl 数组 // 未答题:使用任务的 TaskUrl 数组 // ============================================ var images = new List(); if (isCrossPage) { // 跨页题目:查询该 GroupId 下所有用户的答题记录 var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskGroupId == task.GroupId && a.UserId == userId && !a.IsDeleted) .OrderBy(a => a.JournalPageTaskId) .ToListAsync(); if (userAnswers.Any(a => !string.IsNullOrEmpty(a.PageAnswerUrl))) { // 已答题:使用 PageAnswerUrl 数组 images.AddRange(userAnswers.Where(a => !string.IsNullOrEmpty(a.PageAnswerUrl)).Select(a => a.PageAnswerUrl)); } else { // 未答题,使用TaskUrl数组 foreach (var relatedTask in relatedTasks) { if (!string.IsNullOrEmpty(relatedTask.TaskUrl)) { images.Add(relatedTask.TaskUrl); } } } } else { // 非跨页题目:查询单条答题记录 var userAnswer = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskId == journalPageTaskId && a.UserId == userId && !a.IsDeleted) .FirstAsync(); if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl)) { // 已答题,使用PageAnswerUrl images.Add(userAnswer.PageAnswerUrl); } else { // 未答题,使用TaskUrl if (!string.IsNullOrEmpty(task.TaskUrl)) { images.Add(task.TaskUrl); } } } return new CrossPageTaskOutput { FirstLevelCatalogName = firstLevelCatalogName, CatalogName = catalogName, TaskName = task.Task ?? string.Empty, Images = images, IsCrossPage = isCrossPage }; } /// /// ============================================ /// 方法思路:领取任务积分 /// 1. 根据 GroupId 查询任务信息,判断是否跨页题目 /// - 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId /// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId /// 2. 通过 JournalPageTaskGroupId 查询用户答题记录 /// 3. 跨页题目完成校验:必须该 GroupId 下所有任务都完成(Status=1) /// 普通题目校验:只需要该任务完成(Status=1) /// 4. 重复领取校验:通过 PointsRecord 表的 RelatedId + ChangeType 判断 /// 5. 积分计算: /// - 跨页题目:累计该 GroupId 下所有任务的积分 /// - 普通题目:取当前任务的积分 /// 6. 得分计算:从 JournalPageTaskUserAnswer 表获取 Score /// ============================================ /// /// 任务分组Id(JournalPageTask.GroupId) /// 用户ID /// 领取积分结果 public async Task ClaimTaskPointsAsync(long journalPageTaskGroupId, long userId) { // ============================================ // 1. 根据 GroupId 查询所有相关任务 // ============================================ var relatedTasks = await taskRepository.Context.Queryable() .Where(t => t.GroupId == journalPageTaskGroupId && !t.IsDeleted) .ToListAsync(); if (relatedTasks == null || relatedTasks.Count == 0) { throw new BusinessException("任务不存在", 404); } // ============================================ // 2. 判断是否跨页题目 // 普通题目:该 GroupId 下只有1个任务,且该任务的 Id == GroupId // 跨页题目:该 GroupId 下有多个任务,或任务的 Id != GroupId // ============================================ bool isCrossPage; JournalPageTask mainTask; if (relatedTasks.Count == 1 && relatedTasks[0].Id == journalPageTaskGroupId) { // 普通题目:只有1个任务,且 Id == GroupId isCrossPage = false; mainTask = relatedTasks[0]; } else { // 跨页题目:有多个任务,或者 Id != GroupId isCrossPage = true; // 取 Id == GroupId 的任务作为主任务(跨页题目的第一条记录) mainTask = relatedTasks.FirstOrDefault(t => t.Id == journalPageTaskGroupId) ?? relatedTasks[0]; } // ============================================ // 3. 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询) // ============================================ var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskGroupId == journalPageTaskGroupId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); if (userAnswers == null || userAnswers.Count == 0) { throw new BusinessException("请先完成答题再领取积分", 400); } // ============================================ // 4. 跨页题目完成校验 // 跨页题目必须该 GroupId 下所有任务都完成(Status=1)才能领取积分 // 普通题目只需要该任务完成(Status=1)即可领取 // ============================================ if (isCrossPage) { // 跨页题目:检查该 GroupId 下每个任务是否都有 Status=1 的答题记录 foreach (var task in relatedTasks) { var completedAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id && a.Status == (int)UserAnswerStatusEnum.Complete); if (completedAnswer == null) { throw new BusinessException("跨页题目需要完成所有任务才能领取积分", 400); } } } else { // 普通题目:只需要 Status=1 即可 if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete) { throw new BusinessException("请先完成答题再领取积分", 400); } } // ============================================ // 5. 积分计算 // 跨页题目:累计该 GroupId 下所有任务的积分 // 普通题目:取当前任务的积分 // ============================================ int totalPoints = 0; if (isCrossPage) { // 跨页题目:累计所有任务的积分 totalPoints = relatedTasks.Sum(t => t.Points); } else { // 普通题目:取主任务的积分 totalPoints = mainTask.Points; } if (totalPoints <= 0) { throw new BusinessException("该任务无积分可领取", 400); } // ============================================ // 6. 得分计算 // 从 JournalPageTaskUserAnswer 表获取 Score // 【关键】Score 取 JournalPageTaskId == JournalPageTaskGroupId 的分数 // 跨页题目:取主任务(Id == GroupId)的分数,不是累加所有任务的分数 // 普通题目:取当前任务对应答题记录的分数 // ============================================ float totalScore = 0; if (isCrossPage) { // 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数 totalScore = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == journalPageTaskGroupId)?.Score ?? 0; } else { // 普通题目:取当前任务对应答题记录的分数 totalScore = userAnswers.FirstOrDefault()?.Score ?? 0; } // ============================================ // 7. 重复领取校验 // 通过 PointsRecord 表的 RelatedId + ChangeType 判断是否已领取 // 使用 GroupId 作为 RelatedId // ============================================ var existingRecord = await answerRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted) .Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == journalPageTaskGroupId) .FirstAsync(); if (existingRecord != null) { throw new BusinessException("积分已领取,请勿重复领取", 400); } // ============================================ // 8. 调用积分服务添加积分记录 // ============================================ var addPointsResult = await pointsService.AddPointsAsync(new AddPointsInput { UserId = userId, Amount = totalPoints, ChangeType = PointsChangeTypeEnum.TaskReward, RelatedId = journalPageTaskGroupId, Description = $"完成任务奖励:{mainTask.Task}", OperatorName = "系统" }); return new ClaimTaskPointsOutput { RecordId = addPointsResult.RecordId, PreviousBalance = addPointsResult.PreviousBalance, NewBalance = addPointsResult.NewBalance, AddedAmount = addPointsResult.AddedAmount, TaskName = mainTask.Task ?? string.Empty, Score = totalScore }; } /// /// ============================================ /// 方法思路:批量领取任务积分(一键领取) /// 1. 遍历每个传入的 GroupId: /// a. 根据 GroupId 查询所有相关任务,判断是否跨页题目 /// - 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId /// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId /// b. 通过 JournalPageTaskGroupId 查询用户答题记录 /// c. 跨页题目完成校验:必须该 GroupId 下所有任务都完成(Status=1) /// d. 重复领取校验 /// e. 计算积分、成长值、得分: /// - 跨页题目:所有计算都只算一次(第一个任务的积分/成长值/得分) /// - 普通题目:正常计算 /// 2. 返回结果包含: /// - 成功数量和失败数量 /// - 成功详情列表(每个GroupId的名称、积分、成长值、得分) /// - 失败详情列表(每个GroupId的错误原因) /// - 总积分、总成长值、领取后余额 /// ============================================ /// /// 批量领取输入(GroupId列表) /// 用户ID /// 批量领取结果 public async Task BatchClaimPointsAsync(BatchClaimPointsInput input, long userId) { var result = new BatchClaimPointsOutput(); if (input == null || input.GroupIds == null || input.GroupIds.Count == 0) { throw new BusinessException("任务分组Id列表不能为空", 400); } var groupIds = input.GroupIds.Distinct().ToList(); // 1. 批量查询已领取的积分记录(用于重复领取校验) var claimedRecords = await answerRepository.Context.Queryable() .Where(r => groupIds.Contains((long)r.RelatedId!) && r.UserId == userId && !r.IsDeleted) .Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString()) .ToListAsync(); var claimedGroupIds = claimedRecords.Select(r => r.RelatedId!.Value).Distinct().ToHashSet(); // 2. 逐个处理每个 GroupId int totalPoints = 0; int totalGrowthPoints = 0; int currentBalance = 0; foreach (var groupId in groupIds) { try { // ============================================ // 2.1 根据 GroupId 查询所有相关任务 // ============================================ var relatedTasks = await taskRepository.Context.Queryable() .Where(t => t.GroupId == groupId && !t.IsDeleted) .ToListAsync(); if (relatedTasks == null || relatedTasks.Count == 0) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "任务不存在" }); continue; } // ============================================ // 2.2 判断是否跨页题目 // 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId // 跨页题目:有多个任务,或 Id != GroupId // ============================================ bool isCrossPage; JournalPageTask mainTask; if (relatedTasks.Count == 1 && relatedTasks[0].Id == groupId) { // 普通题目 isCrossPage = false; mainTask = relatedTasks[0]; } else { // 跨页题目 isCrossPage = true; // 取 Id == GroupId 的任务作为主任务 mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0]; } // ============================================ // 2.3 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询) // ============================================ var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); if (userAnswers == null || userAnswers.Count == 0) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "请先完成答题再领取积分" }); continue; } // ============================================ // 2.4 跨页题目完成校验 // 跨页题目必须该 GroupId 下所有任务都完成(Status=1) // 普通题目只需要该任务完成(Status=1) // ============================================ if (isCrossPage) { // 跨页题目:检查该 GroupId 下每个任务是否都有 Status=1 的答题记录 foreach (var task in relatedTasks) { var completedAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id && a.Status == (int)UserAnswerStatusEnum.Complete); if (completedAnswer == null) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "跨页题目需要完成所有任务才能领取积分" }); continue; } } } else { // 普通题目:只需要 Status=1 即可 if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "请先完成答题再领取积分" }); continue; } } // ============================================ // 2.5 重复领取校验 // ============================================ if (claimedGroupIds.Contains(groupId)) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "积分已领取,请勿重复领取" }); continue; } // ============================================ // 2.6 计算积分、成长值、得分 // 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值) // Score 取 JournalPageTaskId == JournalPageTaskGroupId 的分数,不是累加所有任务的分数 // ============================================ int points; int growthPoints; float score; if (isCrossPage) { // 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的积分、成长值、得分 // mainTask 已经是 Id == GroupId 的任务,所以 mainTask.Id == groupId points = mainTask.Points; growthPoints = mainTask.GrowthPoint ?? 0; // 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数 score = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId)?.Score ?? 0; } else { // 普通题目:正常计算 points = mainTask.Points; growthPoints = mainTask.GrowthPoint ?? 0; score = userAnswers.FirstOrDefault()?.Score ?? 0; } if (points <= 0) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "该任务无积分可领取" }); continue; } // 获取任务名称 var taskName = mainTask.Task ?? string.Empty; // 调用积分服务增加积分 var addResult = await pointsService.AddPointsAsync(new AddPointsInput { UserId = userId, Amount = points, ChangeType = PointsChangeTypeEnum.TaskReward, RelatedId = groupId, Description = $"完成任务奖励:{taskName}", OperatorName = "系统" }); // 累计总积分和总成长值 totalPoints += points; totalGrowthPoints += growthPoints; currentBalance = addResult.NewBalance; result.SuccessCount++; // 添加成功详情到成功列表 result.SuccessItems.Add(new BatchClaimSuccessItem { GroupId = groupId, TaskName = taskName, Points = points, GrowthPoints = growthPoints, Score = score }); } catch (Exception ex) { // 捕获异常并添加到失败列表,不中断其他任务的处理 result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = ex.Message }); } } result.FailCount = result.FailItems.Count; result.TotalPoints = totalPoints; result.TotalGrowthPoints = totalGrowthPoints; result.BalanceAfter = currentBalance; return result; } /// /// ============================================ /// 方法思路:获取杂志列表 /// 1. 查询所有已归档的杂志(Status=Archive) /// 2. 查询用户已绑定的杂志ID列表 /// 3. 根据queryType过滤: /// - "Bound":只返回已绑定的杂志 /// - "Unbound":只返回未绑定的杂志 /// - 其他:返回所有杂志 /// 4. 返回每个杂志的ID、名称、标题、封面、是否已绑定 /// ============================================ /// /// 用户ID /// 查询类型:Bound/Unbound/空(全部) /// 杂志列表 public async Task> GetJournalListAsync(long userId, string? queryType) { var allJournals = await journalRepository.Queryable() .Where(j => !j.IsDeleted && j.Status == (int)JournalStatusEnum.Archive) .OrderByDescending(j => j.CreatedAt) .ToListAsync(); var userJournals = await userJournalRepository.Context.Queryable() .Where(uj => uj.UserId == userId && !uj.IsDeleted) .Select(uj => uj.JournalId) .ToListAsync(); var boundJournalIds = userJournals.ToHashSet(); IEnumerable filteredJournals = queryType switch { "Bound" => allJournals.Where(j => boundJournalIds.Contains(j.Id)), "Unbound" => allJournals.Where(j => !boundJournalIds.Contains(j.Id)), _ => allJournals }; var result = filteredJournals.Select(j => new JournalListOutput { Id = j.Id, Name = j.Name ?? string.Empty, Title = j.Title ?? string.Empty, Cover = j.Cover ?? string.Empty, IsBound = boundJournalIds.Contains(j.Id) }).ToList(); return result; } /// /// ============================================ /// 方法思路:新增学生 /// 1. 创建新的Users记录 /// 2. WxUserId设置为当前登录人ID(wxUserId参数传入) /// 3. 初始化积分和成长值为0 /// 4. 用户类型设置为普通用户(Normal) /// 5. 插入数据库并返回结果 /// ============================================ /// /// 新增学生输入(姓名、头像URL) /// 当前登录人ID(微信用户ID) /// 是否插入成功 public async Task AddStudentAsync(AddStudentInput input, long wxUserId) { // 创建新的学生记录 var student = new Users { WxUserId = wxUserId, Name = input.Name, AvatarUrl = input.AvatarUrl, GrowthPoints = 0, Points = 0, Type = UsersTypeEnum.Normal, IsLastOnline = false }; // 插入数据库 return await usersRepository.InsertAsync(student); } }