13 changed files with 582 additions and 0 deletions
@ -1,9 +1,19 @@ |
|||
package com.project.statistics.application; |
|||
|
|||
import com.project.base.domain.result.Result; |
|||
import com.project.exam.domain.entity.ExamRecordEntity; |
|||
import com.project.statistics.domain.dto.ErrorProneStatisticsDTO; |
|||
import com.project.statistics.domain.dto.ExamTaskStatisticsDTO; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface StatisticsApplicationService { |
|||
|
|||
Result<ExamTaskStatisticsDTO> examTaskStatistics(Long taskId); |
|||
|
|||
void collectErrorProneStatistics(Long id, Integer examMode, List<ExamRecordEntity.QuestionSnapshot> answerSnapshot); |
|||
|
|||
Result<List<ErrorProneStatisticsDTO>> searchErrorProneTop(Long taskId); |
|||
|
|||
void singleExamStatistics(Long examRecordId) throws Exception; |
|||
} |
|||
|
|||
@ -0,0 +1,91 @@ |
|||
package com.project.statistics.domain.dto; |
|||
|
|||
import cn.hutool.core.collection.CollUtil; |
|||
import com.project.base.domain.dto.BaseDTO; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
import lombok.Data; |
|||
import org.springframework.beans.BeanUtils; |
|||
|
|||
import java.util.List; |
|||
import java.util.function.Supplier; |
|||
|
|||
@Data |
|||
public class ErrorProneStatisticsDTO extends BaseDTO { |
|||
private Long id; |
|||
|
|||
/** |
|||
* 考试模式:0-生成式,1-经典套题 |
|||
*/ |
|||
private Integer examMode; |
|||
|
|||
/** |
|||
* 考试ID |
|||
*/ |
|||
private Long taskId; |
|||
|
|||
/** |
|||
* 关联题目ID列表 |
|||
*/ |
|||
private List<Long> questionIdList; |
|||
|
|||
/** |
|||
* 知识点ID |
|||
*/ |
|||
private Long kpId; |
|||
|
|||
/** |
|||
* 内容:生成式为知识点内容,经典套题为题目内容 |
|||
*/ |
|||
private String content; |
|||
|
|||
/** |
|||
* 题目类型 |
|||
*/ |
|||
private Integer questionType; |
|||
|
|||
/** |
|||
* 错误次数 |
|||
*/ |
|||
private Integer errorCount; |
|||
|
|||
/** |
|||
* 出现次数 |
|||
*/ |
|||
private Integer appearCount; |
|||
|
|||
/** |
|||
* 占比(%):错误次数/出现次数*100 |
|||
*/ |
|||
private String proportion; |
|||
|
|||
/** |
|||
* 选项详情(经典套题才有) |
|||
*/ |
|||
private List<OptionDetailDTO> optionDetailDTOS; |
|||
|
|||
@Data |
|||
public static class OptionDetailDTO { |
|||
private String option; // 选项
|
|||
private String optionContent; // 选项内容
|
|||
private Integer choosenCount; // 选择次数
|
|||
private Boolean right;//正确选项
|
|||
private String proportion; // 占比(%):选择次数/出现次数*100
|
|||
} |
|||
|
|||
@Override |
|||
public <T> T toEntity(Supplier<T> supplier) { |
|||
T result = super.toEntity(supplier); |
|||
if (result instanceof ErrorProneStatisticsEntity entity) { |
|||
// 处理内部列表快照的转换
|
|||
if (CollUtil.isNotEmpty(this.optionDetailDTOS)) { |
|||
List<ErrorProneStatisticsEntity.OptionDetail> optionDetails = this.optionDetailDTOS.stream().map(dto -> { |
|||
ErrorProneStatisticsEntity.OptionDetail optionDetail = new ErrorProneStatisticsEntity.OptionDetail(); |
|||
BeanUtils.copyProperties(dto, optionDetail); |
|||
return optionDetail; |
|||
}).toList(); |
|||
entity.setOptionDetails(optionDetails); |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
@ -0,0 +1,120 @@ |
|||
package com.project.statistics.domain.entity; |
|||
|
|||
import cn.hutool.core.collection.CollUtil; |
|||
import com.baomidou.mybatisplus.annotation.IdType; |
|||
import com.baomidou.mybatisplus.annotation.TableField; |
|||
import com.baomidou.mybatisplus.annotation.TableId; |
|||
import com.baomidou.mybatisplus.annotation.TableName; |
|||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; |
|||
import com.project.base.domain.entity.BaseEntity; |
|||
import com.project.statistics.domain.dto.ErrorProneStatisticsDTO; |
|||
import jakarta.persistence.*; |
|||
import lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
import org.hibernate.annotations.Comment; |
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.springframework.beans.BeanUtils; |
|||
|
|||
import java.util.List; |
|||
import java.util.function.Supplier; |
|||
|
|||
|
|||
@Data |
|||
@Table(name = "evaluator_error_prone_statistics") |
|||
@Entity |
|||
@TableName(value = "evaluator_error_prone_statistics", autoResultMap = true) |
|||
@EqualsAndHashCode(callSuper = true) |
|||
public class ErrorProneStatisticsEntity extends BaseEntity { |
|||
@TableId(type = IdType.ASSIGN_ID) |
|||
@Id |
|||
private Long id; |
|||
|
|||
@Column(name = "exam_mode") |
|||
@Comment("考试模式:0-生成式,1-经典套题") |
|||
@TableField("exam_mode") |
|||
private Integer examMode = 0; |
|||
|
|||
@Column(name = "task_id") |
|||
@TableField("task_id") |
|||
@Comment("考试任务ID") |
|||
private Long taskId; |
|||
|
|||
/** |
|||
* 关联题目idList |
|||
* 存储为 JSON 数组: [1, 10, 101] |
|||
* 生成式:多个题目ID,经典套题:只有一个题目ID |
|||
*/ |
|||
@TableField(value = "question_id_list" , typeHandler = JacksonTypeHandler.class) |
|||
@Column(name = "question_id_list", columnDefinition = "json comment '关联题目列表'") |
|||
@JdbcTypeCode(SqlTypes.JSON) |
|||
private List<Long> questionIdList; |
|||
|
|||
@Column(name = "kp_id") |
|||
@TableField("kp_id") |
|||
@Comment("知识点ID") |
|||
private Long kpId; |
|||
|
|||
@Column(name = "content", columnDefinition = "TEXT comment '内容:生成式为知识点内容,经典套题为题目内容'") |
|||
private String content; |
|||
|
|||
@Column(name = "question_type") |
|||
@Comment("题目类型") |
|||
@TableField("question_type") |
|||
private Integer questionType; |
|||
|
|||
@Column(name = "error_count") |
|||
@Comment("错误次数") |
|||
@TableField("error_count") |
|||
private Integer errorCount; // 错误次数
|
|||
|
|||
@Column(name = "appear_count") |
|||
@Comment("出现次数") |
|||
@TableField("appear_count") |
|||
private Integer appearCount; // 出现次数
|
|||
|
|||
@TableField(value = "option_detail" , typeHandler = JacksonTypeHandler.class) |
|||
@JdbcTypeCode(SqlTypes.JSON) |
|||
@Column(name = "option_detail", columnDefinition = "json comment '选项内容'") |
|||
private List<OptionDetail> optionDetails; |
|||
|
|||
@Data |
|||
public static class OptionDetail { |
|||
private String option; // 选项
|
|||
private String optionContent; // 选项内容
|
|||
private Integer choosenCount; // 选择次数
|
|||
private Boolean right;//正确选项
|
|||
} |
|||
|
|||
@Override |
|||
public <T> T toDTO(Supplier<T> supplier) { |
|||
T result = super.toDTO(supplier); |
|||
if (result instanceof ErrorProneStatisticsDTO dto) { |
|||
// 计算主实体占比:错误次数/出现次数*100,保留整数
|
|||
int appear = this.appearCount == null ? 0 : this.appearCount; |
|||
int error = this.errorCount == null ? 0 : this.errorCount; |
|||
dto.setProportion(calculateProportion(error, appear)); |
|||
|
|||
// 处理内部快照列表从 Entity 到 DTO 的转换
|
|||
if (CollUtil.isNotEmpty(this.optionDetails)) { |
|||
List<ErrorProneStatisticsDTO.OptionDetailDTO> dtoList = this.optionDetails.stream().map(optionDetail -> { |
|||
ErrorProneStatisticsDTO.OptionDetailDTO optionDetailDTO = new ErrorProneStatisticsDTO.OptionDetailDTO(); |
|||
BeanUtils.copyProperties(optionDetail, optionDetailDTO); |
|||
// 计算选项占比:选择次数/出现次数*100,保留整数
|
|||
int chosen = optionDetail.getChoosenCount() == null ? 0 : optionDetail.getChoosenCount(); |
|||
optionDetailDTO.setProportion(calculateProportion(chosen, appear)); |
|||
return optionDetailDTO; |
|||
}).toList(); |
|||
dto.setOptionDetailDTOS(dtoList); |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
private String calculateProportion(int numerator, int denominator) { |
|||
if (denominator == 0) { |
|||
return "0"; |
|||
} |
|||
return String.valueOf(Math.round(numerator * 100.0 / denominator)); |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
package com.project.statistics.domain.service; |
|||
|
|||
import com.project.base.domain.service.IBaseService; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
|
|||
public interface ErrorProneStatisticsBaseService extends IBaseService<ErrorProneStatisticsEntity> { |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.project.statistics.domain.service; |
|||
|
|||
import com.project.exam.domain.entity.ExamRecordEntity; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface SaveErrorProneStatisticsDomainService { |
|||
|
|||
/** |
|||
* 异步收集易错统计 |
|||
* |
|||
* @param taskId 考试任务ID |
|||
* @param examMode 考试模式 |
|||
* @param snapshots 答题快照 |
|||
*/ |
|||
void collectErrorProneStatistics(Long taskId, Integer examMode, List<ExamRecordEntity.QuestionSnapshot> snapshots); |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.project.statistics.domain.service; |
|||
|
|||
import com.project.base.domain.result.Result; |
|||
import com.project.statistics.domain.dto.ErrorProneStatisticsDTO; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface SearchErrorProneStatisticsDomainService { |
|||
|
|||
Result<List<ErrorProneStatisticsDTO>> searchTopByTaskId(Long taskId); |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.project.statistics.domain.service.impl; |
|||
|
|||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
import com.project.statistics.domain.service.ErrorProneStatisticsBaseService; |
|||
import com.project.statistics.mapper.ErrorProneStatisticsMapper; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
@Service |
|||
public class ErrorProneStatisticsBaseServiceImpl extends ServiceImpl<ErrorProneStatisticsMapper, ErrorProneStatisticsEntity> implements ErrorProneStatisticsBaseService { |
|||
} |
|||
@ -0,0 +1,212 @@ |
|||
package com.project.statistics.domain.service.impl; |
|||
|
|||
import cn.hutool.core.collection.CollUtil; |
|||
import cn.hutool.core.collection.CollectionUtil; |
|||
import cn.hutool.core.util.StrUtil; |
|||
import com.project.exam.domain.entity.ExamRecordEntity; |
|||
import com.project.question.domain.entity.QuestionEntity; |
|||
import com.project.question.domain.entity.TaskKnowledgePointEntity; |
|||
import com.project.question.domain.service.QuestionBaseService; |
|||
import com.project.question.domain.service.TaskKnowledgePointBaseService; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
import com.project.statistics.domain.service.ErrorProneStatisticsBaseService; |
|||
import com.project.statistics.domain.service.SaveErrorProneStatisticsDomainService; |
|||
import com.project.task.domain.enums.ExamModeEnum; |
|||
import com.project.task.domain.enums.QuestionTypeEnum; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.scheduling.annotation.Async; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
|
|||
@Service |
|||
@Slf4j |
|||
public class SaveSaveErrorProneStatisticsDomainServiceImpl implements SaveErrorProneStatisticsDomainService { |
|||
|
|||
@Autowired |
|||
private ErrorProneStatisticsBaseService errorProneStatisticsBaseService; |
|||
|
|||
@Autowired |
|||
private QuestionBaseService questionBaseService; |
|||
|
|||
@Autowired |
|||
private TaskKnowledgePointBaseService taskKnowledgePointBaseService; |
|||
|
|||
@Override |
|||
@Async |
|||
public void collectErrorProneStatistics(Long taskId, Integer examMode, List<ExamRecordEntity.QuestionSnapshot> snapshots) { |
|||
if (taskId == null || CollUtil.isEmpty(snapshots)) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
if (ExamModeEnum.GENERATIVE.getValue().equals(examMode)) { |
|||
collectGenerativeStatistics(taskId, snapshots); |
|||
} else if (ExamModeEnum.CLASSIC.getValue().equals(examMode)) { |
|||
collectClassicStatistics(taskId, snapshots); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("[易错统计] 收集失败, taskId={}", taskId, e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 生成式套题:以知识点为单位统计 |
|||
*/ |
|||
private void collectGenerativeStatistics(Long taskId, List<ExamRecordEntity.QuestionSnapshot> snapshots) { |
|||
for (ExamRecordEntity.QuestionSnapshot snapshot : snapshots) { |
|||
// 跳过简答题
|
|||
if (isShortAnswer(snapshot.getType())) { |
|||
continue; |
|||
} |
|||
Long questionId = snapshot.getQuestionId(); |
|||
if (questionId == null) { |
|||
continue; |
|||
} |
|||
QuestionEntity question = questionBaseService.getById(questionId); |
|||
if (question == null || CollUtil.isEmpty(question.getKpIdList())) { |
|||
continue; |
|||
} |
|||
|
|||
List<TaskKnowledgePointEntity> kpList = taskKnowledgePointBaseService.listByIds(question.getKpIdList()); |
|||
for (TaskKnowledgePointEntity kp : kpList) { |
|||
if (kp == null || StrUtil.isBlank(kp.getContent())) { |
|||
continue; |
|||
} |
|||
|
|||
ErrorProneStatisticsEntity entity = errorProneStatisticsBaseService.lambdaQuery() |
|||
.eq(ErrorProneStatisticsEntity::getTaskId, taskId) |
|||
.eq(ErrorProneStatisticsEntity::getKpId, kp.getId()) |
|||
.one(); |
|||
|
|||
if (entity == null) { |
|||
entity = new ErrorProneStatisticsEntity(); |
|||
entity.setTaskId(taskId); |
|||
entity.setExamMode(ExamModeEnum.GENERATIVE.getValue()); |
|||
entity.setContent(kp.getContent()); |
|||
entity.setKpId(kp.getId()); |
|||
entity.setErrorCount(0); |
|||
entity.setAppearCount(0); |
|||
} |
|||
|
|||
List<Long> questionIdList = CollectionUtil.isEmpty(entity.getQuestionIdList()) ? new ArrayList<>() : entity.getQuestionIdList(); |
|||
questionIdList.add(questionId); |
|||
entity.setQuestionIdList(questionIdList); |
|||
entity.setAppearCount(entity.getAppearCount() + 1); |
|||
if (Boolean.FALSE.equals(snapshot.getIsRight())) { |
|||
entity.setErrorCount(entity.getErrorCount() + 1); |
|||
} |
|||
|
|||
errorProneStatisticsBaseService.saveOrUpdate(entity); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 经典套题:以题目为单位统计,并统计选项选择情况 |
|||
*/ |
|||
private void collectClassicStatistics(Long taskId, List<ExamRecordEntity.QuestionSnapshot> snapshots) { |
|||
for (ExamRecordEntity.QuestionSnapshot snapshot : snapshots) { |
|||
// 跳过简答题
|
|||
if (isShortAnswer(snapshot.getType())) { |
|||
continue; |
|||
} |
|||
|
|||
String content = snapshot.getQuestionContent(); |
|||
if (StrUtil.isBlank(content)) { |
|||
continue; |
|||
} |
|||
|
|||
ErrorProneStatisticsEntity entity = errorProneStatisticsBaseService.lambdaQuery() |
|||
.eq(ErrorProneStatisticsEntity::getTaskId, taskId) |
|||
.eq(ErrorProneStatisticsEntity::getExamMode, snapshot.getQuestionId()) |
|||
.one(); |
|||
|
|||
if (entity == null) { |
|||
entity = new ErrorProneStatisticsEntity(); |
|||
entity.setTaskId(taskId); |
|||
entity.setQuestionIdList(List.of(snapshot.getQuestionId())); |
|||
entity.setExamMode(ExamModeEnum.CLASSIC.getValue()); |
|||
entity.setContent(content); |
|||
entity.setQuestionType(snapshot.getType()); |
|||
entity.setErrorCount(0); |
|||
entity.setAppearCount(0); |
|||
entity.setOptionDetails(buildOptionDetail(snapshot)); |
|||
} |
|||
|
|||
entity.setAppearCount(entity.getAppearCount() + 1); |
|||
if (Boolean.FALSE.equals(snapshot.getIsRight())) { |
|||
entity.setErrorCount(entity.getErrorCount() + 1); |
|||
} |
|||
|
|||
updateOptionDetail(entity, snapshot.getUserAnswer()); |
|||
|
|||
errorProneStatisticsBaseService.saveOrUpdate(entity); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 构建选项详情 |
|||
*/ |
|||
private List<ErrorProneStatisticsEntity.OptionDetail> buildOptionDetail(ExamRecordEntity.QuestionSnapshot snapshot) { |
|||
List<ErrorProneStatisticsEntity.OptionDetail> list = new ArrayList<>(); |
|||
Map<String, String> options = snapshot.getOptions(); |
|||
if (options == null) { |
|||
return list; |
|||
} |
|||
|
|||
String normalizedRightAnswer = normalizeAnswer(snapshot.getRightAnswer()); |
|||
|
|||
for (Map.Entry<String, String> entry : options.entrySet()) { |
|||
ErrorProneStatisticsEntity.OptionDetail od = new ErrorProneStatisticsEntity.OptionDetail(); |
|||
od.setOption(entry.getKey()); |
|||
od.setOptionContent(entry.getValue()); |
|||
od.setChoosenCount(0); |
|||
od.setRight(normalizedRightAnswer.contains(entry.getKey().toUpperCase())); |
|||
list.add(od); |
|||
} |
|||
return list; |
|||
} |
|||
|
|||
/** |
|||
* 更新选项被选择次数 |
|||
*/ |
|||
private void updateOptionDetail(ErrorProneStatisticsEntity entity, String userAnswer) { |
|||
String normalizedUserAnswer = normalizeAnswer(userAnswer); |
|||
if (StrUtil.isBlank(normalizedUserAnswer)) { |
|||
return; |
|||
} |
|||
|
|||
List<ErrorProneStatisticsEntity.OptionDetail> optionDetailList = entity.getOptionDetails(); |
|||
if (CollUtil.isEmpty(optionDetailList)) { |
|||
return; |
|||
} |
|||
|
|||
for (ErrorProneStatisticsEntity.OptionDetail od : optionDetailList) { |
|||
if (normalizedUserAnswer.contains(od.getOption().toUpperCase())) { |
|||
od.setChoosenCount((od.getChoosenCount() == null ? 0 : od.getChoosenCount()) + 1); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 标准化答案:去除非字母数字字符并转大写 |
|||
*/ |
|||
private String normalizeAnswer(String answer) { |
|||
if (StrUtil.isBlank(answer)) { |
|||
return ""; |
|||
} |
|||
return answer.replaceAll("[^a-zA-Z0-9]", "").toUpperCase(); |
|||
} |
|||
|
|||
/** |
|||
* 是否为简答题 |
|||
*/ |
|||
private boolean isShortAnswer(Integer type) { |
|||
return Objects.equals(type, QuestionTypeEnum.SHORT_ANSWER.getValue()); |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.project.statistics.domain.service.impl; |
|||
|
|||
import com.project.base.domain.result.Result; |
|||
import com.project.statistics.domain.dto.ErrorProneStatisticsDTO; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
import com.project.statistics.domain.service.ErrorProneStatisticsBaseService; |
|||
import com.project.statistics.domain.service.SearchErrorProneStatisticsDomainService; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.util.List; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Service |
|||
public class SearchErrorProneStatisticsDomainServiceImpl implements SearchErrorProneStatisticsDomainService { |
|||
|
|||
@Autowired |
|||
private ErrorProneStatisticsBaseService errorProneStatisticsBaseService; |
|||
|
|||
@Override |
|||
public Result<List<ErrorProneStatisticsDTO>> searchTopByTaskId(Long taskId) { |
|||
List<ErrorProneStatisticsEntity> entityList = errorProneStatisticsBaseService.lambdaQuery() |
|||
.eq(ErrorProneStatisticsEntity::getTaskId, taskId) |
|||
.orderByDesc(ErrorProneStatisticsEntity::getErrorCount) |
|||
.last("LIMIT 10") |
|||
.list(); |
|||
|
|||
List<ErrorProneStatisticsDTO> dtoList = entityList.stream().map(entity -> entity.toDTO(ErrorProneStatisticsDTO::new)).collect(Collectors.toList()); |
|||
return Result.success(dtoList); |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.project.statistics.mapper; |
|||
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
|||
import com.project.statistics.domain.entity.ErrorProneStatisticsEntity; |
|||
import org.apache.ibatis.annotations.Mapper; |
|||
|
|||
@Mapper |
|||
public interface ErrorProneStatisticsMapper extends BaseMapper<ErrorProneStatisticsEntity> { |
|||
|
|||
} |
|||
Loading…
Reference in new issue