using Microsoft.Extensions.Logging; using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Common; using QYZH.InteractiveMagazine.Models.Dto; 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 userJournalRepository, BaseRepository usersRepository, BaseRepository checkInRecordRepository, IPointsService pointsService, ILogger logger) : BaseRepository, IUserAnswerTaskService { /// /// ============================================ /// 方法思路: /// 1. 批量预加载策略:为避免N+1查询问题,先按期刊维度批量加载所有关联数据 /// 2. 期刊来源: /// - 用户绑定的杂志(UserJournal表) /// - 用户有作答记录的杂志 /// 合并去重后返回 /// 3. 跨页题目处理核心: /// - 跨页题目定义:JournalPageTask.Id != GroupId(同一个GroupId下有多个任务) /// - 进度计算:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分(取第一个任务的积分) /// 4. 状态判定逻辑: /// - 未开始:无任何作答记录 /// - 进行中:有作答记录但未全部完成 /// - 已完成:所有GroupId都完成 /// 5. 用户未作答任何任务时: /// - 仍返回绑定杂志的进度信息 /// - 进度、成长值、积分为0 /// - 连续打卡、剩余篇数正常计算 /// ============================================ /// /// 用户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 都完成 // ============================================ #region 1. 查询用户作答记录 // 1. 查询用户的所有作答记录 var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.UserId == userId && !a.IsDeleted) .ToListAsync(); #endregion #region 2. 收集期刊ID(绑定+作答去重) // ============================================ // 2. 收集所有需要展示的期刊ID // - 用户绑定的杂志(UserJournal表) // - 用户有作答记录的杂志 // 合并去重 // ============================================ var boundJournalIds = await userJournalRepository.Context.Queryable() .Where(uj => uj.UserId == userId && !uj.IsDeleted) .Select(uj => uj.JournalId) .ToListAsync(); var answeredJournalIds = userAnswers.Select(a => a.JournalId).Distinct().ToList(); var journalIds = boundJournalIds .Concat(answeredJournalIds) .Distinct() .ToList(); if (journalIds.Count == 0) { return new List(); } #endregion #region 3. 批量预加载关联数据 // 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(); #endregion #region 4. 查询连续打卡天数 // 4. 查询用户连续打卡天数 var consecutiveDays = await CalculateConsecutiveDaysAsync(userId); #endregion #region 5. 提取用户已作答/已完成任务ID集合 // 5. 提取用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet(); var userAnsweredPageIds = userAnswers.Select(a => a.JournalPageId).ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet(); #endregion #region 6. 按期刊分组聚合并返回结果 // 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; #endregion } /// /// ============================================ /// 方法思路:计算用户连续打卡天数 /// 1. 获取今天和昨天的日期 /// 2. 查询昨天是否有打卡记录 /// 3. 如果昨天没有打卡记录,返回0(连续打卡中断) /// 4. 如果昨天有打卡记录: /// - 查询今天是否有打卡记录 /// - 今天有记录:返回今天的连续天数 /// - 今天无记录:返回昨天的连续天数 /// ============================================ /// /// 用户ID /// 连续打卡天数 private async Task CalculateConsecutiveDaysAsync(long userId) { var today = DateTime.Now.Date; var yesterday = today.AddDays(-1); // 查询昨天是否有打卡记录 var yesterdayRecord = await checkInRecordRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate.Date == yesterday) .FirstAsync(); // 如果昨天没有打卡记录,连续天数为0(连续打卡中断) if (yesterdayRecord == null) { return 0; } // 昨天有打卡记录,检查今天是否已打卡 var todayRecord = await checkInRecordRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate.Date == today) .FirstAsync(); // 今天已打卡:返回今天的连续天数;今天未打卡:返回昨天的连续天数 return todayRecord?.ConsecutiveDays ?? yesterdayRecord.ConsecutiveDays; } /// /// ============================================ /// 方法思路:获取目录列表(按一级目录分组展示) /// 1. 批量预加载:该期刊下所有目录、页面、任务、用户作答记录 /// 2. 目录结构处理: /// - 一级目录:ParentId=0 且 Type=Unit /// - 二级目录:ParentId=一级目录Id 且 Type=Unit /// - 文章目录:Type=Page /// 3. 【关键】如果一级目录下没有二级栏目(Type=Unit的子目录), /// 则该一级目录不参与任何计算,直接跳过 /// 4. 跨页题目处理: /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分 /// - 已作答判断:只要有一个任务作答就算该GroupId已作答(包含进行中和已完成) /// 5. 状态判定: /// - 未开始:无任何GroupId被作答 /// - 进行中:有GroupId被作答但未全部完成 /// - 已完成:所有GroupId都完成 /// ============================================ /// /// 期刊ID /// 用户ID /// 目录列表(按一级目录分组) public async Task> GetCatalogListAsync(long journalId, long userId) { #region 1. 查询期刊 // 1. 查询期刊 var journal = await journalRepository.Context.Queryable() .Where(j => j.Id == journalId && !j.IsDeleted) .FirstAsync(); if (journal == null) { return new List(); } #endregion #region 2. 批量加载关联数据 // 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(); #endregion #region 3. 用户已作答/已完成任务ID集合 // 3. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet(); #endregion #region 4. 按一级目录分组计算 // 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(); 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) // ============================================ int articleCount = 0; var children = new List(); if (secondLevelCatalogs.Count > 0) { // 有二级单元:按二级单元分组 // 先统计一级栏目下直接的文章数(Type=Page,ParentId=一级栏目Id) articleCount = pageCatalogs.Count; foreach (var secondCatalog in secondLevelCatalogs) { // 二级单元下的 Type=Page 子目录 var subPageCatalogs = allCatalogs .Where(c => c.ParentId == secondCatalog.Id && c.Type == JournalCatalogTypeEnum.Page) .ToList(); // 累加该二级栏目下的文章数 articleCount += subPageCatalogs.Count; 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 { // 没有二级单元:只统计一级栏目下直接的文章数,Children为空集合 articleCount = pageCatalogs.Count; // Children保持为空集合,不添加任何子项 } // 计算该一级目录的总成长值和总积分(基于所有文章页面的任务) 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 }); } #endregion #region 5. 返回结果 return result; #endregion } /// /// ============================================ /// 方法思路:获取一级目录概览(含二级目录详情和任务完成状态) /// 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) { #region 1. 查询一级目录 // 1. 查询一级目录 var firstCatalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalogId && !c.IsDeleted) .FirstAsync(); if (firstCatalog == null) { return null; } #endregion #region 2. 批量加载关联数据 // 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(); #endregion #region 3. 用户已作答/已完成任务ID集合 // 3. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet(); #endregion #region 4. 获取一级栏目下所有Type=Page子目录 // 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; #endregion #region 5. 总任务数与子目录详情 // 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 }); } #endregion #region 6. 返回结果 return new CatalogOverviewOutput { CatalogId = firstCatalog.Id, FirstLevelCatalogName = firstCatalog.Name ?? string.Empty, PageCount = articleCount, TotalTaskCount = totalTaskCount, SubCatalogs = subCatalogs }; #endregion } /// /// ============================================ /// 方法思路:获取用户答题任务列表 /// 1. 根据二级栏目ID查询栏目基本信息(一级栏目名称、栏目名称、页码范围 /// 2. 按GroupId分组,每个GroupId对应一个任务项 /// 3. 每个任务项包含:任务名称、总成长值、总积分、状态 /// 4. 【跨页题目处理】 /// - 是否跨页:Id != GroupId /// - 任务名称:取 Id == GroupId 的主任务的Task字段 /// - 成长值/积分:取主任务的,每个GroupId只算一次 /// - 状态判断:必须该GroupId下所有任务都完成(Status=1)才算该GroupId完成 /// ============================================ /// 二级栏目ID/param> /// 用户ID /// /// 用户答题任务列表信息 public async Task GetUserAnswerTaskListAsync(long catalogId, long userId) { #region 1. 查询二级栏目目录 // 1. 查询目录(二级栏目) var catalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalogId && !c.IsDeleted) .FirstAsync(); if (catalog == null) { return null; } #endregion #region 2. 获取一级栏目名称 // ============================================ // 【获取一级栏目名称】 // 通过 ParentId 关联查询一级栏目名称 // ============================================ 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; } #endregion #region 3. 批量加载关联数据 // ============================================ // 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(); #endregion #region 4. 获取该目录下的页面ID及任务 // 3. 获取该目录下的所有页面ID var pageIds = allPages.Select(p => p.Id).ToHashSet(); // 4. 该目录下的所有任务(仅用于筛选哪些GroupId属于当前栏目) var catalogTasks = allTasks.Where(t => pageIds.Contains(t.JournalPageId)).ToList(); // 【跨页题目-关键】 // 跨页题目的任务可能分布在不同页面(甚至不同栏目)下 // 所以需要从整个期刊的所有任务(allTasks)中按GroupId获取完整的任务组 // 否则无法正确判断跨页题目是否全部完成 var catalogGroupIds = catalogTasks.Select(t => t.GroupId).ToHashSet(); // 5. 用户已作答的任务ID集合(包含进行中和已完成) var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet(); // 用户已完成的任务ID集合(Status=1表示已完成) var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet(); // 6. 计算页码范围 var startPage = allPages.Count > 0 ? allPages.Min(p => p.PageNum) : 0; var endPage = allPages.Count > 0 ? allPages.Max(p => p.PageNum) : 0; #endregion #region 5. 按GroupId分组生成任务列表 // ============================================ // 【跨页题目-按GroupId分组生成任务列表】 // 关键:从整个期刊的所有任务中按GroupId取完整分组 // 每个GroupId对应一个任务项 // - 任务名称:取 Id == GroupId 的主任务的Task字段 // - 成长值/积分:取主任务的,每个GroupId只算一次 // - 状态: // - 未开始:该GroupId下无任何任务被作答 // - 已完成:该GroupId下所有任务都完成(Status=1) // - 进行中:有作答但未全部完成 // ============================================ var taskItems = new List(); // 从整个期刊的所有任务中,筛选出属于当前栏目的GroupId的完整分组 var groupTasks = allTasks .Where(t => catalogGroupIds.Contains(t.GroupId)) .GroupBy(t => t.GroupId) .ToList(); foreach (var group in groupTasks) { var groupId = group.Key; // 取主任务(Id == GroupId) var mainTask = group.FirstOrDefault(t => t.Id == groupId) ?? group.First(); // 计算该GroupId下所有任务的答题时间(秒) var taskIds = group.Select(t => t.Id).ToHashSet(); var groupAnswerSeconds = userAnswers .Where(a => taskIds.Contains(a.JournalPageTaskId)) .Sum(a => a.AnswerSeconds); var groupAnswerMinutes = Math.Round(groupAnswerSeconds / 60.0, 2); // 判断该GroupId是否有作答记录 bool hasAnswer = group.Any(t => userAnsweredTaskIds.Contains(t.Id)); // 判断该GroupId是否全部完成 bool isAllCompleted = group.All(t => userCompletedTaskIds.Contains(t.Id)); // 计算状态 string taskStatus; if (!hasAnswer) taskStatus = "未开始"; else if (isAllCompleted) taskStatus = "已完成"; else taskStatus = "进行中"; taskItems.Add(new UserAnswerTaskItemOutput { GroupId = groupId, TaskName = mainTask.Task ?? string.Empty, TotalGrowthPoints = mainTask.GrowthPoint ?? 0, TotalPoints = mainTask.Points, AnswerMinutes = groupAnswerMinutes, Status = taskStatus }); } #endregion #region 6. 返回结果 return new UserAnswerTaskListOutput { FirstLevelCatalogName = firstLevelCatalogName, CatalogName = catalog.Name ?? string.Empty, StartPage = startPage, EndPage = endPage, Tasks = taskItems }; #endregion } /// /// ============================================ /// 方法思路:获取跨页题目详情 /// 1. 根据 groupId 查询该分组下的所有任务 /// 2. 跨页题目判定: /// - 任务数 > 1 → 跨页题目 /// - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入) /// - 任务数 == 1 且 Id == groupId → 普通题目 /// 3. 图片数组获取逻辑: /// - 已答题:使用用户上传的 PageAnswerUrl 数组 /// - 未答题:使用任务的 TaskUrl 数组补齐 /// 4. 栏目名称获取: /// - 二级栏目名称:通过页面关联的 JournalCatalogId 查询 /// - 一级栏目名称:通过二级栏目的 ParentId 查询 /// 5. 是否可领取积分判断: /// - 任务必须已完成(Status=1) /// - 跨页题目:所有任务都完成才能领取 /// - 普通题目:当前任务完成即可领取 /// - 且未领取过积分 /// ============================================ /// /// 任务分组ID(GroupId) /// 用户ID /// 跨页题目详情 public async Task GetCrossPageTaskAsync(long groupId, long userId) { // 1. 查询该 GroupId 下的所有任务 var allTasks = await taskRepository.Context.Queryable() .Where(t => t.GroupId == groupId && !t.IsDeleted) .OrderBy(t => t.Id) .ToListAsync(); if (allTasks == null || allTasks.Count == 0) { return null; } #region 1. 跨页题目判定 // ============================================ // 【跨页题目判定】 // 判断标准: // - 任务数 > 1 → 跨页题目 // - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入) // - 任务数 == 1 且 Id == groupId → 普通题目 // ============================================ bool isCrossPage = allTasks.Count > 1 || (allTasks.Count == 1 && allTasks[0].Id != groupId); // 获取主任务(Id == GroupId),用于跨页题目获取任务名称、积分等 var mainTask = allTasks.FirstOrDefault(t => t.Id == groupId); // 获取任一任务作为基础信息(用于栏目查询等) var baseTask = allTasks[0]; #endregion #region 2. 获取相关任务列表 // ============================================ // 【获取相关任务列表】 // 跨页题目:allTasks 即为相关任务列表 // 非跨页题目:单任务列表 // ============================================ List relatedTasks = allTasks; #endregion #region 3. 获取栏目名称(一级/二级) // ============================================ // 【获取栏目名称】 // 二级栏目名称:通过页面关联的 JournalCatalogId 查询 // 一级栏目名称:通过二级栏目的 ParentId 查询 // ============================================ var page = await pageRepository.Context.Queryable() .Where(p => p.Id == baseTask.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; } } #endregion #region 4. 获取图片数组(Images) // ============================================ // 【获取图片数组】 // 核心逻辑:已答题返回 PageAnswerUrl,未答题返回 TaskUrl 补齐 // 跨页题目: // - 用户答了部分:已答页用 PageAnswerUrl,未答页用 TaskUrl 补齐 // - 用户全部作答:全部用 PageAnswerUrl // - 用户未作答:全部用 TaskUrl // - 按主页面页码(PageNum)升序排列,确保拼图顺序正确 // 非跨页题目:取单条答题记录的 PageAnswerUrl,未答题返回空集合 // ============================================ var images = new List(); // 统一查询用户答题记录(用于图片获取和后续领取积分判断) List userAnswers; if (isCrossPage) { // 跨页题目:查询该 GroupId 下所有用户的答题记录 userAnswers = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted) .OrderBy(a => a.JournalPageTaskId) .ToListAsync(); // ============================================ // 【跨页题目图片获取逻辑】 // 1. 收集相关任务所在页面的页码信息 // 2. 遍历每个相关任务:已答用 PageAnswerUrl,未答用 TaskUrl 补齐 // 3. 按页码升序排序后输出,确保拼图顺序正确 // ============================================ // 查询相关任务对应的页面信息(用于排序) var pageIds = relatedTasks.Select(t => t.JournalPageId).Distinct().ToList(); var pageInfoList = await pageRepository.Context.Queryable() .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted) .ToListAsync(); var pageInfoDict = pageInfoList.ToDictionary(p => p.Id, p => p.PageNum); // 组装图片项:(页码, 图片地址) var imageItems = new List<(int PageNum, string Url)>(); foreach (var relatedTask in relatedTasks) { var pageNum = pageInfoDict.TryGetValue(relatedTask.JournalPageId, out var num) ? num : 0; var userAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == relatedTask.Id); string imageUrl; if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl)) { // 已答题:使用 PageAnswerUrl imageUrl = userAnswer.PageAnswerUrl; } else if (!string.IsNullOrEmpty(relatedTask.TaskUrl)) { // 未答题或无 PageAnswerUrl:使用任务的 TaskUrl 补齐 imageUrl = relatedTask.TaskUrl; } else { // 无任何图片可用,跳过 continue; } imageItems.Add((pageNum, imageUrl)); } // 按页码升序排序后输出图片地址 images.AddRange(imageItems.OrderBy(x => x.PageNum).Select(x => x.Url)); } else { // 非跨页题目:查询单条答题记录 var userAnswer = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskId == mainTask.Id && a.UserId == userId && !a.IsDeleted) .FirstAsync(); // 赋值给 userAnswers(用于后续领取积分判断) userAnswers = userAnswer != null ? new List { userAnswer } : new List(); if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl)) { // 已答题,使用 PageAnswerUrl images.Add(userAnswer.PageAnswerUrl); } // 未答题不返回任何图片,images 保持空集合 } #endregion #region 5. 获取页面作答点读图片集合(PageAnswerDotUrl) // ============================================ // 【获取页面作答点读图片地址集合】 // 规则:已答题且 PageAnswerDotUrl 有值才返回;未答题或字段为空则跳过 // 跨页题目:遍历所有相关任务,收集有答题记录的 PageAnswerDotUrl // 非跨页题目:取当前任务答题记录的 PageAnswerDotUrl(仅在有值时返回) // ============================================ var pageAnswerDotUrls = new List(); if (isCrossPage) { // 跨页题目:遍历相关任务,收集有答题记录的 PageAnswerDotUrl foreach (var relatedTask in relatedTasks) { var userAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == relatedTask.Id); if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerDotUrl)) { pageAnswerDotUrls.Add(userAnswer.PageAnswerDotUrl); } } } else { // 非跨页题目:取当前任务答题记录的 PageAnswerDotUrl // 仅在已答题且 PageAnswerDotUrl 有值时返回 if (userAnswers != null && userAnswers.Count > 0) { var userAnswer = userAnswers.FirstOrDefault(); if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerDotUrl)) { pageAnswerDotUrls.Add(userAnswer.PageAnswerDotUrl); } } } #endregion #region 6. 判断是否可领取积分 // ============================================ // 【是否可领取积分判断】 // 条件:任务已完成(Status=1)且未领取过积分 // 跨页题目:所有任务都完成才能领取 // 普通题目:当前任务完成即可领取 // ============================================ bool canClaimPoints = false; // 查询是否已领取过积分 var existingRecord = await answerRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted) .Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == groupId) .AnyAsync(); if (!existingRecord) { // 未领取过积分,检查任务是否完成 if (isCrossPage) { // 跨页题目:检查该 GroupId 下所有任务是否都完成(Status=1) var allCompleted = relatedTasks.All(t => userAnswers.Any(a => a.JournalPageTaskId == t.Id && a.Status == (int)UserAnswerStatusEnum.Complete)); canClaimPoints = allCompleted; } else { // 普通题目:检查当前任务是否完成(Status=1) var userAnswer = await answerRepository.Context.Queryable() .Where(a => a.JournalPageTaskId == mainTask.Id && a.UserId == userId && !a.IsDeleted) .FirstAsync(); canClaimPoints = userAnswer?.Status == (int)UserAnswerStatusEnum.Complete; } } #endregion #region 7. 获取任务名称 // ============================================ // 【获取任务名称】 // 跨页题目:取主任务(Id == GroupId)的 Task 字段 // 普通题目:取当前任务的 Task 字段 // ============================================ string taskName; if (isCrossPage) { taskName = mainTask?.Task ?? string.Empty; } else { taskName = mainTask?.Task ?? string.Empty; } #endregion #region 8. 返回结果 return new CrossPageTaskOutput { FirstLevelCatalogName = firstLevelCatalogName, CatalogName = catalogName, TaskName = taskName, Images = images, PageAnswerDotUrl = pageAnswerDotUrls, IsCrossPage = isCrossPage, CanClaimPoints = canClaimPoints }; #endregion } /// /// ============================================ /// 方法思路:领取任务积分 /// 1. 根据 GroupId 查询任务信息,判断是否跨页题目 /// - 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId /// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId /// 2. 通过 groupId 查询用户答题记录 /// 3. 跨页题目完成校验:必须该 GroupId 下所有任务都完成(Status=1) /// 普通题目校验:只需要该任务完成(Status=1) /// 4. 重复领取校验:通过 PointsRecord 表的 RelatedId + ChangeType 判断 /// 5. 积分计算: /// - 跨页题目:累计该 GroupId 下所有任务的积分 /// - 普通题目:取当前任务的积分 /// 6. 得分计算:从 JournalPageTaskUserAnswer 表获取 Score /// ============================================ /// /// 任务分组Id(JournalPageTask.GroupId) /// 用户ID /// 领取积分结果 public async Task ClaimTaskPointsAsync(long groupId, long userId) { #region 1. 根据GroupId查询所有相关任务 // ============================================ // 1. 根据 GroupId 查询所有相关任务 // ============================================ var relatedTasks = await taskRepository.Context.Queryable() .Where(t => t.GroupId == groupId && !t.IsDeleted) .ToListAsync(); if (relatedTasks == null || relatedTasks.Count == 0) { throw new BusinessException("任务不存在", ResultCode.NOT_FOUND); } #endregion #region 2. 判断是否跨页题目 // ============================================ // 2. 判断是否跨页题目 // 普通题目:该 GroupId 下只有1个任务,且该任务的 Id == GroupId // 跨页题目:该 GroupId 下有多个任务,或任务的 Id != GroupId // ============================================ bool isCrossPage; JournalPageTask mainTask; if (relatedTasks.Count == 1 && relatedTasks[0].Id == groupId) { // 普通题目:只有1个任务,且 Id == GroupId isCrossPage = false; mainTask = relatedTasks[0]; } else { // 跨页题目:有多个任务,或者 Id != GroupId isCrossPage = true; // 取 Id == GroupId 的任务作为主任务(跨页题目的第一条记录) mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0]; } #endregion #region 3. 查询用户答题记录 // ============================================ // 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) { throw new BusinessException("请先完成答题再领取积分", ResultCode.BAD_REQUEST); } #endregion #region 4. 跨页题目完成校验 // ============================================ // 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("跨页题目需要完成所有任务才能领取积分", ResultCode.BAD_REQUEST); } } } else { // 普通题目:只需要 Status=1 即可 if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete) { throw new BusinessException("请先完成答题再领取积分", ResultCode.BAD_REQUEST); } } #endregion #region 5. 积分计算 // ============================================ // 5. 积分计算 // 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值) // 跨页题目:只取主任务(Id == GroupId)的积分,不是累加所有任务的积分 // 普通题目:取主任务的积分 // ============================================ int totalPoints = 0; if (isCrossPage) { // 跨页题目:只取主任务(Id == GroupId)的积分 totalPoints = mainTask.Points; } else { // 普通题目:取主任务的积分 totalPoints = mainTask.Points; } if (totalPoints <= 0) { throw new BusinessException("该任务无积分可领取", ResultCode.BAD_REQUEST); } #endregion #region 6. 得分计算 // ============================================ // 6. 得分计算 // 从 JournalPageTaskUserAnswer 表获取 Score // 【关键】Score 取 JournalPageTaskId == JournalPageTaskGroupId 的分数 // 跨页题目:取主任务(Id == GroupId)的分数,不是累加所有任务的分数 // 普通题目:取当前任务对应答题记录的分数 // ============================================ float totalScore = 0; if (isCrossPage) { // 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数 totalScore = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId)?.Score ?? 0; } else { // 普通题目:取当前任务对应答题记录的分数 totalScore = userAnswers.FirstOrDefault()?.Score ?? 0; } #endregion #region 7. 重复领取校验 // ============================================ // 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 == groupId) .FirstAsync(); if (existingRecord != null) { throw new BusinessException("积分已领取,请勿重复领取", ResultCode.BAD_REQUEST); } #endregion #region 8. 添加积分记录并返回结果 // ============================================ // 8. 调用积分服务添加积分记录 // ============================================ var addPointsResult = await pointsService.AddPointsAsync(new AddPointsInput { UserId = userId, Amount = totalPoints, ChangeType = PointsChangeTypeEnum.TaskReward, RelatedId = groupId, Description = $"完成任务奖励:{mainTask.Task}", OperatorName = "系统" }); // ============================================ // 【任务编号计算】 // 从 JournalPageTask.No 字段按 - 分割,取索引为 2 的部分 // No 最多出现索引为 3(即最多4段,0-3) // 例如:"A-B-C-D" → 分割后 ["A","B","C","D"] → 取索引2 = "C" // ============================================ string taskNo = string.Empty; if (!string.IsNullOrEmpty(mainTask.No)) { var parts = mainTask.No.Split('-'); if (parts.Length >= 3) { taskNo = parts[2]; } } return new ClaimTaskPointsOutput { RecordId = addPointsResult.RecordId, PreviousBalance = addPointsResult.PreviousBalance, NewBalance = addPointsResult.NewBalance, AddedAmount = addPointsResult.AddedAmount, TaskName = mainTask.Task ?? string.Empty, TaskNo = !string.IsNullOrWhiteSpace(taskNo) ? "任务:"+ taskNo : string.Empty, Score = totalScore }; #endregion } /// /// ============================================ /// 方法思路:批量领取任务积分(一键领取) /// 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) { #region 1. 初始化与参数校验 var result = new BatchClaimPointsOutput(); if (input == null || input.GroupIds == null || input.GroupIds.Count == 0) { throw new BusinessException("任务分组Id列表不能为空", ResultCode.BAD_REQUEST); } var groupIds = input.GroupIds.Distinct().ToList(); #endregion #region 2. 批量查询已领取的积分记录 // 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).ToHashSet(); #endregion #region 3. 逐个处理每个 GroupId // 2. 逐个处理每个 GroupId int totalPoints = 0; int totalGrowthPoints = 0; int currentBalance = 0; foreach (var groupId in groupIds) { try { #region 3.1 根据 GroupId 查询所有相关任务 // ============================================ // 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; } #endregion #region 3.2 判断是否跨页题目 // ============================================ // 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]; } #endregion #region 3.3 查询用户答题记录 // ============================================ // 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; } #endregion #region 3.4 跨页题目完成校验 // ============================================ // 2.4 跨页题目完成校验 // 跨页题目必须该 GroupId 下所有任务都完成(Status=1) // 普通题目只需要该任务完成(Status=1) // ============================================ bool isCompleted = true; // 完成状态标志 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) { isCompleted = false; break; // 发现未完成的任务,立即退出循环 } } if (!isCompleted) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "跨页题目需要完成所有任务才能领取积分" }); continue; // 跳出外层循环,处理下一个 groupId } } else { // 普通题目:只需要 Status=1 即可 if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "请先完成答题再领取积分" }); continue; } } #endregion #region 3.5 重复领取校验 // ============================================ // 2.5 重复领取校验 // ============================================ if (claimedGroupIds.Contains(groupId)) { result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = "积分已领取,请勿重复领取" }); continue; } #endregion #region 3.6 计算积分、成长值、得分 // ============================================ // 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; // ============================================ // 【任务编号计算】 // 从 JournalPageTask.No 字段按 - 分割,取索引为 2 的部分 // No 最多出现索引为 3(即最多4段,0-3) // 例如:"A-B-C-D" → 分割后 ["A","B","C","D"] → 取索引2 = "C" // ============================================ string taskNo = string.Empty; if (!string.IsNullOrEmpty(mainTask.No)) { var parts = mainTask.No.Split('-'); if (parts.Length >= 3) { taskNo = parts[2]; } } #endregion #region 3.7 调用积分服务并记录结果 // 调用积分服务增加积分 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, TaskNo = !string.IsNullOrWhiteSpace(taskNo) ? "任务:" + taskNo : string.Empty, Points = points, GrowthPoints = growthPoints, Score = score }); #endregion } catch (Exception ex) { // 捕获异常并添加到失败列表,不中断其他任务的处理 result.FailItems.Add(new BatchClaimFailItem { GroupId = groupId, Message = ex.Message }); } } #endregion #region 4. 组装返回结果 result.FailCount = result.FailItems.Count; result.TotalPoints = totalPoints; result.TotalGrowthPoints = totalGrowthPoints; result.BalanceAfter = currentBalance; return result; #endregion } /// /// ============================================ /// 方法思路:获取杂志列表 /// 1. 查询所有已归档的杂志(Status=Archive) /// 2. 查询用户已绑定的杂志ID列表 /// 3. 根据queryType过滤(大小写不敏感): /// - "Bound":只返回已绑定的杂志 /// - "Unbound":只返回未绑定的杂志 /// - 其他:返回空集合 /// 4. 返回每个杂志的ID、名称、标题、封面、是否已绑定 /// ============================================ /// /// 用户ID /// 查询类型:bound/unbound(大小写不敏感),其他返回空 /// 杂志列表 public async Task> GetJournalListAsync(long userId, string? queryType = "Bound") { #region 1. 查询所有已归档杂志 // 查询所有已归档且未删除的杂志 var allJournals = await journalRepository.Queryable() .Where(j => !j.IsDeleted && j.Status == (int)JournalStatusEnum.Archive) .OrderByDescending(j => j.CreatedAt) .ToListAsync(); #endregion #region 2. 查询用户已绑定杂志 // 查询用户已绑定的杂志ID列表 var userJournals = await userJournalRepository.Context.Queryable() .Where(uj => uj.UserId == userId && !uj.IsDeleted) .Select(uj => uj.JournalId) .ToListAsync(); var boundJournalIds = userJournals.ToHashSet(); #endregion #region 3. 根据queryType过滤 // ============================================ // 【大小写兼容处理 + 合法值校验】 // queryType 只接受两种值(大小写不敏感): // - "Bound" / "bound" / "BOUND" → 返回已绑定杂志 // - "Unbound" / "unbound" / "UNBOUND" → 返回未绑定杂志 // - 空 / 其他值 → 返回空集合 // ============================================ var normalizedQueryType = queryType?.ToLowerInvariant() ?? string.Empty; IEnumerable filteredJournals; if (normalizedQueryType == "bound") { filteredJournals = allJournals.Where(j => boundJournalIds.Contains(j.Id)); } else if (normalizedQueryType == "unbound") { filteredJournals = allJournals.Where(j => !boundJournalIds.Contains(j.Id)); } else { // 非 Bound/Unbound 类型,返回空集合 filteredJournals = Enumerable.Empty(); } #endregion #region 4. 返回结果映射 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; #endregion } /// /// ============================================ /// 方法思路:添加用户与杂志关联(绑定杂志) /// 1. 验证杂志是否存在 /// 2. 检查用户是否已绑定过该杂志 /// - 查询 UserJournal 表,条件:UserId + JournalId /// 3. 已绑定过:返回 false /// 4. 未绑定:插入 UserJournal 记录,返回 true /// ============================================ /// /// 杂志ID /// 用户ID /// 是否绑定成功(已绑定过返回false) public async Task AddUserJournalAsync(long journalId, long userId) { #region 1. 验证杂志是否存在 var journal = await journalRepository.Queryable() .Where(j => j.Id == journalId && !j.IsDeleted) .FirstAsync(); if (journal == null) { throw new BusinessException("杂志不存在", 404); } #endregion #region 2. 检查用户是否已绑定过该杂志 var exist = await userJournalRepository.Context.Queryable() .Where(uj => uj.UserId == userId && uj.JournalId == journalId && !uj.IsDeleted) .AnyAsync(); if (exist) { // 已绑定过,返回 false return false; } #endregion #region 3. 插入 UserJournal 记录 var userJournal = new UserJournal { UserId = userId, JournalId = journalId, Type = UserJournalTypeEnum.Subscribe }; var result = await userJournalRepository.InsertAsync(userJournal); return result; #endregion } /// /// ============================================ /// 方法思路:新增学生 /// 1. 创建新的Users记录 /// 2. WxUserId设置为当前登录人ID(wxUserId参数传入) /// 3. 初始化积分和成长值为0 /// 4. 用户类型设置为普通用户(Normal) /// 5. 插入数据库并返回结果 /// ============================================ /// /// 新增学生输入(姓名、头像URL) /// 当前登录人ID(微信用户ID) /// 是否插入成功 public async Task AddStudentAsync(AddStudentInput input, long wxUserId) { #region 1. 创建学生记录 var student = new Users { WxUserId = wxUserId, Name = input.Name, AvatarUrl = input.AvatarUrl, GrowthPoints = 0, Points = 0, Type = UsersTypeEnum.Normal, IsLastOnline = false }; #endregion #region 2. 插入数据库 return await usersRepository.InsertAsync(student); #endregion } }