mi-assessment/.kiro/specs/assessment-report-generation/design.md
2026-02-24 13:08:13 +08:00

14 KiB
Raw Blame History

设计文档:测评结论生成服务

概述

测评结论生成服务ReportGenerationService是学业邑规划测评系统中衔接"答案提交"与"报告查看"的核心后端模块。用户完成80道测评题目提交答案后AssessmentService.SubmitAnswersAsync 将测评记录状态设为3生成中并提交事务随后调用本服务执行以下流水线

  1. 得分计算:根据 ScoreRule累加/二值)计算各叶子分类的得分、满分和百分比
  2. 排名计算:在同一 CategoryType 维度内按百分比降序排名(支持并列)
  3. 星级评定根据排名位置分配1-5星
  4. 结论匹配:将星级映射为结论类型,查询对应结论文本
  5. 结果持久化:在单次事务中写入所有 AssessmentResult 记录并更新状态为4已完成

该服务作为独立类注入 DI 容器,使用 MiAssessmentDbContext 访问 Business 库。

设计决策

  • 同步调用而非异步队列当前测评数据量80题 × 8维度计算量小同步调用即可满足性能要求避免引入消息队列的复杂度
  • 在 MiAssessment.Core 项目中实现:与 AssessmentService 同层,便于直接依赖注入和调用
  • 复用 MiAssessmentDbContext:遵循双数据库架构规范,小程序 API 通过此上下文读写 Business 库
  • 需在 MiAssessment.Model 中新增实体QuestionCategoryMapping 和 ReportConclusion 实体目前仅存在于 Admin.Business 项目中,需在 Model 项目中创建对应实体以供 Core 层使用

架构

调用流程

sequenceDiagram
    participant Client as 小程序客户端
    participant API as AssessmentController
    participant AS as AssessmentService
    participant RGS as ReportGenerationService
    participant DB as MiAssessmentDbContext

    Client->>API: POST /api/assessment/submitAnswers
    API->>AS: SubmitAnswersAsync(userId, request)
    AS->>DB: 保存答案 + 状态设为3生成中
    AS->>DB: CommitAsync()
    AS->>RGS: GenerateReportAsync(recordId)
    RGS->>DB: 查询答案、分类映射、结论
    RGS->>RGS: 计算得分 → 排名 → 星级 → 匹配结论
    RGS->>DB: BeginTransaction
    RGS->>DB: 删除旧结果 + 写入新结果 + 状态设为4
    RGS->>DB: CommitAsync()
    RGS-->>AS: 完成
    AS-->>API: SubmitAnswersResponse
    API-->>Client: { success: true }

项目层级

MiAssessment.Model/
├── Entities/
│   ├── QuestionCategoryMapping.cs  ← 新增
│   └── ReportConclusion.cs         ← 新增
└── Data/
    └── MiAssessmentDbContext.cs     ← 新增 DbSet

MiAssessment.Core/
└── Services/
    ├── AssessmentService.cs         ← 修改:调用 ReportGenerationService
    └── ReportGenerationService.cs   ← 新增

组件与接口

ReportGenerationService

namespace MiAssessment.Core.Services;

/// <summary>
/// 测评报告生成服务
/// </summary>
public class ReportGenerationService
{
    private readonly MiAssessmentDbContext _dbContext;
    private readonly ILogger<ReportGenerationService> _logger;

    public ReportGenerationService(
        MiAssessmentDbContext dbContext,
        ILogger<ReportGenerationService> logger);

    /// <summary>
    /// 根据测评记录ID生成报告
    /// </summary>
    /// <param name="recordId">测评记录ID</param>
    public async Task GenerateReportAsync(long recordId);
}

内部处理步骤

GenerateReportAsync 方法内部按以下顺序执行:

  1. ValidateRecord(recordId)验证记录存在、未软删除、Status=3、有答案数据
  2. LoadData(record):一次性加载答案、分类映射、叶子分类、结论数据
  3. CalculateScores(answers, mappings, categories):按 ScoreRule 计算各分类得分
  4. CalculateRanks(scores):按 CategoryType 分组排名
  5. AssignStarLevels(rankedScores):根据排名分配星级
  6. MatchConclusions(starResults, conclusions):星级→结论类型映射
  7. PersistResults(recordId, results):事务内写入结果并更新状态

AssessmentService 集成修改

SubmitAnswersAsync 方法的事务提交成功后,添加对 ReportGenerationService.GenerateReportAsync 的调用:

// 现有代码:事务提交后
await transaction.CommitAsync();

// 新增:触发报告生成
try
{
    await _reportGenerationService.GenerateReportAsync(request.RecordId);
}
catch (Exception ex)
{
    _logger.LogError(ex, "报告生成失败recordId: {RecordId}", request.RecordId);
    // 报告生成失败不影响答案提交的返回状态保持为3生成中
}

数据模型

新增实体QuestionCategoryMappingMiAssessment.Model

参照 MiAssessment.Admin.Business.Entities.QuestionCategoryMapping 的结构,在 Model 项目中创建对应实体:

namespace MiAssessment.Model.Entities;

[Table("question_category_mappings")]
public class QuestionCategoryMapping
{
    [Key]
    public long Id { get; set; }
    public long QuestionId { get; set; }
    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; }
}

新增实体ReportConclusionMiAssessment.Model

参照 MiAssessment.Admin.Business.Entities.ReportConclusion 的结构:

namespace MiAssessment.Model.Entities;

[Table("report_conclusions")]
public class ReportConclusion
{
    [Key]
    public long Id { get; set; }
    public long CategoryId { get; set; }
    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; }
}

MiAssessmentDbContext 扩展

新增两个 DbSet

public virtual DbSet<QuestionCategoryMapping> QuestionCategoryMappings { get; set; }
public virtual DbSet<ReportConclusion> ReportConclusions { get; set; }

核心计算逻辑数据流

flowchart TD
    A[AssessmentAnswer 列表] --> B[按 QuestionCategoryMapping 分组到各 Category]
    B --> C{ScoreRule?}
    C -->|1 累加| D[Score = Σ AnswerValue]
    C -->|2 二值| E[Score = Σ (AnswerValue >= 6 ? 1 : 0)]
    D --> F[MaxScore = 题目数 × 10]
    E --> G[MaxScore = 题目数 × 1]
    F --> H[Percentage = Score / MaxScore × 100]
    G --> H
    H --> I[按 CategoryType 分组]
    I --> J[组内按 Percentage 降序排名]
    J --> K[根据排名位置分配 1-5 星]
    K --> L{StarLevel 映射}
    L -->|5星| M[ConclusionType = 1 最强]
    L -->|4星| N[ConclusionType = 2 较强]
    L -->|3星| N
    L -->|2星| O[ConclusionType = 3 较弱]
    L -->|1星| P[ConclusionType = 4 最弱]
    M --> Q[查询 ReportConclusion]
    N --> Q
    O --> Q
    P --> Q
    Q --> R[写入 AssessmentResult]

星级分配算法

对于同一 CategoryType 维度内的 N 个叶子分类(已按百分比降序排名):

  • N ≥ 5排名第1 → 5星排名最后 → 1星中间按排名位置均匀映射到 2-4 星
    • 公式:starLevel = 5 - round((rank - 1) / (N - 1) * 4)
  • N < 5排名第1 → 5星排名最后 → 1星中间按比例分配
    • 公式同上,适用于任意 N ≥ 2
  • N = 1:唯一分类 → 5星
  • 并列排名:相同百分比的分类获得相同排名和相同星级

正确性属性Correctness Properties

属性Property是指在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做之事的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。

Property 1: 累加计分正确性

For any 一组答案AnswerValue 范围 1-10和一个 ScoreRule=1 的叶子分类该分类的计算结果应满足Score 等于所有映射题目 AnswerValue 之和MaxScore 等于映射题目数量乘以 10Percentage 等于 round(Score / MaxScore × 100, 2)。

Validates: Requirements 1.1, 1.4, 1.5

Property 2: 二值计分正确性

For any 一组答案AnswerValue 范围 1-10和一个 ScoreRule=2 的叶子分类该分类的计算结果应满足Score 等于映射题目中 AnswerValue ≥ 6 的数量MaxScore 等于映射题目数量乘以 1Percentage 等于 round(Score / MaxScore × 100, 2)。

Validates: Requirements 1.2, 1.4, 1.5

Property 3: 多分类映射得分独立性

For any 一道题目映射到多个分类的情况,每个分类的得分计算应独立包含该题目的贡献,且一道题目对不同分类的贡献互不影响。

Validates: Requirements 1.3

Property 4: 排名顺序正确性

For any 同一 CategoryType 维度内的一组叶子分类及其百分比,排名结果应满足:百分比更高的分类排名值更小(更靠前),且百分比相同的分类排名值相同。

Validates: Requirements 2.1, 2.2

Property 5: 仅叶子节点参与排名

For any 包含父子层级关系的分类树排名结果中应仅包含叶子节点分类ParentId 不被其他分类引用的分类),父分类不应出现在排名结果中。

Validates: Requirements 2.3

Property 6: 星级分配正确性

For any 同一 CategoryType 维度内 N 个已排名的叶子分类N ≥ 2星级分配应满足排名第 1 的分类获得 5 星,排名最后的分类获得 1 星,所有星级值在 [1, 5] 范围内,且排名更靠前的分类星级不低于排名更靠后的分类。当 N = 1 时,唯一分类获得 5 星。

Validates: Requirements 3.1, 3.2, 3.3

Property 7: 星级到结论类型映射正确性

For any 星级值1-5映射到的结论类型应满足5→1最强4→2较强3→2较强2→3较弱1→4最弱

Validates: Requirements 4.1

Property 8: 结果持久化完整性

For any 成功完成的报告生成,写入 AssessmentResult 表的记录应与计算结果一一对应:每条记录的 RecordId、CategoryId、Score、MaxScore、Percentage、Rank、StarLevel 均与内存计算值一致。

Validates: Requirements 5.1, 4.2

Property 9: 重新生成幂等性

For any 已有 AssessmentResult 记录的测评记录,重新执行报告生成后,数据库中该 RecordId 的结果记录应完全等于最新一次计算的结果,不存在旧数据残留。

Validates: Requirements 5.3

Property 10: 生成完成后状态与时间一致性

For any 成功完成报告生成的测评记录,其 Status 应为 4已完成CompleteTime 应非空且不早于生成开始时间。

Validates: Requirements 6.1, 6.2

错误处理

场景 处理方式 状态影响
RecordId 对应的记录不存在或已软删除 抛出 InvalidOperationException,记录错误日志 无状态变更
Record.Status ≠ 3 抛出 InvalidOperationException,记录错误日志 无状态变更
答案记录数量为 0 抛出 InvalidOperationException,记录错误日志 无状态变更
数据库写入异常 回滚事务,记录错误日志 Status 保持为 3生成中
某分类无匹配结论 结论文本记录为空,继续处理其余分类 不影响整体流程

错误处理遵循以下原则:

  • 验证阶段的错误(记录不存在、状态不对、无答案)直接抛出异常,由调用方捕获
  • 持久化阶段的错误通过事务回滚保证数据一致性
  • 结论缺失属于数据不完整而非系统错误,采用容错处理

测试策略

测试框架

  • 单元测试xUnit + Moq
  • 属性测试FsCheck.NET 属性测试库)
  • 数据库测试:使用 EF Core InMemory Provider 或 SQLite InMemory

单元测试

单元测试覆盖以下场景:

  1. 得分计算:验证累加计分和二值计分的具体示例
  2. 排名计算:验证正常排名、并列排名的具体示例
  3. 星级分配:验证 N=1, N=3, N=5, N=8 等不同分类数量的星级分配
  4. 结论匹配:验证有结论和无结论的情况
  5. 错误处理:验证各种异常输入(记录不存在、状态不对、无答案等)
  6. 集成测试:验证完整的报告生成流程(从答案到最终结果)

属性测试

每个正确性属性对应一个属性测试,使用 FsCheck 生成随机输入,最少运行 100 次迭代。

属性测试标注格式:

Feature: assessment-report-generation, Property {number}: {property_text}

示例:

/// <summary>
/// Feature: assessment-report-generation, Property 1: 累加计分正确性
/// *For any* 一组答案和 ScoreRule=1 的分类Score 应等于 AnswerValue 之和
/// **Validates: Requirements 1.1, 1.4, 1.5**
/// </summary>
[Property(MaxTest = 100)]
public Property CumulativeScoring_ShouldSumAnswerValues()
{
    // FsCheck 生成随机答案值列表,验证计算结果
}

测试覆盖矩阵

属性 属性测试 单元测试 边界情况
Property 1: 累加计分 ✓ 随机答案值 ✓ 具体示例 全1分、全10分
Property 2: 二值计分 ✓ 随机答案值 ✓ 具体示例 全5分边界、全6分
Property 3: 多分类映射 ✓ 随机映射关系 ✓ 一题多分类示例 -
Property 4: 排名顺序 ✓ 随机百分比集合 ✓ 并列排名示例 全部相同百分比
Property 5: 叶子节点 ✓ 随机分类树 ✓ 含父子层级示例 -
Property 6: 星级分配 ✓ 随机排名 ✓ N=1,3,5,8 示例 N=1, N=2
Property 7: 星级映射 ✓ 所有星级值 - -
Property 8: 持久化完整性 ✓ 随机计算结果 ✓ 完整流程示例 -
Property 9: 重新生成 ✓ 随机旧数据+新数据 ✓ 覆盖示例 -
Property 10: 状态一致性 ✓ 随机成功场景 ✓ 状态验证示例 -