21
This commit is contained in:
parent
8858e0eef3
commit
e43adee35c
|
|
@ -6,36 +6,36 @@
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] 1. 新增实体类并扩展 DbContext
|
||||
- [ ] 1.1 创建 QuestionCategoryMapping 实体类
|
||||
- [x] 1. 新增实体类并扩展 DbContext
|
||||
- [x] 1.1 创建 QuestionCategoryMapping 实体类
|
||||
- 在 `MiAssessment.Model/Entities/QuestionCategoryMapping.cs` 中创建实体
|
||||
- 映射到 `question_category_mappings` 表,包含 Id、QuestionId、CategoryId、CreateTime 字段
|
||||
- 添加 Question 和 ReportCategory 的导航属性及 ForeignKey 特性
|
||||
- 遵循现有实体类的 XML 注释和命名规范
|
||||
- _Requirements: 8.3_
|
||||
|
||||
- [ ] 1.2 创建 ReportConclusion 实体类
|
||||
- [x] 1.2 创建 ReportConclusion 实体类
|
||||
- 在 `MiAssessment.Model/Entities/ReportConclusion.cs` 中创建实体
|
||||
- 映射到 `report_conclusions` 表,包含 Id、CategoryId、ConclusionType、Title、Content、CreateTime、UpdateTime、IsDeleted 字段
|
||||
- Content 字段使用 `[Column(TypeName = "nvarchar(max)")]`,Title 使用 `[MaxLength(100)]`
|
||||
- 添加 ReportCategory 的导航属性及 ForeignKey 特性
|
||||
- _Requirements: 8.3_
|
||||
|
||||
- [ ] 1.3 在 MiAssessmentDbContext 中添加 DbSet
|
||||
- [x] 1.3 在 MiAssessmentDbContext 中添加 DbSet
|
||||
- 在 `MiAssessment.Model/Data/MiAssessmentDbContext.cs` 的测评业务表区域添加:
|
||||
- `DbSet<QuestionCategoryMapping> QuestionCategoryMappings`
|
||||
- `DbSet<ReportConclusion> ReportConclusions`
|
||||
- _Requirements: 8.1, 8.2_
|
||||
|
||||
- [ ] 2. 实现 ReportGenerationService 核心计算逻辑
|
||||
- [ ] 2.1 创建 ReportGenerationService 类骨架
|
||||
- [x] 2. 实现 ReportGenerationService 核心计算逻辑
|
||||
- [x] 2.1 创建 ReportGenerationService 类骨架
|
||||
- 在 `MiAssessment.Core/Services/ReportGenerationService.cs` 中创建服务类
|
||||
- 注入 MiAssessmentDbContext 和 ILogger<ReportGenerationService>
|
||||
- 定义 `GenerateReportAsync(long recordId)` 公开方法
|
||||
- 实现 ValidateRecord 验证逻辑:检查记录存在且未软删除、Status=3、有答案数据
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
|
||||
- [ ] 2.2 实现得分计算逻辑
|
||||
- [x] 2.2 实现得分计算逻辑
|
||||
- 在 GenerateReportAsync 中实现 LoadData:一次性加载答案、分类映射、叶子分类、结论数据
|
||||
- 实现 CalculateScores:按 ScoreRule 区分累加计分(AnswerValue 直接累加)和二值计分(AnswerValue>=6 计1分,否则0分)
|
||||
- 计算每个叶子分类的 Score、MaxScore(累加: 题目数×10,二值: 题目数×1)、Percentage(Score/MaxScore×100,保留两位小数)
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
- 验证一道题目映射到多个分类时,各分类得分计算独立,互不影响
|
||||
- **Validates: Requirements 1.3**
|
||||
|
||||
- [ ] 2.6 实现排名计算逻辑
|
||||
- [x] 2.6 实现排名计算逻辑
|
||||
- 实现 CalculateRanks:按 CategoryType 分组,组内按 Percentage 降序排名
|
||||
- 支持并列排名:相同 Percentage 的分类分配相同排名值
|
||||
- 仅对叶子节点分类(无子分类)进行排名
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
- 验证排名结果中仅包含叶子节点分类,父分类不出现在排名结果中
|
||||
- **Validates: Requirements 2.3**
|
||||
|
||||
- [ ] 2.9 实现星级评定逻辑
|
||||
- [x] 2.9 实现星级评定逻辑
|
||||
- 实现 AssignStarLevels:根据排名位置分配 1-5 星
|
||||
- N≥2 时公式:`starLevel = 5 - round((rank - 1) / (N - 1) * 4)`
|
||||
- N=1 时:唯一分类获得 5 星
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
- 使用 FsCheck 生成随机排名,验证排名第1→5星、排名最后→1星、星级在[1,5]范围内、排名靠前星级不低于靠后
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3**
|
||||
|
||||
- [ ] 2.11 实现结论匹配逻辑
|
||||
- [x] 2.11 实现结论匹配逻辑
|
||||
- 实现 MatchConclusions:星级→结论类型映射(5→1, 4→2, 3→2, 2→3, 1→4)
|
||||
- 查询 ReportConclusion 表匹配 CategoryId 和 ConclusionType
|
||||
- 无匹配结论时记录为空,继续处理其余分类
|
||||
|
|
@ -96,11 +96,11 @@
|
|||
- 验证所有星级值(1-5)映射到正确的结论类型:5→1, 4→2, 3→2, 2→3, 1→4
|
||||
- **Validates: Requirements 4.1**
|
||||
|
||||
- [ ] 3. Checkpoint - 确保核心计算逻辑正确
|
||||
- [x] 3. Checkpoint - 确保核心计算逻辑正确
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 4. 实现结果持久化与状态流转
|
||||
- [ ] 4.1 实现 PersistResults 事务逻辑
|
||||
- [x] 4. 实现结果持久化与状态流转
|
||||
- [x] 4.1 实现 PersistResults 事务逻辑
|
||||
- 在 GenerateReportAsync 中实现事务内操作:
|
||||
- 删除该 RecordId 已有的 AssessmentResult 记录(支持重新生成)
|
||||
- 批量写入新的 AssessmentResult 记录(RecordId、CategoryId、Score、MaxScore、Percentage、Rank、StarLevel、CreateTime)
|
||||
|
|
@ -123,20 +123,20 @@
|
|||
- 验证成功完成后 Status=4,CompleteTime 非空且不早于生成开始时间
|
||||
- **Validates: Requirements 6.1, 6.2**
|
||||
|
||||
- [ ] 5. 服务集成与 DI 注册
|
||||
- [ ] 5.1 在 ServiceModule 中注册 ReportGenerationService
|
||||
- [x] 5. 服务集成与 DI 注册
|
||||
- [x] 5.1 在 ServiceModule 中注册 ReportGenerationService
|
||||
- 在 `MiAssessment.Infrastructure/Modules/ServiceModule.cs` 中添加 ReportGenerationService 的 Autofac 注册
|
||||
- 注入 MiAssessmentDbContext 和 ILogger<ReportGenerationService>,使用 InstancePerLifetimeScope
|
||||
- _Requirements: 7.2_
|
||||
|
||||
- [ ] 5.2 修改 AssessmentService 集成 ReportGenerationService
|
||||
- [x] 5.2 修改 AssessmentService 集成 ReportGenerationService
|
||||
- 在 AssessmentService 构造函数中添加 ReportGenerationService 依赖
|
||||
- 在 SubmitAnswersAsync 方法的 `transaction.CommitAsync()` 之后添加 `_reportGenerationService.GenerateReportAsync(request.RecordId)` 调用
|
||||
- 用 try-catch 包裹报告生成调用,失败时记录错误日志但不影响答案提交返回(状态保持为3)
|
||||
- 更新 ServiceModule 中 AssessmentService 的注册,添加 ReportGenerationService 依赖解析
|
||||
- _Requirements: 7.1, 7.3_
|
||||
|
||||
- [ ] 6. Final checkpoint - 确保所有测试通过
|
||||
- [x] 6. Final checkpoint - 确保所有测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
|
|
|||
|
|
@ -24,18 +24,22 @@ public class AssessmentService : IAssessmentService
|
|||
{
|
||||
private readonly MiAssessmentDbContext _dbContext;
|
||||
private readonly ILogger<AssessmentService> _logger;
|
||||
private readonly ReportGenerationService _reportGenerationService;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文</param>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
/// <param name="reportGenerationService">报告生成服务</param>
|
||||
public AssessmentService(
|
||||
MiAssessmentDbContext dbContext,
|
||||
ILogger<AssessmentService> logger)
|
||||
ILogger<AssessmentService> logger,
|
||||
ReportGenerationService reportGenerationService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
_reportGenerationService = reportGenerationService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -285,6 +289,17 @@ public class AssessmentService : IAssessmentService
|
|||
_logger.LogInformation("测评答案提交成功,userId: {UserId}, recordId: {RecordId}, answerCount: {AnswerCount}",
|
||||
userId, request.RecordId, answers.Count);
|
||||
|
||||
// 触发报告生成
|
||||
try
|
||||
{
|
||||
await _reportGenerationService.GenerateReportAsync(request.RecordId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "报告生成失败,recordId: {RecordId}", request.RecordId);
|
||||
// 报告生成失败不影响答案提交的返回,状态保持为3(生成中)
|
||||
}
|
||||
|
||||
return new SubmitAnswersResponse
|
||||
{
|
||||
Success = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,503 @@
|
|||
using MiAssessment.Model.Data;
|
||||
using MiAssessment.Model.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MiAssessment.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 测评报告生成服务
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 负责根据测评记录ID执行完整的报告生成流水线:
|
||||
/// - 验证测评记录有效性
|
||||
/// - 计算各分类得分
|
||||
/// - 排名与星级评定
|
||||
/// - 匹配结论文本
|
||||
/// - 持久化结果并更新状态
|
||||
/// </remarks>
|
||||
public class ReportGenerationService
|
||||
{
|
||||
private readonly MiAssessmentDbContext _dbContext;
|
||||
private readonly ILogger<ReportGenerationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文</param>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
public ReportGenerationService(
|
||||
MiAssessmentDbContext dbContext,
|
||||
ILogger<ReportGenerationService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据测评记录ID生成报告
|
||||
/// </summary>
|
||||
/// <param name="recordId">测评记录ID</param>
|
||||
public async Task GenerateReportAsync(long recordId)
|
||||
{
|
||||
_logger.LogInformation("开始生成测评报告,recordId: {RecordId}", recordId);
|
||||
|
||||
// 步骤1:验证测评记录
|
||||
var record = await ValidateRecordAsync(recordId);
|
||||
|
||||
// 步骤2:加载答案、分类映射、叶子分类、结论数据
|
||||
var (answers, mappings, leafCategories, conclusions) = await LoadDataAsync(recordId);
|
||||
|
||||
// 步骤3:按 ScoreRule 计算各分类得分
|
||||
var categoryScores = CalculateScores(answers, mappings, leafCategories);
|
||||
|
||||
_logger.LogDebug("得分计算完成,recordId: {RecordId}, 分类数量: {Count}", recordId, categoryScores.Count);
|
||||
|
||||
// 步骤4:按 CategoryType 分组排名
|
||||
var rankedScores = CalculateRanks(categoryScores);
|
||||
|
||||
_logger.LogDebug("排名计算完成,recordId: {RecordId}, 排名数量: {Count}", recordId, rankedScores.Count);
|
||||
|
||||
// 步骤5:根据排名分配星级
|
||||
var starredScores = AssignStarLevels(rankedScores);
|
||||
|
||||
_logger.LogDebug("星级评定完成,recordId: {RecordId}, 星级数量: {Count}", recordId, starredScores.Count);
|
||||
// 步骤6:星级→结论类型映射,匹配结论文本
|
||||
var finalResults = MatchConclusions(starredScores, conclusions);
|
||||
|
||||
_logger.LogDebug("结论匹配完成,recordId: {RecordId}, 结果数量: {Count}", recordId, finalResults.Count);
|
||||
// 步骤7:事务内写入结果并更新状态
|
||||
await PersistResultsAsync(recordId, finalResults);
|
||||
|
||||
_logger.LogInformation("测评报告生成完成,recordId: {RecordId}", recordId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证测评记录有效性
|
||||
/// </summary>
|
||||
/// <param name="recordId">测评记录ID</param>
|
||||
/// <returns>验证通过的测评记录</returns>
|
||||
/// <exception cref="InvalidOperationException">记录不存在、已删除、状态不正确或无答案数据时抛出</exception>
|
||||
private async Task<Model.Entities.AssessmentRecord> ValidateRecordAsync(long recordId)
|
||||
{
|
||||
// 检查记录是否存在且未软删除
|
||||
var record = await _dbContext.AssessmentRecords
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == recordId && !r.IsDeleted);
|
||||
|
||||
if (record == null)
|
||||
{
|
||||
_logger.LogError("测评记录不存在或已删除,recordId: {RecordId}", recordId);
|
||||
throw new InvalidOperationException($"测评记录不存在或已删除,recordId: {recordId}");
|
||||
}
|
||||
|
||||
// 检查记录状态是否为3(生成中)
|
||||
if (record.Status != 3)
|
||||
{
|
||||
_logger.LogError("测评记录状态不正确,recordId: {RecordId}, 当前状态: {Status}, 期望状态: 3", recordId, record.Status);
|
||||
throw new InvalidOperationException($"测评记录状态不正确,recordId: {recordId}, 当前状态: {record.Status}, 期望状态: 3");
|
||||
}
|
||||
|
||||
// 检查是否有答案数据
|
||||
var answerCount = await _dbContext.AssessmentAnswers
|
||||
.CountAsync(a => a.RecordId == recordId);
|
||||
|
||||
if (answerCount == 0)
|
||||
{
|
||||
_logger.LogError("测评记录无答案数据,recordId: {RecordId}", recordId);
|
||||
throw new InvalidOperationException($"测评记录无答案数据,recordId: {recordId}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("测评记录验证通过,recordId: {RecordId}, 答案数量: {AnswerCount}", recordId, answerCount);
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一次性加载报告生成所需的全部数据
|
||||
/// </summary>
|
||||
/// <param name="recordId">测评记录ID</param>
|
||||
/// <returns>答案列表、分类映射列表、叶子分类列表、结论列表</returns>
|
||||
private async Task<(
|
||||
List<AssessmentAnswer> answers,
|
||||
List<QuestionCategoryMapping> mappings,
|
||||
List<ReportCategory> leafCategories,
|
||||
List<ReportConclusion> conclusions)> LoadDataAsync(long recordId)
|
||||
{
|
||||
// 加载该记录的所有答案
|
||||
var answers = await _dbContext.AssessmentAnswers
|
||||
.AsNoTracking()
|
||||
.Where(a => a.RecordId == recordId)
|
||||
.ToListAsync();
|
||||
|
||||
// 加载所有题目-分类映射
|
||||
var mappings = await _dbContext.QuestionCategoryMappings
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
// 加载所有分类,用于确定叶子节点
|
||||
var allCategories = await _dbContext.ReportCategories
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
// 叶子分类:其 Id 不被任何其他分类的 ParentId 引用
|
||||
var parentIds = new HashSet<long>(allCategories.Select(c => c.ParentId));
|
||||
var leafCategories = allCategories
|
||||
.Where(c => !parentIds.Contains(c.Id))
|
||||
.ToList();
|
||||
|
||||
// 加载所有未删除的结论数据
|
||||
var conclusions = await _dbContext.ReportConclusions
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogDebug("数据加载完成,答案: {AnswerCount}, 映射: {MappingCount}, 叶子分类: {LeafCount}, 结论: {ConclusionCount}",
|
||||
answers.Count, mappings.Count, leafCategories.Count, conclusions.Count);
|
||||
|
||||
return (answers, mappings, leafCategories, conclusions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 ScoreRule 计算各叶子分类的得分、满分和百分比
|
||||
/// </summary>
|
||||
/// <param name="answers">答案列表</param>
|
||||
/// <param name="mappings">题目-分类映射列表</param>
|
||||
/// <param name="leafCategories">叶子分类列表</param>
|
||||
/// <returns>各分类的得分计算结果</returns>
|
||||
internal static List<CategoryScore> CalculateScores(
|
||||
List<AssessmentAnswer> answers,
|
||||
List<QuestionCategoryMapping> mappings,
|
||||
List<ReportCategory> leafCategories)
|
||||
{
|
||||
// 构建题目ID → 答案值的快速查找字典
|
||||
var answerMap = answers.ToDictionary(a => a.QuestionId, a => a.AnswerValue);
|
||||
|
||||
// 构建分类ID → 该分类下的题目ID列表(通过映射表)
|
||||
var categoryQuestionMap = mappings
|
||||
.GroupBy(m => m.CategoryId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(m => m.QuestionId).ToList());
|
||||
|
||||
var leafCategoryIds = new HashSet<long>(leafCategories.Select(c => c.Id));
|
||||
var scores = new List<CategoryScore>();
|
||||
|
||||
foreach (var category in leafCategories)
|
||||
{
|
||||
// 获取该分类下的题目ID列表
|
||||
if (!categoryQuestionMap.TryGetValue(category.Id, out var questionIds))
|
||||
{
|
||||
// 该分类无映射题目,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 筛选出有答案的题目值
|
||||
var answerValues = questionIds
|
||||
.Where(qId => answerMap.ContainsKey(qId))
|
||||
.Select(qId => answerMap[qId])
|
||||
.ToList();
|
||||
|
||||
if (answerValues.Count == 0)
|
||||
{
|
||||
// 该分类下无有效答案,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
decimal score;
|
||||
decimal maxScore;
|
||||
|
||||
if (category.ScoreRule == 1)
|
||||
{
|
||||
// 累加计分:AnswerValue 直接累加,满分 = 题目数 × 10
|
||||
score = answerValues.Sum();
|
||||
maxScore = answerValues.Count * 10m;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 二值计分:AnswerValue >= 6 计1分,否则0分,满分 = 题目数 × 1
|
||||
score = answerValues.Count(v => v >= 6);
|
||||
maxScore = answerValues.Count * 1m;
|
||||
}
|
||||
|
||||
// 百分比 = Score / MaxScore × 100,保留两位小数
|
||||
var percentage = Math.Round(score / maxScore * 100m, 2);
|
||||
|
||||
scores.Add(new CategoryScore(category.Id, category.CategoryType, score, maxScore, percentage));
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类得分计算结果
|
||||
/// </summary>
|
||||
/// <param name="CategoryId">分类ID</param>
|
||||
/// <param name="CategoryType">分类类型(1-8)</param>
|
||||
/// <param name="Score">得分</param>
|
||||
/// <param name="MaxScore">满分</param>
|
||||
/// <param name="Percentage">百分比</param>
|
||||
internal record CategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage);
|
||||
|
||||
/// <summary>
|
||||
/// 按 CategoryType 分组,组内按 Percentage 降序排名
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 排名规则:
|
||||
/// - 按 CategoryType 分组,每组独立排名
|
||||
/// - 组内按 Percentage 降序排列,最高百分比排名为 1
|
||||
/// - 相同 Percentage 的分类分配相同排名值(并列排名)
|
||||
/// - 并列排名后,下一个排名跳过并列数量(如两个并列第1,下一个为第3)
|
||||
/// </remarks>
|
||||
/// <param name="scores">各分类的得分计算结果</param>
|
||||
/// <returns>带排名信息的分类得分列表</returns>
|
||||
internal static List<RankedCategoryScore> CalculateRanks(List<CategoryScore> scores)
|
||||
{
|
||||
var result = new List<RankedCategoryScore>();
|
||||
|
||||
// 按 CategoryType 分组,每组独立排名
|
||||
var groups = scores.GroupBy(s => s.CategoryType);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// 组内按 Percentage 降序排列
|
||||
var sorted = group.OrderByDescending(s => s.Percentage).ToList();
|
||||
|
||||
var rank = 1;
|
||||
for (var i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
// 如果不是第一个元素,且百分比与前一个不同,则排名 = 当前位置 + 1
|
||||
if (i > 0 && sorted[i].Percentage != sorted[i - 1].Percentage)
|
||||
{
|
||||
rank = i + 1;
|
||||
}
|
||||
|
||||
var s = sorted[i];
|
||||
result.Add(new RankedCategoryScore(s.CategoryId, s.CategoryType, s.Score, s.MaxScore, s.Percentage, rank));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带排名的分类得分计算结果
|
||||
/// </summary>
|
||||
/// <param name="CategoryId">分类ID</param>
|
||||
/// <param name="CategoryType">分类类型(1-8)</param>
|
||||
/// <param name="Score">得分</param>
|
||||
/// <param name="MaxScore">满分</param>
|
||||
/// <param name="Percentage">百分比</param>
|
||||
/// <param name="Rank">组内排名(1为最高)</param>
|
||||
internal record RankedCategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank);
|
||||
|
||||
/// <summary>
|
||||
/// 根据排名位置分配 1-5 星级
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 星级分配规则:
|
||||
/// - 按 CategoryType 分组,每组独立计算星级
|
||||
/// - N=1 时:唯一分类获得 5 星
|
||||
/// - N≥2 时:starLevel = 5 - round((rank - 1) / (N - 1) * 4),结果限制在 [1, 5] 范围内
|
||||
/// - 相同排名的分类自然获得相同星级(公式保证)
|
||||
/// </remarks>
|
||||
/// <param name="rankedScores">带排名的分类得分列表</param>
|
||||
/// <returns>带星级的分类得分列表</returns>
|
||||
internal static List<StarredCategoryScore> AssignStarLevels(List<RankedCategoryScore> rankedScores)
|
||||
{
|
||||
var result = new List<StarredCategoryScore>();
|
||||
|
||||
// 按 CategoryType 分组,每组独立计算星级
|
||||
var groups = rankedScores.GroupBy(s => s.CategoryType);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var items = group.ToList();
|
||||
var n = items.Count;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
int starLevel;
|
||||
|
||||
if (n == 1)
|
||||
{
|
||||
// 唯一分类获得 5 星
|
||||
starLevel = 5;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 公式:starLevel = 5 - round((rank - 1) / (N - 1) * 4)
|
||||
starLevel = 5 - (int)Math.Round((item.Rank - 1.0) / (n - 1.0) * 4.0);
|
||||
|
||||
// 限制在 [1, 5] 范围内
|
||||
starLevel = Math.Clamp(starLevel, 1, 5);
|
||||
}
|
||||
|
||||
result.Add(new StarredCategoryScore(
|
||||
item.CategoryId, item.CategoryType,
|
||||
item.Score, item.MaxScore, item.Percentage,
|
||||
item.Rank, starLevel));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带星级的分类得分计算结果
|
||||
/// </summary>
|
||||
/// <param name="CategoryId">分类ID</param>
|
||||
/// <param name="CategoryType">分类类型(1-8)</param>
|
||||
/// <param name="Score">得分</param>
|
||||
/// <param name="MaxScore">满分</param>
|
||||
/// <param name="Percentage">百分比</param>
|
||||
/// <param name="Rank">组内排名(1为最高)</param>
|
||||
/// <param name="StarLevel">星级(1-5)</param>
|
||||
internal record StarredCategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank, int StarLevel);
|
||||
/// <summary>
|
||||
/// 最终分类结果(含结论匹配)
|
||||
/// </summary>
|
||||
/// <param name="CategoryId">分类ID</param>
|
||||
/// <param name="CategoryType">分类类型(1-8)</param>
|
||||
/// <param name="Score">得分</param>
|
||||
/// <param name="MaxScore">满分</param>
|
||||
/// <param name="Percentage">百分比</param>
|
||||
/// <param name="Rank">组内排名(1为最高)</param>
|
||||
/// <param name="StarLevel">星级(1-5)</param>
|
||||
/// <param name="ConclusionType">结论类型(1最强 2较强 3较弱 4最弱)</param>
|
||||
/// <param name="ConclusionContent">结论内容,无匹配时为 null</param>
|
||||
internal record FinalCategoryResult(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank, int StarLevel, int ConclusionType, string? ConclusionContent);
|
||||
|
||||
/// <summary>
|
||||
/// 将星级映射为结论类型
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 映射规则:
|
||||
/// - 5星 → 结论类型1(最强)
|
||||
/// - 4星 → 结论类型2(较强)
|
||||
/// - 3星 → 结论类型2(较强)
|
||||
/// - 2星 → 结论类型3(较弱)
|
||||
/// - 1星 → 结论类型4(最弱)
|
||||
/// </remarks>
|
||||
/// <param name="starLevel">星级(1-5)</param>
|
||||
/// <returns>结论类型(1-4)</returns>
|
||||
internal static int MapStarToConclusionType(int starLevel)
|
||||
{
|
||||
return starLevel switch
|
||||
{
|
||||
5 => 1, // 最强
|
||||
4 => 2, // 较强
|
||||
3 => 2, // 较强
|
||||
2 => 3, // 较弱
|
||||
1 => 4, // 最弱
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(starLevel), starLevel, "星级必须在 1-5 范围内")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据星级匹配结论文本
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 匹配逻辑:
|
||||
/// - 将星级映射为结论类型(通过 MapStarToConclusionType)
|
||||
/// - 在结论列表中查找匹配 CategoryId 和 ConclusionType 的记录
|
||||
/// - 找到则使用其 Content 字段,未找到则结论内容为 null,继续处理其余分类
|
||||
/// </remarks>
|
||||
/// <param name="starredScores">带星级的分类得分列表</param>
|
||||
/// <param name="conclusions">报告结论列表</param>
|
||||
/// <returns>包含结论匹配结果的最终分类结果列表</returns>
|
||||
internal static List<FinalCategoryResult> MatchConclusions(
|
||||
List<StarredCategoryScore> starredScores,
|
||||
List<ReportConclusion> conclusions)
|
||||
{
|
||||
// 构建 (CategoryId, ConclusionType) → Content 的快速查找字典
|
||||
var conclusionMap = conclusions
|
||||
.GroupBy(c => (c.CategoryId, c.ConclusionType))
|
||||
.ToDictionary(g => g.Key, g => g.First().Content);
|
||||
|
||||
var results = new List<FinalCategoryResult>();
|
||||
|
||||
foreach (var item in starredScores)
|
||||
{
|
||||
// 星级→结论类型映射
|
||||
var conclusionType = MapStarToConclusionType(item.StarLevel);
|
||||
|
||||
// 查找匹配的结论内容
|
||||
conclusionMap.TryGetValue((item.CategoryId, conclusionType), out var conclusionContent);
|
||||
|
||||
results.Add(new FinalCategoryResult(
|
||||
item.CategoryId, item.CategoryType,
|
||||
item.Score, item.MaxScore, item.Percentage,
|
||||
item.Rank, item.StarLevel,
|
||||
conclusionType, conclusionContent));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在事务内持久化计算结果并更新测评记录状态
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 事务内执行以下操作:
|
||||
/// - 删除该 RecordId 已有的 AssessmentResult 记录(支持重新生成)
|
||||
/// - 批量写入新的 AssessmentResult 记录
|
||||
/// - 更新 AssessmentRecord 的 Status 为 4(已完成)并设置 CompleteTime
|
||||
/// 异常时回滚事务,Status 保持为 3(生成中)
|
||||
/// </remarks>
|
||||
/// <param name="recordId">测评记录ID</param>
|
||||
/// <param name="finalResults">最终分类计算结果列表</param>
|
||||
private async Task PersistResultsAsync(long recordId, List<FinalCategoryResult> finalResults)
|
||||
{
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// 删除该 RecordId 已有的结果记录(支持重新生成)
|
||||
var existingResults = await _dbContext.AssessmentResults
|
||||
.Where(r => r.RecordId == recordId)
|
||||
.ToListAsync();
|
||||
|
||||
if (existingResults.Count > 0)
|
||||
{
|
||||
_dbContext.AssessmentResults.RemoveRange(existingResults);
|
||||
_logger.LogDebug("删除已有结果记录,recordId: {RecordId}, 数量: {Count}", recordId, existingResults.Count);
|
||||
}
|
||||
|
||||
// 批量写入新的 AssessmentResult 记录
|
||||
var now = DateTime.Now;
|
||||
var newResults = finalResults.Select(r => new AssessmentResult
|
||||
{
|
||||
RecordId = recordId,
|
||||
CategoryId = r.CategoryId,
|
||||
Score = r.Score,
|
||||
MaxScore = r.MaxScore,
|
||||
Percentage = r.Percentage,
|
||||
Rank = r.Rank,
|
||||
StarLevel = r.StarLevel,
|
||||
CreateTime = now
|
||||
}).ToList();
|
||||
|
||||
await _dbContext.AssessmentResults.AddRangeAsync(newResults);
|
||||
|
||||
// 重新加载测评记录(带跟踪),更新状态为4(已完成)
|
||||
var record = await _dbContext.AssessmentRecords
|
||||
.FirstAsync(r => r.Id == recordId);
|
||||
|
||||
record.Status = 4;
|
||||
record.CompleteTime = now;
|
||||
record.UpdateTime = now;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation("结果持久化完成,recordId: {RecordId}, 写入记录数: {Count}", recordId, newResults.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 回滚事务,Status 保持为 3(生成中)
|
||||
await transaction.RollbackAsync();
|
||||
_logger.LogError(ex, "结果持久化失败,事务已回滚,recordId: {RecordId}", recordId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -131,12 +131,21 @@ public class ServiceModule : Module
|
|||
|
||||
// ========== 小程序测评模块服务注册 ==========
|
||||
|
||||
// 注册报告生成服务
|
||||
builder.Register(c =>
|
||||
{
|
||||
var dbContext = c.Resolve<MiAssessmentDbContext>();
|
||||
var logger = c.Resolve<ILogger<ReportGenerationService>>();
|
||||
return new ReportGenerationService(dbContext, logger);
|
||||
}).InstancePerLifetimeScope();
|
||||
|
||||
// 注册测评服务
|
||||
builder.Register(c =>
|
||||
{
|
||||
var dbContext = c.Resolve<MiAssessmentDbContext>();
|
||||
var logger = c.Resolve<ILogger<AssessmentService>>();
|
||||
return new AssessmentService(dbContext, logger);
|
||||
var reportGenerationService = c.Resolve<ReportGenerationService>();
|
||||
return new AssessmentService(dbContext, logger, reportGenerationService);
|
||||
}).As<IAssessmentService>().InstancePerLifetimeScope();
|
||||
|
||||
// ========== 小程序订单模块服务注册 ==========
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ public partial class MiAssessmentDbContext : DbContext
|
|||
|
||||
public virtual DbSet<ReportCategory> ReportCategories { get; set; }
|
||||
|
||||
public virtual DbSet<QuestionCategoryMapping> QuestionCategoryMappings { get; set; }
|
||||
|
||||
public virtual DbSet<ReportConclusion> ReportConclusions { get; set; }
|
||||
|
||||
public virtual DbSet<ScoreOption> ScoreOptions { get; set; }
|
||||
|
||||
public virtual DbSet<Order> Orders { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MiAssessment.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 题目分类映射表
|
||||
/// </summary>
|
||||
[Table("question_category_mappings")]
|
||||
public class QuestionCategoryMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键ID
|
||||
/// </summary>
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目ID
|
||||
/// </summary>
|
||||
public long QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类ID
|
||||
/// </summary>
|
||||
public long CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的题目
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(QuestionId))]
|
||||
public virtual Question? Question { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的报告分类
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(CategoryId))]
|
||||
public virtual ReportCategory? Category { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MiAssessment.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 报告结论表
|
||||
/// </summary>
|
||||
[Table("report_conclusions")]
|
||||
public class ReportConclusion
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键ID
|
||||
/// </summary>
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类ID
|
||||
/// </summary>
|
||||
public long CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结论类型:1最强 2较强 3较弱 4最弱
|
||||
/// </summary>
|
||||
public int ConclusionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结论标题
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结论内容
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column(TypeName = "nvarchar(max)")]
|
||||
public string Content { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
public DateTime UpdateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 软删除标记
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联的报告分类
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(CategoryId))]
|
||||
public virtual ReportCategory? Category { get; set; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user