From 4f33d81aa3d1377d5bda50d177432deabfa4da7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=B7=E6=AD=A6=20=E9=BB=84?= <865114447@qq.com> Date: Mon, 29 Jun 2026 15:09:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BB=A3=E7=A0=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IUserAnswerTaskService.cs | 14 +- .../CatalogListOutput.cs | 2 +- .../ClaimTaskPointsOutput.cs | 2 +- .../CrossPageTaskOutput.cs | 12 +- .../UserAnswerTaskListOutput.cs | 39 +- .../UserAnswerTaskService.cs | 906 ++++++++++++------ .../WeChat/UserAnswerTaskController.cs | 69 +- .../appsettings.json | 6 +- 8 files changed, 706 insertions(+), 344 deletions(-) diff --git a/QYZH.InteractiveMagazine.IService/IUserAnswerTaskService.cs b/QYZH.InteractiveMagazine.IService/IUserAnswerTaskService.cs index 6b21c58..5ad013c 100644 --- a/QYZH.InteractiveMagazine.IService/IUserAnswerTaskService.cs +++ b/QYZH.InteractiveMagazine.IService/IUserAnswerTaskService.cs @@ -34,7 +34,7 @@ public interface IUserAnswerTaskService : IBaseService /// 获取用户答题任务列表 /// - /// 二级目录Id(JournalCatalogId) + /// 二级目录Id(CatalogId) /// 用户Id /// 答题任务列表 Task GetUserAnswerTaskListAsync(long catalogId, long userId); @@ -50,10 +50,10 @@ public interface IUserAnswerTaskService : IBaseService /// 领取任务积分 /// - /// 任务分组Id(JournalPageTask.GroupId) + /// 任务分组Id(JournalPageTask.GroupId) /// 用户Id /// 领取结果 - Task ClaimTaskPointsAsync(long journalPageTaskGroupId, long userId); + Task ClaimTaskPointsAsync(long groupId, long userId); /// /// 批量领取任务积分(一键领取) @@ -71,6 +71,14 @@ public interface IUserAnswerTaskService : IBaseService杂志列表 Task> GetJournalListAsync(long userId, string? queryType); + /// + /// 添加用户与杂志关联(绑定杂志) + /// + /// 杂志ID + /// 用户ID + /// 是否绑定成功(已绑定过返回false) + Task AddUserJournalAsync(long journalId, long userId); + /// /// 新增学生 /// diff --git a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CatalogListOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CatalogListOutput.cs index 8d0b001..e09fce5 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CatalogListOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CatalogListOutput.cs @@ -28,5 +28,5 @@ public class CatalogListOutput /// /// 二级栏目列表 /// - public List Children { get; set; } = new(); + public List Children { get; set; } = []; } \ No newline at end of file diff --git a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/ClaimTaskPointsOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/ClaimTaskPointsOutput.cs index 4a6f831..41f3d12 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/ClaimTaskPointsOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/ClaimTaskPointsOutput.cs @@ -31,7 +31,7 @@ public class ClaimTaskPointsOutput public string TaskName { get; set; } /// - /// 题目得分(从 JournalPageTaskUserAnswer 表获取,跨页题目为所有相关任务得分之和) + /// 题目得分(从 JournalPageTaskUserAnswer 表获取,JournalPageTaskId == GroupId 的 Score) /// public float Score { get; set; } } diff --git a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CrossPageTaskOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CrossPageTaskOutput.cs index c9c9200..c266f63 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CrossPageTaskOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/CrossPageTaskOutput.cs @@ -23,10 +23,20 @@ public class CrossPageTaskOutput /// /// 图片数组(已答题用PageAnswerUrl,未答题用TaskUrl) /// - public List Images { get; set; } = new(); + public List Images { get; set; } = []; + + /// + /// 页面作答点读图片地址集合(已答题返回,未答题返回空集合) + /// + public List PageAnswerDotUrl { get; set; } = []; /// /// 是否跨页题目 /// public bool IsCrossPage { get; set; } + + /// + /// 是否可以领取积分(任务已完成且未领取过积分) + /// + public bool CanClaimPoints { get; set; } } diff --git a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/UserAnswerTaskListOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/UserAnswerTaskListOutput.cs index b9d3951..4f75e7a 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/UserAnswerTaskListOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/UserAnswerTaskService/UserAnswerTaskListOutput.cs @@ -26,24 +26,25 @@ public class UserAnswerTaskListOutput public int EndPage { get; set; } /// - /// 任务名称列表 + /// 任务列表 /// - public List TaskNames { get; set; } = new(); + public List Tasks { get; set; } = new(); +} + +/// +/// 用户答题任务项输出 +/// +public class UserAnswerTaskItemOutput +{ + /// + /// 任务GroupId + /// + public long GroupId { get; set; } /// - /// 任务状态(进行中、已完成、未开始) + /// 任务名称 /// - public string Status { get; set; } - - /// - /// 答题时间(分钟) - /// - public double AnswerMinutes { get; set; } - - /// - /// 徽章个数 - /// - public int MedalCount { get; set; } + public string TaskName { get; set; } /// /// 总成长值 @@ -54,4 +55,14 @@ public class UserAnswerTaskListOutput /// 总积分 /// public int TotalPoints { get; set; } + + /// + /// 答题时间(分钟) + /// + public double AnswerMinutes { get; set; } + + /// + /// 任务状态(进行中、已完成、未开始) + /// + public string Status { get; set; } } diff --git a/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs b/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs index c5a832f..d1093cb 100644 --- a/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs +++ b/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs @@ -17,9 +17,7 @@ public class UserAnswerTaskService( BaseRepository journalRepository, BaseRepository catalogRepository, BaseRepository pageRepository, - BaseRepository taskRepository, - BaseRepository userMedalRepository, - BaseRepository medalRepository, + BaseRepository taskRepository, BaseRepository userJournalRepository, BaseRepository usersRepository, BaseRepository checkInRecordRepository, @@ -31,14 +29,22 @@ public class UserAnswerTaskService( /// ============================================ /// 方法思路: /// 1. 批量预加载策略:为避免N+1查询问题,先按期刊维度批量加载所有关联数据 - /// 2. 跨页题目处理核心: + /// 2. 期刊来源: + /// - 用户绑定的杂志(UserJournal表) + /// - 用户有作答记录的杂志 + /// 合并去重后返回 + /// 3. 跨页题目处理核心: /// - 跨页题目定义:JournalPageTask.Id != GroupId(同一个GroupId下有多个任务) /// - 进度计算:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分(取第一个任务的积分) - /// 3. 状态判定逻辑: + /// 4. 状态判定逻辑: /// - 未开始:无任何作答记录 /// - 进行中:有作答记录但未全部完成 /// - 已完成:所有GroupId都完成 + /// 5. 用户未作答任何任务时: + /// - 仍返回绑定杂志的进度信息 + /// - 进度、成长值、积分为0 + /// - 连续打卡、剩余篇数正常计算 /// ============================================ /// /// 用户ID @@ -59,19 +65,39 @@ public class UserAnswerTaskService( // - 已完成:所有 GroupId 都完成 // ============================================ + #region 1. 查询用户作答记录 // 1. 查询用户的所有作答记录 var userAnswers = await answerRepository.Context.Queryable() .Where(a => a.UserId == userId && !a.IsDeleted) .ToListAsync(); + #endregion - if (userAnswers == null || userAnswers.Count == 0) + #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 - // 2. 提取关联的期刊ID(去重) - var journalIds = userAnswers.Select(a => a.JournalId).Distinct().ToList(); - + #region 3. 批量预加载关联数据 // 3. 批量预加载关联实体(按期刊维度全量加载,避免N+1查询) var journals = await journalRepository.Context.Queryable() .Where(j => journalIds.Contains(j.Id) && !j.IsDeleted) @@ -91,17 +117,23 @@ public class UserAnswerTaskService( 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).Distinct().ToHashSet(); - var userAnsweredPageIds = userAnswers.Select(a => a.JournalPageId).Distinct().ToHashSet(); + 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).Distinct().ToHashSet(); + var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet(); + #endregion + #region 6. 按期刊分组聚合并返回结果 // 6. 按期刊分组聚合 var result = new List(); @@ -155,7 +187,7 @@ public class UserAnswerTaskService( // 剩余篇数:该期刊总页数 - 用户已作答页数 var journalPages = allPages.Where(p => p.JournalId == journal.Id).ToList(); - var answeredPageCount = journalPages.Count(p => userAnsweredPageIds.Contains(p.Id)); + var answeredPageCount = journalPages.Count(p => userAnsweredPageIds.Contains(p.Id)); var remainingPages = journalPages.Count - answeredPageCount; result.Add(new JournalProgressOutput @@ -174,14 +206,19 @@ public class UserAnswerTaskService( } return result; + #endregion } /// /// ============================================ /// 方法思路:计算用户连续打卡天数 - /// 1. 查询用户最近一次签到记录 - /// 2. 判断是否连续:如果最后一次签到是今天或昨天,连续天数延续 - /// 3. 否则返回0(中断了) + /// 1. 获取今天和昨天的日期 + /// 2. 查询昨天是否有打卡记录 + /// 3. 如果昨天没有打卡记录,返回0(连续打卡中断) + /// 4. 如果昨天有打卡记录: + /// - 查询今天是否有打卡记录 + /// - 今天有记录:返回今天的连续天数 + /// - 今天无记录:返回昨天的连续天数 /// ============================================ /// /// 用户ID @@ -189,27 +226,26 @@ public class UserAnswerTaskService( private async Task CalculateConsecutiveDaysAsync(long userId) { var today = DateTime.Now.Date; + var yesterday = today.AddDays(-1); - // 查询最近一次签到记录 - var lastRecord = await checkInRecordRepository.Context.Queryable() - .Where(r => r.UserId == userId && !r.IsDeleted) - .OrderByDescending(r => r.CheckInDate) + // 查询昨天是否有打卡记录 + var yesterdayRecord = await checkInRecordRepository.Context.Queryable() + .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate.Date == yesterday) .FirstAsync(); - if (lastRecord == null) + // 如果昨天没有打卡记录,连续天数为0(连续打卡中断) + if (yesterdayRecord == null) { return 0; } - var lastDate = lastRecord.CheckInDate.Date; - - // 如果最后一次签到是今天或昨天,则连续天数延续 - if (lastDate == today || lastDate == today.AddDays(-1)) - { - return lastRecord.ConsecutiveDays; - } + // 昨天有打卡记录,检查今天是否已打卡 + var todayRecord = await checkInRecordRepository.Context.Queryable() + .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate.Date == today) + .FirstAsync(); - return 0; + // 今天已打卡:返回今天的连续天数;今天未打卡:返回昨天的连续天数 + return todayRecord?.ConsecutiveDays ?? yesterdayRecord.ConsecutiveDays; } /// @@ -218,13 +254,15 @@ public class UserAnswerTaskService( /// 1. 批量预加载:该期刊下所有目录、页面、任务、用户作答记录 /// 2. 目录结构处理: /// - 一级目录:ParentId=0 且 Type=Unit - /// - 二级目录:ParentId=一级目录Id 且 Type=Unit(可选) + /// - 二级目录:ParentId=一级目录Id 且 Type=Unit /// - 文章目录:Type=Page - /// 3. 跨页题目处理: + /// 3. 【关键】如果一级目录下没有二级栏目(Type=Unit的子目录), + /// 则该一级目录不参与任何计算,直接跳过 + /// 4. 跨页题目处理: /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分 /// - 已作答判断:只要有一个任务作答就算该GroupId已作答(包含进行中和已完成) - /// 4. 状态判定: + /// 5. 状态判定: /// - 未开始:无任何GroupId被作答 /// - 进行中:有GroupId被作答但未全部完成 /// - 已完成:所有GroupId都完成 @@ -235,6 +273,7 @@ public class UserAnswerTaskService( /// 目录列表(按一级目录分组) public async Task> GetCatalogListAsync(long journalId, long userId) { + #region 1. 查询期刊 // 1. 查询期刊 var journal = await journalRepository.Context.Queryable() .Where(j => j.Id == journalId && !j.IsDeleted) @@ -244,7 +283,9 @@ public class UserAnswerTaskService( { return new List(); } + #endregion + #region 2. 批量加载关联数据 // 2. 批量加载关联数据 var allCatalogs = await catalogRepository.Context.Queryable() .Where(c => c.JournalId == journalId && !c.IsDeleted) @@ -261,16 +302,19 @@ public class UserAnswerTaskService( 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).Distinct().ToHashSet(); + 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).Distinct().ToHashSet(); + 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 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) @@ -281,8 +325,6 @@ public class UserAnswerTaskService( .OrderBy(c => c.Sort) .ToList(); - // 文章篇数 = Type=Page 的子目录数量 - var articleCount = pageCatalogs.Count; var pageCatalogIds = pageCatalogs.Select(c => c.Id).ToHashSet(); // 获取所有文章下的页面ID @@ -297,18 +339,30 @@ public class UserAnswerTaskService( .OrderBy(c => c.Sort) .ToList(); - // 如果没有二级单元,则将 Type=Page 的文章作为二级列表展示 + // ============================================ + // 文章篇数计算逻辑: + // - 有二级栏目:累加所有二级栏目下的文章数 + 一级栏目下直接的文章数 + // - 没有二级栏目:计算一级栏目下直接的文章数(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(); // 二级单元下的所有页面 @@ -337,24 +391,24 @@ public class UserAnswerTaskService( .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(); + // 这个字段用于判断状态:未开始 / 进行中 / 已完成 + 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 = "进行中"; - } + // 状态判定逻辑: + // - answeredGroupCount == 0:没有任何 GroupId 被作答 → 未开始 + // - completedGroupCount == totalGroupCount:所有 GroupId 都完成 → 已完成 + // - 其他:有 GroupId 被作答但未全部完成 → 进行中 + if (answeredGroupCount == 0) + status = "未开始"; + else if (completedGroupCount == totalGroupCount) + status = "已完成"; + else + status = "进行中"; + } // 计算该二级目录的总成长值和总积分(跨页题目按GroupId去重) var distinctSecondLevelTasks = secondLevelTasks @@ -376,66 +430,9 @@ public class UserAnswerTaskService( } 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 - }); - } + // 没有二级单元:只统计一级栏目下直接的文章数,Children为空集合 + articleCount = pageCatalogs.Count; + // Children保持为空集合,不添加任何子项 } // 计算该一级目录的总成长值和总积分(基于所有文章页面的任务) @@ -450,8 +447,11 @@ public class UserAnswerTaskService( Children = children }); } + #endregion + #region 5. 返回结果 return result; + #endregion } /// @@ -472,6 +472,7 @@ public class UserAnswerTaskService( /// 一级目录概览信息 public async Task GetCatalogOverviewAsync(long catalogId, long userId) { + #region 1. 查询一级目录 // 1. 查询一级目录 var firstCatalog = await catalogRepository.Context.Queryable() .Where(c => c.Id == catalogId && !c.IsDeleted) @@ -481,7 +482,9 @@ public class UserAnswerTaskService( { return null; } + #endregion + #region 2. 批量加载关联数据 // 2. 批量加载关联数据 var allCatalogs = await catalogRepository.Context.Queryable() .Where(c => c.JournalId == firstCatalog.JournalId && !c.IsDeleted) @@ -498,13 +501,17 @@ public class UserAnswerTaskService( 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).Distinct().ToHashSet(); + 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).Distinct().ToHashSet(); + 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) @@ -513,7 +520,9 @@ public class UserAnswerTaskService( // 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(); @@ -587,7 +596,9 @@ public class UserAnswerTaskService( Status = status }); } + #endregion + #region 6. 返回结果 return new CatalogOverviewOutput { CatalogId = firstCatalog.Id, @@ -596,26 +607,29 @@ public class UserAnswerTaskService( TotalTaskCount = totalTaskCount, SubCatalogs = subCatalogs }; + #endregion } /// /// ============================================ - /// 方法思路:获取用户答题任务列表(某个二级栏目/文章下的所有任务) - /// 1. 查询目录信息:通过catalogId查询二级栏目 - /// 2. 查询一级栏目名称:通过ParentId关联查询一级栏目名称 - /// 3. 批量预加载:该栏目下的所有页面、该期刊下所有任务、用户作答记录 - /// 4. 跨页题目处理: - /// - 状态判断:按GroupId去重,已完成条件是该GroupId下所有任务都完成(Status=1) - /// - 积分计算:按GroupId去重,每个GroupId只计算一次积分和成长值 - /// - 已作答判断:只要有一个任务作答就算该GroupId已作答 - /// 5. 徽章统计:统计该期刊下已获得的徽章数量 + /// 方法思路:获取用户答题任务列表 + /// 1. 根据二级栏目ID查询栏目基本信息(一级栏目名称、栏目名称、页码范围 + /// 2. 按GroupId分组,每个GroupId对应一个任务项 + /// 3. 每个任务项包含:任务名称、总成长值、总积分、状态 + /// 4. 【跨页题目处理】 + /// - 是否跨页:Id != GroupId + /// - 任务名称:取 Id == GroupId 的主任务的Task字段 + /// - 成长值/积分:取主任务的,每个GroupId只算一次 + /// - 状态判断:必须该GroupId下所有任务都完成(Status=1)才算该GroupId完成 /// ============================================ - /// - /// 二级栏目/文章目录ID + /// 二级栏目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) @@ -626,11 +640,13 @@ public class UserAnswerTaskService( return null; } + #endregion + + #region 2. 获取一级栏目名称 + // ============================================ // 【获取一级栏目名称】 // 通过 ParentId 关联查询一级栏目名称 - // 一级栏目:ParentId = 0 - // 二级栏目/文章:ParentId = 一级栏目Id,Type = Page // ============================================ string firstLevelCatalogName = string.Empty; if (catalog.ParentId > 0) @@ -641,6 +657,10 @@ public class UserAnswerTaskService( firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty; } + #endregion + + #region 3. 批量加载关联数据 + // ============================================ // 2. 批量加载关联数据 // ============================================ @@ -659,93 +679,98 @@ public class UserAnswerTaskService( .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. 该目录下的所有任务 + // 4. 该目录下的所有任务(仅用于筛选哪些GroupId属于当前栏目) 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(); + // 【跨页题目-关键】 + // 跨页题目的任务可能分布在不同页面(甚至不同栏目)下 + // 所以需要从整个期刊的所有任务(allTasks)中按GroupId获取完整的任务组 + // 否则无法正确判断跨页题目是否全部完成 + var catalogGroupIds = catalogTasks.Select(t => t.GroupId).ToHashSet(); - // 6. 用户已作答的任务ID集合(包含进行中和已完成) - var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).Distinct().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).Distinct().ToHashSet(); + var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).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. 计算页码范围 + // 6. 计算页码范围 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 + #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) - .Select(g => g.First()) .ToList(); - var totalGrowthPoints = distinctCatalogTasks.Sum(t => t.GrowthPoint) ?? 0; - var totalPoints = distinctCatalogTasks.Sum(t => t.Points); + + 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 { @@ -753,69 +778,79 @@ public class UserAnswerTaskService( CatalogName = catalog.Name ?? string.Empty, StartPage = startPage, EndPage = endPage, - TaskNames = taskNames, - Status = status, - AnswerMinutes = answerMinutes, - MedalCount = userMedalCount, - TotalGrowthPoints = totalGrowthPoints, - TotalPoints = totalPoints + Tasks = taskItems }; + + #endregion } /// /// ============================================ /// 方法思路:获取跨页题目详情 - /// 1. 判断是否跨页题目:journalPageTaskId != GroupId 则为跨页题目 - /// 2. 跨页题目处理:根据GroupId查询所有相关任务 + /// 1. 根据 groupId 查询该分组下的所有任务 + /// 2. 跨页题目判定: + /// - 任务数 > 1 → 跨页题目 + /// - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入) + /// - 任务数 == 1 且 Id == groupId → 普通题目 /// 3. 图片数组获取逻辑: - /// - 已答题:使用用户上传的PageAnswerUrl数组 - /// - 未答题:使用任务的TaskUrl数组 + /// - 已答题:使用用户上传的 PageAnswerUrl 数组 + /// - 未答题:使用任务的 TaskUrl 数组补齐 /// 4. 栏目名称获取: - /// - 二级栏目名称:通过页面关联的JournalCatalogId查询 - /// - 一级栏目名称:通过二级栏目的ParentId查询 + /// - 二级栏目名称:通过页面关联的 JournalCatalogId 查询 + /// - 一级栏目名称:通过二级栏目的 ParentId 查询 + /// 5. 是否可领取积分判断: + /// - 任务必须已完成(Status=1) + /// - 跨页题目:所有任务都完成才能领取 + /// - 普通题目:当前任务完成即可领取 + /// - 且未领取过积分 /// ============================================ /// - /// 任务ID + /// 任务分组ID(GroupId) /// 用户ID /// 跨页题目详情 - public async Task GetCrossPageTaskAsync(long journalPageTaskId, long userId) + public async Task GetCrossPageTaskAsync(long groupId, long userId) { - // 1. 查询任务信息 - var task = await taskRepository.Context.Queryable() - .Where(t => t.Id == journalPageTaskId && !t.IsDeleted) - .FirstAsync(); + // 1. 查询该 GroupId 下的所有任务 + var allTasks = await taskRepository.Context.Queryable() + .Where(t => t.GroupId == groupId && !t.IsDeleted) + .OrderBy(t => t.Id) + .ToListAsync(); - if (task == null) + if (allTasks == null || allTasks.Count == 0) { return null; } - // ============================================ - // 【跨页题目判定】 - // 判断标准:journalPageTaskId != task.GroupId 则为跨页题目 - // 跨页题目有多个任务,但共享同一个 GroupId - // ============================================ - var isCrossPage = journalPageTaskId != task.GroupId; + #region 1. 跨页题目判定 // ============================================ - // 【获取相关任务】 - // 跨页题目:根据 GroupId 查询所有相关任务 - // 非跨页题目:只返回当前任务 + // 【跨页题目判定】 + // 判断标准: + // - 任务数 > 1 → 跨页题目 + // - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入) + // - 任务数 == 1 且 Id == 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 }; - } + 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. 获取栏目名称(一级/二级) // ============================================ // 【获取栏目名称】 @@ -823,14 +858,14 @@ public class UserAnswerTaskService( // 一级栏目名称:通过二级栏目的 ParentId 查询 // ============================================ var page = await pageRepository.Context.Queryable() - .Where(p => p.Id == task.JournalPageId && !p.IsDeleted) + .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(); @@ -846,67 +881,203 @@ public class UserAnswerTaskService( } } + #endregion + + #region 4. 获取图片数组(Images) + // ============================================ // 【获取图片数组】 - // 已答题:使用用户上传的 PageAnswerUrl 数组 - // 未答题:使用任务的 TaskUrl 数组 + // 核心逻辑:已答题返回 PageAnswerUrl,未答题返回 TaskUrl 补齐 + // 跨页题目: + // - 用户答了部分:已答页用 PageAnswerUrl,未答页用 TaskUrl 补齐 + // - 用户全部作答:全部用 PageAnswerUrl + // - 用户未作答:全部用 TaskUrl + // - 按主页面页码(PageNum)升序排列,确保拼图顺序正确 + // 非跨页题目:取单条答题记录的 PageAnswerUrl,未答题返回空集合 // ============================================ var images = new List(); + + // 统一查询用户答题记录(用于图片获取和后续领取积分判断) + List userAnswers; if (isCrossPage) { // 跨页题目:查询该 GroupId 下所有用户的答题记录 - var userAnswers = await answerRepository.Context.Queryable() - .Where(a => a.JournalPageTaskGroupId == task.GroupId && a.UserId == userId && !a.IsDeleted) + userAnswers = await answerRepository.Context.Queryable() + .Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted) .OrderBy(a => a.JournalPageTaskId) .ToListAsync(); - if (userAnswers.Any(a => !string.IsNullOrEmpty(a.PageAnswerUrl))) + // ============================================ + // 【跨页题目图片获取逻辑】 + // 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) { - // 已答题:使用 PageAnswerUrl 数组 - images.AddRange(userAnswers.Where(a => !string.IsNullOrEmpty(a.PageAnswerUrl)).Select(a => a.PageAnswerUrl)); - } - else - { - // 未答题,使用TaskUrl数组 - 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)) { - if (!string.IsNullOrEmpty(relatedTask.TaskUrl)) - { - images.Add(relatedTask.TaskUrl); - } + // 已答题:使用 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 == journalPageTaskId && a.UserId == userId && !a.IsDeleted) + .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 + // 已答题,使用 PageAnswerUrl images.Add(userAnswer.PageAnswerUrl); } - else + // 未答题不返回任何图片,images 保持空集合 + } + + #endregion + + #region 5. 获取页面作答点读图片集合(PageAnswerDotUrl) + + // ============================================ + // 【获取页面作答点读图片地址集合】 + // 规则:已答题且 PageAnswerDotUrl 有值才返回;未答题或字段为空则跳过 + // 跨页题目:遍历所有相关任务,收集有答题记录的 PageAnswerDotUrl + // 非跨页题目:取当前任务答题记录的 PageAnswerDotUrl(仅在有值时返回) + // ============================================ + var pageAnswerDotUrls = new List(); + if (isCrossPage) + { + // 跨页题目:遍历相关任务,收集有答题记录的 PageAnswerDotUrl + foreach (var relatedTask in relatedTasks) { - // 未答题,使用TaskUrl - if (!string.IsNullOrEmpty(task.TaskUrl)) + var userAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == relatedTask.Id); + if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerDotUrl)) { - images.Add(task.TaskUrl); + 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 = task.Task ?? string.Empty, + TaskName = taskName, Images = images, - IsCrossPage = isCrossPage + PageAnswerDotUrl = pageAnswerDotUrls, + IsCrossPage = isCrossPage, + CanClaimPoints = canClaimPoints }; + + #endregion } /// @@ -915,7 +1086,7 @@ public class UserAnswerTaskService( /// 1. 根据 GroupId 查询任务信息,判断是否跨页题目 /// - 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId /// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId - /// 2. 通过 JournalPageTaskGroupId 查询用户答题记录 + /// 2. 通过 groupId 查询用户答题记录 /// 3. 跨页题目完成校验:必须该 GroupId 下所有任务都完成(Status=1) /// 普通题目校验:只需要该任务完成(Status=1) /// 4. 重复领取校验:通过 PointsRecord 表的 RelatedId + ChangeType 判断 @@ -925,23 +1096,26 @@ public class UserAnswerTaskService( /// 6. 得分计算:从 JournalPageTaskUserAnswer 表获取 Score /// ============================================ /// - /// 任务分组Id(JournalPageTask.GroupId) + /// 任务分组Id(JournalPageTask.GroupId) /// 用户ID /// 领取积分结果 - public async Task ClaimTaskPointsAsync(long journalPageTaskGroupId, long userId) + public async Task ClaimTaskPointsAsync(long groupId, long userId) { + #region 1. 根据GroupId查询所有相关任务 // ============================================ // 1. 根据 GroupId 查询所有相关任务 // ============================================ var relatedTasks = await taskRepository.Context.Queryable() - .Where(t => t.GroupId == journalPageTaskGroupId && !t.IsDeleted) + .Where(t => t.GroupId == groupId && !t.IsDeleted) .ToListAsync(); if (relatedTasks == null || relatedTasks.Count == 0) { throw new BusinessException("任务不存在", 404); } + #endregion + #region 2. 判断是否跨页题目 // ============================================ // 2. 判断是否跨页题目 // 普通题目:该 GroupId 下只有1个任务,且该任务的 Id == GroupId @@ -949,7 +1123,7 @@ public class UserAnswerTaskService( // ============================================ bool isCrossPage; JournalPageTask mainTask; - if (relatedTasks.Count == 1 && relatedTasks[0].Id == journalPageTaskGroupId) + if (relatedTasks.Count == 1 && relatedTasks[0].Id == groupId) { // 普通题目:只有1个任务,且 Id == GroupId isCrossPage = false; @@ -960,21 +1134,25 @@ public class UserAnswerTaskService( // 跨页题目:有多个任务,或者 Id != GroupId isCrossPage = true; // 取 Id == GroupId 的任务作为主任务(跨页题目的第一条记录) - mainTask = relatedTasks.FirstOrDefault(t => t.Id == journalPageTaskGroupId) ?? relatedTasks[0]; + mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0]; } + #endregion + #region 3. 查询用户答题记录 // ============================================ // 3. 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询) // ============================================ var userAnswers = await answerRepository.Context.Queryable() - .Where(a => a.JournalPageTaskGroupId == journalPageTaskGroupId && a.UserId == userId && !a.IsDeleted) + .Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted) .ToListAsync(); if (userAnswers == null || userAnswers.Count == 0) { throw new BusinessException("请先完成答题再领取积分", 400); } + #endregion + #region 4. 跨页题目完成校验 // ============================================ // 4. 跨页题目完成校验 // 跨页题目必须该 GroupId 下所有任务都完成(Status=1)才能领取积分 @@ -1000,17 +1178,20 @@ public class UserAnswerTaskService( throw new BusinessException("请先完成答题再领取积分", 400); } } + #endregion + #region 5. 积分计算 // ============================================ // 5. 积分计算 - // 跨页题目:累计该 GroupId 下所有任务的积分 - // 普通题目:取当前任务的积分 + // 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值) + // 跨页题目:只取主任务(Id == GroupId)的积分,不是累加所有任务的积分 + // 普通题目:取主任务的积分 // ============================================ int totalPoints = 0; if (isCrossPage) { - // 跨页题目:累计所有任务的积分 - totalPoints = relatedTasks.Sum(t => t.Points); + // 跨页题目:只取主任务(Id == GroupId)的积分 + totalPoints = mainTask.Points; } else { @@ -1022,7 +1203,9 @@ public class UserAnswerTaskService( { throw new BusinessException("该任务无积分可领取", 400); } + #endregion + #region 6. 得分计算 // ============================================ // 6. 得分计算 // 从 JournalPageTaskUserAnswer 表获取 Score @@ -1034,14 +1217,16 @@ public class UserAnswerTaskService( if (isCrossPage) { // 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数 - totalScore = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == journalPageTaskGroupId)?.Score ?? 0; + totalScore = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId)?.Score ?? 0; } else { // 普通题目:取当前任务对应答题记录的分数 totalScore = userAnswers.FirstOrDefault()?.Score ?? 0; } + #endregion + #region 7. 重复领取校验 // ============================================ // 7. 重复领取校验 // 通过 PointsRecord 表的 RelatedId + ChangeType 判断是否已领取 @@ -1049,14 +1234,16 @@ public class UserAnswerTaskService( // ============================================ var existingRecord = await answerRepository.Context.Queryable() .Where(r => r.UserId == userId && !r.IsDeleted) - .Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == journalPageTaskGroupId) + .Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == groupId) .FirstAsync(); if (existingRecord != null) { throw new BusinessException("积分已领取,请勿重复领取", 400); } + #endregion + #region 8. 添加积分记录并返回结果 // ============================================ // 8. 调用积分服务添加积分记录 // ============================================ @@ -1065,7 +1252,7 @@ public class UserAnswerTaskService( UserId = userId, Amount = totalPoints, ChangeType = PointsChangeTypeEnum.TaskReward, - RelatedId = journalPageTaskGroupId, + RelatedId = groupId, Description = $"完成任务奖励:{mainTask.Task}", OperatorName = "系统" }); @@ -1079,6 +1266,7 @@ public class UserAnswerTaskService( TaskName = mainTask.Task ?? string.Empty, Score = totalScore }; + #endregion } /// @@ -1106,6 +1294,7 @@ public class UserAnswerTaskService( /// 批量领取结果 public async Task BatchClaimPointsAsync(BatchClaimPointsInput input, long userId) { + #region 1. 初始化与参数校验 var result = new BatchClaimPointsOutput(); if (input == null || input.GroupIds == null || input.GroupIds.Count == 0) @@ -1114,14 +1303,18 @@ public class UserAnswerTaskService( } 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).Distinct().ToHashSet(); + var claimedGroupIds = claimedRecords.Select(r => r.RelatedId!.Value).ToHashSet(); + #endregion + #region 3. 逐个处理每个 GroupId // 2. 逐个处理每个 GroupId int totalPoints = 0; int totalGrowthPoints = 0; @@ -1131,6 +1324,7 @@ public class UserAnswerTaskService( { try { + #region 3.1 根据 GroupId 查询所有相关任务 // ============================================ // 2.1 根据 GroupId 查询所有相关任务 // ============================================ @@ -1147,7 +1341,9 @@ public class UserAnswerTaskService( }); continue; } + #endregion + #region 3.2 判断是否跨页题目 // ============================================ // 2.2 判断是否跨页题目 // 普通题目:该 GroupId 下只有1个任务,且 Id == GroupId @@ -1168,7 +1364,9 @@ public class UserAnswerTaskService( // 取 Id == GroupId 的任务作为主任务 mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0]; } + #endregion + #region 3.3 查询用户答题记录 // ============================================ // 2.3 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询) // ============================================ @@ -1185,12 +1383,15 @@ public class UserAnswerTaskService( }); continue; } + #endregion + #region 3.4 跨页题目完成校验 // ============================================ // 2.4 跨页题目完成校验 // 跨页题目必须该 GroupId 下所有任务都完成(Status=1) // 普通题目只需要该任务完成(Status=1) // ============================================ + bool isCompleted = true; // 完成状态标志 if (isCrossPage) { // 跨页题目:检查该 GroupId 下每个任务是否都有 Status=1 的答题记录 @@ -1199,14 +1400,19 @@ public class UserAnswerTaskService( 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; + isCompleted = false; + break; // 发现未完成的任务,立即退出循环 } } + if (!isCompleted) + { + result.FailItems.Add(new BatchClaimFailItem + { + GroupId = groupId, + Message = "跨页题目需要完成所有任务才能领取积分" + }); + continue; // 跳出外层循环,处理下一个 groupId + } } else { @@ -1221,7 +1427,9 @@ public class UserAnswerTaskService( continue; } } + #endregion + #region 3.5 重复领取校验 // ============================================ // 2.5 重复领取校验 // ============================================ @@ -1234,7 +1442,9 @@ public class UserAnswerTaskService( }); continue; } + #endregion + #region 3.6 计算积分、成长值、得分 // ============================================ // 2.6 计算积分、成长值、得分 // 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值) @@ -1273,7 +1483,9 @@ public class UserAnswerTaskService( // 获取任务名称 var taskName = mainTask.Task ?? string.Empty; + #endregion + #region 3.7 调用积分服务并记录结果 // 调用积分服务增加积分 var addResult = await pointsService.AddPointsAsync(new AddPointsInput { @@ -1300,6 +1512,7 @@ public class UserAnswerTaskService( GrowthPoints = growthPoints, Score = score }); + #endregion } catch (Exception ex) { @@ -1311,13 +1524,16 @@ public class UserAnswerTaskService( }); } } + #endregion + #region 4. 组装返回结果 result.FailCount = result.FailItems.Count; result.TotalPoints = totalPoints; result.TotalGrowthPoints = totalGrowthPoints; result.BalanceAfter = currentBalance; return result; + #endregion } /// @@ -1325,37 +1541,63 @@ public class UserAnswerTaskService( /// 方法思路:获取杂志列表 /// 1. 查询所有已归档的杂志(Status=Archive) /// 2. 查询用户已绑定的杂志ID列表 - /// 3. 根据queryType过滤: + /// 3. 根据queryType过滤(大小写不敏感): /// - "Bound":只返回已绑定的杂志 /// - "Unbound":只返回未绑定的杂志 - /// - 其他:返回所有杂志 + /// - 其他:返回空集合 /// 4. 返回每个杂志的ID、名称、标题、封面、是否已绑定 /// ============================================ /// /// 用户ID - /// 查询类型:Bound/Unbound/空(全部) + /// 查询类型:bound/unbound(大小写不敏感),其他返回空 /// 杂志列表 - public async Task> GetJournalListAsync(long userId, string? queryType) + 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 - IEnumerable filteredJournals = queryType switch + #region 3. 根据queryType过滤 + // ============================================ + // 【大小写兼容处理 + 合法值校验】 + // queryType 只接受两种值(大小写不敏感): + // - "Bound" / "bound" / "BOUND" → 返回已绑定杂志 + // - "Unbound" / "unbound" / "UNBOUND" → 返回未绑定杂志 + // - 空 / 其他值 → 返回空集合 + // ============================================ + var normalizedQueryType = queryType?.ToLowerInvariant() ?? string.Empty; + + IEnumerable filteredJournals; + if (normalizedQueryType == "bound") { - "Bound" => allJournals.Where(j => boundJournalIds.Contains(j.Id)), - "Unbound" => allJournals.Where(j => !boundJournalIds.Contains(j.Id)), - _ => allJournals - }; + 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, @@ -1366,6 +1608,58 @@ public class UserAnswerTaskService( }).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 } /// @@ -1383,7 +1677,7 @@ public class UserAnswerTaskService( /// 是否插入成功 public async Task AddStudentAsync(AddStudentInput input, long wxUserId) { - // 创建新的学生记录 + #region 1. 创建学生记录 var student = new Users { WxUserId = wxUserId, @@ -1394,8 +1688,10 @@ public class UserAnswerTaskService( Type = UsersTypeEnum.Normal, IsLastOnline = false }; + #endregion - // 插入数据库 - return await usersRepository.InsertAsync(student); + #region 2. 插入数据库 + return await usersRepository.InsertAsync(student); + #endregion } } diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs index c6f5e96..e2d5201 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/WeChat/UserAnswerTaskController.cs @@ -34,7 +34,7 @@ public class UserAnswerTaskController : WeChatBaseController public async Task>> GetProgressAsync() { try - { + { var userId = GetCurrentUserId(); if (userId == 0) { @@ -49,7 +49,8 @@ public class UserAnswerTaskController : WeChatBaseController _logger.LogError(ex, "获取学习进度失败,UserId: {UserId}", GetCurrentUserId()); return BaseResponse>.Fail("获取学习进度失败,请稍后重试"); } - } + } + /// /// 获取目录列表(按一级目录分组,二级目录含完成状态) @@ -57,7 +58,7 @@ public class UserAnswerTaskController : WeChatBaseController /// 期刊Id /// 按一级目录分组的目录列表 [HttpGet("catalog-list/{journalId:long}")] - public async Task>> GetCatalogListAsync([Required] long journalId) + public async Task>> GetCatalogListAsync([Required(ErrorMessage = "参数错误")] long journalId) { try { @@ -88,7 +89,7 @@ public class UserAnswerTaskController : WeChatBaseController /// 一级目录Id /// 一级目录概览数据 [HttpGet("catalog-overview/{catalogId:long}")] - public async Task> GetCatalogOverviewAsync([Required] long catalogId) + public async Task> GetCatalogOverviewAsync([Required(ErrorMessage = "参数错误")] long catalogId) { try { @@ -124,7 +125,7 @@ public class UserAnswerTaskController : WeChatBaseController /// 二级目录Id /// 答题任务列表 [HttpGet("task-list/{catalogId:long}")] - public async Task> GetTaskListAsync([Required] long catalogId) + public async Task> GetTaskListAsync([Required(ErrorMessage = "参数错误")] long catalogId) { try { @@ -160,7 +161,7 @@ public class UserAnswerTaskController : WeChatBaseController /// 任务Id /// 跨页题目详情 [HttpGet("cross-page-task/{journalPageTaskId:long}")] - public async Task> GetCrossPageTaskAsync([Required] long journalPageTaskId) + public async Task> GetCrossPageTaskAsync([Required(ErrorMessage ="参数错误")] long journalPageTaskId) { try { @@ -193,10 +194,10 @@ public class UserAnswerTaskController : WeChatBaseController /// /// 领取任务积分 /// - /// 任务分组Id(JournalPageTask.GroupId) + /// 任务分组Id(GroupId) /// 领取结果 - [HttpPost("claim-points/{journalPageTaskGroupId:long}")] - public async Task> ClaimTaskPointsAsync([Required] long journalPageTaskGroupId) + [HttpPost("claim-points/{groupId:long}")] + public async Task> ClaimTaskPointsAsync([Required(ErrorMessage = "参数错误")] long groupId) { try { @@ -206,22 +207,22 @@ public class UserAnswerTaskController : WeChatBaseController return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息"); } - if (journalPageTaskGroupId <= 0) + if (groupId <= 0) { return BaseResponse.Fail(ResultCode.PARAM_ERROR, "任务分组Id不能为空"); } - var result = await _userAnswerTaskService.ClaimTaskPointsAsync(journalPageTaskGroupId, userId); + var result = await _userAnswerTaskService.ClaimTaskPointsAsync(groupId, userId); return Success(result); } catch (BusinessException ex) { - _logger.LogWarning(ex, "领取任务积分业务异常,JournalPageTaskGroupId: {JournalPageTaskGroupId}, UserId: {UserId}", journalPageTaskGroupId, GetCurrentUserId()); + _logger.LogWarning(ex, "领取任务积分业务异常,groupId: {groupId}, UserId: {UserId}", groupId, GetCurrentUserId()); return BaseResponse.Fail(ex.Message); } catch (Exception ex) { - _logger.LogError(ex, "领取任务积分失败,JournalPageTaskGroupId: {JournalPageTaskGroupId}, UserId: {UserId}", journalPageTaskGroupId, GetCurrentUserId()); + _logger.LogError(ex, "领取任务积分失败,groupId: {groupId}, UserId: {UserId}", groupId, GetCurrentUserId()); return BaseResponse.Fail("领取任务积分失败,请稍后重试"); } } @@ -265,10 +266,10 @@ public class UserAnswerTaskController : WeChatBaseController /// /// 获取杂志列表 /// - /// 查询类型:Bound=已绑定,Unbound=未绑定(添加书籍用),默认是:Bound + /// 查询类型:bound=已绑定,unbound=未绑定(添加杂志用列表),默认是:bound /// 杂志列表 [HttpGet("journal-list")] - public async Task>> GetJournalListAsync([FromQuery] string? queryType = "Bound") + public async Task>> GetJournalListAsync(string? queryType = "bound") { try { @@ -293,6 +294,37 @@ public class UserAnswerTaskController : WeChatBaseController } } + /// + /// 绑定杂志(添加用户与杂志关联) + /// + /// 杂志ID + /// 是否绑定成功(已绑定过返回false) + [HttpPost("add-user-journal/{journalId:long}")] + public async Task> AddUserJournalAsync([Required(ErrorMessage = "参数错误")] long journalId) + { + try + { + var userId = GetCurrentUserId(); + if (userId == 0) + { + return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息"); + } + + var result = await _userAnswerTaskService.AddUserJournalAsync(journalId, userId); + return Success(result); + } + catch (BusinessException ex) + { + _logger.LogWarning(ex, "绑定杂志业务异常,UserId: {UserId}, JournalId: {JournalId}", GetCurrentUserId(), journalId); + return BaseResponse.Fail(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "绑定杂志失败,UserId: {UserId}, JournalId: {JournalId}", GetCurrentUserId(), journalId); + return BaseResponse.Fail("绑定杂志失败,请稍后重试"); + } + } + /// /// 新增学生 /// @@ -308,8 +340,11 @@ public class UserAnswerTaskController : WeChatBaseController { return BaseResponse.Fail(ResultCode.DENY, "未获取到用户信息"); } - - var result = await _userAnswerTaskService.AddStudentAsync(input, GetCurrentWxUserId().Value); + if (GetCurrentWxUserId() == null) + { + return BaseResponse.Fail(ResultCode.DENY, "未获取到微信信息"); + } + var result = await _userAnswerTaskService.AddStudentAsync(input, GetCurrentWxUserId()!.Value); return Success(result); } catch (BusinessException ex) diff --git a/QYZH.InteractiveMagazine.WebApi/appsettings.json b/QYZH.InteractiveMagazine.WebApi/appsettings.json index d6d78f9..dce8961 100644 --- a/QYZH.InteractiveMagazine.WebApi/appsettings.json +++ b/QYZH.InteractiveMagazine.WebApi/appsettings.json @@ -22,8 +22,10 @@ "ClientProvidedName": "Custom connection name" }, "WeChatSettings": { - "AppId": "wx5d8f281c2fd6594c", - "AppSecret": "974f45e4a0aaa075278db0477d0bd8b6" + //"AppId": "wx5d8f281c2fd6594c", + //"AppSecret": "974f45e4a0aaa075278db0477d0bd8b6" + "AppId": "a", + "AppSecret": "a" }, "Serilog": { "MinimumLevel": {