From e43adee35c944fdad923c07083ff92b99f43d0ad Mon Sep 17 00:00:00 2001 From: zpc Date: Tue, 24 Feb 2026 13:39:51 +0800 Subject: [PATCH] 21 --- .../assessment-report-generation/tasks.md | 34 +- .../Services/AssessmentService.cs | 17 +- .../Services/ReportGenerationService.cs | 503 ++++++++++++++++++ .../Modules/ServiceModule.cs | 11 +- .../Data/MiAssessmentDbContext.cs | 4 + .../Entities/QuestionCategoryMapping.cs | 45 ++ .../Entities/ReportConclusion.cs | 62 +++ 7 files changed, 657 insertions(+), 19 deletions(-) create mode 100644 server/MiAssessment/src/MiAssessment.Core/Services/ReportGenerationService.cs create mode 100644 server/MiAssessment/src/MiAssessment.Model/Entities/QuestionCategoryMapping.cs create mode 100644 server/MiAssessment/src/MiAssessment.Model/Entities/ReportConclusion.cs diff --git a/.kiro/specs/assessment-report-generation/tasks.md b/.kiro/specs/assessment-report-generation/tasks.md index f5d2858..77efa2d 100644 --- a/.kiro/specs/assessment-report-generation/tasks.md +++ b/.kiro/specs/assessment-report-generation/tasks.md @@ -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 QuestionCategoryMappings` - `DbSet 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 - 定义 `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,使用 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 diff --git a/server/MiAssessment/src/MiAssessment.Core/Services/AssessmentService.cs b/server/MiAssessment/src/MiAssessment.Core/Services/AssessmentService.cs index 4490a33..fc2d468 100644 --- a/server/MiAssessment/src/MiAssessment.Core/Services/AssessmentService.cs +++ b/server/MiAssessment/src/MiAssessment.Core/Services/AssessmentService.cs @@ -24,18 +24,22 @@ public class AssessmentService : IAssessmentService { private readonly MiAssessmentDbContext _dbContext; private readonly ILogger _logger; + private readonly ReportGenerationService _reportGenerationService; /// /// 构造函数 /// /// 数据库上下文 /// 日志记录器 + /// 报告生成服务 public AssessmentService( MiAssessmentDbContext dbContext, - ILogger logger) + ILogger logger, + ReportGenerationService reportGenerationService) { _dbContext = dbContext; _logger = logger; + _reportGenerationService = reportGenerationService; } /// @@ -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, diff --git a/server/MiAssessment/src/MiAssessment.Core/Services/ReportGenerationService.cs b/server/MiAssessment/src/MiAssessment.Core/Services/ReportGenerationService.cs new file mode 100644 index 0000000..4598ba3 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Core/Services/ReportGenerationService.cs @@ -0,0 +1,503 @@ +using MiAssessment.Model.Data; +using MiAssessment.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MiAssessment.Core.Services; + +/// +/// 测评报告生成服务 +/// +/// +/// 负责根据测评记录ID执行完整的报告生成流水线: +/// - 验证测评记录有效性 +/// - 计算各分类得分 +/// - 排名与星级评定 +/// - 匹配结论文本 +/// - 持久化结果并更新状态 +/// +public class ReportGenerationService +{ + private readonly MiAssessmentDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// 构造函数 + /// + /// 数据库上下文 + /// 日志记录器 + public ReportGenerationService( + MiAssessmentDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// 根据测评记录ID生成报告 + /// + /// 测评记录ID + 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); + } + + /// + /// 验证测评记录有效性 + /// + /// 测评记录ID + /// 验证通过的测评记录 + /// 记录不存在、已删除、状态不正确或无答案数据时抛出 + private async Task 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; + } + + /// + /// 一次性加载报告生成所需的全部数据 + /// + /// 测评记录ID + /// 答案列表、分类映射列表、叶子分类列表、结论列表 + private async Task<( + List answers, + List mappings, + List leafCategories, + List 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(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); + } + + /// + /// 按 ScoreRule 计算各叶子分类的得分、满分和百分比 + /// + /// 答案列表 + /// 题目-分类映射列表 + /// 叶子分类列表 + /// 各分类的得分计算结果 + internal static List CalculateScores( + List answers, + List mappings, + List 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(leafCategories.Select(c => c.Id)); + var scores = new List(); + + 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; + } + + /// + /// 分类得分计算结果 + /// + /// 分类ID + /// 分类类型(1-8) + /// 得分 + /// 满分 + /// 百分比 + internal record CategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage); + + /// + /// 按 CategoryType 分组,组内按 Percentage 降序排名 + /// + /// + /// 排名规则: + /// - 按 CategoryType 分组,每组独立排名 + /// - 组内按 Percentage 降序排列,最高百分比排名为 1 + /// - 相同 Percentage 的分类分配相同排名值(并列排名) + /// - 并列排名后,下一个排名跳过并列数量(如两个并列第1,下一个为第3) + /// + /// 各分类的得分计算结果 + /// 带排名信息的分类得分列表 + internal static List CalculateRanks(List scores) + { + var result = new List(); + + // 按 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; + } + + /// + /// 带排名的分类得分计算结果 + /// + /// 分类ID + /// 分类类型(1-8) + /// 得分 + /// 满分 + /// 百分比 + /// 组内排名(1为最高) + internal record RankedCategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank); + + /// + /// 根据排名位置分配 1-5 星级 + /// + /// + /// 星级分配规则: + /// - 按 CategoryType 分组,每组独立计算星级 + /// - N=1 时:唯一分类获得 5 星 + /// - N≥2 时:starLevel = 5 - round((rank - 1) / (N - 1) * 4),结果限制在 [1, 5] 范围内 + /// - 相同排名的分类自然获得相同星级(公式保证) + /// + /// 带排名的分类得分列表 + /// 带星级的分类得分列表 + internal static List AssignStarLevels(List rankedScores) + { + var result = new List(); + + // 按 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; + } + + /// + /// 带星级的分类得分计算结果 + /// + /// 分类ID + /// 分类类型(1-8) + /// 得分 + /// 满分 + /// 百分比 + /// 组内排名(1为最高) + /// 星级(1-5) + internal record StarredCategoryScore(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank, int StarLevel); + /// + /// 最终分类结果(含结论匹配) + /// + /// 分类ID + /// 分类类型(1-8) + /// 得分 + /// 满分 + /// 百分比 + /// 组内排名(1为最高) + /// 星级(1-5) + /// 结论类型(1最强 2较强 3较弱 4最弱) + /// 结论内容,无匹配时为 null + internal record FinalCategoryResult(long CategoryId, int CategoryType, decimal Score, decimal MaxScore, decimal Percentage, int Rank, int StarLevel, int ConclusionType, string? ConclusionContent); + + /// + /// 将星级映射为结论类型 + /// + /// + /// 映射规则: + /// - 5星 → 结论类型1(最强) + /// - 4星 → 结论类型2(较强) + /// - 3星 → 结论类型2(较强) + /// - 2星 → 结论类型3(较弱) + /// - 1星 → 结论类型4(最弱) + /// + /// 星级(1-5) + /// 结论类型(1-4) + 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 范围内") + }; + } + + /// + /// 根据星级匹配结论文本 + /// + /// + /// 匹配逻辑: + /// - 将星级映射为结论类型(通过 MapStarToConclusionType) + /// - 在结论列表中查找匹配 CategoryId 和 ConclusionType 的记录 + /// - 找到则使用其 Content 字段,未找到则结论内容为 null,继续处理其余分类 + /// + /// 带星级的分类得分列表 + /// 报告结论列表 + /// 包含结论匹配结果的最终分类结果列表 + internal static List MatchConclusions( + List starredScores, + List 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(); + + 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; + } + + /// + /// 在事务内持久化计算结果并更新测评记录状态 + /// + /// + /// 事务内执行以下操作: + /// - 删除该 RecordId 已有的 AssessmentResult 记录(支持重新生成) + /// - 批量写入新的 AssessmentResult 记录 + /// - 更新 AssessmentRecord 的 Status 为 4(已完成)并设置 CompleteTime + /// 异常时回滚事务,Status 保持为 3(生成中) + /// + /// 测评记录ID + /// 最终分类计算结果列表 + private async Task PersistResultsAsync(long recordId, List 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; + } + } + +} diff --git a/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs b/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs index de49e37..92b722f 100644 --- a/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs +++ b/server/MiAssessment/src/MiAssessment.Infrastructure/Modules/ServiceModule.cs @@ -131,12 +131,21 @@ public class ServiceModule : Module // ========== 小程序测评模块服务注册 ========== + // 注册报告生成服务 + builder.Register(c => + { + var dbContext = c.Resolve(); + var logger = c.Resolve>(); + return new ReportGenerationService(dbContext, logger); + }).InstancePerLifetimeScope(); + // 注册测评服务 builder.Register(c => { var dbContext = c.Resolve(); var logger = c.Resolve>(); - return new AssessmentService(dbContext, logger); + var reportGenerationService = c.Resolve(); + return new AssessmentService(dbContext, logger, reportGenerationService); }).As().InstancePerLifetimeScope(); // ========== 小程序订单模块服务注册 ========== diff --git a/server/MiAssessment/src/MiAssessment.Model/Data/MiAssessmentDbContext.cs b/server/MiAssessment/src/MiAssessment.Model/Data/MiAssessmentDbContext.cs index 8604538..90fadf2 100644 --- a/server/MiAssessment/src/MiAssessment.Model/Data/MiAssessmentDbContext.cs +++ b/server/MiAssessment/src/MiAssessment.Model/Data/MiAssessmentDbContext.cs @@ -57,6 +57,10 @@ public partial class MiAssessmentDbContext : DbContext public virtual DbSet ReportCategories { get; set; } + public virtual DbSet QuestionCategoryMappings { get; set; } + + public virtual DbSet ReportConclusions { get; set; } + public virtual DbSet ScoreOptions { get; set; } public virtual DbSet Orders { get; set; } diff --git a/server/MiAssessment/src/MiAssessment.Model/Entities/QuestionCategoryMapping.cs b/server/MiAssessment/src/MiAssessment.Model/Entities/QuestionCategoryMapping.cs new file mode 100644 index 0000000..e70f8a4 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Model/Entities/QuestionCategoryMapping.cs @@ -0,0 +1,45 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MiAssessment.Model.Entities; + +/// +/// 题目分类映射表 +/// +[Table("question_category_mappings")] +public class QuestionCategoryMapping +{ + /// + /// 主键ID + /// + [Key] + public long Id { get; set; } + + /// + /// 题目ID + /// + public long QuestionId { get; set; } + + /// + /// 分类ID + /// + public long CategoryId { get; set; } + + /// + /// 创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 关联的题目 + /// + [ForeignKey(nameof(QuestionId))] + public virtual Question? Question { get; set; } + + /// + /// 关联的报告分类 + /// + [ForeignKey(nameof(CategoryId))] + public virtual ReportCategory? Category { get; set; } +} diff --git a/server/MiAssessment/src/MiAssessment.Model/Entities/ReportConclusion.cs b/server/MiAssessment/src/MiAssessment.Model/Entities/ReportConclusion.cs new file mode 100644 index 0000000..0357da5 --- /dev/null +++ b/server/MiAssessment/src/MiAssessment.Model/Entities/ReportConclusion.cs @@ -0,0 +1,62 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MiAssessment.Model.Entities; + +/// +/// 报告结论表 +/// +[Table("report_conclusions")] +public class ReportConclusion +{ + /// + /// 主键ID + /// + [Key] + public long Id { get; set; } + + /// + /// 分类ID + /// + public long CategoryId { get; set; } + + /// + /// 结论类型:1最强 2较强 3较弱 4最弱 + /// + public int ConclusionType { get; set; } + + /// + /// 结论标题 + /// + [MaxLength(100)] + public string? Title { get; set; } + + /// + /// 结论内容 + /// + [Required] + [Column(TypeName = "nvarchar(max)")] + public string Content { get; set; } = null!; + + /// + /// 创建时间 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间 + /// + public DateTime UpdateTime { get; set; } + + /// + /// 软删除标记 + /// + public bool IsDeleted { get; set; } + + /// + /// 关联的报告分类 + /// + [ForeignKey(nameof(CategoryId))] + public virtual ReportCategory? Category { get; set; } +}