Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/UserAnswerTaskService.cs
2026-06-29 17:16:38 +08:00

1732 lines
75 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.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto.Points;
using QYZH.InteractiveMagazine.Models.Dto.UserAnswerTaskService;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
namespace QYZH.InteractiveMagazine.Service;
/// <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,
IPointsService pointsService,
ILogger<UserAnswerTaskService> logger)
: BaseRepository<JournalPageTaskUserAnswer>, IUserAnswerTaskService
{
/// <summary>
/// ============================================
/// 方法思路:
/// 1. 批量预加载策略为避免N+1查询问题先按期刊维度批量加载所有关联数据
/// 2. 期刊来源:
/// - 用户绑定的杂志UserJournal表
/// - 用户有作答记录的杂志
/// 合并去重后返回
/// 3. 跨页题目处理核心:
/// - 跨页题目定义JournalPageTask.Id != GroupId同一个GroupId下有多个任务
/// - 进度计算按GroupId去重已完成条件是该GroupId下所有任务都完成Status=1
/// - 积分计算按GroupId去重每个GroupId只计算一次积分取第一个任务的积分
/// 4. 状态判定逻辑:
/// - 未开始:无任何作答记录
/// - 进行中:有作答记录但未全部完成
/// - 已完成所有GroupId都完成
/// 5. 用户未作答任何任务时:
/// - 仍返回绑定杂志的进度信息
/// - 进度、成长值、积分为0
/// - 连续打卡、剩余篇数正常计算
/// ============================================
/// </summary>
/// <param name="userId">用户ID</param>
/// <returns>用户所有期刊的学习进度列表</returns>
public async Task<List<JournalProgressOutput>> GetJournalProgressAsync(long userId)
{
// ============================================
// 跨页题目处理核心逻辑说明:
// 1. 跨页题目定义JournalPageTask.Id != GroupId
// - Id 是任务的唯一标识
// - GroupId 是跨页题目的分组标识同一个跨页题目的所有任务共享同一个GroupId
// 2. 进度计算:按 GroupId 去重
// - 已完成条件:该 GroupId 下所有任务都完成Status=1
// 3. 积分计算:按 GroupId 去重,每个 GroupId 只计算一次积分
// 4. 状态判定:
// - 未开始:无任何 GroupId 被作答
// - 进行中:有 GroupId 被作答但未全部完成
// - 已完成:所有 GroupId 都完成
// ============================================
#region 1.
// 1. 查询用户的所有作答记录
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.UserId == userId && !a.IsDeleted)
.ToListAsync();
#endregion
#region 2. ID+
// ============================================
// 2. 收集所有需要展示的期刊ID
// - 用户绑定的杂志UserJournal表
// - 用户有作答记录的杂志
// 合并去重
// ============================================
var boundJournalIds = await userJournalRepository.Context.Queryable<UserJournal>()
.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<JournalProgressOutput>();
}
#endregion
#region 3.
// 3. 批量预加载关联实体按期刊维度全量加载避免N+1查询
var journals = await journalRepository.Context.Queryable<Journal>()
.Where(j => journalIds.Contains(j.Id) && !j.IsDeleted)
.ToListAsync();
// 加载这些期刊的一级目录ParentId == 0 表示一级目录)
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => journalIds.Contains(c.JournalId) && c.ParentId == 0 && !c.IsDeleted)
.ToListAsync();
// 加载这些期刊的所有任务
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => journalIds.Contains(t.JournalId) && !t.IsDeleted)
.ToListAsync();
// 加载这些期刊的所有页面(用于计算剩余篇数)
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => journalIds.Contains(p.JournalId) && !p.IsDeleted)
.ToListAsync();
#endregion
#region 4.
// 4. 查询用户连续打卡天数
var consecutiveDays = await CalculateConsecutiveDaysAsync(userId);
#endregion
#region 5. /ID集合
// 5. 提取用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
var userAnsweredPageIds = userAnswers.Select(a => a.JournalPageId).ToHashSet();
// 用户已完成的任务ID集合Status=1表示已完成
var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet();
#endregion
#region 6.
// 6. 按期刊分组聚合
var result = new List<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 totalTaskCount = journalTasks.Select(t => t.GroupId).Distinct().Count();
// 【跨页题目-已完成判断】已完成任务数:按 GroupId 去重
// 关键:必须该 GroupId 下所有任务都完成,才算该 GroupId 完成
var completedTaskCount = journalTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Count();
// 【跨页题目-积分计算】获取已完成的 GroupId 列表
var completedGroupIds = journalTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Select(g => g.Key)
.ToList();
// 【跨页题目-积分去重】每个已完成的 GroupId 只取第一个任务的积分
// 原因:同一个跨页题目的多个任务只应该计算一次积分
var distinctCompletedTasks = journalTasks
.Where(t => completedGroupIds.Contains(t.GroupId))
.GroupBy(t => t.GroupId)
.Select(g => g.First())
.ToList();
// 累计已完成的跨页题目的总成长值和总积分
var totalGrowthPoints = distinctCompletedTasks.Sum(t => t.GrowthPoint ?? 0);
var totalPoints = distinctCompletedTasks.Sum(t => t.Points);
// 【跨页题目-进度百分比计算】
var totalGroupIds = journalTasks.Select(t => t.GroupId).Distinct().Count();
var completedGroupIdCount = journalTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Count();
var progress = totalGroupIds > 0
? Math.Round((double)completedGroupIdCount / totalGroupIds * 100, 2)
: 0;
// 剩余篇数:该期刊总页数 - 用户已作答页数
var journalPages = allPages.Where(p => p.JournalId == journal.Id).ToList();
var answeredPageCount = journalPages.Count(p => userAnsweredPageIds.Contains(p.Id));
var remainingPages = journalPages.Count - answeredPageCount;
result.Add(new JournalProgressOutput
{
JournalId = journal.Id,
JournalName = journal.Name ?? string.Empty,
CatalogCount = catalogCount,
TotalTaskCount = totalTaskCount,
CompletedTaskCount = completedTaskCount,
TotalGrowthPoints = totalGrowthPoints,
TotalPoints = totalPoints,
Progress = progress,
RemainingPages = remainingPages,
ConsecutiveDays = consecutiveDays
});
}
return result;
#endregion
}
/// <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.IsDeleted && r.CheckInDate.Date == yesterday)
.FirstAsync();
// 如果昨天没有打卡记录连续天数为0连续打卡中断
if (yesterdayRecord == null)
{
return 0;
}
// 昨天有打卡记录,检查今天是否已打卡
var todayRecord = await checkInRecordRepository.Context.Queryable<CheckInRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && 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 1.
// 1. 查询期刊
var journal = await journalRepository.Context.Queryable<Journal>()
.Where(j => j.Id == journalId && !j.IsDeleted)
.FirstAsync();
if (journal == null)
{
return new List<CatalogListOutput>();
}
#endregion
#region 2.
// 2. 批量加载关联数据
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.JournalId == journalId && !c.IsDeleted)
.ToListAsync();
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalId == journalId && !p.IsDeleted)
.ToListAsync();
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == journalId && !t.IsDeleted)
.ToListAsync();
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == journalId && a.UserId == userId && !a.IsDeleted)
.ToListAsync();
#endregion
#region 3. /ID集合
// 3. 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
// 用户已完成的任务ID集合Status=1表示已完成
var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet();
#endregion
#region 4.
// 4. 按一级目录分组
var firstLevelCatalogs = allCatalogs.Where(c => c.ParentId == 0 && c.Type == JournalCatalogTypeEnum.Unit).OrderBy(c => c.Sort).ToList();
var result = new List<CatalogListOutput>();
foreach (var firstCatalog in firstLevelCatalogs)
{
// 该一级目录下的 Type=Page 子目录(即文章)
var pageCatalogs = allCatalogs
.Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Page)
.OrderBy(c => c.Sort)
.ToList();
var pageCatalogIds = pageCatalogs.Select(c => c.Id).ToHashSet();
// 获取所有文章下的页面ID
var allArticlePageIds = allPages
.Where(p => pageCatalogIds.Contains(p.JournalCatalogId))
.Select(p => p.Id)
.ToHashSet();
// 该一级目录下的二级单元目录Type=Unit 的子目录)
var secondLevelCatalogs = allCatalogs
.Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Unit)
.OrderBy(c => c.Sort)
.ToList();
// ============================================
// 文章篇数计算逻辑:
// - 有二级栏目:累加所有二级栏目下的文章数 + 一级栏目下直接的文章数
// - 没有二级栏目计算一级栏目下直接的文章数Type=Page
// ============================================
int articleCount = 0;
var children = new List<SecondCatalogOutput>();
if (secondLevelCatalogs.Count > 0)
{
// 有二级单元:按二级单元分组
// 先统计一级栏目下直接的文章数Type=PageParentId=一级栏目Id
articleCount = pageCatalogs.Count;
foreach (var secondCatalog in secondLevelCatalogs)
{
// 二级单元下的 Type=Page 子目录
var subPageCatalogs = allCatalogs
.Where(c => c.ParentId == secondCatalog.Id && c.Type == JournalCatalogTypeEnum.Page)
.ToList();
// 累加该二级栏目下的文章数
articleCount += subPageCatalogs.Count;
var subPageCatalogIds = subPageCatalogs.Select(c => c.Id).ToHashSet();
// 二级单元下的所有页面
var secondLevelPageIds = allPages
.Where(p => subPageCatalogIds.Contains(p.JournalCatalogId))
.Select(p => p.Id)
.ToHashSet();
// 二级单元下的所有任务
var secondLevelTasks = allTasks.Where(t => secondLevelPageIds.Contains(t.JournalPageId)).ToList();
// 判定状态Status=0进行中Status=1已完成跨页题目按GroupId去重
// 跨页题目必须所有任务都完成,才算该 GroupId 已完成
string status;
if (secondLevelTasks.Count == 0)
{
status = "未开始";
}
else
{
// 总 GroupId 数(跨页题目只算一个)
var totalGroupCount = secondLevelTasks.Select(t => t.GroupId).Distinct().Count();
// 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成)
var completedGroupCount = secondLevelTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Count();
// 【关键】已作答 GroupId 数:只要有一个任务作答就算该 GroupId 已作答(包含进行中和已完成)
// 这个字段用于判断状态:未开始 / 进行中 / 已完成
var answeredGroupCount = secondLevelTasks
.Where(t => userAnsweredTaskIds.Contains(t.Id))
.Select(t => t.GroupId)
.Distinct()
.Count();
// 状态判定逻辑:
// - answeredGroupCount == 0没有任何 GroupId 被作答 → 未开始
// - completedGroupCount == totalGroupCount所有 GroupId 都完成 → 已完成
// - 其他:有 GroupId 被作答但未全部完成 → 进行中
if (answeredGroupCount == 0)
status = "未开始";
else if (completedGroupCount == totalGroupCount)
status = "已完成";
else
status = "进行中";
}
// 计算该二级目录的总成长值和总积分跨页题目按GroupId去重
var distinctSecondLevelTasks = secondLevelTasks
.GroupBy(t => t.GroupId)
.Select(g => g.First())
.ToList();
var totalGrowthPoints = distinctSecondLevelTasks.Sum(t => t.GrowthPoint) ?? 0;
var totalPoints = distinctSecondLevelTasks.Sum(t => t.Points);
children.Add(new SecondCatalogOutput
{
CatalogId = secondCatalog.Id,
CatalogName = secondCatalog.Name ?? string.Empty,
TotalPoints = totalPoints,
TotalGrowthPoints = totalGrowthPoints,
Status = status
});
}
}
else
{
// 没有二级单元只统计一级栏目下直接的文章数Children为空集合
articleCount = pageCatalogs.Count;
// Children保持为空集合不添加任何子项
}
// 计算该一级目录的总成长值和总积分(基于所有文章页面的任务)
var firstLevelTasks = allTasks.Where(t => allArticlePageIds.Contains(t.JournalPageId)).ToList();
result.Add(new CatalogListOutput
{
CatalogId = firstCatalog.Id,
CatalogName = firstCatalog.Name ?? string.Empty,
JournalName = journal.Name ?? string.Empty,
ArticleCount = articleCount,
Children = children
});
}
#endregion
#region 5.
return result;
#endregion
}
/// <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 && !c.IsDeleted)
.FirstAsync();
if (firstCatalog == null)
{
return null;
}
#endregion
#region 2.
// 2. 批量加载关联数据
var allCatalogs = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.JournalId == firstCatalog.JournalId && !c.IsDeleted)
.ToListAsync();
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalId == firstCatalog.JournalId && !p.IsDeleted)
.ToListAsync();
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == firstCatalog.JournalId && !t.IsDeleted)
.ToListAsync();
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == firstCatalog.JournalId && a.UserId == userId && !a.IsDeleted)
.ToListAsync();
#endregion
#region 3. /ID集合
// 3. 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
// 用户已完成的任务ID集合Status=1表示已完成
var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet();
#endregion
#region 4. Type=Page子目录
// 4. 获取该一级目录下所有 Type=Page 的子目录(即文章/页面目录)
var pageCatalogs = allCatalogs
.Where(c => c.ParentId == firstCatalog.Id && c.Type == JournalCatalogTypeEnum.Page)
.OrderBy(c => c.Sort)
.ToList();
// 5. 文章篇数 = Type=Page 的子目录数量
var articleCount = pageCatalogs.Count;
#endregion
#region 5.
// 6. 获取所有文章目录下的页面
var pageCatalogIds = pageCatalogs.Select(c => c.Id).ToHashSet();
var allLevelPages = allPages.Where(p => pageCatalogIds.Contains(p.JournalCatalogId)).ToList();
var allLevelPageIds = allLevelPages.Select(p => p.Id).ToHashSet();
// 7. 总任务数(按 GroupId 去重,跨页题目只算一个)
var totalTaskCount = allTasks
.Where(t => allLevelPageIds.Contains(t.JournalPageId))
.Select(t => t.GroupId)
.Distinct()
.Count();
// 8. 构建子目录详情列表
var subCatalogs = new List<SubCatalogDetailOutput>();
foreach (var pageCatalog in pageCatalogs)
{
// 该文章下的所有页面
var articlePages = allPages
.Where(p => p.JournalCatalogId == pageCatalog.Id)
.OrderBy(p => p.PageNum)
.ToList();
var articlePageIds = articlePages.Select(p => p.Id).ToHashSet();
// 该文章下的所有任务
var articleTasks = allTasks.Where(t => articlePageIds.Contains(t.JournalPageId)).ToList();
// 总 GroupId 数(按 GroupId 去重,跨页题目只算一个)
var totalGroupCount = articleTasks.Select(t => t.GroupId).Distinct().Count();
// 页码范围
var startPage = articlePages.Count > 0 ? articlePages.Min(p => p.PageNum) : 0;
var endPage = articlePages.Count > 0 ? articlePages.Max(p => p.PageNum) : 0;
// 状态格式1/2 进行中、2/2 已完成、0/2 未开始跨页题目按GroupId去重
// 跨页题目必须所有任务都完成,才算该 GroupId 已完成
string status;
if (totalGroupCount == 0)
{
status = $"0/0 未开始";
}
else
{
// 已完成 GroupId 数(该 GroupId 下所有任务都完成才算完成)
var completedGroupCount = articleTasks
.GroupBy(t => t.GroupId)
.Where(g => g.All(t => userCompletedTaskIds.Contains(t.Id)))
.Count();
// 已作答 GroupId 数(只要有一个任务作答就算已作答,包含进行中和已完成)
var answeredGroupCount = articleTasks
.Where(t => userAnsweredTaskIds.Contains(t.Id))
.Select(t => t.GroupId)
.Distinct()
.Count();
if (answeredGroupCount == 0)
status = $"0/{totalGroupCount} 未开始";
else if (completedGroupCount == totalGroupCount)
status = $"{completedGroupCount}/{totalGroupCount} 已完成";
else
status = $"{completedGroupCount}/{totalGroupCount} 进行中";
}
subCatalogs.Add(new SubCatalogDetailOutput
{
CatalogId = pageCatalog.Id,
SecondLevelCatalogName = pageCatalog.Name ?? string.Empty,
StartPage = startPage,
EndPage = endPage,
TaskCount = totalGroupCount,
Status = status
});
}
#endregion
#region 6.
return new CatalogOverviewOutput
{
CatalogId = firstCatalog.Id,
FirstLevelCatalogName = firstCatalog.Name ?? string.Empty,
PageCount = articleCount,
TotalTaskCount = totalTaskCount,
SubCatalogs = subCatalogs
};
#endregion
}
/// <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 && !c.IsDeleted)
.FirstAsync();
if (catalog == null)
{
return null;
}
#endregion
#region 2.
// ============================================
// 【获取一级栏目名称】
// 通过 ParentId 关联查询一级栏目名称
// ============================================
string firstLevelCatalogName = string.Empty;
if (catalog.ParentId > 0)
{
var firstLevelCatalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalog.ParentId && !c.IsDeleted)
.FirstAsync();
firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty;
}
#endregion
#region 3.
// ============================================
// 2. 批量加载关联数据
// ============================================
// 该栏目下的所有页面
var allPages = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => p.JournalCatalogId == catalogId && !p.IsDeleted)
.ToListAsync();
// 该期刊下所有任务(用于跨页题目关联查询)
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.JournalId == catalog.JournalId && !t.IsDeleted)
.ToListAsync();
// 该用户在该期刊下所有作答记录
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalId == catalog.JournalId && a.UserId == userId && !a.IsDeleted)
.ToListAsync();
#endregion
#region 4. ID及任务
// 3. 获取该目录下的所有页面ID
var pageIds = allPages.Select(p => p.Id).ToHashSet();
// 4. 该目录下的所有任务仅用于筛选哪些GroupId属于当前栏目
var catalogTasks = allTasks.Where(t => pageIds.Contains(t.JournalPageId)).ToList();
// 【跨页题目-关键】
// 跨页题目的任务可能分布在不同页面(甚至不同栏目)下
// 所以需要从整个期刊的所有任务(allTasks)中按GroupId获取完整的任务组
// 否则无法正确判断跨页题目是否全部完成
var catalogGroupIds = catalogTasks.Select(t => t.GroupId).ToHashSet();
// 5. 用户已作答的任务ID集合包含进行中和已完成
var userAnsweredTaskIds = userAnswers.Select(a => a.JournalPageTaskId).ToHashSet();
// 用户已完成的任务ID集合Status=1表示已完成
var userCompletedTaskIds = userAnswers.Where(a => a.Status == (int)UserAnswerStatusEnum.Complete).Select(a => a.JournalPageTaskId).ToHashSet();
// 6. 计算页码范围
var startPage = allPages.Count > 0 ? allPages.Min(p => p.PageNum) : 0;
var endPage = allPages.Count > 0 ? allPages.Max(p => p.PageNum) : 0;
#endregion
#region 5. GroupId分组生成任务列表
// ============================================
// 【跨页题目-按GroupId分组生成任务列表】
// 关键从整个期刊的所有任务中按GroupId取完整分组
// 每个GroupId对应一个任务项
// - 任务名称:取 Id == GroupId 的主任务的Task字段
// - 成长值/积分取主任务的每个GroupId只算一次
// - 状态:
// - 未开始该GroupId下无任何任务被作答
// - 已完成该GroupId下所有任务都完成(Status=1)
// - 进行中:有作答但未全部完成
// ============================================
var taskItems = new List<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 6.
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">任务分组IDGroupId</param>
/// <param name="userId">用户ID</param>
/// <returns>跨页题目详情</returns>
public async Task<CrossPageTaskOutput> GetCrossPageTaskAsync(long groupId, long userId)
{
// 1. 查询该 GroupId 下的所有任务
var allTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.GroupId == groupId && !t.IsDeleted)
.OrderBy(t => t.Id)
.ToListAsync();
if (allTasks == null || allTasks.Count == 0)
{
return null;
}
#region 1.
// ============================================
// 【跨页题目判定】
// 判断标准:
// - 任务数 > 1 → 跨页题目
// - 任务数 == 1 且 Id != groupId → 跨页题目(子任务进入)
// - 任务数 == 1 且 Id == groupId → 普通题目
// ============================================
bool isCrossPage = allTasks.Count > 1 || (allTasks.Count == 1 && allTasks[0].Id != groupId);
// 获取主任务Id == GroupId用于跨页题目获取任务名称、积分等
var mainTask = allTasks.FirstOrDefault(t => t.Id == groupId);
// 获取任一任务作为基础信息(用于栏目查询等)
var baseTask = allTasks[0];
#endregion
#region 2.
// ============================================
// 【获取相关任务列表】
// 跨页题目allTasks 即为相关任务列表
// 非跨页题目:单任务列表
// ============================================
List<JournalPageTask> relatedTasks = allTasks;
#endregion
#region 3. /
// ============================================
// 【获取栏目名称】
// 二级栏目名称:通过页面关联的 JournalCatalogId 查询
// 一级栏目名称:通过二级栏目的 ParentId 查询
// ============================================
var page = await pageRepository.Context.Queryable<JournalPage>()
.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<JournalCatalog>()
.Where(c => c.Id == page.JournalCatalogId && !c.IsDeleted)
.FirstAsync();
catalogName = catalog?.Name ?? string.Empty;
// 获取一级栏目名称
if (catalog != null && catalog.ParentId > 0)
{
var firstLevelCatalog = await catalogRepository.Context.Queryable<JournalCatalog>()
.Where(c => c.Id == catalog.ParentId && !c.IsDeleted)
.FirstAsync();
firstLevelCatalogName = firstLevelCatalog?.Name ?? string.Empty;
}
}
#endregion
#region 4. Images
// ============================================
// 【获取图片数组】
// 核心逻辑:已答题返回 PageAnswerUrl未答题返回 TaskUrl 补齐
// 跨页题目:
// - 用户答了部分:已答页用 PageAnswerUrl未答页用 TaskUrl 补齐
// - 用户全部作答:全部用 PageAnswerUrl
// - 用户未作答:全部用 TaskUrl
// - 按主页面页码PageNum升序排列确保拼图顺序正确
// 非跨页题目:取单条答题记录的 PageAnswerUrl未答题返回空集合
// ============================================
var images = new List<string>();
// 统一查询用户答题记录(用于图片获取和后续领取积分判断)
List<JournalPageTaskUserAnswer> userAnswers;
if (isCrossPage)
{
// 跨页题目:查询该 GroupId 下所有用户的答题记录
userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted)
.OrderBy(a => a.JournalPageTaskId)
.ToListAsync();
// ============================================
// 【跨页题目图片获取逻辑】
// 1. 收集相关任务所在页面的页码信息
// 2. 遍历每个相关任务:已答用 PageAnswerUrl未答用 TaskUrl 补齐
// 3. 按页码升序排序后输出,确保拼图顺序正确
// ============================================
// 查询相关任务对应的页面信息(用于排序)
var pageIds = relatedTasks.Select(t => t.JournalPageId).Distinct().ToList();
var pageInfoList = await pageRepository.Context.Queryable<JournalPage>()
.Where(p => pageIds.Contains(p.Id) && !p.IsDeleted)
.ToListAsync();
var pageInfoDict = pageInfoList.ToDictionary(p => p.Id, p => p.PageNum);
// 组装图片项:(页码, 图片地址)
var imageItems = new List<(int PageNum, string Url)>();
foreach (var relatedTask in relatedTasks)
{
var pageNum = pageInfoDict.TryGetValue(relatedTask.JournalPageId, out var num) ? num : 0;
var userAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == relatedTask.Id);
string imageUrl;
if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl))
{
// 已答题:使用 PageAnswerUrl
imageUrl = userAnswer.PageAnswerUrl;
}
else if (!string.IsNullOrEmpty(relatedTask.TaskUrl))
{
// 未答题或无 PageAnswerUrl使用任务的 TaskUrl 补齐
imageUrl = relatedTask.TaskUrl;
}
else
{
// 无任何图片可用,跳过
continue;
}
imageItems.Add((pageNum, imageUrl));
}
// 按页码升序排序后输出图片地址
images.AddRange(imageItems.OrderBy(x => x.PageNum).Select(x => x.Url));
}
else
{
// 非跨页题目:查询单条答题记录
var userAnswer = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskId == mainTask.Id && a.UserId == userId && !a.IsDeleted)
.FirstAsync();
// 赋值给 userAnswers用于后续领取积分判断
userAnswers = userAnswer != null ? new List<JournalPageTaskUserAnswer> { userAnswer } : new List<JournalPageTaskUserAnswer>();
if (userAnswer != null && !string.IsNullOrEmpty(userAnswer.PageAnswerUrl))
{
// 已答题,使用 PageAnswerUrl
images.Add(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(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<PointsRecord>()
.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<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskId == mainTask.Id && a.UserId == userId && !a.IsDeleted)
.FirstAsync();
canClaimPoints = userAnswer?.Status == (int)UserAnswerStatusEnum.Complete;
}
}
#endregion
#region 7.
// ============================================
// 【获取任务名称】
// 跨页题目取主任务Id == GroupId的 Task 字段
// 普通题目:取当前任务的 Task 字段
// ============================================
string taskName;
if (isCrossPage)
{
taskName = mainTask?.Task ?? string.Empty;
}
else
{
taskName = mainTask?.Task ?? string.Empty;
}
#endregion
#region 8.
return new CrossPageTaskOutput
{
FirstLevelCatalogName = firstLevelCatalogName,
CatalogName = catalogName,
TaskName = taskName,
Images = images,
PageAnswerDotUrl = pageAnswerDotUrls,
IsCrossPage = isCrossPage,
CanClaimPoints = canClaimPoints
};
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:领取任务积分
/// 1. 根据 GroupId 查询任务信息,判断是否跨页题目
/// - 普通题目:该 GroupId 下只有1个任务且 Id == GroupId
/// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId
/// 2. 通过 groupId 查询用户答题记录
/// 3. 跨页题目完成校验:必须该 GroupId 下所有任务都完成Status=1
/// 普通题目校验只需要该任务完成Status=1
/// 4. 重复领取校验:通过 PointsRecord 表的 RelatedId + ChangeType 判断
/// 5. 积分计算:
/// - 跨页题目:累计该 GroupId 下所有任务的积分
/// - 普通题目:取当前任务的积分
/// 6. 得分计算:从 JournalPageTaskUserAnswer 表获取 Score
/// ============================================
/// </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. GroupId查询所有相关任务
// ============================================
// 1. 根据 GroupId 查询所有相关任务
// ============================================
var relatedTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.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
// 跨页题目:该 GroupId 下有多个任务,或任务的 Id != GroupId
// ============================================
bool isCrossPage;
JournalPageTask mainTask;
if (relatedTasks.Count == 1 && relatedTasks[0].Id == groupId)
{
// 普通题目只有1个任务且 Id == GroupId
isCrossPage = false;
mainTask = relatedTasks[0];
}
else
{
// 跨页题目:有多个任务,或者 Id != GroupId
isCrossPage = true;
// 取 Id == GroupId 的任务作为主任务(跨页题目的第一条记录)
mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0];
}
#endregion
#region 3.
// ============================================
// 3. 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询)
// ============================================
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.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才能领取积分
// 普通题目只需要该任务完成Status=1即可领取
// ============================================
if (isCrossPage)
{
// 跨页题目:检查该 GroupId 下每个任务是否都有 Status=1 的答题记录
foreach (var task in relatedTasks)
{
var completedAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id && a.Status == (int)UserAnswerStatusEnum.Complete);
if (completedAnswer == null)
{
throw new BusinessException("跨页题目需要完成所有任务才能领取积分", 400);
}
}
}
else
{
// 普通题目:只需要 Status=1 即可
if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete)
{
throw new BusinessException("请先完成答题再领取积分", 400);
}
}
#endregion
#region 5.
// ============================================
// 5. 积分计算
// 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值)
// 跨页题目只取主任务Id == GroupId的积分不是累加所有任务的积分
// 普通题目:取主任务的积分
// ============================================
int totalPoints = 0;
if (isCrossPage)
{
// 跨页题目只取主任务Id == GroupId的积分
totalPoints = mainTask.Points;
}
else
{
// 普通题目:取主任务的积分
totalPoints = mainTask.Points;
}
if (totalPoints <= 0)
{
throw new BusinessException("该任务无积分可领取", 400);
}
#endregion
#region 6.
// ============================================
// 6. 得分计算
// 从 JournalPageTaskUserAnswer 表获取 Score
// 【关键】Score 取 JournalPageTaskId == JournalPageTaskGroupId 的分数
// 跨页题目取主任务Id == GroupId的分数不是累加所有任务的分数
// 普通题目:取当前任务对应答题记录的分数
// ============================================
float totalScore = 0;
if (isCrossPage)
{
// 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数
totalScore = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId)?.Score ?? 0;
}
else
{
// 普通题目:取当前任务对应答题记录的分数
totalScore = userAnswers.FirstOrDefault()?.Score ?? 0;
}
#endregion
#region 7.
// ============================================
// 7. 重复领取校验
// 通过 PointsRecord 表的 RelatedId + ChangeType 判断是否已领取
// 使用 GroupId 作为 RelatedId
// ============================================
var existingRecord = await answerRepository.Context.Queryable<PointsRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted)
.Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString() && r.RelatedId == groupId)
.FirstAsync();
if (existingRecord != null)
{
throw new BusinessException("积分已领取,请勿重复领取", 400);
}
#endregion
#region 8.
// ============================================
// 8. 调用积分服务添加积分记录
// ============================================
var addPointsResult = await pointsService.AddPointsAsync(new AddPointsInput
{
UserId = userId,
Amount = totalPoints,
ChangeType = PointsChangeTypeEnum.TaskReward,
RelatedId = groupId,
Description = $"完成任务奖励:{mainTask.Task}",
OperatorName = "系统"
});
// ============================================
// 【任务编号计算】
// 从 JournalPageTask.No 字段按 - 分割,取索引为 2 的部分
// No 最多出现索引为 3即最多4段0-3
// 例如:"A-B-C-D" → 分割后 ["A","B","C","D"] → 取索引2 = "C"
// ============================================
string taskNo = string.Empty;
if (!string.IsNullOrEmpty(mainTask.No))
{
var parts = mainTask.No.Split('-');
if (parts.Length >= 3)
{
taskNo = parts[2];
}
}
return new ClaimTaskPointsOutput
{
RecordId = addPointsResult.RecordId,
PreviousBalance = addPointsResult.PreviousBalance,
NewBalance = addPointsResult.NewBalance,
AddedAmount = addPointsResult.AddedAmount,
TaskName = mainTask.Task ?? string.Empty,
TaskNo = !string.IsNullOrWhiteSpace(taskNo) ? "任务:"+ taskNo : string.Empty,
Score = totalScore
};
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:批量领取任务积分(一键领取)
/// 1. 遍历每个传入的 GroupId
/// a. 根据 GroupId 查询所有相关任务,判断是否跨页题目
/// - 普通题目:该 GroupId 下只有1个任务且 Id == GroupId
/// - 跨页题目:该 GroupId 下有多个任务,或 Id != GroupId
/// b. 通过 JournalPageTaskGroupId 查询用户答题记录
/// c. 跨页题目完成校验:必须该 GroupId 下所有任务都完成Status=1
/// d. 重复领取校验
/// e. 计算积分、成长值、得分:
/// - 跨页题目:所有计算都只算一次(第一个任务的积分/成长值/得分)
/// - 普通题目:正常计算
/// 2. 返回结果包含:
/// - 成功数量和失败数量
/// - 成功详情列表每个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列表不能为空", 400);
}
var groupIds = input.GroupIds.Distinct().ToList();
#endregion
#region 2.
// 1. 批量查询已领取的积分记录(用于重复领取校验)
var claimedRecords = await answerRepository.Context.Queryable<PointsRecord>()
.Where(r => groupIds.Contains((long)r.RelatedId!) && r.UserId == userId && !r.IsDeleted)
.Where(r => r.ChangeType == PointsChangeTypeEnum.TaskReward.ToString())
.ToListAsync();
var claimedGroupIds = claimedRecords.Select(r => r.RelatedId!.Value).ToHashSet();
#endregion
#region 3. GroupId
// 2. 逐个处理每个 GroupId
int totalPoints = 0;
int totalGrowthPoints = 0;
int currentBalance = 0;
foreach (var groupId in groupIds)
{
try
{
#region 3.1 GroupId
// ============================================
// 2.1 根据 GroupId 查询所有相关任务
// ============================================
var relatedTasks = await taskRepository.Context.Queryable<JournalPageTask>()
.Where(t => t.GroupId == groupId && !t.IsDeleted)
.ToListAsync();
if (relatedTasks == null || relatedTasks.Count == 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "任务不存在"
});
continue;
}
#endregion
#region 3.2
// ============================================
// 2.2 判断是否跨页题目
// 普通题目:该 GroupId 下只有1个任务且 Id == GroupId
// 跨页题目:有多个任务,或 Id != GroupId
// ============================================
bool isCrossPage;
JournalPageTask mainTask;
if (relatedTasks.Count == 1 && relatedTasks[0].Id == groupId)
{
// 普通题目
isCrossPage = false;
mainTask = relatedTasks[0];
}
else
{
// 跨页题目
isCrossPage = true;
// 取 Id == GroupId 的任务作为主任务
mainTask = relatedTasks.FirstOrDefault(t => t.Id == groupId) ?? relatedTasks[0];
}
#endregion
#region 3.3
// ============================================
// 2.3 查询用户答题记录(统一通过 JournalPageTaskGroupId 查询)
// ============================================
var userAnswers = await answerRepository.Context.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.JournalPageTaskGroupId == groupId && a.UserId == userId && !a.IsDeleted)
.ToListAsync();
if (userAnswers == null || userAnswers.Count == 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "请先完成答题再领取积分"
});
continue;
}
#endregion
#region 3.4
// ============================================
// 2.4 跨页题目完成校验
// 跨页题目必须该 GroupId 下所有任务都完成Status=1
// 普通题目只需要该任务完成Status=1
// ============================================
bool isCompleted = true; // 完成状态标志
if (isCrossPage)
{
// 跨页题目:检查该 GroupId 下每个任务是否都有 Status=1 的答题记录
foreach (var task in relatedTasks)
{
var completedAnswer = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == task.Id && a.Status == (int)UserAnswerStatusEnum.Complete);
if (completedAnswer == null)
{
isCompleted = false;
break; // 发现未完成的任务,立即退出循环
}
}
if (!isCompleted)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "跨页题目需要完成所有任务才能领取积分"
});
continue; // 跳出外层循环,处理下一个 groupId
}
}
else
{
// 普通题目:只需要 Status=1 即可
if (userAnswers.FirstOrDefault()?.Status != (int)UserAnswerStatusEnum.Complete)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "请先完成答题再领取积分"
});
continue;
}
}
#endregion
#region 3.5
// ============================================
// 2.5 重复领取校验
// ============================================
if (claimedGroupIds.Contains(groupId))
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "积分已领取,请勿重复领取"
});
continue;
}
#endregion
#region 3.6
// ============================================
// 2.6 计算积分、成长值、得分
// 【关键】跨页题目所有计算都只算一次(取 JournalPageTaskId == JournalPageTaskGroupId 的值)
// Score 取 JournalPageTaskId == JournalPageTaskGroupId 的分数,不是累加所有任务的分数
// ============================================
int points;
int growthPoints;
float score;
if (isCrossPage)
{
// 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的积分、成长值、得分
// mainTask 已经是 Id == GroupId 的任务,所以 mainTask.Id == groupId
points = mainTask.Points;
growthPoints = mainTask.GrowthPoint ?? 0;
// 跨页题目:取 JournalPageTaskId == JournalPageTaskGroupId 的分数
score = userAnswers.FirstOrDefault(a => a.JournalPageTaskId == groupId)?.Score ?? 0;
}
else
{
// 普通题目:正常计算
points = mainTask.Points;
growthPoints = mainTask.GrowthPoint ?? 0;
score = userAnswers.FirstOrDefault()?.Score ?? 0;
}
if (points <= 0)
{
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = "该任务无积分可领取"
});
continue;
}
// 获取任务名称
var taskName = mainTask.Task ?? string.Empty;
// ============================================
// 【任务编号计算】
// 从 JournalPageTask.No 字段按 - 分割,取索引为 2 的部分
// No 最多出现索引为 3即最多4段0-3
// 例如:"A-B-C-D" → 分割后 ["A","B","C","D"] → 取索引2 = "C"
// ============================================
string taskNo = string.Empty;
if (!string.IsNullOrEmpty(mainTask.No))
{
var parts = mainTask.No.Split('-');
if (parts.Length >= 3)
{
taskNo = parts[2];
}
}
#endregion
#region 3.7
// 调用积分服务增加积分
var addResult = await pointsService.AddPointsAsync(new AddPointsInput
{
UserId = userId,
Amount = points,
ChangeType = PointsChangeTypeEnum.TaskReward,
RelatedId = groupId,
Description = $"完成任务奖励:{taskName}",
OperatorName = "系统"
});
// 累计总积分和总成长值
totalPoints += points;
totalGrowthPoints += growthPoints;
currentBalance = addResult.NewBalance;
result.SuccessCount++;
// 添加成功详情到成功列表
result.SuccessItems.Add(new BatchClaimSuccessItem
{
GroupId = groupId,
TaskName = taskName,
TaskNo = !string.IsNullOrWhiteSpace(taskNo) ? "任务:" + taskNo : string.Empty,
Points = points,
GrowthPoints = growthPoints,
Score = score
});
#endregion
}
catch (Exception ex)
{
// 捕获异常并添加到失败列表,不中断其他任务的处理
result.FailItems.Add(new BatchClaimFailItem
{
GroupId = groupId,
Message = ex.Message
});
}
}
#endregion
#region 4.
result.FailCount = result.FailItems.Count;
result.TotalPoints = totalPoints;
result.TotalGrowthPoints = totalGrowthPoints;
result.BalanceAfter = currentBalance;
return result;
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:获取杂志列表
/// 1. 查询所有已归档的杂志Status=Archive
/// 2. 查询用户已绑定的杂志ID列表
/// 3. 根据queryType过滤大小写不敏感
/// - "Bound":只返回已绑定的杂志
/// - "Unbound":只返回未绑定的杂志
/// - 其他:返回空集合
/// 4. 返回每个杂志的ID、名称、标题、封面、是否已绑定
/// ============================================
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="queryType">查询类型bound/unbound大小写不敏感其他返回空</param>
/// <returns>杂志列表</returns>
public async Task<List<JournalListOutput>> 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<UserJournal>()
.Where(uj => uj.UserId == userId && !uj.IsDeleted)
.Select(uj => uj.JournalId)
.ToListAsync();
var boundJournalIds = userJournals.ToHashSet();
#endregion
#region 3. queryType过滤
// ============================================
// 【大小写兼容处理 + 合法值校验】
// queryType 只接受两种值(大小写不敏感):
// - "Bound" / "bound" / "BOUND" → 返回已绑定杂志
// - "Unbound" / "unbound" / "UNBOUND" → 返回未绑定杂志
// - 空 / 其他值 → 返回空集合
// ============================================
var normalizedQueryType = queryType?.ToLowerInvariant() ?? string.Empty;
IEnumerable<Journal> filteredJournals;
if (normalizedQueryType == "bound")
{
filteredJournals = allJournals.Where(j => boundJournalIds.Contains(j.Id));
}
else if (normalizedQueryType == "unbound")
{
filteredJournals = allJournals.Where(j => !boundJournalIds.Contains(j.Id));
}
else
{
// 非 Bound/Unbound 类型,返回空集合
filteredJournals = Enumerable.Empty<Journal>();
}
#endregion
#region 4.
var result = filteredJournals.Select(j => new JournalListOutput
{
Id = j.Id,
Name = j.Name ?? string.Empty,
Title = j.Title ?? string.Empty,
Cover = j.Cover ?? string.Empty,
IsBound = boundJournalIds.Contains(j.Id)
}).ToList();
return result;
#endregion
}
/// <summary>
/// ============================================
/// 方法思路:添加用户与杂志关联(绑定杂志)
/// 1. 验证杂志是否存在
/// 2. 检查用户是否已绑定过该杂志
/// - 查询 UserJournal 表条件UserId + JournalId
/// 3. 已绑定过:返回 false
/// 4. 未绑定:插入 UserJournal 记录,返回 true
/// ============================================
/// </summary>
/// <param name="journalId">杂志ID</param>
/// <param name="userId">用户ID</param>
/// <returns>是否绑定成功已绑定过返回false</returns>
public async Task<bool> 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<UserJournal>()
.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
}
/// <summary>
/// ============================================
/// 方法思路:新增学生
/// 1. 创建新的Users记录
/// 2. WxUserId设置为当前登录人IDwxUserId参数传入
/// 3. 初始化积分和成长值为0
/// 4. 用户类型设置为普通用户Normal
/// 5. 插入数据库并返回结果
/// ============================================
/// </summary>
/// <param name="input">新增学生输入姓名、头像URL</param>
/// <param name="wxUserId">当前登录人ID微信用户ID</param>
/// <returns>是否插入成功</returns>
public async Task<bool> AddStudentAsync(AddStudentInput input, long wxUserId)
{
#region 1.
var student = new Users
{
WxUserId = wxUserId,
Name = input.Name,
AvatarUrl = input.AvatarUrl,
GrowthPoints = 0,
Points = 0,
Type = UsersTypeEnum.Normal,
IsLastOnline = false
};
#endregion
#region 2.
return await usersRepository.InsertAsync(student);
#endregion
}
}