Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs
2026-07-01 08:18:07 +08:00

1666 lines
71 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.Common.Helpers;
using QYZH.InteractiveMagazine.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;
/// <summary>
/// 用户作答任务服务实现
/// </summary>
public class UserAnswerTaskService(
BaseRepository<JournalPageTaskUserAnswer> answerRepository,
BaseRepository<Journal> journalRepository,
BaseRepository<JournalCatalog> catalogRepository,
BaseRepository<JournalPage> pageRepository,
BaseRepository<JournalPageTask> taskRepository,
BaseRepository<UserJournal> userJournalRepository,
BaseRepository<Users> usersRepository,
BaseRepository<CheckInRecord> checkInRecordRepository,
BaseRepository<UserPet> userPetRepository,
IPointsService pointsService,
IPetService petService,
ILogger<UserAnswerTaskService> logger)
: BaseRepository<JournalPageTaskUserAnswer>, IUserAnswerTaskService
{
/// <summary>
/// ============================================
/// 方法思路:
/// 1. 首先验证 UserJournal 表,获取用户绑定的杂志列表
/// - 如果用户没有绑定任何杂志,直接返回空列表
/// 2. 批量预加载策略为避免N+1查询问题先按期刊维度批量加载所有关联数据
/// - 期刊基本信息
/// - 一级栏目ParentId == 0
/// - 所有任务按期刊ID
/// - 所有页面按期刊ID
/// 3. 查询用户在这些期刊下的作答记录
/// 4. 跨页题目处理核心:
/// - 跨页题目定义JournalPageTask.Id != GroupId同一个GroupId下有多个任务
/// - 进度计算按GroupId去重已完成条件是该GroupId下所有任务都完成Status=1
/// - 积分计算按GroupId去重每个GroupId只计算一次积分取主任务的积分
/// 5. 状态判定逻辑:
/// - 未开始:无任何作答记录
/// - 进行中:有作答记录但未全部完成
/// - 已完成所有GroupId都完成
/// 6. 用户未作答任何任务时:
/// - 仍返回绑定杂志的进度信息
/// - 进度、成长值、积分为0
/// - 连续打卡、剩余篇数正常计算
/// ============================================
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>用户所有期刊的学习进度列表</returns>
public async Task<List<JournalProgressOutput>> GetJournalProgressAsync(long userId)
{
#region 1. UserJournal
// ============================================
// 第一步:先验证 UserJournal 表数据
// 获取用户绑定的所有杂志 ID
// 如果没有绑定的杂志,直接返回空列表,不进行后续查询
// ============================================
var boundJournalIds = await userJournalRepository.Context.Queryable<UserJournal>()
.Where(uj => uj.UserId == userId)
.Select(uj => uj.JournalId)
.ToListAsync();
if (boundJournalIds == null || boundJournalIds.Count == 0)
{
return new List<JournalProgressOutput>();
}
var journalIds = boundJournalIds.Distinct().ToList();
#endregion
#region 2.
// ============================================
// 第二步批量预加载关联实体按期刊维度全量加载避免N+1查询
// - 期刊基本信息
// - 一级栏目ParentId == 0 表示一级目录)
// - 所有任务
// - 所有页面(用于计算剩余篇数)
// ============================================
var journals = await journalRepository.Context.Queryable<Journal>()
.Where(j => journalIds.Contains(j.Id))
.ToListAsync();
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => journalIds.Contains(c.JournalId) && c.ParentId == 0)
.ToListAsync();
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => journalIds.Contains(t.JournalId))
.ToListAsync();
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => journalIds.Contains(p.JournalId))
.ToListAsync();
#endregion
#region 3.
// ============================================
// 第三步:查询用户在这些绑定杂志下的作答记录
// 只查询绑定杂志的作答记录,减少数据量
// ============================================
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.UserId == userId && journalIds.Contains(a.JournalId))
.ToListAsync();
#endregion
#region 4.
var consecutiveDays = await CalculateConsecutiveDaysAsync(userId);
#endregion
#region 5. /ID集合
// 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
var userAnsweredPageIds = userAnswers.Select(a => a.JournalPageId).ToHashSet();
// 用户已完成的任务ID集合
// 【已完成判断逻辑】
// Status: 0=未批阅1=已批阅
// AnswerStatus: 0=未答题1=已答题2=正确3=半对4=错误
// 必须同时满足: AnswerStatus >= 1Correct/HalfRight/Error作业才算完成,看情况再加Status == 1已批阅
var userCompletedTaskIds = userAnswers
.Where(a => a.AnswerStatus >= AnswerStatusEnum.Answer)
.Select(a => a.JournalPageTaskId)
.ToHashSet();
#endregion
#region 6.
// ============================================
// 第四步:按期刊分组聚合
// 遍历每个绑定的杂志,计算进度、积分、成长值等
// ============================================
var result = new List<JournalProgressOutput>();
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 totalGroupIds = journalTasks.Select(t => t.GroupId).Distinct().ToList();
var totalTaskCount = totalGroupIds.Count;
// 【跨页题目-已完成判断】已完成的 GroupId 列表
// 关键:必须该 GroupId 下所有任务都完成,才算该 GroupId 完成
var completedGroupIds = journalTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Select(g => g.Key)
.ToList();
var completedTaskCount = completedGroupIds.Count;
// ============================================
// 【积分和成长值计算】
// 1. totalGrowthPoints从已完成作答记录中按 JournalPageTaskGroupId 查询主任务Id=GroupId的 GrowthPoint
// 2. totalPoints统计该期刊下已完成任务的 Points 总数
// ============================================
// 该期刊下用户已完成的作答记录
// 【已完成判断逻辑】
// Status: 0=未批阅1=已批阅
// AnswerStatus: 0=未答题1=已答题2=正确3=半对4=错误
// 必须同时满足: AnswerStatus >= 1Correct/HalfRight/Error作业才算完成看情况再加Status == 1已批阅
var completedAnswers = userAnswers
.Where(a => a.JournalId == journal.Id && a.AnswerStatus >= AnswerStatusEnum.Answer)
.ToList();
// 按 JournalPageTaskGroupId 去重,获取已完成的主任务 ID 列表
var completedGroupIdsSet = completedAnswers.Select(a => a.JournalPageTaskGroupId).Distinct().ToList();
// 从主任务表查询这些 GroupId 对应的 GrowthPoint 和 Points
var mainTasks = allTasks
.Where(t => completedGroupIdsSet.Contains(t.GroupId) && t.Id == t.GroupId)
.ToList();
// 累计成长值(从主任务的 GrowthPoint 累加)
var totalGrowthPoints = mainTasks.Sum(t => t.GrowthPoint ?? 0);
// 累计积分(从已完成任务的 Points 累加,每个 GroupId 只计算一次)
var totalPoints = mainTasks.Sum(t => t.Points);
// 【进度百分比计算】按篇数统计
// 只计算有题目的页面,进度 = 已答篇数 / 有题目的总篇数 × 100
var pagesWithTasks = journalTasks.Select(t => t.JournalPageId).Distinct().ToHashSet();
var totalPagesWithTasks = pagesWithTasks.Count;
var answeredPagesWithTasks = pagesWithTasks.Count(userAnsweredPageIds.Contains);
var progress = totalPagesWithTasks > 0
? Math.Round((double)answeredPagesWithTasks / totalPagesWithTasks * 100, 2)
: 0;
// 剩余篇数:有题目的页面总数 - 已作答的有题目页面数
var remainingPages = totalPagesWithTasks - answeredPagesWithTasks;
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
}
/// <summary>
/// ============================================
/// 方法思路:计算用户连续打卡天数
/// 1. 获取今天和昨天的日期
/// 2. 查询昨天是否有打卡记录
/// 3. 如果昨天没有打卡记录返回0连续打卡中断
/// 4. 如果昨天有打卡记录:
/// - 查询今天是否有打卡记录
/// - 今天有记录:返回今天的连续天数
/// - 今天无记录:返回昨天的连续天数
/// ============================================
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>连续打卡天数</returns>
private async Task<int> CalculateConsecutiveDaysAsync(long userId)
{
var today = DateTime.Now.Date;
var yesterday = today.AddDays(-1);
// 查询昨天是否有打卡记录
var yesterdayRecord = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && r.CheckInDate.Date == yesterday)
.FirstAsync();
// 如果昨天没有打卡记录连续天数为0连续打卡中断
if (yesterdayRecord == null)
{
return 0;
}
// 昨天有打卡记录,检查今天是否已打卡
var todayRecord = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && r.CheckInDate.Date == today)
.FirstAsync();
// 今天已打卡:返回今天的连续天数;今天未打卡:返回昨天的连续天数
return todayRecord?.ConsecutiveDays ?? yesterdayRecord.ConsecutiveDays;
}
/// <summary>
/// ============================================
/// 方法思路:获取目录列表(按一级目录分组展示)
/// 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都完成
/// ============================================
/// </summary>
/// <param name="journalId">期刊ID</param>
/// <param name="userId">用户ID</param>
/// <returns>目录列表(按一级目录分组)</returns>
public async Task<List<CatalogListOutput>> GetCatalogListAsync(long journalId, long userId)
{
#region 0.
// ============================================
// 第一步:验证 UserJournal 表,确认用户是否绑定了该杂志
// 如果用户没有绑定该杂志,直接返回空列表
// ============================================
var isBound = await userJournalRepository.Context.Queryable<UserJournal>()
.Where(uj => uj.UserId == userId && uj.JournalId == journalId)
.AnyAsync();
if (!isBound)
{
return new List<CatalogListOutput>();
}
#endregion
#region 1.
// 1. 查询期刊
var journal = await journalRepository.Context.Queryable<Journal>()
.Where(j => j.Id == journalId)
.FirstAsync();
if (journal == null)
{
return new List<CatalogListOutput>();
}
#endregion
#region 2.
// 2. 批量加载关联数据
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.JournalId == journalId)
.ToListAsync();
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalId == journalId)
.ToListAsync();
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == journalId)
.ToListAsync();
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == journalId && a.UserId == userId)
.ToListAsync();
#endregion
#region 3. /ID集合
// 3. 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
// 用户已完成的任务ID集合
// 【已完成判断逻辑】
// AnswerStatus: 0=未答题1=已答题2=正确3=半对4=错误
// 只要 AnswerStatus >= 1已答题说明作业已完成
var userCompletedTaskIds = userAnswers
.Where(a => a.AnswerStatus >= AnswerStatusEnum.Answer)
.Select(a => a.JournalPageTaskId)
.ToHashSet();
#endregion
#region 4.
// 4. 按一级目录分组
var firstLevelCatalogs = allCatalogs.Where(c => c.ParentId == 0).OrderBy(c => c.Sort).ToList();
var result = new List<CatalogListOutput>();
foreach (var firstCatalog in firstLevelCatalogs)
{
// ============================================
// 文章篇数计算逻辑:
// - 篇数 = 有题目的页面数(有 JournalPageTask 的页面)
// - 有二级栏目:累加所有二级栏目下有题目的页面数
// - 没有二级栏目:统计一级栏目下直接有题目的页面数
// ============================================
int articleCount = 0;
var children = new List<SecondCatalogOutput>();
// 该一级目录下的二级单元目录
var secondLevelCatalogs = allCatalogs
.Where(c => c.ParentId == firstCatalog.Id)
.OrderBy(c => c.Sort)
.ToList();
if (secondLevelCatalogs.Count > 0)
{
// 有二级单元:按二级单元分组统计
foreach (var secondCatalog in secondLevelCatalogs)
{
// 二级目录下的所有页面JournalPage.JournalCatalogId = 二级目录ID
var secondLevelPages = allPages
.Where(p => p.JournalCatalogId == secondCatalog.Id)
.ToList();
var secondLevelPageIds = secondLevelPages.Select(p => p.Id).ToHashSet();
// 二级目录下有题目的页面(有 JournalPageTask 的页面)
var pagesWithTasks = allTasks
.Where(t => secondLevelPageIds.Contains(t.JournalPageId))
.Select(t => t.JournalPageId)
.Distinct()
.ToHashSet();
// 【关键】如果二级目录下没有有题目的页面,则跳过,不统计到 children
if (pagesWithTasks.Count == 0)
{
continue;
}
// 累加该二级栏目下有题目的页面数(文章篇数)
articleCount += pagesWithTasks.Count;
// 二级单元下的所有任务
var secondLevelTasks = allTasks.Where(t => secondLevelPageIds.Contains(t.JournalPageId)).ToList();
// 判定状态(基于 AnswerStatus >= 1 判断已完成,按页面维度统计)
// 【已完成判断逻辑】
// AnswerStatus: 0=未答题1=已答题2=正确3=半对4=错误
// 只要 AnswerStatus >= 1已答题说明该页面已完成
string status;
// 总页面数(有题目的页面)
var totalPageCount = pagesWithTasks.Count;
// 已完成页面数:该页面有答题记录且 AnswerStatus >= 1
var completedPageCount = pagesWithTasks
.Count(pageId => userAnswers.Any(a => a.JournalPageId == pageId && a.AnswerStatus >= AnswerStatusEnum.Answer));
// 已作答页面数:该页面有答题记录即可
var answeredPageCount = pagesWithTasks
.Count(pageId => userAnswers.Any(a => a.JournalPageId == pageId));
// 状态判定逻辑:
// - answeredPageCount == 0没有任何页面被作答 → 未开始
// - completedPageCount == totalPageCount所有页面都完成 → 已完成
// - 其他:有页面被作答但未全部完成 → 进行中
if (answeredPageCount == 0)
status = "未开始";
else if (completedPageCount == totalPageCount)
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
{
// 没有二级单元:统计一级栏目下直接有题目的页面数
var firstLevelPages = allPages
.Where(p => p.JournalCatalogId == firstCatalog.Id)
.ToList();
var firstLevelPageIds = firstLevelPages.Select(p => p.Id).ToHashSet();
var pagesWithTasks = allTasks
.Where(t => firstLevelPageIds.Contains(t.JournalPageId))
.Select(t => t.JournalPageId)
.Distinct()
.ToHashSet();
articleCount = pagesWithTasks.Count;
// Children保持为空集合不添加任何子项
}
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
}
/// <summary>
/// ============================================
/// 方法思路:获取一级目录概览(含二级目录详情和任务完成状态)
/// 1. 批量预加载:该期刊下所有目录、页面、任务、用户作答记录
/// 2. 获取该一级目录下的所有文章目录Type=Page
/// 3. 跨页题目处理:
/// - 总任务数按GroupId去重跨页题目只算一个
/// - 状态判断按GroupId去重已完成条件是该GroupId下所有任务都完成Status=1
/// - 已作答判断只要有一个任务作答就算该GroupId已作答
/// 4. 状态格式:"已完成数/总数 已完成/进行中/未开始"
/// 例如:"2/3 进行中" 表示3个GroupId中已完成2个
/// ============================================
/// </summary>
/// <param name="catalogId">一级目录ID</param>
/// <param name="userId">用户ID</param>
/// <returns>一级目录概览信息</returns>
public async Task<CatalogOverviewOutput> GetCatalogOverviewAsync(long catalogId, long userId)
{
#region 1.
// 1. 查询一级目录
var firstCatalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalogId)
.FirstAsync();
if (firstCatalog == null)
{
return null;
}
#endregion
#region 2.
// ============================================
// 验证 UserJournal 表,确认用户是否绑定了该杂志
// 如果用户没有绑定该杂志,直接返回 null
// ============================================
var isBound = await userJournalRepository.Context.Queryable<UserJournal>()
.Where(uj => uj.UserId == userId && uj.JournalId == firstCatalog.JournalId)
.AnyAsync();
if (!isBound)
{
return null;
}
#endregion
#region 3.
// 2. 批量加载关联数据
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.JournalId == firstCatalog.JournalId)
.ToListAsync();
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalId == firstCatalog.JournalId)
.ToListAsync();
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == firstCatalog.JournalId)
.ToListAsync();
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == firstCatalog.JournalId && a.UserId == userId)
.ToListAsync();
#endregion
#region 4. /ID集合
// 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
// 用户已完成的任务ID集合
// 【已完成判断逻辑】
// AnswerStatus: 0=未答题1=已答题2=正确3=半对4=错误
// 只要 AnswerStatus >= 1已答题说明作业已完成
var userCompletedTaskIds = userAnswers
.Where(a => a.AnswerStatus >= AnswerStatusEnum.Answer)
.Select(a => a.JournalPageTaskId)
.ToHashSet();
#endregion
#region 5.
// 获取该一级目录下所有二级目录不区分Type
var secondLevelCatalogs = allCatalogs
.Where(c => c.ParentId == firstCatalog.Id)
.OrderBy(c => c.Sort)
.ToList();
// 文章篇数 = 有题目的页面数(有 JournalPageTask 的页面)
int articleCount = 0;
// 总任务数(按 GroupId 去重)
int totalTaskCount = 0;
// 构建子目录详情列表
var subCatalogs = new List<SubCatalogDetailOutput>();
foreach (var secondCatalog in secondLevelCatalogs)
{
// 二级目录下的所有页面
var secondLevelPages = allPages
.Where(p => p.JournalCatalogId == secondCatalog.Id)
.OrderBy(p => p.PageNum)
.ToList();
var secondLevelPageIds = secondLevelPages.Select(p => p.Id).ToHashSet();
// 二级目录下有题目的页面(有 JournalPageTask 的页面)
var pagesWithTasks = allTasks
.Where(t => secondLevelPageIds.Contains(t.JournalPageId))
.Select(t => t.JournalPageId)
.Distinct()
.ToHashSet();
// 如果二级目录下没有有题目的页面,则跳过
if (pagesWithTasks.Count == 0)
{
continue;
}
// 累加文章篇数
articleCount += pagesWithTasks.Count;
// 该文章下的所有任务
var articleTasks = allTasks.Where(t => secondLevelPageIds.Contains(t.JournalPageId)).ToList();
// taskCount = GroupId 去重数(跨页题目只算一个)
var taskCount = articleTasks.Select(t => t.GroupId).Distinct().Count();
// GroupId 去重数(用于状态判断)
var totalGroupCount = taskCount;
// 累加总任务数
totalTaskCount += taskCount;
// 页码范围
var startPage = secondLevelPages.Count > 0 ? secondLevelPages.Min(p => p.PageNum) : 0;
var endPage = secondLevelPages.Count > 0 ? secondLevelPages.Max(p => p.PageNum) : 0;
// 状态格式1/2 进行中、2/2 已完成、0/2 未开始(按 GroupId 去重统计)
// 只要 AnswerStatus >= 1已答题说明该 GroupId 已完成
string status;
if (totalGroupCount == 0)
{
status = "0/0 未开始";
}
else
{
// 已完成 GroupId 数(该 GroupId 下所有任务都 AnswerStatus >= 1 才算完成)
var completedGroupCount = articleTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Count();
// 已作答 GroupId 数(只要有一个任务作答就算该 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 = $"{totalGroupCount}/{totalGroupCount} 已完成";
else
status = $"{completedGroupCount}/{totalGroupCount} 进行中";
}
subCatalogs.Add(new SubCatalogDetailOutput
{
CatalogId = secondCatalog.Id,
SecondLevelCatalogName = secondCatalog.Name ?? string.Empty,
StartPage = startPage,
EndPage = endPage,
TaskCount = taskCount,
Status = status
});
}
#endregion
#region 6.
return new CatalogOverviewOutput
{
CatalogId = firstCatalog.Id,
FirstLevelCatalogName = firstCatalog.Name ?? string.Empty,
PageCount = articleCount,
TotalTaskCount = totalTaskCount,
SubCatalogs = subCatalogs
};
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:获取用户答题任务列表
/// 1. 根据二级栏目ID查询栏目基本信息一级栏目名称、栏目名称、页码范围
/// 2. 按GroupId分组每个GroupId对应一个任务项
/// 3. 每个任务项包含:任务名称、总成长值、总积分、状态
/// 4. 【跨页题目处理】
/// - 是否跨页Id != GroupId
/// - 任务名称:取 Id == GroupId 的主任务的Task字段
/// - 成长值/积分取主任务的每个GroupId只算一次
/// - 状态判断必须该GroupId下所有任务都完成(Status=1)才算该GroupId完成
/// ============================================
/// <param name="catalogId">二级栏目ID/param>
/// <param name="userId">用户ID</param>
/// </summary>
/// <returns>用户答题任务列表信息</returns>
public async Task<UserAnswerTaskListOutput> GetUserAnswerTaskListAsync(long catalogId, long userId)
{
#region 1.
// 1. 查询目录(二级栏目)
var catalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalogId)
.FirstAsync();
if (catalog == null)
{
return null;
}
#endregion
#region 2.
// ============================================
// 验证 UserJournal 表,确认用户是否绑定了该杂志
// 如果用户没有绑定该杂志,直接返回 null
// ============================================
var isBound = await userJournalRepository.Context.Queryable<UserJournal>()
.Where(uj => uj.UserId == userId && uj.JournalId == catalog.JournalId)
.AnyAsync();
if (!isBound)
{
return null;
}
#endregion
#region 3.
// ============================================
// 【获取一级栏目名称】
// 通过 ParentId 关联查询一级栏目名称
// ============================================
string firstLevelCatalogName = string.Empty;
if (catalog.ParentId > 0)
{
var firstLevelCatalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalog.ParentId)
.FirstAsync();
firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty;
}
#endregion
#region 4.
// ============================================
// 批量加载关联数据
// ============================================
// 该栏目下的所有页面
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalCatalogId == catalogId)
.ToListAsync();
// 该期刊下所有任务(用于跨页题目关联查询)
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == catalog.JournalId)
.ToListAsync();
// 该用户在该期刊下所有作答记录
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == catalog.JournalId && a.UserId == userId)
.ToListAsync();
#endregion
#region 5. ID及任务
// 获取该目录下的所有页面ID
var pageIds = allPages.Select(p => p.Id).ToHashSet();
// 该目录下的所有任务仅用于筛选哪些GroupId属于当前栏目
var catalogTasks = allTasks.Where(t => pageIds.Contains(t.JournalPageId)).ToList();
// 【跨页题目-关键】
// 跨页题目的任务可能分布在不同页面(甚至不同栏目)下
// 所以需要从整个期刊的所有任务(allTasks)中按GroupId获取完整的任务组
// 否则无法正确判断跨页题目是否全部完成
var catalogGroupIds = catalogTasks.Select(t => t.GroupId).ToHashSet();
// 用户已作答的任务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();
// 计算页码范围
var startPage = allPages.Count > 0 ? allPages.Min(p => p.PageNum) : 0;
var endPage = allPages.Count > 0 ? allPages.Max(p => p.PageNum) : 0;
#endregion
#region 6. GroupId分组生成任务列表
// ============================================
// 【跨页题目-按GroupId分组生成任务列表】
// 关键从整个期刊的所有任务中按GroupId取完整分组
// 每个GroupId对应一个任务项
// - 任务名称:取 Id == GroupId 的主任务的Task字段
// - 成长值/积分取主任务的每个GroupId只算一次
// - 状态:
// - 未开始该GroupId下无任何任务被作答
// - 已完成该GroupId下所有任务都完成(Status=1)
// - 进行中:有作答但未全部完成
// ============================================
var taskItems = new List<UserAnswerTaskItemOutput>();
// 从整个期刊的所有任务中筛选出属于当前栏目的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 7.
return new UserAnswerTaskListOutput
{
FirstLevelCatalogName = firstLevelCatalogName,
CatalogName = catalog.Name ?? string.Empty,
StartPage = startPage,
EndPage = endPage,
Tasks = taskItems
};
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:获取跨页题目详情
/// 1. 根据 groupId 查询该分组下的所有任务
/// 2. 跨页题目判定:
/// - 任务数 > 1 → 跨页题目
/// - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入)
/// - 任务数 == 1 且 Id == groupId → 普通题目
/// 3. 图片数组获取逻辑:
/// - 已答题:使用用户上传的 PageAnswerUrl 数组
/// - 未答题:使用任务的 TaskUrl 数组补齐
/// 4. 栏目名称获取:
/// - 二级栏目名称:通过页面关联的 JournalCatalogId 查询
/// - 一级栏目名称:通过二级栏目的 ParentId 查询
/// 5. 是否可领取积分判断:
/// - 任务必须已完成Status=1
/// - 跨页题目:所有任务都完成才能领取
/// - 普通题目:当前任务完成即可领取
/// - 且未领取过积分
/// ============================================
/// </summary>
/// <param name="groupId">任务分组IDJournalPageTaskGroupId</param>
/// <param name="userId">用户ID</param>
/// <returns>跨页题目详情</returns>
public async Task<CrossPageTaskOutput> GetCrossPageTaskAsync(long groupId, long userId)
{
#region 0.
// 1. 查询该 GroupId 下的所有任务
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.GroupId == groupId)
.OrderBy(t => t.Id)
.ToListAsync();
if (allTasks == null || allTasks.Count == 0)
{
return null;
}
// ============================================
// 验证 UserJournal 表,确认用户是否绑定了该杂志
// 如果用户没有绑定该杂志,直接返回 null
// ============================================
var journalId = allTasks[0].JournalId;
var isBound = await userJournalRepository.Context.Queryable<UserJournal>()
.Where(uj => uj.UserId == userId && uj.JournalId == journalId)
.AnyAsync();
if (!isBound)
{
return null;
}
#endregion
#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<JournalPageTask> relatedTasks = allTasks;
#endregion
#region 3. /
// ============================================
// 【获取栏目名称】
// 二级栏目名称:通过页面关联的 JournalCatalogId 查询
// 一级栏目名称:通过二级栏目的 ParentId 查询
// ============================================
var page = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.Id == baseTask.JournalPageId)
.FirstAsync();
string catalogName = string.Empty;
string firstLevelCatalogName = string.Empty;
if (page != null)
{
// 获取二级栏目
var catalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == page.JournalCatalogId)
.FirstAsync();
catalogName = catalog?.Name ?? string.Empty;
// 获取一级栏目名称
if (catalog != null && catalog.ParentId > 0)
{
var firstLevelCatalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalog.ParentId)
.FirstAsync();
firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty;
}
}
#endregion
#region 4. Images
// ============================================
// 【获取图片数组】
// 核心逻辑:已答题返回 PageAnswerUrl未答题返回 TaskUrl 补齐
// 跨页题目:
// - 用户答了部分:已答页用 PageAnswerUrl未答页用 TaskUrl 补齐
// - 用户全部作答:全部用 PageAnswerUrl
// - 用户未作答:全部用 TaskUrl
// - 按主页面页码PageNum升序排列确保拼图顺序正确
// 非跨页题目:取单条答题记录的 PageAnswerUrl未答题返回空集合
// ============================================
var images = new List<string>();
// 统一查询用户答题记录(用于图片获取和后续领取积分判断)
List<JournalPageTaskUserAnswer> userAnswers;
if (isCrossPage)
{
// 跨页题目:查询该 GroupId 下所有用户的答题记录
userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId)
.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<JournalPage>()
.Where(p => pageIds.Contains(p.Id))
.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 = DomainHelper.OssFullUrl(userAnswer.PageAnswerUrl);
}
else if (!string.IsNullOrEmpty(relatedTask.TaskUrl))
{
// 未答题或无 PageAnswerUrl使用任务的 TaskUrl 补齐(加上域名)
imageUrl = DomainHelper.OssFullUrl(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<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskId == mainTask.Id && a.UserId == userId)
.FirstAsync();
// 赋值给 userAnswers用于后续领取积分判断
userAnswers = userAnswer != null ? new List<JournalPageTaskUserAnswer> { userAnswer } : new List<JournalPageTaskUserAnswer>();
if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl))
{
// 已答题,使用 PageAnswerUrl加上域名
images.Add(DomainHelper.OssFullUrl(userAnswer.PageAnswerUrl));
}
// 未答题不返回任何图片images 保持空集合
}
#endregion
#region 5. PageAnswerDotUrl
// ============================================
// 【获取页面作答点读图片地址集合】
// 规则:已答题且 PageAnswerDotUrl 有值才返回;未答题或字段为空则跳过
// 跨页题目:遍历所有相关任务,收集有答题记录的 PageAnswerDotUrl
// 非跨页题目:取当前任务答题记录的 PageAnswerDotUrl仅在有值时返回
// ============================================
var pageAnswerDotUrls = new List<string>();
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(DomainHelper.OssFullUrl(userAnswer.PageAnswerDotUrl));
}
}
}
else
{
// 非跨页题目:取当前任务答题记录的 PageAnswerDotUrl加上域名
// 仅在已答题且 PageAnswerDotUrl 有值时返回
if (userAnswers != null && userAnswers.Count > 0)
{
var userAnswer = userAnswers.FirstOrDefault();
if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerDotUrl))
{
pageAnswerDotUrls.Add(DomainHelper.OssFullUrl(userAnswer.PageAnswerDotUrl));
}
}
}
#endregion
#region 6.
// ============================================
// 【是否可领取积分判断】
// 条件:任务已完成且未领取过积分
// - Status = 1已批阅
// - AnswerStatus >= 1已答题正确/半对/错误)
// 跨页题目:所有任务都满足条件才能领取
// 普通题目:当前任务满足条件即可领取
// 【关键】子题目入口mainTask == null无法领取积分
// ============================================
bool canClaimPoints = false;
// 【关键】如果主任务不存在(子题目入口),无法领取积分
if (mainTask == null)
{
// 子题目无法单独领取积分CanClaimPoints 为 false
}
else
{
// 查询是否已领取过积分(用主任务的 GroupId 作为 RelatedId
var existingRecord = await answerRepository.Context.Queryable<PointsRecord>()
.Where(r => r.UserId == userId)
.Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == mainTask.GroupId)
.AnyAsync();
if (!existingRecord)
{
// 未领取过积分,检查任务是否完成
// 条件Status=1 且 AnswerStatus>=1
canClaimPoints = relatedTasks.All(t =>
userAnswers.Any(a => a.JournalPageTaskId == t.Id
&& a.Status == (int)UserAnswerStatusEnum.Complete
&& a.AnswerStatus >= AnswerStatusEnum.Answer));
}
}
#endregion
#region 7.
// ============================================
// 【获取任务名称】
// 跨页题目取主任务Id == GroupId的 Task 字段
// 普通题目:取当前任务的 Task 字段
// 子题目入口:查询主任务的 Task 字段
// ============================================
string taskName;
if (mainTask != null)
{
// 主任务入口,直接使用 mainTask
taskName = mainTask.Task ?? string.Empty;
}
else
{
// 子题目入口查询主任务Id == GroupId
var realMainTask = allTasks.FirstOrDefault(t => t.Id == groupId);
if (realMainTask != null)
{
taskName = realMainTask.Task ?? string.Empty;
}
else
{
// 通过 GroupId 查询主任务(适用于子任务入口的情况)
var mainTaskByGroupId = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.Id == t.GroupId && t.GroupId == groupId)
.FirstAsync();
taskName = mainTaskByGroupId?.Task ?? string.Empty;
}
}
#endregion
#region 8.
return new CrossPageTaskOutput
{
FirstLevelCatalogName = firstLevelCatalogName,
CatalogName = catalogName,
TaskName = taskName,
Images = images,
PageAnswerDotUrl = pageAnswerDotUrls,
IsCrossPage = isCrossPage,
CanClaimPoints = canClaimPoints
};
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:领取任务积分
/// 1. 根据 GroupId 查询任务信息,判断是否跨页题目
/// 2. 查询用户答题记录校验主任务JournalPageTaskId == GroupId才能领取
/// 3. 完成校验Status=1 且 AnswerStatus>=1跨页题目需所有任务都完成
/// 4. 重复领取校验:通过 PointsRecord 表的 RelatedId + ChangeType 判断
/// 5. 计算积分、成长值、得分(均取主任务数据)
/// 6. 调用积分服务增加积分
/// ============================================
/// </summary>
/// <param name="groupId">任务分组IdJournalPageTask.GroupId</param>
/// <param name="userId">用户ID</param>
/// <returns>领取积分结果(总成长值、总积分、得分、任务编号)</returns>
public async Task<ClaimTaskPointsOutput> ClaimTaskPointsAsync(long groupId, long userId)
{
#region 1.
// ============================================
// 1. 查询任务与答题记录
// ============================================
var relatedTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.GroupId == groupId)
.ToListAsync();
if (relatedTasks == null || relatedTasks.Count == 0)
{
throw new BusinessException("任务不存在", ResultCode.NOT_FOUND);
}
// 主任务Id == GroupId
var mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId);
if (mainTask == null)
{
throw new BusinessException("子题目无法单独领取积分,请从主任务入口领取", ResultCode.BAD_REQUEST);
}
// 查询用户答题记录
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId)
.ToListAsync();
if (userAnswers == null || userAnswers.Count == 0)
{
throw new BusinessException("请先完成答题再领取积分", ResultCode.BAD_REQUEST);
}
#endregion
#region 2.
// ============================================
// 2. 完成校验
// 主任务校验 + 跨页题目所有任务校验
// 完成条件Status=1 且 AnswerStatus>=1
// ============================================
// 校验主任务
var mainTaskAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId);
if (mainTaskAnswer == null || mainTaskAnswer.Status != (int)UserAnswerStatusEnum.Complete || mainTaskAnswer.AnswerStatus < AnswerStatusEnum.Answer)
{
throw new BusinessException("请先完成答题再领取积分", ResultCode.BAD_REQUEST);
}
// 跨页题目:校验所有任务
if (relatedTasks.Count > 1)
{
foreach (var task in relatedTasks)
{
var answer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id);
if (answer == null || answer.Status != (int)UserAnswerStatusEnum.Complete || answer.AnswerStatus < AnswerStatusEnum.Answer)
{
throw new BusinessException("跨页题目需要完成所有任务才能领取积分", ResultCode.BAD_REQUEST);
}
}
}
#endregion
#region 3.
// ============================================
// 3. 重复领取校验与积分计算
// ============================================
// 重复领取校验
var existingRecord = await answerRepository.Context.Queryable<PointsRecord>()
.Where(r => r.UserId == userId)
.Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == groupId)
.FirstAsync();
if (existingRecord != null)
{
throw new BusinessException("积分已领取,请勿重复领取", ResultCode.BAD_REQUEST);
}
// 积分、成长值、得分(均取主任务数据)
int totalPoints = mainTask.Points;
int totalGrowthPoints = mainTask.GrowthPoint ?? 0;
float score = mainTaskAnswer.Score;
if (totalPoints <= 0)
{
throw new BusinessException("该任务无积分可领取", ResultCode.BAD_REQUEST);
}
#endregion
#region 4.
// ============================================
// 4. 事务执行领取操作
// - 手动更新 Users.Points积分累加
// - 插入积分流水记录
// - 喂养宠物(如有活跃宠物,更新宠物成长值)
// ============================================
// 查询用户信息(获取当前积分)
var user = await usersRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
}
// 查询用户活跃宠物
var pet = await userPetRepository.Context.Queryable<UserPet>()
.Where(p => p.UserId == userId && p.Status == (int)UserPetStatusEnum.Active)
.FirstAsync();
var result = new ClaimTaskPointsOutput();
await answerRepository.UseTranAsync(async () =>
{
// 4a. 手动更新 Users.Points积分累加
var newPointsBalance = user.Points + totalPoints;
await answerRepository.Context.Updateable<Users>()
.SetColumns(u => u.Points == newPointsBalance)
.SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId)
.ExecuteCommandAsync();
// 4b. 插入积分流水记录
var pointsRecord = new PointsRecord
{
UserId = userId,
ChangeAmount = totalPoints,
BalanceAfter = newPointsBalance,
ChangeType = PointsChangeTypeEnum.TaskReward.ToString(),
RelatedId = groupId,
Description = $"完成任务奖励:{mainTask.Task}",
Type = PointsFlowTypeEnum.Income,
Status = (int)PointsRecordStatusEnum.Success,
IsDeleted = false,
CreatedBy = "系统",
CreatedAt = DateTime.Now,
UpdatedBy = "系统",
UpdatedAt = DateTime.Now
};
await answerRepository.Context.Insertable(pointsRecord).ExecuteCommandAsync();
// 4c. 如果有活跃宠物,喂养宠物(同一事务内,更新宠物成长值)
if (pet != null && totalGrowthPoints > 0)
{
await petService.FeedPetInTranAsync(userId, new Models.Dto.Pet.FeedPetInput
{
PetId = pet.Id,
GrowthPoints = totalGrowthPoints
});
}
// ============================================
// 【任务编号计算】
// 从 JournalPageTask.No 字段按 - 分割,取索引为 2 的部分
// 例如:"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]}";
}
}
result.TotalGrowthPoints = totalGrowthPoints;
result.TotalPoints = totalPoints;
result.Score = score;
result.TaskNo = taskNo;
});
return result;
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:批量领取任务积分(一键领取)
/// 1. 遍历每个传入的 GroupId做校验筛选出可领取的任务
/// 2. 在事务中执行所有写操作:
/// - 更新 Users 表 Points积分服务内部处理
/// - 更新 Users 表 GrowthPoints累加
/// - 喂养宠物(如有活跃宠物)
/// 3. 返回结果包含:
/// - 成功数量和失败数量
/// - 成功详情列表每个GroupId的名称、积分、成长值、得分
/// - 失败详情列表每个GroupId的错误原因
/// - 总积分、总成长值、领取后余额
/// ============================================
/// </summary>
/// <param name="input">批量领取输入GroupId列表</param>
/// <param name="userId">用户ID</param>
/// <returns>批量领取结果</returns>
public async Task<BatchClaimPointsOutput> 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.
// 批量查询已领取的积分记录(用于重复领取校验)
var claimedRecords = await answerRepository.Context.Queryable<PointsRecord>()
.Where(r => groupIds.Contains((long)r.RelatedId!) && r.UserId == userId)
.Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString())
.ToListAsync();
var claimedGroupIds = claimedRecords.Select(r => r.RelatedId!.Value).ToHashSet();
#endregion
#region 3. GroupId
// 存储可领取的任务信息
var claimableTasks = new List<(long GroupId, int Points, int GrowthPoints, float Score, string TaskName, string TaskNo)>();
foreach (var groupId in groupIds)
{
try
{
#region 3.1 GroupId
var relatedTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.GroupId == groupId)
.ToListAsync();
if (relatedTasks == null || relatedTasks.Count == 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "任务不存在"
});
continue;
}
#endregion
#region 3.2
bool isCrossPage = relatedTasks.Count > 1;
// 主任务Id == GroupId
var mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId);
if (mainTask == null)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "子题目无法单独领取积分,请从主任务入口领取"
});
continue;
}
#endregion
#region 3.3
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId)
.ToListAsync();
if (userAnswers == null || userAnswers.Count == 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "请先完成答题再领取积分"
});
continue;
}
#endregion
#region 3.4
// 校验主任务完成状态Status=1 且 AnswerStatus>=1
var mainTaskAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId);
if (mainTaskAnswer == null || mainTaskAnswer.Status != (int)UserAnswerStatusEnum.Complete || mainTaskAnswer.AnswerStatus < AnswerStatusEnum.Answer)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "请先完成答题再领取积分"
});
continue;
}
// 跨页题目:校验所有任务
if (isCrossPage)
{
bool allCompleted = true;
foreach (var task in relatedTasks)
{
var answer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id);
if (answer == null || answer.Status != (int)UserAnswerStatusEnum.Complete || answer.AnswerStatus < AnswerStatusEnum.Answer)
{
allCompleted = false;
break;
}
}
if (!allCompleted)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "跨页题目需要完成所有任务才能领取积分"
});
continue;
}
}
#endregion
#region 3.5
if (claimedGroupIds.Contains(groupId))
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "积分已领取,请勿重复领取"
});
continue;
}
#endregion
#region 3.6
int points = mainTask.Points;
int growthPoints = mainTask.GrowthPoint ?? 0;
float score = mainTaskAnswer.Score;
if (points <= 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "该任务无积分可领取"
});
continue;
}
var taskName = mainTask.Task ?? string.Empty;
// 任务编号计算
string taskNo = string.Empty;
if (!string.IsNullOrEmpty(mainTask.No))
{
var parts = mainTask.No.Split('-');
if (parts.Length >= 3)
{
taskNo = $"任务{parts[2]}";
}
}
#endregion
// 添加到可领取列表
claimableTasks.Add((groupId, points, growthPoints, score, taskName, taskNo));
}
catch (Exception ex)
{
// 捕获异常并添加到失败列表,不中断其他任务的处理
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = ex.Message
});
}
}
#endregion
#region 4.
// ============================================
// 4. 事务执行所有领取操作
// - 手动更新 Users.Points积分累加
// - 插入积分流水记录
// - 喂养宠物(如有活跃宠物,更新宠物成长值)
// ============================================
if (claimableTasks.Count > 0)
{
// 查询用户信息(获取当前积分)
var user = await usersRepository.GetByIdAsync(userId);
if (user == null)
{
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
}
// 查询用户活跃宠物
var pet = await userPetRepository.Context.Queryable<UserPet>()
.Where(p => p.UserId == userId && p.Status == (int)UserPetStatusEnum.Active)
.FirstAsync();
int totalGrowthPoints = 0;
int totalPointsAmount = 0;
await answerRepository.UseTranAsync(async () =>
{
foreach (var task in claimableTasks)
{
totalPointsAmount += task.Points;
totalGrowthPoints += task.GrowthPoints;
// 添加成功详情
result.SuccessItems.Add(new BatchClaimSuccessItem
{
TaskNo = task.TaskNo,
Points = task.Points,
GrowthPoints = task.GrowthPoints,
Score = task.Score
});
}
// 4a. 手动更新 Users.Points积分累加
var newPointsBalance = user.Points + totalPointsAmount;
await answerRepository.Context.Updateable<Users>()
.SetColumns(u => u.Points == newPointsBalance)
.SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId)
.ExecuteCommandAsync();
// 4b. 批量插入积分流水记录
var records = new List<PointsRecord>();
foreach (var task in claimableTasks)
{
records.Add(new PointsRecord
{
UserId = userId,
ChangeAmount = task.Points,
BalanceAfter = newPointsBalance,
ChangeType = PointsChangeTypeEnum.TaskReward.ToString(),
RelatedId = task.GroupId,
Description = $"完成任务奖励:{task.TaskName}",
Type = PointsFlowTypeEnum.Income,
Status = (int)PointsRecordStatusEnum.Success,
IsDeleted = false,
CreatedBy = "系统",
CreatedAt = DateTime.Now,
UpdatedBy = "系统",
UpdatedAt = DateTime.Now
});
}
await answerRepository.Context.Insertable(records).ExecuteCommandAsync();
// 4c. 喂养宠物(同一事务内,更新宠物成长值)
if (pet != null && totalGrowthPoints > 0)
{
await petService.FeedPetInTranAsync(userId, new Models.Dto.Pet.FeedPetInput
{
PetId = pet.Id,
GrowthPoints = totalGrowthPoints
});
}
});
result.SuccessCount = claimableTasks.Count;
}
#endregion
#region 5.
result.FailCount = result.FailItems.Count;
return result;
#endregion
}
}