records = mapper.selectPage(schoolId, userName, studentId, classId, size, offset);
+ long pages = total == 0 ? 0 : (total + size - 1) / size;
+ return new TeacherReportPage(current, size, total, pages, records);
+ }
+
+ /**
+ * 教师给实训报告评分:按 id 定位报告,写入教师评分(experiment_report.teacher_score)。
+ *
+ * 校验:报告不存在或已删除 → 404;报告不属于当前教师所属学校 → 403;
+ * 评分为空、超过 2 位小数或不在 0-100 之间 → 400。
+ *
+ * @param schoolId 当前教师所属学校编号(数据隔离)
+ * @param id 实训报告编号
+ * @param score 教师评分
+ * @param comment 教师评语,最多 500 字;传 null 表示不修改原评语,传空字符串表示清空
+ * @return 评分后的报告视图(含 teacherScore、teacherComment)
+ */
+ public ExperimentReportView score(String schoolId, String id, BigDecimal score, String comment) {
+ if (score == null) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "评分不能为空");
+ }
+ if (score.scale() > 2 || score.compareTo(BigDecimal.ZERO) < 0
+ || score.compareTo(new BigDecimal("100")) > 0) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "评分最多保留 2 位小数且必须在 0 到 100 之间");
+ }
+ ExperimentReportEntity report = reportMapper.selectById(id);
+ if (report == null || Boolean.TRUE.equals(report.getDeleted())) {
+ throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "实训报告不存在");
+ }
+ if (report.getSchoolId() == null || !report.getSchoolId().equals(schoolId)) {
+ throw new BusinessException(ErrorCode.FORBIDDEN, "无权为其他学校的学生评分");
+ }
+ // 评语:null 表示不改,空串表示清空,其余按 trimmed 文本写入
+ String text = comment == null ? report.getTeacherComment() : comment.trim();
+ if (text != null && text.length() > 500) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "教师评语不能超过 500 字");
+ }
+ report.setTeacherScore(score);
+ report.setTeacherComment(text);
+ report.setUpdatedAt(LocalDateTime.now());
+ reportMapper.updateById(report);
+ return new ExperimentReportView(report);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java
new file mode 100644
index 0000000..e5bd896
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/infrastructure/TeacherReportQueryMapper.java
@@ -0,0 +1,47 @@
+package com.yau.digitalrmb.training.experimentreport.teacher.infrastructure;
+
+import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportRow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 教师端实验报告查询:按当前学校查 experiment_report 关联 sys_user(学生姓名/学号/班级筛选、分页)。
+ * 只读,不写入任何表。列名以驼峰别名返回,避免依赖 mapUnderscoreToCamelCase 配置。
+ */
+@Mapper
+public interface TeacherReportQueryMapper {
+
+ @Select("")
+ List selectPage(@Param("schoolId") String schoolId,
+ @Param("userName") String userName,
+ @Param("studentId") String studentId,
+ @Param("classId") String classId,
+ @Param("size") int size,
+ @Param("offset") int offset);
+
+ @Select("")
+ long selectCount(@Param("schoolId") String schoolId,
+ @Param("userName") String userName,
+ @Param("studentId") String studentId,
+ @Param("classId") String classId);
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java
new file mode 100644
index 0000000..8abd5fb
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportController.java
@@ -0,0 +1,72 @@
+package com.yau.digitalrmb.training.experimentreport.teacher.interfaces.rest;
+
+import com.yau.digitalrmb.security.application.CurrentUser;
+import com.yau.digitalrmb.security.application.CurrentUserService;
+import com.yau.digitalrmb.shared.api.ApiResponse;
+import com.yau.digitalrmb.shared.web.TraceIdFilter;
+import com.yau.digitalrmb.training.experimentreport.application.ExperimentReportView;
+import com.yau.digitalrmb.training.experimentreport.interfaces.dto.ScoreReportRequest;
+import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportPage;
+import com.yau.digitalrmb.training.experimentreport.teacher.application.TeacherReportService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.MDC;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+/**
+ * 教师端实验报告:按当前学校分页查询学生提交的报告(与学生端报告列表同字段,但查全校)。
+ */
+@RestController
+@RequestMapping("/api/v1/experiment-reports/teacher")
+@Tag(name = "教师端实验报告", description = "按当前学校查询学生提交的全部实验报告,支持姓名/学号/班级筛选,与实验报告上传无耦合")
+public class TeacherExperimentReportController {
+
+ private final TeacherReportService service;
+ private final CurrentUserService currentUserService;
+
+ public TeacherExperimentReportController(TeacherReportService service, CurrentUserService currentUserService) {
+ this.service = service;
+ this.currentUserService = currentUserService;
+ }
+
+ @GetMapping
+ @Operation(summary = "教师端分页查询实验报告",
+ description = "按当前教师所属学校查询学生提交的全部报告,支持姓名(模糊)、学号(模糊)、班级 id(精确)筛选,分页返回。"
+ + "每行包括姓名、学号、班级、报告名称、上传时间、报告大小、教师评分、文件地址。班级下拉复用 /training-scores/teacher/classes。")
+ public ApiResponse page(
+ @Parameter(description = "页码,从 1 开始", example = "1")
+ @RequestParam(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "每页数量", example = "10")
+ @RequestParam(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "姓名模糊匹配")
+ @RequestParam(value = "userName", required = false) String userName,
+ @Parameter(description = "学号模糊匹配")
+ @RequestParam(value = "studentId", required = false) String studentId,
+ @Parameter(description = "班级 id 精确匹配")
+ @RequestParam(value = "classId", required = false) String classId) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ service.pageQuery(user.getSchoolId(), userName, studentId, classId, page, size),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @PostMapping("/score")
+ @Operation(summary = "教师给实训报告评分",
+ description = "按报告 id 定位学生提交的实训报告并写入教师评分(teacher_score);返回评分后的报告信息。"
+ + "报告不存在 → 404;报告不属于当前教师所属学校 → 403;评分最多 2 位小数且必须在 0-100 之间,否则 400。"
+ + "写入后学生端总成绩中的实验报告成绩立即按新分数计算。")
+ public ApiResponse score(@Valid @RequestBody ScoreReportRequest request) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ service.score(user.getSchoolId(), request.getId(), request.getScore(), request.getComment()),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java
new file mode 100644
index 0000000..fc76bf1
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/ModuleScoreSummaryResult.java
@@ -0,0 +1,57 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 学生端模块成绩汇总,一行对应一个实验模块,展示字段与成绩查询页面一致。
+ */
+@Getter
+@Schema(description = "学生端模块成绩汇总")
+public class ModuleScoreSummaryResult {
+ @Schema(description = "模块序号 1-6", example = "1")
+ private final Integer mouduleCode;
+ @Schema(description = "模块名称", example = "中国人民银行生成数字货币")
+ private final String moudule;
+ @Schema(description = "计分规则", example = "选错/填错一个扣 0.5 分")
+ private final String scoringCriteria;
+ @Schema(description = "选错/填错一个扣的分值(来自权重表,单位:分)", example = "0.5")
+ private final BigDecimal deductScore;
+ @Schema(description = "操作次数(含历史已删除轮次)", example = "3")
+ private final Integer operationCount;
+ @Schema(description = "当前有效操作次数(未删除记录数)", example = "1")
+ private final Integer currentCount;
+ @Schema(description = "模块权重,如 0.30 表示 30%", example = "0.30")
+ private final BigDecimal moduleWeight;
+ @Schema(description = "完成进度百分比", example = "66.67")
+ private final Double schedule;
+ @Schema(description = "最高成绩", example = "98.00")
+ private final Double maxScore;
+ @Schema(description = "最低成绩", example = "52.00")
+ private final Double minScore;
+ @Schema(description = "平均成绩,保留 2 位小数", example = "60.00")
+ private final Double avgScore;
+ @Schema(description = "该模块原始成绩明细(不含已删除记录)")
+ private final List details;
+
+ public ModuleScoreSummaryResult(Integer mouduleCode, String moudule, String scoringCriteria,
+ BigDecimal deductScore, Integer operationCount, Integer currentCount,
+ BigDecimal moduleWeight, Double schedule, Double maxScore, Double minScore,
+ Double avgScore, List details) {
+ this.mouduleCode = mouduleCode;
+ this.moudule = moudule;
+ this.scoringCriteria = scoringCriteria;
+ this.deductScore = deductScore;
+ this.operationCount = operationCount;
+ this.currentCount = currentCount;
+ this.moduleWeight = moduleWeight;
+ this.schedule = schedule;
+ this.maxScore = maxScore;
+ this.minScore = minScore;
+ this.avgScore = avgScore;
+ this.details = details;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java
new file mode 100644
index 0000000..27273f3
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreItem.java
@@ -0,0 +1,36 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 总成绩页面的一行:实验实训 / 实验报告 / 总成绩。
+ *
+ * 字段与前端总成绩表一一对应(项目、成绩、权重、进度、最终成绩)。
+ * 最终成绩 = 进度(百分比)/100 × 权重 × 成绩。
+ */
+@Getter
+@Schema(description = "总成绩页面一行")
+public class OverallScoreItem {
+ @Schema(description = "项目名称:实验实训 / 实验报告 / 总成绩", example = "实验实训")
+ private final String project;
+ @Schema(description = "成绩:实训为各模块最高分按模块权重加权;报告为教师评分;总成绩行无此项", example = "36.40")
+ private final BigDecimal score;
+ @Schema(description = "权重,如 0.80 表示 80%", example = "0.80")
+ private final BigDecimal weight;
+ @Schema(description = "完成进度百分比:实训为 6 个模块进度均值(缺的按 0,仍除 6);报告有提交即 100;总成绩行无此项", example = "16.67")
+ private final BigDecimal progress;
+ @Schema(description = "最终成绩 = 进度/100 × 权重 × 成绩,保留 2 位小数", example = "4.85")
+ private final BigDecimal finalScore;
+
+ public OverallScoreItem(String project, BigDecimal score, BigDecimal weight, BigDecimal progress,
+ BigDecimal finalScore) {
+ this.project = project;
+ this.score = score;
+ this.weight = weight;
+ this.progress = progress;
+ this.finalScore = finalScore;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java
new file mode 100644
index 0000000..485b16c
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/OverallScoreResult.java
@@ -0,0 +1,24 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 学生端总成绩查询结果:实验实训、实验报告、总成绩三行,及汇总后的最终总成绩。
+ */
+@Getter
+@Schema(description = "学生端总成绩查询结果")
+public class OverallScoreResult {
+ @Schema(description = "三行明细:实验实训 / 实验报告 / 总成绩")
+ private final List items;
+ @Schema(description = "最终总成绩 = 实验实训最终成绩 + 实验报告最终成绩", example = "22.85")
+ private final BigDecimal totalFinal;
+
+ public OverallScoreResult(List items, BigDecimal totalFinal) {
+ this.items = items;
+ this.totalFinal = totalFinal;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java
new file mode 100644
index 0000000..8fc39bf
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/StuModuleScoreResult.java
@@ -0,0 +1,49 @@
+package com.yau.digitalrmb.training.score.application;
+
+import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+/**
+ * 学生模块成绩明细查询结果,字段与 stu_module_score_details 表一一对应,不返回逻辑删除标记。
+ */
+@Getter
+@Schema(description = "学生模块成绩明细")
+public class StuModuleScoreResult {
+ @Schema(description = "成绩明细主键")
+ private final String id;
+ @Schema(description = "实验模块名称,对应表字段 moudule", example = "中国人民银行生成数字货币")
+ private final String moudule;
+ @Schema(description = "学习项目", example = "实验实训")
+ private final String learningProjects;
+ @Schema(description = "考核项目", example = "三步流程错误次数")
+ private final String assessmentItems;
+ @Schema(description = "评分标准", example = "0.5")
+ private final String scoringCriteria;
+ @Schema(description = "完成情况,数字表示累计错误次数", example = "2")
+ private final String completionStatus;
+ @Schema(description = "项目得分", example = "88.50")
+ private final Double scoreProject;
+ @Schema(description = "用户编号")
+ private final String userId;
+ @Schema(description = "项目总分", example = "100.00")
+ private final Double totalScore;
+ @Schema(description = "模块序号", example = "3")
+ private final Integer serialNumber;
+ @Schema(description = "完成进度百分比", example = "66.67")
+ private final Double schedule;
+
+ public StuModuleScoreResult(StuModuleScoreDetailsEntity value) {
+ this.id = value.getId();
+ this.moudule = value.getMoudule();
+ this.learningProjects = value.getLearningProjects();
+ this.assessmentItems = value.getAssessmentItems();
+ this.scoringCriteria = value.getScoringCriteria();
+ this.completionStatus = value.getCompletionStatus();
+ this.scoreProject = value.getScoreProject();
+ this.userId = value.getUserId();
+ this.totalScore = value.getTotalScore();
+ this.serialNumber = value.getSerialNumber();
+ this.schedule = value.getSchedule();
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java
new file mode 100644
index 0000000..b342969
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailResult.java
@@ -0,0 +1,37 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 学生端「成绩详情」:当前用户模块一~模块六各自最高成绩 + 综合分数。
+ * 最高成绩口径与模块成绩汇总一致:该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
+ * 该模块没有成绩时对应字段为 null。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "学生端成绩详情(模块一~模块六最高成绩 + 综合分数)")
+public class StudentScoreDetailResult {
+
+ @Schema(description = "模块一(中国人民银行生成数字货币)最高成绩")
+ private final Double module1Score;
+
+ @Schema(description = "模块二(中国人民银行发行数字货币)最高成绩")
+ private final Double module2Score;
+
+ @Schema(description = "模块三(开通数字货币钱包)最高成绩")
+ private final Double module3Score;
+
+ @Schema(description = "模块四(用户兑换数字货币)最高成绩")
+ private final Double module4Score;
+
+ @Schema(description = "模块五(用户支付数字货币)最高成绩")
+ private final Double module5Score;
+
+ @Schema(description = "模块六(数字人民币创新应用场景仿真)最高成绩")
+ private final Double module6Score;
+
+ @Schema(description = "综合分数:各模块最高分按模块权重加权求和(权重和为 1,缺失模块计 0),保留 2 位小数", example = "36.40")
+ private final Double totalScore;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java
new file mode 100644
index 0000000..900e4ec
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/StudentScoreDetailService.java
@@ -0,0 +1,49 @@
+package com.yau.digitalrmb.training.score.application;
+
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.List;
+
+/**
+ * 学生端「成绩详情」计算:当前用户模块一~模块六各取最高成绩,
+ * 并按各模块最高分×模块权重加权求和得到综合分数(totalScore)。
+ *
+ * 直接复用 {@link TrainingScoreQueryService#summary(String, String, String)} 的模块汇总结果:
+ * maxScore 口径一致(全部轮次含已逻辑删除取最大值),moduleWeight 来自权重表
+ * (本校未配置时由权重服务落默认行),与教师端「成绩详情」的综合分数完全同口径。
+ */
+@Service
+public class StudentScoreDetailService {
+ private final TrainingScoreQueryService scoreQueryService;
+
+ public StudentScoreDetailService(TrainingScoreQueryService scoreQueryService) {
+ this.scoreQueryService = scoreQueryService;
+ }
+
+ /**
+ * 查询当前用户成绩详情。
+ *
+ * @param userId 当前登录用户编号
+ * @param schoolId 当前用户所属学校编号(用于读取本校模块权重,未配置时落默认)
+ */
+ public StudentScoreDetailResult detail(String userId, String schoolId) {
+ List modules = scoreQueryService.summary(userId, schoolId, null);
+
+ Double[] maxScores = new Double[TrainingModules.MODULE_COUNT];
+ BigDecimal total = BigDecimal.ZERO;
+ for (ModuleScoreSummaryResult m : modules) {
+ Integer code = m.getMouduleCode();
+ if (code == null || code < 1 || code > TrainingModules.MODULE_COUNT || m.getMaxScore() == null) {
+ continue;
+ }
+ maxScores[code - 1] = m.getMaxScore();
+ BigDecimal weight = m.getModuleWeight() == null ? BigDecimal.ZERO : m.getModuleWeight();
+ total = total.add(weight.multiply(BigDecimal.valueOf(m.getMaxScore())));
+ }
+ return new StudentScoreDetailResult(
+ maxScores[0], maxScores[1], maxScores[2], maxScores[3], maxScores[4], maxScores[5],
+ total.setScale(2, RoundingMode.HALF_UP).doubleValue());
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java
new file mode 100644
index 0000000..96b9244
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightItem.java
@@ -0,0 +1,26 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 教师端单个模块权重项。
+ */
+@Getter
+@Schema(description = "模块权重项")
+public class TrainingModuleWeightItem {
+ @Schema(description = "模块名称", example = "中国人民银行生成数字货币")
+ private final String moudule;
+ @Schema(description = "模块序号 1-6", example = "1")
+ private final Integer mouduleCode;
+ @Schema(description = "模块权重,如 0.30 表示 30%", example = "0.30")
+ private final BigDecimal weight;
+
+ public TrainingModuleWeightItem(String moudule, Integer mouduleCode, BigDecimal weight) {
+ this.moudule = moudule;
+ this.mouduleCode = mouduleCode;
+ this.weight = weight;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java
new file mode 100644
index 0000000..9164a22
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModuleWeightResult.java
@@ -0,0 +1,31 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 教师端模块权重查询结果:记录编号 + 扣分值 + 6 个模块权重。
+ */
+@Getter
+@Schema(description = "模块权重配置")
+public class TrainingModuleWeightResult {
+ @Schema(description = "配置记录编号,修改接口需回传该值", example = "3f2a...-... ")
+ private final String id;
+ @Schema(description = "选错/填错一个扣分", example = "0.50")
+ private final BigDecimal deductScore;
+ @Schema(description = "6 个模块权重")
+ private final List modules;
+ @Schema(description = "是否为本校已有配置;false 表示本次查询时刚落库一条默认配置")
+ private final boolean configured;
+
+ public TrainingModuleWeightResult(String id, BigDecimal deductScore, List modules,
+ boolean configured) {
+ this.id = id;
+ this.deductScore = deductScore;
+ this.modules = modules;
+ this.configured = configured;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java
new file mode 100644
index 0000000..86d8769
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingModules.java
@@ -0,0 +1,81 @@
+package com.yau.digitalrmb.training.score.application;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 实验模块常量:模块序号、标准名称、成绩表模块名与默认权重的映射。
+ * 模块名称与权重设置页面保持一致,默认权重合计 100%。
+ */
+public final class TrainingModules {
+ public static final int MODULE_COUNT = 6;
+ public static final BigDecimal DEFAULT_DEDUCT_SCORE = new BigDecimal("0.50");
+ public static final BigDecimal DEFAULT_TRAINING_WEIGHT = new BigDecimal("0.80");
+ public static final BigDecimal DEFAULT_REPORT_WEIGHT = new BigDecimal("0.20");
+
+ /** 模块序号 → 设置页标准模块名称。 */
+ private static final Map STANDARD_NAMES = new LinkedHashMap();
+ /** 模块序号 → 成绩明细表 moudule 字段实际记录的模块名。 */
+ private static final Map SCORE_NAMES = new LinkedHashMap();
+ /** 模块序号 → 默认权重。 */
+ private static final Map DEFAULT_WEIGHTS = new LinkedHashMap();
+
+ static {
+ STANDARD_NAMES.put(1, "中国人民银行生成数字货币");
+ STANDARD_NAMES.put(2, "中国人民银行发行数字货币");
+ STANDARD_NAMES.put(3, "开通数字货币钱包");
+ STANDARD_NAMES.put(4, "用户兑换数字货币");
+ STANDARD_NAMES.put(5, "用户支付数字货币");
+ STANDARD_NAMES.put(6, "数字人民币创新应用场景仿真");
+
+ SCORE_NAMES.put(1, "中国人民银行生成数字货币");
+ SCORE_NAMES.put(2, "发行数字货币");
+ SCORE_NAMES.put(3, "开通数字货币钱包");
+ SCORE_NAMES.put(4, "数字货币兑换");
+ SCORE_NAMES.put(5, "用户支付数字货币");
+ SCORE_NAMES.put(6, "数字人民币创新应用场景仿真");
+
+ DEFAULT_WEIGHTS.put(1, new BigDecimal("0.30"));
+ DEFAULT_WEIGHTS.put(2, new BigDecimal("0.25"));
+ DEFAULT_WEIGHTS.put(3, new BigDecimal("0.10"));
+ DEFAULT_WEIGHTS.put(4, new BigDecimal("0.10"));
+ DEFAULT_WEIGHTS.put(5, new BigDecimal("0.10"));
+ DEFAULT_WEIGHTS.put(6, new BigDecimal("0.15"));
+ }
+
+ private TrainingModules() {
+ }
+
+ public static List codes() {
+ return Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4, 5, 6));
+ }
+
+ public static String standardName(int code) {
+ return STANDARD_NAMES.get(code);
+ }
+
+ public static String scoreName(int code) {
+ return SCORE_NAMES.get(code);
+ }
+
+ /** 成绩表模块名 → 模块序号;未知模块名返回 null。 */
+ public static Integer codeOfScoreName(String scoreName) {
+ if (scoreName == null) {
+ return null;
+ }
+ for (Map.Entry entry : SCORE_NAMES.entrySet()) {
+ if (entry.getValue().equals(scoreName)) {
+ return entry.getKey();
+ }
+ }
+ return null;
+ }
+
+ public static Map defaultWeights() {
+ return Collections.unmodifiableMap(DEFAULT_WEIGHTS);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java
new file mode 100644
index 0000000..48cf5f3
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingOverallScoreService.java
@@ -0,0 +1,96 @@
+package com.yau.digitalrmb.training.score.application;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
+import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 学生端总成绩计算:
+ *
+ * - 实验实训成绩 = 各模块最高分按其模块权重加权之和(缺失模块计 0);
+ * - 实验实训进度 = 6 个模块进度相加 ÷ 6(缺失模块计 0,仍除 6);
+ * - 实验报告成绩 = 该学生唯一报告记录的教师评分(未评分计 0);
+ * - 实验报告进度 = 有提交记录即 100%,无则 0;
+ * - 各自最终成绩 = 进度(百分比)/100 × 权重 × 成绩;
+ * - 总成绩 = 实验实训最终成绩 + 实验报告最终成绩。
+ *
+ * 权重(实验实训/报告)取自总成绩权重表,本校未配置时落默认 0.80/0.20。
+ */
+@Service
+public class TrainingOverallScoreService {
+ private final TrainingScoreQueryService scoreQueryService;
+ private final TrainingWeightService weightService;
+ private final ExperimentReportMapper reportMapper;
+
+ public TrainingOverallScoreService(TrainingScoreQueryService scoreQueryService,
+ TrainingWeightService weightService,
+ ExperimentReportMapper reportMapper) {
+ this.scoreQueryService = scoreQueryService;
+ this.weightService = weightService;
+ this.reportMapper = reportMapper;
+ }
+
+ @Transactional
+ public OverallScoreResult overall(String userId, String schoolId) {
+ List modules = scoreQueryService.summary(userId, schoolId, null);
+
+ // 总成绩权重(实验实训 / 实验报告),本校未配置则落默认
+ BigDecimal trainingWeight;
+ BigDecimal reportWeight;
+ if (schoolId != null && !schoolId.trim().isEmpty()) {
+ TrainingSchoolWeightResult sw = weightService.schoolWeight(schoolId);
+ trainingWeight = sw.getTrainingWeight();
+ reportWeight = sw.getReportWeight();
+ } else {
+ trainingWeight = TrainingModules.DEFAULT_TRAINING_WEIGHT;
+ reportWeight = TrainingModules.DEFAULT_REPORT_WEIGHT;
+ }
+
+ // 实验实训:成绩按模块权重加权,进度为 6 模块均值(缺失计 0)
+ BigDecimal trainingScore = BigDecimal.ZERO;
+ BigDecimal progressSum = BigDecimal.ZERO;
+ for (ModuleScoreSummaryResult m : modules) {
+ BigDecimal moduleWeight = m.getModuleWeight() == null ? BigDecimal.ZERO : m.getModuleWeight();
+ BigDecimal max = m.getMaxScore() == null ? BigDecimal.ZERO : BigDecimal.valueOf(m.getMaxScore());
+ trainingScore = trainingScore.add(moduleWeight.multiply(max));
+ BigDecimal sch = m.getSchedule() == null ? BigDecimal.ZERO : BigDecimal.valueOf(m.getSchedule());
+ progressSum = progressSum.add(sch);
+ }
+ BigDecimal trainingScoreR2 = trainingScore.setScale(2, RoundingMode.HALF_UP);
+ // 6 个模块进度相加 /6(缺的按 0,仍除 6)
+ BigDecimal trainingProgress = progressSum
+ .divide(BigDecimal.valueOf(TrainingModules.MODULE_COUNT), 2, RoundingMode.HALF_UP);
+ BigDecimal trainingFinal = trainingProgress
+ .divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
+ .multiply(trainingWeight).multiply(trainingScoreR2)
+ .setScale(2, RoundingMode.HALF_UP);
+
+ // 实验报告:唯一记录,有提交即进度 100%,成绩取教师评分(未评分计 0)
+ ExperimentReportEntity report = reportMapper.selectOne(new LambdaQueryWrapper()
+ .eq(ExperimentReportEntity::getUserId, userId)
+ .eq(ExperimentReportEntity::getDeleted, false)
+ .last("LIMIT 1"));
+ BigDecimal reportProgress = report != null ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
+ BigDecimal reportScore = (report != null && report.getTeacherScore() != null)
+ ? report.getTeacherScore() : BigDecimal.ZERO;
+ BigDecimal reportFinal = reportProgress
+ .divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
+ .multiply(reportWeight).multiply(reportScore)
+ .setScale(2, RoundingMode.HALF_UP);
+
+ BigDecimal totalFinal = trainingFinal.add(reportFinal).setScale(2, RoundingMode.HALF_UP);
+
+ List items = new ArrayList();
+ items.add(new OverallScoreItem("实验实训", trainingScoreR2, trainingWeight, trainingProgress, trainingFinal));
+ items.add(new OverallScoreItem("实验报告", reportScore, reportWeight, reportProgress, reportFinal));
+ items.add(new OverallScoreItem("总成绩", null, BigDecimal.ONE, null, totalFinal));
+ return new OverallScoreResult(items, totalFinal);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java
new file mode 100644
index 0000000..f5ca35a
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingSchoolWeightResult.java
@@ -0,0 +1,30 @@
+package com.yau.digitalrmb.training.score.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 教师端总成绩权重查询结果:记录编号 + 实验实训权重比 + 实训报告权重比。
+ */
+@Getter
+@Schema(description = "总成绩权重配置")
+public class TrainingSchoolWeightResult {
+ @Schema(description = "配置记录编号,修改接口需回传该值", example = "3f2a...-... ")
+ private final String id;
+ @Schema(description = "实验实训权重比,如 0.80 表示 80%", example = "0.80")
+ private final BigDecimal trainingWeight;
+ @Schema(description = "实训报告权重比,如 0.20 表示 20%", example = "0.20")
+ private final BigDecimal reportWeight;
+ @Schema(description = "是否为本校已有配置;false 表示本次查询时刚落库一条默认配置")
+ private final boolean configured;
+
+ public TrainingSchoolWeightResult(String id, BigDecimal trainingWeight, BigDecimal reportWeight,
+ boolean configured) {
+ this.id = id;
+ this.trainingWeight = trainingWeight;
+ this.reportWeight = reportWeight;
+ this.configured = configured;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java
new file mode 100644
index 0000000..2681bea
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingScoreQueryService.java
@@ -0,0 +1,122 @@
+package com.yau.digitalrmb.training.score.application;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsEntity;
+import com.yau.digitalrmb.institutionidentity.infrastructure.StuModuleScoreDetailsMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 按当前登录用户查询统一成绩明细表 stu_module_score_details,
+ * 并按模块汇总展示计分规则、操作次数、模块权重、进度和最高/最低/平均成绩。
+ *
+ * 模块权重与扣分值统一从权重表(training_module_weight)读取:本校尚无配置时,
+ * 由 TrainingWeightService 落一条默认配置后再返回,保证学生端成绩查询结果始终有权重与扣分数据。
+ */
+@Service
+public class TrainingScoreQueryService {
+ private final StuModuleScoreDetailsMapper scoreDetailsMapper;
+ private final TrainingWeightService weightService;
+
+ public TrainingScoreQueryService(StuModuleScoreDetailsMapper scoreDetailsMapper,
+ TrainingWeightService weightService) {
+ this.scoreDetailsMapper = scoreDetailsMapper;
+ this.weightService = weightService;
+ }
+
+ /**
+ * 按模块汇总查询当前用户成绩。
+ *
+ * @param userId 当前登录用户编号
+ * @param schoolId 当前登录用户所属学校编号,用于读取(必要时落库)模块权重与扣分值
+ * @param module 模块名称;支持设置页标准名或成绩表模块名,为空时查询全部模块
+ * @return 模块成绩汇总列表。明细(details)不含已逻辑删除的数据;
+ * 但最高/最低/平均/操作次数均包含已删除(历史轮次)的记录。
+ */
+ @Transactional
+ public List summary(String userId, String schoolId, String module) {
+ if (userId == null || userId.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+ // 模块权重与扣分值:优先从权重表读取;本校尚无配置时由权重服务落一条默认配置
+ Map weights;
+ BigDecimal deductScore;
+ if (schoolId != null && !schoolId.trim().isEmpty()) {
+ TrainingModuleWeightResult weightResult = weightService.moduleWeight(schoolId);
+ weights = new HashMap(TrainingModules.MODULE_COUNT);
+ for (TrainingModuleWeightItem item : weightResult.getModules()) {
+ weights.put(item.getMouduleCode(), item.getWeight());
+ }
+ deductScore = weightResult.getDeductScore();
+ } else {
+ weights = TrainingModules.defaultWeights();
+ deductScore = TrainingModules.DEFAULT_DEDUCT_SCORE;
+ }
+ String scoringCriteria = "选错/填错一个扣 " + deductScore.stripTrailingZeros().toPlainString() + " 分";
+
+ List result = new ArrayList();
+ for (Integer code : TrainingModules.codes()) {
+ if (module != null && !module.trim().isEmpty()
+ && !matchesModule(code, module.trim())) {
+ continue;
+ }
+ result.add(summarize(userId, code, scoringCriteria, deductScore, weights.get(code)));
+ }
+ return result;
+ }
+
+ private ModuleScoreSummaryResult summarize(String userId, Integer code, String scoringCriteria,
+ BigDecimal deductScore, BigDecimal moduleWeight) {
+ String scoreName = TrainingModules.scoreName(code);
+ int operationCount = scoreDetailsMapper.countAllIncludingDeleted(userId, scoreName);
+
+ List rows = scoreDetailsMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(StuModuleScoreDetailsEntity::getUserId, userId)
+ .eq(StuModuleScoreDetailsEntity::getMoudule, scoreName)
+ .orderByAsc(StuModuleScoreDetailsEntity::getSerialNumber));
+
+ // 最高/最低/平均:当前用户本人在该模块的全部轮次(含已删除历史轮次)的统计
+ List allRows = scoreDetailsMapper.selectAllIncludingDeleted(userId, scoreName);
+ Double maxScore = null;
+ Double minScore = null;
+ double scoreSum = 0.0;
+ int scoredCount = 0;
+ for (StuModuleScoreDetailsEntity row : allRows) {
+ if (row.getScoreProject() != null) {
+ double score = row.getScoreProject();
+ maxScore = maxScore == null || score > maxScore ? score : maxScore;
+ minScore = minScore == null || score < minScore ? score : minScore;
+ scoreSum += score;
+ scoredCount++;
+ }
+ }
+ Double avgScore = scoredCount == 0 ? null
+ : BigDecimal.valueOf(scoreSum)
+ .divide(BigDecimal.valueOf(scoredCount), 2, RoundingMode.HALF_UP)
+ .doubleValue();
+
+ List details = new ArrayList(rows.size());
+ Double schedule = null;
+ for (StuModuleScoreDetailsEntity row : rows) {
+ details.add(new StuModuleScoreResult(row));
+ schedule = row.getSchedule() == null ? schedule : row.getSchedule();
+ }
+ return new ModuleScoreSummaryResult(code, TrainingModules.standardName(code), scoringCriteria,
+ deductScore, operationCount, rows.size(), moduleWeight, schedule, maxScore, minScore,
+ avgScore, details);
+ }
+
+ private boolean matchesModule(Integer code, String module) {
+ return module.equals(TrainingModules.standardName(code))
+ || module.equals(TrainingModules.scoreName(code));
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java
new file mode 100644
index 0000000..301a4ef
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/application/TrainingWeightService.java
@@ -0,0 +1,215 @@
+package com.yau.digitalrmb.training.score.application;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.yau.digitalrmb.shared.api.ErrorCode;
+import com.yau.digitalrmb.shared.exception.BusinessException;
+import com.yau.digitalrmb.training.score.infrastructure.TrainingModuleWeightEntity;
+import com.yau.digitalrmb.training.score.infrastructure.TrainingModuleWeightMapper;
+import com.yau.digitalrmb.training.score.infrastructure.TrainingSchoolScoreWeightEntity;
+import com.yau.digitalrmb.training.score.infrastructure.TrainingSchoolScoreWeightMapper;
+import com.yau.digitalrmb.training.score.interfaces.dto.SaveModuleWeightsRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * 教师端成绩权重配置:总成绩权重(实验实训 + 实训报告)、6 个模块权重与扣分值。
+ * 未配置时返回默认值;保存时校验合计与精度。
+ */
+@Service
+public class TrainingWeightService {
+ private final TrainingSchoolScoreWeightMapper schoolWeightMapper;
+ private final TrainingModuleWeightMapper moduleWeightMapper;
+
+ public TrainingWeightService(TrainingSchoolScoreWeightMapper schoolWeightMapper,
+ TrainingModuleWeightMapper moduleWeightMapper) {
+ this.schoolWeightMapper = schoolWeightMapper;
+ this.moduleWeightMapper = moduleWeightMapper;
+ }
+
+ /**
+ * 查询总成绩权重;本校尚无配置时,按默认值 0.80 / 0.20 落库一条记录后返回。
+ */
+ @Transactional
+ public TrainingSchoolWeightResult schoolWeight(String schoolId) {
+ requireSchool(schoolId);
+ TrainingSchoolScoreWeightEntity config = schoolConfig(schoolId);
+ boolean existing = config != null;
+ if (!existing) {
+ LocalDateTime now = LocalDateTime.now();
+ config = new TrainingSchoolScoreWeightEntity();
+ config.setId(UUID.randomUUID().toString());
+ config.setSchoolId(schoolId);
+ config.setTrainingWeight(TrainingModules.DEFAULT_TRAINING_WEIGHT);
+ config.setReportWeight(TrainingModules.DEFAULT_REPORT_WEIGHT);
+ config.setCreatedAt(now);
+ config.setUpdatedAt(now);
+ config.setDeleted(false);
+ schoolWeightMapper.insert(config);
+ }
+ return new TrainingSchoolWeightResult(config.getId(), config.getTrainingWeight(),
+ config.getReportWeight(), existing);
+ }
+
+ /** 按 id 修改总成绩权重:实验实训权重比 + 实训报告权重比必须等于 1。 */
+ @Transactional
+ public void saveSchoolWeight(String schoolId, String id, BigDecimal trainingWeight,
+ BigDecimal reportWeight) {
+ requireSchool(schoolId);
+ if (trainingWeight == null || reportWeight == null) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "权重比不能为空");
+ }
+ if (trainingWeight.add(reportWeight).compareTo(BigDecimal.ONE) != 0) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "实验实训权重比与实训报告权重比之和必须等于 1");
+ }
+ TrainingSchoolScoreWeightEntity config = requireSchoolConfig(schoolId, id);
+ config.setTrainingWeight(trainingWeight);
+ config.setReportWeight(reportWeight);
+ config.setUpdatedAt(LocalDateTime.now());
+ schoolWeightMapper.updateById(config);
+ }
+
+ /**
+ * 查询模块权重与扣分值;本校尚无配置时,按默认权重与默认扣分落库一条记录后返回。
+ */
+ @Transactional
+ public TrainingModuleWeightResult moduleWeight(String schoolId) {
+ requireSchool(schoolId);
+ TrainingModuleWeightEntity config = moduleConfig(schoolId);
+ boolean existing = config != null;
+ if (!existing) {
+ LocalDateTime now = LocalDateTime.now();
+ config = new TrainingModuleWeightEntity();
+ config.setId(UUID.randomUUID().toString());
+ config.setSchoolId(schoolId);
+ for (Integer code : TrainingModules.codes()) {
+ config.setWeight(code, TrainingModules.defaultWeights().get(code));
+ }
+ config.setDeductScore(TrainingModules.DEFAULT_DEDUCT_SCORE);
+ config.setCreatedAt(now);
+ config.setUpdatedAt(now);
+ config.setDeleted(false);
+ moduleWeightMapper.insert(config);
+ }
+ List items = new ArrayList();
+ for (Integer code : TrainingModules.codes()) {
+ BigDecimal weight = config.weightOf(code);
+ items.add(new TrainingModuleWeightItem(TrainingModules.standardName(code), code,
+ weight == null ? TrainingModules.defaultWeights().get(code) : weight));
+ }
+ BigDecimal deductScore = config.getDeductScore() == null
+ ? TrainingModules.DEFAULT_DEDUCT_SCORE : config.getDeductScore();
+ return new TrainingModuleWeightResult(config.getId(), deductScore, items, existing);
+ }
+
+ /**
+ * 按 id 修改模块权重与扣分值:必须一次提交 6 个模块,序号 1-6 不重复、名称与标准名一致、
+ * 权重最多 2 位小数且合计等于 1。
+ */
+ @Transactional
+ public void saveModuleWeights(String schoolId, String id,
+ List entries,
+ BigDecimal deductScore) {
+ requireSchool(schoolId);
+ TrainingModuleWeightEntity config = requireModuleConfig(schoolId, id);
+ if (deductScore == null) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "扣分值不能为空");
+ }
+ if (deductScore.scale() > 2 || deductScore.compareTo(BigDecimal.ZERO) < 0
+ || deductScore.compareTo(new BigDecimal("100")) > 0) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "扣分值最多保留 2 位小数且必须在 0 到 100 之间");
+ }
+ if (entries == null || entries.size() != TrainingModules.MODULE_COUNT) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "必须一次性保存全部 " + TrainingModules.MODULE_COUNT + " 个模块权重");
+ }
+ Set codes = new HashSet();
+ BigDecimal sum = BigDecimal.ZERO;
+ for (SaveModuleWeightsRequest.ModuleWeightEntry entry : entries) {
+ Integer code = entry.getMouduleCode();
+ if (code == null || code < 1 || code > TrainingModules.MODULE_COUNT) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块序号必须在 1-6 之间");
+ }
+ if (!codes.add(code)) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块序号不能重复");
+ }
+ String standardName = TrainingModules.standardName(code);
+ if (!standardName.equals(entry.getMoudule())) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "模块序号 " + code + " 对应的模块名称必须为" + standardName);
+ }
+ BigDecimal weight = entry.getWeight();
+ if (weight == null || weight.scale() > 2) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块权重最多保留 2 位小数");
+ }
+ if (weight.compareTo(BigDecimal.ZERO) < 0 || weight.compareTo(BigDecimal.ONE) > 0) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "模块权重必须在 0 到 1 之间");
+ }
+ sum = sum.add(weight);
+ }
+ if (sum.compareTo(BigDecimal.ONE) != 0) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR, "6 个模块权重合计必须等于 1");
+ }
+
+ for (SaveModuleWeightsRequest.ModuleWeightEntry entry : entries) {
+ config.setWeight(entry.getMouduleCode(), entry.getWeight());
+ }
+ config.setDeductScore(deductScore);
+ config.setUpdatedAt(LocalDateTime.now());
+ moduleWeightMapper.updateById(config);
+ }
+
+ /** 按 id 取总成绩权重配置:不存在报 404,不属于本校报 403。 */
+ private TrainingSchoolScoreWeightEntity requireSchoolConfig(String schoolId, String id) {
+ TrainingSchoolScoreWeightEntity config = schoolWeightMapper.selectById(id);
+ if (config == null || Boolean.TRUE.equals(config.getDeleted())) {
+ throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "总成绩权重配置不存在");
+ }
+ if (!schoolId.equals(config.getSchoolId())) {
+ throw new BusinessException(ErrorCode.FORBIDDEN, "无权修改其他学校的权重配置");
+ }
+ return config;
+ }
+
+ /** 按 id 取模块权重配置:不存在报 404,不属于本校报 403。 */
+ private TrainingModuleWeightEntity requireModuleConfig(String schoolId, String id) {
+ TrainingModuleWeightEntity config = moduleWeightMapper.selectById(id);
+ if (config == null || Boolean.TRUE.equals(config.getDeleted())) {
+ throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "模块权重配置不存在");
+ }
+ if (!schoolId.equals(config.getSchoolId())) {
+ throw new BusinessException(ErrorCode.FORBIDDEN, "无权修改其他学校的权重配置");
+ }
+ return config;
+ }
+
+ private TrainingSchoolScoreWeightEntity schoolConfig(String schoolId) {
+ return schoolWeightMapper.selectOne(new LambdaQueryWrapper()
+ .eq(TrainingSchoolScoreWeightEntity::getSchoolId, schoolId)
+ .eq(TrainingSchoolScoreWeightEntity::getDeleted, false)
+ .last("LIMIT 1"));
+ }
+
+ private TrainingModuleWeightEntity moduleConfig(String schoolId) {
+ return moduleWeightMapper.selectOne(new LambdaQueryWrapper()
+ .eq(TrainingModuleWeightEntity::getSchoolId, schoolId)
+ .eq(TrainingModuleWeightEntity::getDeleted, false)
+ .last("LIMIT 1"));
+ }
+
+ private void requireSchool(String schoolId) {
+ if (schoolId == null || schoolId.trim().isEmpty()) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "当前用户缺少学校信息,无法配置成绩权重");
+ }
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java
new file mode 100644
index 0000000..950ed1d
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightEntity.java
@@ -0,0 +1,104 @@
+package com.yau.digitalrmb.training.score.infrastructure;
+
+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 lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 教师端模块权重配置:一校一行,6 个模块权重横向存储,并保存扣分值。
+ */
+@Getter
+@Setter
+@TableName("training_module_weight")
+public class TrainingModuleWeightEntity {
+ @TableId(type = IdType.ASSIGN_UUID)
+ private String id;
+
+ @TableField("school_id")
+ private String schoolId;
+
+ /** 模块一:中国人民银行生成数字货币 */
+ @TableField("module1_weight")
+ private BigDecimal module1Weight;
+
+ /** 模块二:中国人民银行发行数字货币 */
+ @TableField("module2_weight")
+ private BigDecimal module2Weight;
+
+ /** 模块三:开通数字货币钱包 */
+ @TableField("module3_weight")
+ private BigDecimal module3Weight;
+
+ /** 模块四:用户兑换数字货币 */
+ @TableField("module4_weight")
+ private BigDecimal module4Weight;
+
+ /** 模块五:用户支付数字货币 */
+ @TableField("module5_weight")
+ private BigDecimal module5Weight;
+
+ /** 模块六:数字人民币创新应用场景仿真 */
+ @TableField("module6_weight")
+ private BigDecimal module6Weight;
+
+ @TableField("deduct_score")
+ private BigDecimal deductScore;
+
+ private LocalDateTime createdAt;
+
+ private LocalDateTime updatedAt;
+
+ private Boolean deleted;
+
+ /** 按模块序号 1-6 读取权重。 */
+ public BigDecimal weightOf(int moduleCode) {
+ switch (moduleCode) {
+ case 1:
+ return module1Weight;
+ case 2:
+ return module2Weight;
+ case 3:
+ return module3Weight;
+ case 4:
+ return module4Weight;
+ case 5:
+ return module5Weight;
+ case 6:
+ return module6Weight;
+ default:
+ return null;
+ }
+ }
+
+ /** 按模块序号 1-6 写入权重,序号非法时直接抛错。 */
+ public void setWeight(int moduleCode, BigDecimal weight) {
+ switch (moduleCode) {
+ case 1:
+ this.module1Weight = weight;
+ break;
+ case 2:
+ this.module2Weight = weight;
+ break;
+ case 3:
+ this.module3Weight = weight;
+ break;
+ case 4:
+ this.module4Weight = weight;
+ break;
+ case 5:
+ this.module5Weight = weight;
+ break;
+ case 6:
+ this.module6Weight = weight;
+ break;
+ default:
+ throw new IllegalArgumentException("模块序号必须在 1-6 之间:" + moduleCode);
+ }
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java
new file mode 100644
index 0000000..cc39c17
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingModuleWeightMapper.java
@@ -0,0 +1,8 @@
+package com.yau.digitalrmb.training.score.infrastructure;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TrainingModuleWeightMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java
new file mode 100644
index 0000000..7cfcb1d
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightEntity.java
@@ -0,0 +1,34 @@
+package com.yau.digitalrmb.training.score.infrastructure;
+
+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 lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@TableName("training_school_score_weight")
+public class TrainingSchoolScoreWeightEntity {
+ @TableId(type = IdType.ASSIGN_UUID)
+ private String id;
+
+ @TableField("school_id")
+ private String schoolId;
+
+ @TableField("training_weight")
+ private BigDecimal trainingWeight;
+
+ @TableField("report_weight")
+ private BigDecimal reportWeight;
+
+ private LocalDateTime createdAt;
+
+ private LocalDateTime updatedAt;
+
+ private Boolean deleted;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java
new file mode 100644
index 0000000..fab4fea
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/infrastructure/TrainingSchoolScoreWeightMapper.java
@@ -0,0 +1,8 @@
+package com.yau.digitalrmb.training.score.infrastructure;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TrainingSchoolScoreWeightMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java
new file mode 100644
index 0000000..7a28da7
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveModuleWeightsRequest.java
@@ -0,0 +1,56 @@
+package com.yau.digitalrmb.training.score.interfaces.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.Valid;
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.Digits;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 教师端修改 6 个模块权重的请求体;按 id 定位记录,扣分值随模块权重一起保存。
+ */
+@Getter
+@Setter
+@Schema(description = "修改模块权重请求")
+public class SaveModuleWeightsRequest {
+ @Schema(description = "配置记录编号,取自查询接口返回的 id")
+ @NotBlank(message = "配置记录编号不能为空")
+ private String id;
+
+ @Schema(description = "选错/填错一个扣分", example = "0.50")
+ @NotNull(message = "扣分值不能为空")
+ @Digits(integer = 3, fraction = 2, message = "扣分值最多保留 2 位小数")
+ @DecimalMin(value = "0", message = "扣分值不能小于 0")
+ @DecimalMax(value = "100", message = "扣分值不能大于 100")
+ private BigDecimal deductScore;
+
+ @Schema(description = "模块权重列表,必须包含全部 6 个模块")
+ @NotEmpty(message = "模块权重列表不能为空")
+ @Valid
+ private List modules;
+
+ @Getter
+ @Setter
+ @Schema(description = "单个模块权重项")
+ public static class ModuleWeightEntry {
+ @Schema(description = "模块名称", example = "中国人民银行生成数字货币")
+ @NotNull(message = "模块名称不能为空")
+ private String moudule;
+
+ @Schema(description = "模块序号 1-6", example = "1")
+ @NotNull(message = "模块序号不能为空")
+ private Integer mouduleCode;
+
+ @Schema(description = "模块权重,两位小数", example = "0.30")
+ @NotNull(message = "模块权重不能为空")
+ private BigDecimal weight;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java
new file mode 100644
index 0000000..22804f8
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/dto/SaveSchoolWeightRequest.java
@@ -0,0 +1,38 @@
+package com.yau.digitalrmb.training.score.interfaces.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.Digits;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+/**
+ * 教师端修改总成绩权重的请求体:按 id 定位记录,实验实训权重 + 实训报告权重必须等于 1。
+ */
+@Getter
+@Setter
+@Schema(description = "修改总成绩权重请求")
+public class SaveSchoolWeightRequest {
+ @Schema(description = "配置记录编号,取自查询接口返回的 id")
+ @NotBlank(message = "配置记录编号不能为空")
+ private String id;
+
+ @Schema(description = "实验实训权重比", example = "0.80")
+ @NotNull(message = "实验实训权重比不能为空")
+ @Digits(integer = 1, fraction = 2, message = "实验实训权重比最多保留 2 位小数")
+ @DecimalMin(value = "0", message = "实验实训权重比不能小于 0")
+ @DecimalMax(value = "1", message = "实验实训权重比不能大于 1")
+ private BigDecimal trainingWeight;
+
+ @Schema(description = "实训报告权重比", example = "0.20")
+ @NotNull(message = "实训报告权重比不能为空")
+ @Digits(integer = 1, fraction = 2, message = "实训报告权重比最多保留 2 位小数")
+ @DecimalMin(value = "0", message = "实训报告权重比不能小于 0")
+ @DecimalMax(value = "1", message = "实训报告权重比不能大于 1")
+ private BigDecimal reportWeight;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java
new file mode 100644
index 0000000..8d8b9f5
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreController.java
@@ -0,0 +1,85 @@
+package com.yau.digitalrmb.training.score.interfaces.rest;
+
+import com.yau.digitalrmb.security.application.CurrentUser;
+import com.yau.digitalrmb.security.application.CurrentUserService;
+import com.yau.digitalrmb.shared.api.ApiResponse;
+import com.yau.digitalrmb.shared.web.TraceIdFilter;
+import com.yau.digitalrmb.training.score.application.ModuleScoreSummaryResult;
+import com.yau.digitalrmb.training.score.application.OverallScoreResult;
+import com.yau.digitalrmb.training.score.application.StudentScoreDetailResult;
+import com.yau.digitalrmb.training.score.application.StudentScoreDetailService;
+import com.yau.digitalrmb.training.score.application.TrainingOverallScoreService;
+import com.yau.digitalrmb.training.score.application.TrainingScoreQueryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.MDC;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/training-scores")
+@Tag(name = "实训成绩查询", description = "按当前登录用户查询 stu_module_score_details,按模块汇总模块名称、计分规则、操作次数、模块权重、完成进度与最高/最低/平均成绩")
+public class TrainingScoreController {
+ private final TrainingScoreQueryService service;
+ private final TrainingOverallScoreService overallService;
+ private final StudentScoreDetailService detailService;
+ private final CurrentUserService currentUserService;
+
+ public TrainingScoreController(TrainingScoreQueryService service,
+ TrainingOverallScoreService overallService,
+ StudentScoreDetailService detailService,
+ CurrentUserService currentUserService) {
+ this.service = service;
+ this.overallService = overallService;
+ this.detailService = detailService;
+ this.currentUserService = currentUserService;
+ }
+
+ @GetMapping
+ @Operation(summary = "查询当前用户各模块成绩",
+ description = "不传 moudule 时固定返回 6 个模块(无数据的模块同样返回,统计为 null、明细为空),"
+ + "传 moudule 时只返回该模块(支持设置页标准名或成绩表模块名)。"
+ + "maxScore/minScore/avgScore 为当前用户本人在该模块全部轮次(含已逻辑删除的历史轮次)的成绩统计,"
+ + "operationCount 口径相同;currentCount 与 details 只含未删除记录。")
+ public ApiResponse> scores(
+ @Parameter(description = "模块名称,留空查询全部模块", example = "中国人民银行生成数字货币")
+ @RequestParam(value = "moudule", required = false) String moudule,
+ @Parameter(description = "moudule 的别名参数,二者传其一即可", hidden = true)
+ @RequestParam(value = "module", required = false) String module) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ String target = moudule == null || moudule.trim().isEmpty() ? module : moudule;
+ return ApiResponse.success(
+ service.summary(user.getUserId(), user.getSchoolId(), target),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @GetMapping("/overall")
+ @Operation(summary = "查询当前用户总成绩",
+ description = "返回实验实训、实验报告、总成绩三行。实验实训成绩为各模块最高分按模块权重加权,"
+ + "进度为 6 个模块进度均值(缺失模块计 0,仍除 6);实验报告进度有提交即 100%,成绩取教师评分;"
+ + "最终成绩 = 进度/100 × 权重 × 成绩,总成绩 = 实训最终 + 报告最终。")
+ public ApiResponse overall() {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ overallService.overall(user.getUserId(), user.getSchoolId()),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @GetMapping("/details")
+ @Operation(summary = "查询当前用户成绩详情",
+ description = "返回当前用户模块一~模块六各自最高成绩与综合分数。最高成绩口径与模块成绩汇总一致:"
+ + "该模块全部轮次(含已逻辑删除的历史轮次)取最大值,没有成绩的模块为 null;"
+ + "综合分数 = Σ(模块最高分 × 模块权重)(权重取自本校权重配置,未配置时落默认),"
+ + "缺失模块计 0,保留 2 位小数,与教师端成绩详情同口径。")
+ public ApiResponse details() {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ detailService.detail(user.getUserId(), user.getSchoolId()),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java
new file mode 100644
index 0000000..3f1cc98
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingWeightController.java
@@ -0,0 +1,79 @@
+package com.yau.digitalrmb.training.score.interfaces.rest;
+
+import com.yau.digitalrmb.security.application.CurrentUserService;
+import com.yau.digitalrmb.shared.api.ApiResponse;
+import com.yau.digitalrmb.shared.web.TraceIdFilter;
+import com.yau.digitalrmb.training.score.application.TrainingModuleWeightResult;
+import com.yau.digitalrmb.training.score.application.TrainingSchoolWeightResult;
+import com.yau.digitalrmb.training.score.application.TrainingWeightService;
+import com.yau.digitalrmb.training.score.interfaces.dto.SaveModuleWeightsRequest;
+import com.yau.digitalrmb.training.score.interfaces.dto.SaveSchoolWeightRequest;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.MDC;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+@RestController
+@RequestMapping("/api/v1/training-weights")
+@Tag(name = "成绩权重配置", description = "教师端总成绩权重与模块权重配置,按学校隔离")
+public class TrainingWeightController {
+ private final TrainingWeightService service;
+ private final CurrentUserService currentUserService;
+
+ public TrainingWeightController(TrainingWeightService service, CurrentUserService currentUserService) {
+ this.service = service;
+ this.currentUserService = currentUserService;
+ }
+
+ @GetMapping("/school")
+ @Operation(summary = "查询总成绩权重",
+ description = "返回记录编号 id、实验实训权重比与实训报告权重比;本校首次查询时按默认值 0.80、0.20 "
+ + "落库一条记录,configured=false 表示这条默认记录是本次刚生成的。")
+ public ApiResponse schoolWeight() {
+ return ApiResponse.success(service.schoolWeight(schoolId()), traceId());
+ }
+
+ @PostMapping("/school")
+ @Operation(summary = "修改总成绩权重",
+ description = "按 id 定位配置记录;实验实训权重比与实训报告权重比之和必须等于 1,均最多 2 位小数。")
+ public ApiResponse saveSchoolWeight(
+ @Valid @RequestBody SaveSchoolWeightRequest request) {
+ service.saveSchoolWeight(schoolId(), request.getId(), request.getTrainingWeight(),
+ request.getReportWeight());
+ return ApiResponse.success(service.schoolWeight(schoolId()), traceId());
+ }
+
+ @GetMapping("/modules")
+ @Operation(summary = "查询模块权重与扣分值",
+ description = "返回记录编号 id、扣分值 deductScore 与 6 个实验模块的序号、名称、权重;"
+ + "本校首次查询时按默认值(扣分 0.50,权重 30%、25%、10%、10%、10%、15%)落库一条记录,"
+ + "configured=false 表示这条默认记录是本次刚生成的。")
+ public ApiResponse moduleWeights() {
+ return ApiResponse.success(service.moduleWeight(schoolId()), traceId());
+ }
+
+ @PostMapping("/modules")
+ @Operation(summary = "修改模块权重与扣分值",
+ description = "按 id 定位配置记录;必须一次性提交 6 个模块,mouduleCode 为 1-6 且不重复,"
+ + "moudule 名称与标准名一致,权重最多 2 位小数且合计等于 1;deductScore 为选错/填错一个扣多少分。")
+ public ApiResponse saveModuleWeights(
+ @Valid @RequestBody SaveModuleWeightsRequest request) {
+ service.saveModuleWeights(schoolId(), request.getId(), request.getModules(),
+ request.getDeductScore());
+ return ApiResponse.success(service.moduleWeight(schoolId()), traceId());
+ }
+
+ private String schoolId() {
+ return currentUserService.getCurrentUser().getSchoolId();
+ }
+
+ private String traceId() {
+ return MDC.get(TraceIdFilter.MDC_KEY);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java
new file mode 100644
index 0000000..0bc7983
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherClassOption.java
@@ -0,0 +1,18 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 班级下拉项:去重后的班级 id 与班级名称。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "班级下拉项")
+public class TeacherClassOption {
+ @Schema(description = "班级 id", example = "2001")
+ private final String classId;
+ @Schema(description = "班级名称", example = "成绩测试班")
+ private final String className;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java
new file mode 100644
index 0000000..d884a1f
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherDetailExportService.java
@@ -0,0 +1,74 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * 教师端「成绩详情」导出:按当前筛选条件导出全部学生(不分页)为 xlsx,
+ * 顺序与详情页一致(按综合分数降序,同分按学号升序)。
+ * 列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、综合分数。
+ */
+@Service
+public class TeacherDetailExportService {
+
+ private final TeacherScoreService scoreService;
+
+ public TeacherDetailExportService(TeacherScoreService scoreService) {
+ this.scoreService = scoreService;
+ }
+
+ /** 导出文件名(含时间戳)。 */
+ public String fileName() {
+ return "学生成绩详情_"
+ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".xlsx";
+ }
+
+ /** 导出 xlsx 字节流。 */
+ public byte[] export(String schoolId, String userName, String studentId, String classId) {
+ List rows = scoreService.studentDetailList(schoolId, userName, studentId, classId);
+ try (XSSFWorkbook wb = new XSSFWorkbook();
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ Sheet sheet = wb.createSheet("成绩详情");
+ Row head = sheet.createRow(0);
+ String[] headers = {"姓名", "学号", "班级",
+ "模块一", "模块二", "模块三", "模块四", "模块五", "模块六", "综合分数"};
+ for (int i = 0; i < headers.length; i++) {
+ head.createCell(i).setCellValue(headers[i]);
+ }
+ int r = 1;
+ for (TeacherStudentDetailRow row : rows) {
+ Row data = sheet.createRow(r++);
+ data.createCell(0).setCellValue(nullToEmpty(row.getUserName()));
+ data.createCell(1).setCellValue(nullToEmpty(row.getStudentId()));
+ data.createCell(2).setCellValue(nullToEmpty(row.getClassName()));
+ setScore(data, 3, row.getModule1Score());
+ setScore(data, 4, row.getModule2Score());
+ setScore(data, 5, row.getModule3Score());
+ setScore(data, 6, row.getModule4Score());
+ setScore(data, 7, row.getModule5Score());
+ setScore(data, 8, row.getModule6Score());
+ data.createCell(9).setCellValue(row.getTotalScore() == null ? 0.0 : row.getTotalScore());
+ }
+ wb.write(out);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("生成成绩详情导出文件失败", e);
+ }
+ }
+
+ private void setScore(Row row, int col, Double score) {
+ row.createCell(col).setCellValue(score == null ? 0.0 : score);
+ }
+
+ private String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java
new file mode 100644
index 0000000..b49064d
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleMaxScore.java
@@ -0,0 +1,17 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 聚合查询结果行:一名学生在某个成绩表模块名下的最高成绩。
+ * moudule 为成绩表 stu_module_score_details 里的原始模块名(如"发行数字货币"),
+ * 由 {@link com.yau.digitalrmb.training.score.application.TrainingModules#codeOfScoreName(String)} 映射为模块序号。
+ */
+@Getter
+@AllArgsConstructor
+public class TeacherModuleMaxScore {
+ private final String userId;
+ private final String moudule;
+ private final Double maxScore;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java
new file mode 100644
index 0000000..b7abbd4
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherModuleSchedule.java
@@ -0,0 +1,25 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import lombok.Getter;
+
+/**
+ * 教师端「成绩详情/总成绩」只读复用 stu_module_score_details:
+ * 批量取每个 (user_id, moudule) 最新一条有效(未逻辑删除)记录的完成进度(schedule)。
+ * 与 {@link TeacherModuleMaxScore} 分开,是因为最高分口径含已删除轮次、进度取最新有效记录,
+ * 二者来源不同,不能用同一条 GROUP BY 聚合。
+ */
+@Getter
+public class TeacherModuleSchedule {
+ /** 学生编号 */
+ private final String userId;
+ /** 模块名称(成绩表 moudule 列,拼写同库) */
+ private final String moudule;
+ /** 该模块最新有效记录的完成进度百分比 */
+ private final Double schedule;
+
+ public TeacherModuleSchedule(String userId, String moudule, Double schedule) {
+ this.userId = userId;
+ this.moudule = moudule;
+ this.schedule = schedule;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java
new file mode 100644
index 0000000..64e6696
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreExportService.java
@@ -0,0 +1,115 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import com.yau.digitalrmb.training.score.application.TrainingModules;
+import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherModuleScoreMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 教师端总成绩导出:按当前筛选条件导出全部学生(不分页)为 xlsx。
+ * 列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、实训成绩、实验报告成绩、最终成绩。
+ *
+ * 模块最高分整体批量取(一条 GROUP BY SQL),不再对每个学生逐条调用 summary,避免导出 N+1。
+ */
+@Service
+public class TeacherScoreExportService {
+
+ /** 模块数量固定为 6,与学生端模块数一致。 */
+ private static final int MODULE_COUNT = 6;
+
+ private final TeacherScoreService scoreService;
+ private final TeacherModuleScoreMapper moduleScoreMapper;
+
+ public TeacherScoreExportService(TeacherScoreService scoreService, TeacherModuleScoreMapper moduleScoreMapper) {
+ this.scoreService = scoreService;
+ this.moduleScoreMapper = moduleScoreMapper;
+ }
+
+ /** 生成导出文件名(含时间戳)。 */
+ public String fileName() {
+ String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
+ return "学生总成绩_" + ts + ".xlsx";
+ }
+
+ /** 导出 xlsx 字节流。 */
+ public byte[] export(String schoolId, String userName, String studentId, String classId) {
+ List rows = scoreService.studentOverallList(schoolId, userName, studentId, classId);
+
+ // 整体批量取各学生各模块最高成绩(含已删除轮次,与查询口径一致),一次 SQL
+ List userIds = new ArrayList(rows.size());
+ for (TeacherStudentOverallRow r : rows) {
+ userIds.add(r.getUserId());
+ }
+ Map> moduleMaxByUser = new HashMap>();
+ for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
+ Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
+ if (code == null || m.getMaxScore() == null) {
+ continue;
+ }
+ moduleMaxByUser.computeIfAbsent(m.getUserId(), k -> new HashMap()).put(code, m.getMaxScore());
+ }
+
+ try (XSSFWorkbook wb = new XSSFWorkbook()) {
+ Sheet sheet = wb.createSheet("学生总成绩");
+
+ Row head = sheet.createRow(0);
+ String[] headers = {"姓名", "学号", "班级", "模块一", "模块二", "模块三", "模块四", "模块五", "模块六",
+ "实训成绩", "实验报告成绩", "最终成绩"};
+ for (int i = 0; i < headers.length; i++) {
+ Cell c = head.createCell(i);
+ c.setCellValue(headers[i]);
+ }
+
+ int r = 1;
+ for (TeacherStudentOverallRow row : rows) {
+ Row data = sheet.createRow(r++);
+ data.createCell(0).setCellValue(row.getUserName() == null ? "" : row.getUserName());
+ data.createCell(1).setCellValue(row.getStudentId() == null ? "" : row.getStudentId());
+ data.createCell(2).setCellValue(row.getClassName() == null ? "" : row.getClassName());
+
+ // 各模块最高成绩(按模块序号 1..6 对齐)
+ Double[] moduleMax = moduleMax(row.getUserId(), moduleMaxByUser);
+ for (int m = 0; m < MODULE_COUNT; m++) {
+ data.createCell(3 + m).setCellValue(moduleMax[m] == null ? 0.0 : moduleMax[m]);
+ }
+ data.createCell(9).setCellValue(row.getExperimentTrainingScore() == null ? 0.0 : row.getExperimentTrainingScore().doubleValue());
+ data.createCell(10).setCellValue(row.getExperimentReportScore() == null ? 0.0 : row.getExperimentReportScore().doubleValue());
+ data.createCell(11).setCellValue(row.getFinalScore() == null ? 0.0 : row.getFinalScore().doubleValue());
+ }
+
+ for (int i = 0; i < headers.length; i++) {
+ sheet.autoSizeColumn(i);
+ }
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ wb.write(out);
+ return out.toByteArray();
+ } catch (Exception e) {
+ throw new RuntimeException("生成总成绩导出文件失败:" + e.getMessage(), e);
+ }
+ }
+
+ /** 取该学生各模块最高成绩,按下标 0..5 对应模块一..模块六(来自批量 map)。 */
+ private Double[] moduleMax(String userId, Map> moduleMaxByUser) {
+ Double[] max = new Double[MODULE_COUNT];
+ Map byCode = moduleMaxByUser.get(userId);
+ if (byCode != null) {
+ for (int code = 1; code <= MODULE_COUNT; code++) {
+ max[code - 1] = byCode.get(code);
+ }
+ }
+ return max;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java
new file mode 100644
index 0000000..3163cb2
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherScoreService.java
@@ -0,0 +1,326 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
+import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportEntity;
+import com.yau.digitalrmb.training.experimentreport.infrastructure.ExperimentReportMapper;
+import com.yau.digitalrmb.training.score.application.TrainingModuleWeightItem;
+import com.yau.digitalrmb.training.score.application.TrainingModuleWeightResult;
+import com.yau.digitalrmb.training.score.application.TrainingModules;
+import com.yau.digitalrmb.training.score.application.TrainingSchoolWeightResult;
+import com.yau.digitalrmb.training.score.application.TrainingWeightService;
+import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherModuleScoreMapper;
+import com.yau.digitalrmb.training.score.teacher.infrastructure.TeacherUserMapper;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 教师端总成绩:按当前学校查询学生列表(支持用户名/学号/班级 id 筛选、分页),
+ * 复用学生端同一个总成绩算法计算每行总成绩;并提供当前学校去重班级下拉。
+ *
+ * 性能:总成绩计算原本对每个分页学生都调用一次 {@code overall},而 {@code overall} 内部又各自查询
+ * 模块汇总、模块权重、总成绩权重、实验报告(每学生约 20+ 次 DB 查询),形成严重 N+1:
+ * 一个 page=10 的请求会触发 200+ 次查询。现改为按当前分页/筛选得到的学生整体批量查询——
+ * 模块最高分与进度各一条 {@code GROUP BY} SQL、实验报告一条 {@code IN} 查询、权重整请求只查一次,
+ * 查询次数降到常数级(与每页人数无关),与学生端总成绩算法口径完全一致。
+ *
+ *
学生以 role_id = 4 标识(与成绩录入用户一致)。逻辑删除由 @TableLogic 自动过滤(分页查询走 BaseMapper);
+ * 班级下拉走原生 SQL,已手动过滤 is_deleted。
+ */
+@Service
+public class TeacherScoreService {
+ /** 学生角色:与成绩录入用户的 role_id 一致。 */
+ private static final int STUDENT_ROLE_ID = 4;
+
+ private final TeacherUserMapper userMapper;
+ private final TeacherModuleScoreMapper moduleScoreMapper;
+ private final TrainingWeightService weightService;
+ private final ExperimentReportMapper reportMapper;
+
+ public TeacherScoreService(TeacherUserMapper userMapper, TeacherModuleScoreMapper moduleScoreMapper,
+ TrainingWeightService weightService, ExperimentReportMapper reportMapper) {
+ this.userMapper = userMapper;
+ this.moduleScoreMapper = moduleScoreMapper;
+ this.weightService = weightService;
+ this.reportMapper = reportMapper;
+ }
+
+ /**
+ * 分页查询当前学校学生总成绩。
+ *
+ * @param schoolId 当前教师所属学校编号(来自登录用户,用于隔离数据)
+ * @param userName 姓名模糊匹配,可空
+ * @param studentId 学号模糊匹配,可空
+ * @param classId 班级 id 精确匹配,可空
+ * @param current 页码,从 1 开始
+ * @param size 每页数量
+ */
+ public TeacherStudentOverallPage studentOverallPage(String schoolId, String userName, String studentId,
+ String classId, int current, int size) {
+ IPage result = userMapper.selectPage(new Page(current, size),
+ studentWrapper(schoolId, userName, studentId, classId));
+ List rows = buildOverallRows(result.getRecords(), schoolId);
+ return new TeacherStudentOverallPage(result.getCurrent(), result.getSize(),
+ result.getTotal(), result.getPages(), rows);
+ }
+
+ /**
+ * 不分页查询当前学校学生总成绩(导出用),筛选规则与 {@link #studentOverallPage} 一致。
+ */
+ public List studentOverallList(String schoolId, String userName,
+ String studentId, String classId) {
+ return buildOverallRows(userMapper.selectList(studentWrapper(schoolId, userName, studentId, classId)), schoolId);
+ }
+
+ /**
+ * 批量计算学生总成绩:整体批量取模块最高分、进度、报告评分,权重整请求只查一次,避免 N+1。
+ * 计算口径与学生端 {@code TrainingOverallScoreService#overall} 完全一致。
+ */
+ private List buildOverallRows(List users, String schoolId) {
+ if (users.isEmpty()) {
+ return new ArrayList();
+ }
+ // 权重(全校一份,整请求仅查一次)
+ Map weights = moduleWeights(schoolId);
+ BigDecimal trainingWeight;
+ BigDecimal reportWeight;
+ if (schoolId != null && !schoolId.trim().isEmpty()) {
+ TrainingSchoolWeightResult sw = weightService.schoolWeight(schoolId);
+ trainingWeight = sw.getTrainingWeight();
+ reportWeight = sw.getReportWeight();
+ } else {
+ trainingWeight = TrainingModules.DEFAULT_TRAINING_WEIGHT;
+ reportWeight = TrainingModules.DEFAULT_REPORT_WEIGHT;
+ }
+ // 批量取当前学生的模块数据(各一条 SQL,与每页人数无关)
+ List userIds = new ArrayList(users.size());
+ for (UserEntity u : users) {
+ userIds.add(u.getUserId());
+ }
+ Map> maxByUser = batchMaxScores(userIds);
+ Map> scheduleByUser = batchSchedules(userIds);
+ Map reportByUser = batchReports(userIds);
+
+ List rows = new ArrayList(users.size());
+ for (UserEntity u : users) {
+ rows.add(buildOverallRow(u, weights, trainingWeight, reportWeight,
+ maxByUser.get(u.getUserId()), scheduleByUser.get(u.getUserId()), reportByUser));
+ }
+ return rows;
+ }
+
+ /** 构建单个学生的总成绩行:与学生端 overall 同口径(含已删除轮次取最高分、进度取最新有效记录)。 */
+ private TeacherStudentOverallRow buildOverallRow(UserEntity u, Map weights,
+ BigDecimal trainingWeight, BigDecimal reportWeight,
+ Map maxMap, Map scheduleMap,
+ Map reportByUser) {
+ // 实验实训:各模块最高分按模块权重加权,进度为 6 模块均值(缺失计 0)
+ BigDecimal trainingScore = BigDecimal.ZERO;
+ BigDecimal progressSum = BigDecimal.ZERO;
+ for (int code = 1; code <= TrainingModules.MODULE_COUNT; code++) {
+ BigDecimal moduleWeight = weights.getOrDefault(code, BigDecimal.ZERO);
+ Double max = (maxMap == null || maxMap.get(code) == null) ? 0.0 : maxMap.get(code);
+ trainingScore = trainingScore.add(moduleWeight.multiply(BigDecimal.valueOf(max)));
+ Double sch = (scheduleMap == null || scheduleMap.get(code) == null) ? 0.0 : scheduleMap.get(code);
+ progressSum = progressSum.add(BigDecimal.valueOf(sch));
+ }
+ BigDecimal trainingScoreR2 = trainingScore.setScale(2, RoundingMode.HALF_UP);
+ BigDecimal trainingProgress = progressSum
+ .divide(BigDecimal.valueOf(TrainingModules.MODULE_COUNT), 2, RoundingMode.HALF_UP);
+ BigDecimal trainingFinal = trainingProgress
+ .divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
+ .multiply(trainingWeight).multiply(trainingScoreR2)
+ .setScale(2, RoundingMode.HALF_UP);
+
+ // 实验报告:有提交即进度 100%,成绩取教师评分(未评分计 0);整请求只查一次 IN
+ boolean hasReport = reportByUser.containsKey(u.getUserId());
+ BigDecimal reportScore = hasReport ? reportByUser.get(u.getUserId()) : BigDecimal.ZERO;
+ BigDecimal reportProgress = hasReport ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
+ BigDecimal reportFinal = reportProgress
+ .divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
+ .multiply(reportWeight).multiply(reportScore)
+ .setScale(2, RoundingMode.HALF_UP);
+
+ BigDecimal totalFinal = trainingFinal.add(reportFinal).setScale(2, RoundingMode.HALF_UP);
+ return new TeacherStudentOverallRow(
+ u.getUserId(), u.getUserName(), u.getStudentId(),
+ u.getClassId(), u.getClassName(),
+ trainingScoreR2, trainingWeight, reportScore, reportWeight, totalFinal);
+ }
+
+ /** 批量取每个学生在各模块的最高成绩(含已删除轮次,与学生端口径一致),userId -> (模块序号 -> 最高分)。 */
+ private Map> batchMaxScores(List userIds) {
+ Map> maxByUser = new HashMap>();
+ for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
+ Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
+ if (code == null || m.getMaxScore() == null) {
+ continue;
+ }
+ maxByUser.computeIfAbsent(m.getUserId(), k -> new HashMap()).put(code, m.getMaxScore());
+ }
+ return maxByUser;
+ }
+
+ /** 批量取每个学生在各模块的最新有效记录进度,userId -> (模块序号 -> 进度)。 */
+ private Map> batchSchedules(List userIds) {
+ Map> scheduleByUser = new HashMap>();
+ for (TeacherModuleSchedule s : moduleScoreMapper.selectLatestScheduleByUserIds(userIds)) {
+ Integer code = TrainingModules.codeOfScoreName(s.getMoudule());
+ if (code == null || s.getSchedule() == null) {
+ continue;
+ }
+ scheduleByUser.computeIfAbsent(s.getUserId(), k -> new HashMap()).put(code, s.getSchedule());
+ }
+ return scheduleByUser;
+ }
+
+ /** 批量取每个学生的实验报告教师评分(仅未删除报告),userId -> 教师评分(无评分记 0,但仍标记有报告)。 */
+ private Map batchReports(List userIds) {
+ Map reportByUser = new HashMap();
+ List reports = reportMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(ExperimentReportEntity::getUserId, userIds)
+ .eq(ExperimentReportEntity::getDeleted, false));
+ for (ExperimentReportEntity r : reports) {
+ // 每个学生取第一条报告的教师评分(与 overall 的 LIMIT 1 任意一条等价);无评分记 0 但仍标记有报告
+ reportByUser.putIfAbsent(r.getUserId(),
+ r.getTeacherScore() == null ? BigDecimal.ZERO : r.getTeacherScore());
+ }
+ return reportByUser;
+ }
+
+ /** 当前学校去重班级列表,用于前端筛选下拉。 */
+ public List classOptions(String schoolId) {
+ return userMapper.selectClassesBySchool(schoolId);
+ }
+
+ /**
+ * 分页查询当前学校学生「成绩详情」:一行一名学生,模块一~模块六各取该生最高成绩,
+ * 并按各模块最高分×模块权重加权求和得到综合分数(totalScore),按综合分数降序排列(同分按学号升序)。
+ * 最高成绩口径与学生端一致:该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
+ * 没有成绩的模块为 null、综合分数按 0 计。排序需全量计算后进行,故先查全部匹配学生再在内存分页。
+ */
+ public TeacherStudentDetailPage studentDetailPage(String schoolId, String userName, String studentId,
+ String classId, int current, int size) {
+ List all = studentDetailList(schoolId, userName, studentId, classId);
+ int from = (current - 1) * size;
+ List records = from >= all.size()
+ ? new ArrayList()
+ : new ArrayList(all.subList(from, Math.min(from + size, all.size())));
+ long total = all.size();
+ long pages = size <= 0 ? 0 : (total + size - 1) / size;
+ return new TeacherStudentDetailPage((long) current, (long) size, total, pages, records);
+ }
+
+ /**
+ * 不分页查询当前学校学生「成绩详情」(导出用):按综合分数降序(同分按学号升序),
+ * 综合分数 = Σ(模块最高分 × 模块权重),权重取自权重表(本校未配置时落默认),缺失模块计 0。
+ */
+ public List studentDetailList(String schoolId, String userName,
+ String studentId, String classId) {
+ List rows =
+ buildDetailRows(userMapper.selectList(studentWrapper(schoolId, userName, studentId, classId)),
+ moduleWeights(schoolId));
+ rows.sort((a, b) -> {
+ int c = b.getTotalScore().compareTo(a.getTotalScore());
+ if (c != 0) {
+ return c;
+ }
+ String sa = a.getStudentId() == null ? "" : a.getStudentId();
+ String sb = b.getStudentId() == null ? "" : b.getStudentId();
+ return sa.compareTo(sb);
+ });
+ return rows;
+ }
+
+ /** 模块序号 → 权重:读权重表(本校未配置时由权重服务落默认行);无学校归属时用默认权重。 */
+ private Map moduleWeights(String schoolId) {
+ if (schoolId == null || schoolId.trim().isEmpty()) {
+ return TrainingModules.defaultWeights();
+ }
+ Map weights = new HashMap(TrainingModules.MODULE_COUNT);
+ TrainingModuleWeightResult result = weightService.moduleWeight(schoolId);
+ for (TrainingModuleWeightItem item : result.getModules()) {
+ weights.put(item.getMouduleCode(), item.getWeight());
+ }
+ return weights;
+ }
+
+ /** 批量构建成绩详情行:一条 SQL 取全部学生各模块最高分,按模块序号摊平到 6 列并计算综合分数。 */
+ private List buildDetailRows(List users, Map weights) {
+ List rows = new ArrayList(users.size());
+ if (users.isEmpty()) {
+ return rows;
+ }
+ List userIds = new ArrayList(users.size());
+ for (UserEntity u : users) {
+ userIds.add(u.getUserId());
+ }
+ // userId -> (模块序号 -> 最高成绩)
+ Map> maxByUser = new HashMap>();
+ for (TeacherModuleMaxScore m : moduleScoreMapper.selectMaxScoresByUserIds(userIds)) {
+ Integer code = TrainingModules.codeOfScoreName(m.getMoudule());
+ if (code == null || m.getMaxScore() == null) {
+ continue;
+ }
+ Map byCode = maxByUser.get(m.getUserId());
+ if (byCode == null) {
+ byCode = new HashMap();
+ maxByUser.put(m.getUserId(), byCode);
+ }
+ Double prev = byCode.get(code);
+ if (prev == null || m.getMaxScore() > prev) {
+ byCode.put(code, m.getMaxScore());
+ }
+ }
+ for (UserEntity u : users) {
+ Map byCode = maxByUser.get(u.getUserId());
+ // 综合分数 = Σ(模块最高分 × 模块权重),缺失模块计 0,保留 2 位小数
+ BigDecimal total = BigDecimal.ZERO;
+ for (int code = 1; code <= TrainingModules.MODULE_COUNT; code++) {
+ Double maxScore = byCode == null ? null : byCode.get(code);
+ if (maxScore != null) {
+ total = total.add(weights.getOrDefault(code, BigDecimal.ZERO)
+ .multiply(BigDecimal.valueOf(maxScore)));
+ }
+ }
+ rows.add(new TeacherStudentDetailRow(
+ u.getUserId(), u.getUserName(), u.getStudentId(), u.getClassId(), u.getClassName(),
+ byCode == null ? null : byCode.get(1),
+ byCode == null ? null : byCode.get(2),
+ byCode == null ? null : byCode.get(3),
+ byCode == null ? null : byCode.get(4),
+ byCode == null ? null : byCode.get(5),
+ byCode == null ? null : byCode.get(6),
+ total.setScale(2, RoundingMode.HALF_UP).doubleValue()));
+ }
+ return rows;
+ }
+
+ /** 学生分页/列表共用的查询条件:本校学生(role_id=4)+ 姓名/学号模糊、班级精确筛选。 */
+ private LambdaQueryWrapper studentWrapper(String schoolId, String userName,
+ String studentId, String classId) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(UserEntity::getSchoolId, schoolId)
+ .eq(UserEntity::getRoleId, STUDENT_ROLE_ID);
+ if (userName != null && !userName.trim().isEmpty()) {
+ wrapper.like(UserEntity::getUserName, userName.trim());
+ }
+ if (studentId != null && !studentId.trim().isEmpty()) {
+ wrapper.like(UserEntity::getStudentId, studentId.trim());
+ }
+ if (classId != null && !classId.trim().isEmpty()) {
+ wrapper.eq(UserEntity::getClassId, classId.trim());
+ }
+ wrapper.orderByAsc(UserEntity::getStudentId);
+ return wrapper;
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java
new file mode 100644
index 0000000..b057f5e
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailPage.java
@@ -0,0 +1,31 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+/**
+ * 教师端「成绩详情」分页结果,字段与现有分页约定(current/size/total/pages/records)保持一致。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "教师端成绩详情分页结果")
+public class TeacherStudentDetailPage {
+
+ @Schema(description = "当前页码", example = "1")
+ private final Long current;
+
+ @Schema(description = "每页数量", example = "10")
+ private final Long size;
+
+ @Schema(description = "总记录数", example = "37")
+ private final Long total;
+
+ @Schema(description = "总页数", example = "4")
+ private final Long pages;
+
+ @Schema(description = "当前页学生成绩详情列表")
+ private final List records;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java
new file mode 100644
index 0000000..f788128
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentDetailRow.java
@@ -0,0 +1,52 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 教师端「成绩详情」行:一名学生 + 模块一~模块六各自最高成绩。
+ * 最高成绩口径与学生端一致:当前学生该模块全部轮次(含已逻辑删除的历史轮次)取最大值;
+ * 该模块没有成绩时对应字段为 null。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "教师端成绩详情行(学生 + 模块一~模块六最高成绩)")
+public class TeacherStudentDetailRow {
+
+ @Schema(description = "学生用户编号")
+ private final String userId;
+
+ @Schema(description = "姓名", example = "学生甲")
+ private final String userName;
+
+ @Schema(description = "学号", example = "stu-a")
+ private final String studentId;
+
+ @Schema(description = "班级 id", example = "2071")
+ private final String classId;
+
+ @Schema(description = "班级名称", example = "教师测班1")
+ private final String className;
+
+ @Schema(description = "模块一(中国人民银行生成数字货币)最高成绩")
+ private final Double module1Score;
+
+ @Schema(description = "模块二(中国人民银行发行数字货币)最高成绩")
+ private final Double module2Score;
+
+ @Schema(description = "模块三(开通数字货币钱包)最高成绩")
+ private final Double module3Score;
+
+ @Schema(description = "模块四(用户兑换数字货币)最高成绩")
+ private final Double module4Score;
+
+ @Schema(description = "模块五(用户支付数字货币)最高成绩")
+ private final Double module5Score;
+
+ @Schema(description = "模块六(数字人民币创新应用场景仿真)最高成绩")
+ private final Double module6Score;
+
+ @Schema(description = "综合分数:各模块最高分按模块权重加权求和(权重和为 1,缺失模块计 0),保留 2 位小数", example = "36.40")
+ private final Double totalScore;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java
new file mode 100644
index 0000000..b89fc6d
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallPage.java
@@ -0,0 +1,26 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+/**
+ * 教师端学生总成绩分页结果,字段顺序与现有分页约定(current/size/total/pages/records)保持一致。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "教师端学生总成绩分页结果")
+public class TeacherStudentOverallPage {
+ @Schema(description = "当前页码", example = "1")
+ private final Long current;
+ @Schema(description = "每页数量", example = "10")
+ private final Long size;
+ @Schema(description = "总记录数", example = "128")
+ private final Long total;
+ @Schema(description = "总页数", example = "13")
+ private final Long pages;
+ @Schema(description = "当前页学生总成绩列表")
+ private final List records;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java
new file mode 100644
index 0000000..64dbf15
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/application/TeacherStudentOverallRow.java
@@ -0,0 +1,40 @@
+package com.yau.digitalrmb.training.score.teacher.application;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+/**
+ * 教师端总成绩列表的一行:学生基本信息 + 与学生端同口径的总成绩三行数据。
+ *
+ * 实验实训成绩 = 各模块最高分按模块权重加权;实验实训权重 = 该生所属学校配置的实验实训权重;
+ * 实验报告成绩 = 教师评分;实验报告权重 = 该校配置的实验报告权重;最终成绩 = 实训最终 + 报告最终。
+ */
+@Getter
+@AllArgsConstructor
+@Schema(description = "教师端学生总成绩一行")
+public class TeacherStudentOverallRow {
+ @Schema(description = "学生用户编号", example = "eef57628-4a17-40e7-b1cc-079eda54e997")
+ private final String userId;
+ @Schema(description = "姓名", example = "张三")
+ private final String userName;
+ @Schema(description = "学号", example = "2021010101")
+ private final String studentId;
+ @Schema(description = "班级 id", example = "2001")
+ private final String classId;
+ @Schema(description = "班级名称", example = "成绩测试班")
+ private final String className;
+
+ @Schema(description = "实验实训成绩(各模块最高分按模块权重加权)", example = "36.40")
+ private final BigDecimal experimentTrainingScore;
+ @Schema(description = "实验实训权重(来自学校总成绩权重配置,如 0.80)", example = "0.80")
+ private final BigDecimal experimentTrainingWeight;
+ @Schema(description = "实验报告成绩(教师评分,未评分计 0)", example = "90.00")
+ private final BigDecimal experimentReportScore;
+ @Schema(description = "实验报告权重(来自学校总成绩权重配置,如 0.20)", example = "0.20")
+ private final BigDecimal experimentReportWeight;
+ @Schema(description = "最终成绩 = 实训最终 + 报告最终", example = "22.85")
+ private final BigDecimal finalScore;
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java
new file mode 100644
index 0000000..b2d2625
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherModuleScoreMapper.java
@@ -0,0 +1,51 @@
+package com.yau.digitalrmb.training.score.teacher.infrastructure;
+
+import com.yau.digitalrmb.training.score.teacher.application.TeacherModuleMaxScore;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherModuleSchedule;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 教师端「成绩详情」只读复用 stu_module_score_details:
+ * 批量统计一批学生在各模块的最高成绩(手写 SQL,不含逻辑删除过滤——
+ * 最高成绩口径与学生端一致,已删除的历史轮次同样参与取最大值)。
+ */
+@Mapper
+public interface TeacherModuleScoreMapper {
+
+ /**
+ * 按 user_id + moudule 分组取最高成绩。
+ *
+ * @param userIds 学生编号集合(当前页学生,非空)
+ */
+ @Select("")
+ List selectMaxScoresByUserIds(@Param("userIds") List userIds);
+
+ /**
+ * 批量取每个 (user_id, moudule) 最新一条有效(未逻辑删除)记录的完成进度(schedule)。
+ * 进度口径与学生端一致:取该模块 serial_number 最大且未删除的记录。
+ * 与 {@link #selectMaxScoresByUserIds} 分开,因为最高分口径含已删除轮次、进度取最新有效记录。
+ */
+ @Select("")
+ List selectLatestScheduleByUserIds(@Param("userIds") List userIds);
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java
new file mode 100644
index 0000000..d6e85f4
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/infrastructure/TeacherUserMapper.java
@@ -0,0 +1,27 @@
+package com.yau.digitalrmb.training.score.teacher.infrastructure;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherClassOption;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 教师端只读复用 sys_user:仅用于按学校筛选学生与班级下拉,不写入用户表。
+ */
+@Mapper
+public interface TeacherUserMapper extends BaseMapper {
+
+ /**
+ * 当前学校去重班级:class_id 与 class_name 配对去重,按 class_id 排序。
+ * 逻辑删除(is_deleted)需手动过滤,因为本查询走原生 SQL 不经过 @TableLogic。
+ */
+ @Select("SELECT DISTINCT class_id, class_name FROM sys_user "
+ + "WHERE school_id = #{schoolId} AND (is_deleted = 0 OR is_deleted IS NULL) "
+ + "AND class_id IS NOT NULL AND class_name IS NOT NULL "
+ + "ORDER BY class_id")
+ List selectClassesBySchool(@Param("schoolId") String schoolId);
+}
diff --git a/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java b/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java
new file mode 100644
index 0000000..9887eaa
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherController.java
@@ -0,0 +1,155 @@
+package com.yau.digitalrmb.training.score.teacher.interfaces.rest;
+
+import com.yau.digitalrmb.security.application.CurrentUser;
+import com.yau.digitalrmb.security.application.CurrentUserService;
+import com.yau.digitalrmb.shared.api.ApiResponse;
+import com.yau.digitalrmb.shared.web.TraceIdFilter;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherClassOption;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherDetailExportService;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherScoreExportService;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherScoreService;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherStudentDetailPage;
+import com.yau.digitalrmb.training.score.teacher.application.TeacherStudentOverallPage;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.MDC;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLEncoder;
+import java.util.List;
+
+/**
+ * 教师端总成绩:按当前学校分页查询学生总成绩(支持用户名/学号/班级筛选),
+ * 以及当前学校去重班级下拉、总成绩 Excel 导出。
+ */
+@RestController
+@RequestMapping("/api/v1/training-scores/teacher")
+@Tag(name = "教师端总成绩", description = "按当前学校查询学生总成绩列表与班级下拉,成绩计算与学生端同口径")
+public class TrainingScoreTeacherController {
+ private final TeacherScoreService service;
+ private final TeacherScoreExportService exportService;
+ private final TeacherDetailExportService detailExportService;
+ private final CurrentUserService currentUserService;
+
+ public TrainingScoreTeacherController(TeacherScoreService service, TeacherScoreExportService exportService,
+ TeacherDetailExportService detailExportService,
+ CurrentUserService currentUserService) {
+ this.service = service;
+ this.exportService = exportService;
+ this.detailExportService = detailExportService;
+ this.currentUserService = currentUserService;
+ }
+
+ @GetMapping("/overall")
+ @Operation(summary = "教师端分页查询学生总成绩",
+ description = "按当前教师所属学校查询学生列表,支持用户名(模糊)、学号(模糊)、班级 id(精确)筛选,分页返回。"
+ + "每行成绩与学生端总成绩同口径:实验实训成绩=各模块最高分按模块权重加权、实验实训权重取自学校配置;"
+ + "实验报告成绩=教师评分、实验报告权重取自学校配置;最终成绩=实训最终+报告最终。")
+ public ApiResponse overall(
+ @Parameter(description = "页码,从 1 开始", example = "1")
+ @RequestParam(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "每页数量", example = "10")
+ @RequestParam(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "姓名模糊匹配")
+ @RequestParam(value = "userName", required = false) String userName,
+ @Parameter(description = "学号模糊匹配")
+ @RequestParam(value = "studentId", required = false) String studentId,
+ @Parameter(description = "班级 id 精确匹配")
+ @RequestParam(value = "classId", required = false) String classId) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ service.studentOverallPage(user.getSchoolId(), userName, studentId, classId, page, size),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @GetMapping("/details")
+ @Operation(summary = "教师端分页查询学生成绩详情",
+ description = "按当前教师所属学校查询学生列表(用户名模糊、学号模糊、班级 id 精确筛选),每行一名学生,"
+ + "module1Score~module6Score 为该生对应模块的最高成绩(取该模块全部轮次含已删除的最大值,"
+ + "与学生端最高成绩口径一致;没有成绩的模块为 null)。")
+ public ApiResponse details(
+ @Parameter(description = "页码,从 1 开始", example = "1")
+ @RequestParam(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "每页数量", example = "10")
+ @RequestParam(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "姓名模糊匹配")
+ @RequestParam(value = "userName", required = false) String userName,
+ @Parameter(description = "学号模糊匹配")
+ @RequestParam(value = "studentId", required = false) String studentId,
+ @Parameter(description = "班级 id 精确匹配")
+ @RequestParam(value = "classId", required = false) String classId) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ service.studentDetailPage(user.getSchoolId(), userName, studentId, classId, page, size),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @GetMapping("/classes")
+ @Operation(summary = "当前学校班级下拉",
+ description = "从 sys_user 按当前学校去重返回班级 id 与班级名称,逻辑删除记录已过滤。")
+ public ApiResponse> classes() {
+ CurrentUser user = currentUserService.getCurrentUser();
+ return ApiResponse.success(
+ service.classOptions(user.getSchoolId()),
+ MDC.get(TraceIdFilter.MDC_KEY));
+ }
+
+ @GetMapping("/details/export")
+ @Operation(summary = "教师端导出学生成绩详情",
+ description = "按当前教师所属学校与筛选条件导出全部学生(不分页)为 xlsx,顺序与详情页一致"
+ + "(按综合分数降序,同分按学号升序)。列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、综合分数。")
+ public ResponseEntity exportDetails(
+ @Parameter(description = "姓名模糊匹配")
+ @RequestParam(value = "userName", required = false) String userName,
+ @Parameter(description = "学号模糊匹配")
+ @RequestParam(value = "studentId", required = false) String studentId,
+ @Parameter(description = "班级 id 精确匹配")
+ @RequestParam(value = "classId", required = false) String classId) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ byte[] bytes = detailExportService.export(user.getSchoolId(), userName, studentId, classId);
+ String filename = detailExportService.fileName();
+ String encoded;
+ try {
+ encoded = URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
+ } catch (java.io.UnsupportedEncodingException e) {
+ encoded = filename;
+ }
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .body(bytes);
+ }
+
+ @GetMapping("/overall/export")
+ @Operation(summary = "教师端导出学生总成绩",
+ description = "按当前教师所属学校与筛选条件,导出全部学生(不分页)为 xlsx 文件。"
+ + "列:姓名、学号、班级、模块一~模块六(各模块最高成绩)、实训成绩、实验报告成绩、最终成绩。")
+ public ResponseEntity exportOverall(
+ @Parameter(description = "姓名模糊匹配")
+ @RequestParam(value = "userName", required = false) String userName,
+ @Parameter(description = "学号模糊匹配")
+ @RequestParam(value = "studentId", required = false) String studentId,
+ @Parameter(description = "班级 id 精确匹配")
+ @RequestParam(value = "classId", required = false) String classId) {
+ CurrentUser user = currentUserService.getCurrentUser();
+ byte[] bytes = exportService.export(user.getSchoolId(), userName, studentId, classId);
+ String filename = exportService.fileName();
+ String encoded;
+ try {
+ encoded = URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
+ } catch (java.io.UnsupportedEncodingException e) {
+ encoded = filename;
+ }
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .body(bytes);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java b/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java
index 588b0d4..c4efc2f 100644
--- a/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java
+++ b/src/main/java/com/yau/digitalrmb/walletopening/application/SmartContractService.java
@@ -7,6 +7,8 @@ import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplication;
import com.yau.digitalrmb.walletopening.domain.BankReceivedApplicationRepository;
+import com.yau.digitalrmb.walletopening.domain.CentralWalletActivation;
+import com.yau.digitalrmb.walletopening.domain.CentralWalletActivationRepository;
import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistration;
import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistrationRepository;
import com.yau.digitalrmb.walletopening.domain.SmartContractGeneration;
@@ -40,6 +42,8 @@ public class SmartContractService {
@Resource
private CentralWalletRegistrationRepository centralRegistrationRepository;
@Resource
+ private CentralWalletActivationRepository activationRepository;
+ @Resource
private InstitutionKeyService institutionKeyService;
@Resource
private SmInstitutionIdentityCryptography cryptography;
@@ -202,10 +206,48 @@ public class SmartContractService {
}
c.markSent();
contractRepository.update(c);
+ // 第四步发送成功即完成:后端内部串联央行端激活链路,落库 wallet_activated=TRUE、status=ACTIVATED
+ completeCentralActivation(subject, c);
scoreService.markStepCompleted(subject.getUserId(), 6);
return "成功";
}
+ /**
+ * 商业银行发送开通确认报文后,由后端完成央行端(接收→验签→重算摘要→摘要匹配→激活)链路,
+ * 保证模块四能查询到已激活钱包;已推进到步骤五的记录不回退。
+ */
+ private void completeCentralActivation(InstitutionKeySubject subject, SmartContractGeneration c) {
+ CentralWalletActivation a = activationRepository
+ .findLatest(subject.getUserId(), subject.getSchoolId(), subject.getClassId()).orElse(null);
+ if (a == null) {
+ a = activationRepository.save(
+ CentralWalletActivation.create(subject.getUserId(), subject.getSchoolId(), subject.getClassId()));
+ }
+ if (a.getStatus() != null
+ && a.getStatus().ordinal() > CentralWalletActivation.Status.ACTIVATED.ordinal()) {
+ return;
+ }
+ if (!a.isReceived()) {
+ a.receive(c.getConfirmId(), c.getWalletId(), c.getOrgCode(), c.getContractId(),
+ c.getActivateDigest(), c.getActivateSignature(), c.getActivateTimestamp(), c.getConfirmJson());
+ }
+ if (a.getSignVerifyResult() == null || !a.getSignVerifyResult().startsWith("通过")) {
+ String bankPublicKey = institutionKeyService.commercialBankSecondPublicKeyOrNull(subject);
+ boolean verified = bankPublicKey != null
+ && cryptography.verify(bankPublicKey, a.getDigest(), a.getSignature());
+ a.verifySignature(bankPublicKey != null ? bankPublicKey : "", verified);
+ }
+ if (a.getRecalculatedDigest() == null) {
+ a.recalculateDigest(cryptography.sm3(c.getActivatePlaintext()));
+ }
+ if (a.getDigestMatchResult() == null || !a.getDigestMatchResult().startsWith("通过")) {
+ a.matchDigest(a.getRecalculatedDigest() != null
+ && a.getRecalculatedDigest().equalsIgnoreCase(a.getDigest()));
+ }
+ a.activateWallet();
+ activationRepository.update(a);
+ }
+
private String buildContractJson(String contractId, SmartContractGeneration c) {
return "{\n"
+ " \"contractId\": \"" + contractId + "\",\n"
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index b7b855b..247f1df 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -22,4 +22,4 @@ security:
file:
type: local
- path: D:\新建文件夹
+ path: /usr/local/tianzeProject/digital-rmb-backend/uploadFile
diff --git a/src/main/resources/db/manual/experiment_report_upgrade.sql b/src/main/resources/db/manual/experiment_report_upgrade.sql
new file mode 100644
index 0000000..272ebf3
--- /dev/null
+++ b/src/main/resources/db/manual/experiment_report_upgrade.sql
@@ -0,0 +1,23 @@
+-- 存量库升级:新增实训报告表(与实验模块解耦,一个用户可多次提交)
+CREATE TABLE IF NOT EXISTS experiment_report (
+ id CHAR(36) PRIMARY KEY COMMENT '主键',
+ report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
+ file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
+ file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
+ upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+ teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
+ school_id VARCHAR(36) NULL COMMENT '学校ID',
+ class_id VARCHAR(36) NULL COMMENT '班级ID',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ INDEX idx_experiment_report_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实训报告表';
+
+-- 已建过本表的环境执行(对齐 sys_user 的 utf8mb4_unicode_ci,否则 join 报
+-- Illegal mix of collations utf8mb4_0900_ai_ci vs utf8mb4_unicode_ci):
+ALTER TABLE experiment_report CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- 存量库升级:实训报告新增教师评语(教师评分接口 /api/v1/experiment-reports/teacher/score 一并写入)
+ALTER TABLE experiment_report ADD COLUMN teacher_comment VARCHAR(500) NULL COMMENT '教师评语' AFTER teacher_score;
diff --git a/src/main/resources/db/manual/module_6_charity_upgrade.sql b/src/main/resources/db/manual/module_6_charity_upgrade.sql
index b96dadc..d4b94af 100644
--- a/src/main/resources/db/manual/module_6_charity_upgrade.sql
+++ b/src/main/resources/db/manual/module_6_charity_upgrade.sql
@@ -1,5 +1,86 @@
-- 模块六公益捐赠三步流程增量建表脚本。
-- 已有环境升级时执行;全新环境由 schema.sql 自动创建。
+-- 本脚本可重复执行;四张表均使用 IF NOT EXISTS,只补缺失的表。
+
+-- 模块六:捐赠企业发起捐赠表 - 捐赠企业向受赠机构发起捐赠全流程
+CREATE TABLE IF NOT EXISTS donation (
+ id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
+ school_id BIGINT NOT NULL COMMENT '学校ID',
+ class_id BIGINT NOT NULL COMMENT '班级ID',
+ donation_id VARCHAR(64) COMMENT '捐赠编号(如:DONATE_20260801_001)',
+ donating_enterprise VARCHAR(200) COMMENT '捐赠企业名称(如:创新科技公司)',
+ corp_wallet_id VARCHAR(128) COMMENT '付款钱包ID(CORP_前缀+十六进制)',
+ recipient_organization VARCHAR(200) COMMENT '受赠机构名称(如:阳光公益基金)',
+ org_wallet_id VARCHAR(128) COMMENT '收款钱包ID(CORP_前缀+十六进制)',
+ amount VARCHAR(32) COMMENT '捐赠金额(单位:元DC,保留两位小数)',
+ project VARCHAR(200) COMMENT '捐赠项目名称(如:乡村教育支持计划)',
+ memo VARCHAR(500) COMMENT '捐赠备注说明',
+ timestamp VARCHAR(20) COMMENT '时间戳,格式yyyyMMddHHmmss',
+ concatenated_message TEXT COMMENT '拼接捐赠原文(格式:DONATE|付款钱包ID|收款钱包ID|金额|项目|备注|时间戳)',
+ digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
+ digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
+ corp_signature TEXT COMMENT '企业法人SM2签名值(DER编码十六进制)',
+ output_json TEXT COMMENT '输出JSON报文',
+ status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出',
+ created_at TIMESTAMP NOT NULL COMMENT '创建时间',
+ updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
+ created_by VARCHAR(64) NOT NULL COMMENT '创建人',
+ updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
+ deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-捐赠企业发起捐赠表';
+
+-- 模块六:受赠机构接收捐款与公示表 - 受赠机构查看捐款、提取公示原文、生成摘要、发布公示、输出
+CREATE TABLE IF NOT EXISTS donation_receipt (
+ id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
+ school_id BIGINT NOT NULL COMMENT '学校ID',
+ class_id BIGINT NOT NULL COMMENT '班级ID',
+ publication_id VARCHAR(64) COMMENT '公示编号(如:PUBLIC_20260801_001)',
+ org_id VARCHAR(128) COMMENT '受赠机构钱包ID(CORP_前缀+十六进制)',
+ org_name VARCHAR(200) COMMENT '受赠机构名称',
+ concatenated_message TEXT COMMENT '拼接公示原文(格式:PUBLIC|机构钱包ID|机构名称|公示标题|捐赠企业|金额|项目|时间戳)',
+ digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
+ digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
+ publish_title VARCHAR(200) COMMENT '公示标题',
+ publish_content VARCHAR(500) COMMENT '公示内容',
+ publish_url VARCHAR(500) COMMENT '公示网站',
+ publish_time VARCHAR(20) COMMENT '公示时间,格式yyyyMMddHHmmss',
+ output_json TEXT COMMENT '输出JSON报文',
+ status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, CONCATENATED-已提取原文, DIGESTED-已生成摘要, PUBLISHED-已公示, OUTPUT-已输出',
+ created_at TIMESTAMP NOT NULL COMMENT '创建时间',
+ updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
+ created_by VARCHAR(64) NOT NULL COMMENT '创建人',
+ updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
+ deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-受赠机构接收捐款与公示表';
+
+-- 模块六:第三方监管监督与审核表 - 监管机构提取审核原文、生成摘要、SM2签名、备案、输出
+CREATE TABLE IF NOT EXISTS donation_supervision (
+ id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID(关联登录用户)',
+ school_id BIGINT NOT NULL COMMENT '学校ID',
+ class_id BIGINT NOT NULL COMMENT '班级ID',
+ approval_id VARCHAR(64) COMMENT '审核编号(如:APPROVE_20260801_001)',
+ donation_id VARCHAR(64) COMMENT '捐赠编号(关联donation表)',
+ supervisor VARCHAR(200) COMMENT '监管机构名称',
+ concatenated_message TEXT COMMENT '拼接审核原文(格式:SUPERVISE|捐赠编号|监管机构|金额|项目|时间戳)',
+ digest_algorithm VARCHAR(16) COMMENT '摘要算法(如:SM3)',
+ digest_value VARCHAR(128) COMMENT 'SM3摘要值(十六进制大写)',
+ sign_algorithm VARCHAR(16) COMMENT '签名算法(如:SM2)',
+ supervisor_signature TEXT COMMENT '监管机构SM2签名值(DER编码十六进制)',
+ approval_time VARCHAR(20) COMMENT '审核时间,格式yyyyMMddHHmmss',
+ record_number VARCHAR(64) COMMENT '备案编号(如:RECORD_20260801_001)',
+ output_json TEXT COMMENT '输出JSON报文',
+ status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT-草稿, CONCATENATED-已提取原文, DIGESTED-已生成摘要, SIGNED-已签名, OUTPUT-已输出',
+ created_at TIMESTAMP NOT NULL COMMENT '创建时间',
+ updated_at TIMESTAMP NOT NULL COMMENT '更新时间',
+ created_by VARCHAR(64) NOT NULL COMMENT '创建人',
+ updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
+ deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-第三方监管监督与审核表';
+
+-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程合并记录表
CREATE TABLE IF NOT EXISTS donation_record (
id BIGINT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
diff --git a/src/main/resources/db/manual/training_weight_config_upgrade.sql b/src/main/resources/db/manual/training_weight_config_upgrade.sql
new file mode 100644
index 0000000..e741e27
--- /dev/null
+++ b/src/main/resources/db/manual/training_weight_config_upgrade.sql
@@ -0,0 +1,25 @@
+-- 实验成绩权重配置升级脚本:教师端总成绩权重与模块权重
+CREATE TABLE IF NOT EXISTS training_school_score_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
+ report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE TABLE IF NOT EXISTS training_module_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
+ module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
+ module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
+ module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
+ module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
+ module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
+ deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
index 3999b39..2acce82 100644
--- a/src/main/resources/schema.sql
+++ b/src/main/resources/schema.sql
@@ -1269,7 +1269,6 @@ CREATE TABLE IF NOT EXISTS salary_batch (
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-工资发放批次表';
-<<<<<<< HEAD
-- 模块六:捐赠企业发起捐赠表 - 捐赠企业向受赠机构发起捐赠全流程
CREATE TABLE IF NOT EXISTS donation (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
@@ -1347,7 +1346,7 @@ CREATE TABLE IF NOT EXISTS donation_supervision (
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记:false-未删除, true-已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-第三方监管监督与审核表';
-=======
+
-- 模块六:企业使用数字人民币进行公益捐赠 - 发起、公示、监管审核全流程
CREATE TABLE IF NOT EXISTS donation_record (
id BIGINT PRIMARY KEY COMMENT '主键ID(雪花算法生成)',
@@ -1391,7 +1390,6 @@ CREATE TABLE IF NOT EXISTS donation_record (
deleted BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_donation_subject (user_id, school_id, class_id, deleted, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模块六-公益捐赠记录表';
->>>>>>> 8af6f539bf42ee657bec4cc824118ff56d043811
-- 步骤四商业银行端:生成合约信息与开通确认
CREATE TABLE IF NOT EXISTS smart_contract_generation (
@@ -1466,3 +1464,48 @@ CREATE TABLE IF NOT EXISTS experiment_statistics (
INSERT INTO experiment_statistics (id, total_experiments, completed_experiments, in_progress_experiments, not_started_experiments)
VALUES (1, 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE id = id;
+
+-- 教师端总成绩权重配置(实验实训 + 实训报告 = 1),一校一行
+CREATE TABLE IF NOT EXISTS training_school_score_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
+ report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 教师端模块权重配置,一校一行,6 个模块权重横向存储,合计 100%
+CREATE TABLE IF NOT EXISTS training_module_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
+ module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
+ module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
+ module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
+ module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
+ module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
+ deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 实训报告表:与实验模块解耦,一个用户可多次提交,按 user_id 查询
+CREATE TABLE IF NOT EXISTS experiment_report (
+ id CHAR(36) PRIMARY KEY COMMENT '主键',
+ report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
+ file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
+ file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
+ upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+ teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
+ teacher_comment VARCHAR(500) NULL COMMENT '教师评语',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
+ school_id VARCHAR(36) NULL COMMENT '学校ID',
+ class_id VARCHAR(36) NULL COMMENT '班级ID',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ INDEX idx_experiment_report_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实训报告表';
diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java
index e601cbc..fa44141 100644
--- a/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java
+++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/CorporateInnovationTrainingResetServiceTest.java
@@ -4,6 +4,14 @@ import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicat
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.CorporateWalletApplicationEntity;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationEntity;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationMapper;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptEntity;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationReceiptMapper;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordEntity;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationRecordMapper;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionEntity;
+import com.yau.digitalrmb.corporatewallet.infrastructure.DonationSupervisionMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationEntity;
import com.yau.digitalrmb.corporatewallet.infrastructure.RemoteWalletApplicationMapper;
import com.yau.digitalrmb.corporatewallet.infrastructure.SalaryBatchEntity;
@@ -35,7 +43,10 @@ class CorporateInnovationTrainingResetServiceTest {
@Mock private CorporateWalletApplicationMapper corporateWalletMapper;
@Mock private RemoteWalletApplicationMapper remoteWalletMapper;
@Mock private SalaryBatchMapper salaryBatchMapper;
+ @Mock private DonationMapper donationMapper;
@Mock private DonationRecordMapper donationRecordMapper;
+ @Mock private DonationReceiptMapper donationReceiptMapper;
+ @Mock private DonationSupervisionMapper donationSupervisionMapper;
@Mock private StuModuleScoreDetailsMapper scoreDetailsMapper;
@Mock private UserStepProgressNewService stepProgressService;
@InjectMocks private CorporateInnovationTrainingResetService service;
@@ -46,7 +57,10 @@ class CorporateInnovationTrainingResetServiceTest {
TableInfoHelper.initTableInfo(assistant, CorporateWalletApplicationEntity.class);
TableInfoHelper.initTableInfo(assistant, RemoteWalletApplicationEntity.class);
TableInfoHelper.initTableInfo(assistant, SalaryBatchEntity.class);
+ TableInfoHelper.initTableInfo(assistant, DonationEntity.class);
TableInfoHelper.initTableInfo(assistant, DonationRecordEntity.class);
+ TableInfoHelper.initTableInfo(assistant, DonationReceiptEntity.class);
+ TableInfoHelper.initTableInfo(assistant, DonationSupervisionEntity.class);
TableInfoHelper.initTableInfo(assistant, StuModuleScoreDetailsEntity.class);
}
@@ -55,13 +69,19 @@ class CorporateInnovationTrainingResetServiceTest {
when(corporateWalletMapper.update(any(), any())).thenReturn(2);
when(remoteWalletMapper.update(any(), any())).thenReturn(3);
when(salaryBatchMapper.update(any(), any())).thenReturn(4);
- when(donationRecordMapper.update(any(), any())).thenReturn(5);
+ when(donationMapper.update(any(), any())).thenReturn(2);
+ when(donationReceiptMapper.update(any(), any())).thenReturn(2);
+ when(donationRecordMapper.update(any(), any())).thenReturn(1);
+ when(donationSupervisionMapper.update(any(), any())).thenReturn(1);
when(scoreDetailsMapper.update(any(), any())).thenReturn(1);
CorporateInnovationTrainingResetResult result = service.reset(SUBJECT);
assertThat(result.isReset()).isTrue();
- assertThat(result.getDeletedRecordCount()).isEqualTo(15);
+ assertThat(result.getDeletedRecordCount()).isEqualTo(16);
+ verify(donationRecordMapper).update(any(), any());
+ verify(donationReceiptMapper).update(any(), any());
+ verify(donationSupervisionMapper).update(any(), any());
verify(stepProgressService).reset("reset-user", "innovation-scene");
}
}
diff --git a/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java
index 6080d7c..693bc45 100644
--- a/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java
+++ b/src/test/java/com/yau/digitalrmb/corporatewallet/application/SalaryBatchServiceTest.java
@@ -6,6 +6,7 @@ import com.yau.digitalrmb.corporatewallet.domain.SalaryBatch;
import com.yau.digitalrmb.corporatewallet.domain.SalaryBatchRepository;
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
+import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -17,6 +18,7 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
@@ -64,17 +66,17 @@ class SalaryBatchServiceTest {
service.fetchBlockchain(SUBJECT, "tester");
service.concatenate(SUBJECT);
- SalaryDigestResult firstDigest = service.computeDigest(SUBJECT);
+ SalaryDigestResult firstDigest = service.computeDigest(SUBJECT, "SM3");
SalaryBatch firstBatch = latest.get();
assertThat(firstBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED);
- SalarySignatureResult firstSignature = service.sign(SUBJECT, PRIVATE_KEY);
+ SalarySignatureResult firstSignature = service.sign(SUBJECT, PRIVATE_KEY, "SM2");
service.fetchBlockchain(SUBJECT, "tester");
service.concatenate(SUBJECT);
- SalaryDigestResult secondDigest = service.computeDigest(SUBJECT);
+ SalaryDigestResult secondDigest = service.computeDigest(SUBJECT, "SM3");
SalaryBatch secondBatch = latest.get();
assertThat(secondBatch.getStatus()).isEqualTo(SalaryBatch.Status.DIGESTED);
- SalarySignatureResult secondSignature = service.sign(SUBJECT, PRIVATE_KEY);
+ SalarySignatureResult secondSignature = service.sign(SUBJECT, PRIVATE_KEY, "SM2");
assertThat(secondBatch).isNotSameAs(firstBatch);
assertThat(secondBatch.getId()).isNotEqualTo(firstBatch.getId());
@@ -87,4 +89,52 @@ class SalaryBatchServiceTest {
verify(cryptography).sign(PRIVATE_KEY, "DIGEST_ONE");
verify(cryptography).sign(PRIVATE_KEY, "DIGEST_TWO");
}
+
+ @Test
+ void recordsErrorWhenDigestOrSignAlgorithmIsWrong() {
+ AtomicReference latest = new AtomicReference<>();
+ AtomicLong ids = new AtomicLong(20L);
+ when(repository.save(any(SalaryBatch.class))).thenAnswer(invocation -> {
+ SalaryBatch batch = invocation.getArgument(0);
+ batch.setId(ids.incrementAndGet());
+ latest.set(batch);
+ return batch;
+ });
+ when(repository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenAnswer(invocation -> Optional.ofNullable(latest.get()));
+
+ CorporateWalletApplication wallet = CorporateWalletApplication.create(
+ SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId());
+ wallet.setWalletId("CORP_WALLET_002");
+ when(corporateWalletRepository.findLatest(
+ SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenReturn(Optional.of(wallet));
+ when(cryptography.sm3(any(String.class))).thenReturn("DIGEST_ALG_CHECK");
+ when(keyService.getOrCreatePrivateKey(
+ any(InstitutionKeySubject.class), eq("数币操作员私钥"), eq("王五")))
+ .thenReturn(PRIVATE_KEY);
+ when(cryptography.sign(PRIVATE_KEY, "DIGEST_ALG_CHECK")).thenReturn("SIG_ALG_CHECK");
+
+ service.fetchBlockchain(SUBJECT, "tester");
+ service.concatenate(SUBJECT);
+
+ // 摘要算法选错(如 SHA256):拒绝生成摘要并错误数+1
+ assertThatThrownBy(() -> service.computeDigest(SUBJECT, "SHA256"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("算法选择有误");
+ verify(scoreService).recordError(SUBJECT.getUserId());
+
+ // 正常算法通过
+ assertThat(service.computeDigest(SUBJECT, "SM3").getDigestValue()).isEqualTo("DIGEST_ALG_CHECK");
+
+ // 签名算法选错(如 RSA):拒绝签名并错误数+1
+ assertThatThrownBy(() -> service.sign(SUBJECT, PRIVATE_KEY, "RSA"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("签名算法选择有误");
+ verify(scoreService, times(2)).recordError(SUBJECT.getUserId());
+
+ // 算法正确时正常签名,不再计错误
+ assertThat(service.sign(SUBJECT, PRIVATE_KEY, "SM2").getSignature()).isEqualTo("SIG_ALG_CHECK");
+ verify(scoreService, times(2)).recordError(SUBJECT.getUserId());
+ }
}
diff --git a/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java b/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java
new file mode 100644
index 0000000..c6d9d6c
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/training/experimentreport/interfaces/rest/ExperimentReportControllerTest.java
@@ -0,0 +1,196 @@
+package com.yau.digitalrmb.training.experimentreport.interfaces.rest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class ExperimentReportControllerTest {
+
+ private static final String USER = "00000000-0000-0000-0000-000000000941";
+ private static final String OTHER_USER = "00000000-0000-0000-0000-000000000942";
+ private static final String SCHOOL = "1001";
+ private static final String OTHER_SCHOOL = "1002";
+ private static final String STORED_PATH = "/2026/09/29/abcdef.pdf";
+
+ /** 1x1 透明 PNG,用于验证“图片自动转 PDF”链路。 */
+ private static final byte[] PNG_BYTES = java.util.Base64.getDecoder().decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JdbcTemplate jdbc;
+
+ @MockBean
+ private com.yau.digitalrmb.shared.config.IFileUtil fileUtil;
+
+ @BeforeEach
+ void seed() {
+ clean();
+ insertUser(USER, "report-a", SCHOOL);
+ insertUser(OTHER_USER, "report-b", OTHER_SCHOOL);
+ when(fileUtil.upload(anyString(), any(java.io.InputStream.class))).thenReturn(STORED_PATH);
+ }
+
+ @AfterEach
+ void tearDown() {
+ clean();
+ }
+
+ @Test
+ void uploadImageConvertsToPdfAndStoresRow() throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "实验报告.png",
+ "image/png", PNG_BYTES);
+
+ mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.id").isNotEmpty())
+ .andExpect(jsonPath("$.data.reportName").value("实验报告.png"))
+ .andExpect(jsonPath("$.data.fileUrl").value(STORED_PATH))
+ .andExpect(jsonPath("$.data.fileSize").value(PNG_BYTES.length))
+ .andExpect(jsonPath("$.data.userId").value(USER))
+ .andExpect(jsonPath("$.data.schoolId").value(SCHOOL))
+ .andExpect(jsonPath("$.data.classId").value("2001"))
+ .andExpect(jsonPath("$.data.teacherScore").isEmpty());
+
+ assertThat(jdbc.queryForObject(
+ "SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
+ assertThat(jdbc.queryForObject(
+ "SELECT teacher_score FROM experiment_report WHERE user_id=?", Integer.class, USER)).isNull();
+ assertThat(jdbc.queryForObject(
+ "SELECT deleted FROM experiment_report WHERE user_id=?", Boolean.class, USER)).isFalse();
+ }
+
+ @Test
+ void rejectsEmptyFile() throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "empty.png",
+ "image/png", new byte[0]);
+
+ mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+ }
+
+ @Test
+ void secondUploadOverwritesExistingReportInsteadOfInsertingNew() throws Exception {
+ // 首次上传
+ MockMultipartFile first = new MockMultipartFile("file", "实验报告.png",
+ "image/png", PNG_BYTES);
+ mockMvc.perform(multipart("/api/v1/experiment-reports").file(first).with(currentUser()))
+ .andExpect(status().isOk());
+
+ // 取出首传 id,再传一次
+ String firstId = jdbc.queryForObject(
+ "SELECT id FROM experiment_report WHERE user_id=?", String.class, USER);
+ MockMultipartFile second = new MockMultipartFile("file", "实验报告V2.png",
+ "image/png", PNG_BYTES);
+ mockMvc.perform(multipart("/api/v1/experiment-reports").file(second).with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(firstId))
+ .andExpect(jsonPath("$.data.reportName").value("实验报告V2.png"));
+
+ // 仍为一条有效报告,且文件名已更新
+ assertThat(jdbc.queryForObject(
+ "SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
+ assertThat(jdbc.queryForObject(
+ "SELECT report_name FROM experiment_report WHERE user_id=?", String.class, USER))
+ .isEqualTo("实验报告V2.png");
+ }
+
+ @Test
+ void rejectsUploadAfterTeacherScored() throws Exception {
+ // 教师先评分
+ insertReport(USER, SCHOOL, "报告A.png");
+ String reportId = jdbc.queryForObject(
+ "SELECT id FROM experiment_report WHERE user_id=?", String.class, USER);
+ jdbc.update("UPDATE experiment_report SET teacher_score=88 WHERE id=?", reportId);
+
+ MockMultipartFile file = new MockMultipartFile("file", "实验报告.png",
+ "image/png", PNG_BYTES);
+ mockMvc.perform(multipart("/api/v1/experiment-reports").file(file).with(currentUser()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+
+ // 报告未被替换,仍是教师评分后的那一份
+ assertThat(jdbc.queryForObject(
+ "SELECT COUNT(*) FROM experiment_report WHERE user_id=?", Integer.class, USER)).isEqualTo(1);
+ assertThat(jdbc.queryForObject(
+ "SELECT teacher_score FROM experiment_report WHERE user_id=?", Integer.class, USER))
+ .isEqualTo(88);
+ }
+
+ @Test
+ void listReturnsOnlyCurrentUserReports() throws Exception {
+ insertReport(USER, SCHOOL, "报告A.png");
+ insertReport(OTHER_USER, OTHER_SCHOOL, "报告B.png");
+
+ mockMvc.perform(get("/api/v1/experiment-reports").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(1))
+ .andExpect(jsonPath("$.data[0].reportName").value("报告A.png"))
+ .andExpect(jsonPath("$.data[0].schoolId").value(SCHOOL));
+
+ mockMvc.perform(get("/api/v1/experiment-reports").with(otherUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.length()").value(1))
+ .andExpect(jsonPath("$.data[0].reportName").value("报告B.png"));
+ }
+
+ @Test
+ void rejectsAnonymousAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/experiment-reports")).andExpect(status().isUnauthorized());
+ mockMvc.perform(multipart("/api/v1/experiment-reports")
+ .file(new MockMultipartFile("file", "x.png", "image/png", PNG_BYTES)))
+ .andExpect(status().isUnauthorized());
+ }
+
+ private RequestPostProcessor currentUser() {
+ return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
+ .claim("preferred_username", "report-a"));
+ }
+
+ private RequestPostProcessor otherUser() {
+ return jwt().jwt(token -> token.subject(OTHER_USER).claim("userId", OTHER_USER)
+ .claim("preferred_username", "report-b"));
+ }
+
+ private void insertUser(String id, String account, String schoolId) {
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,'报告测试班',?,'延安大学',4,0,?)",
+ id, account, "unused", account, "2001", schoolId, account);
+ }
+
+ private void insertReport(String userId, String schoolId, String reportName) {
+ jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,user_id,"
+ + "school_id,class_id,created_at,updated_at,deleted) VALUES (?,?,?,?,CURRENT_TIMESTAMP,?,?,"
+ + "'2001',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
+ java.util.UUID.randomUUID().toString(), reportName, STORED_PATH, 1024L, userId, schoolId);
+ }
+
+ private void clean() {
+ jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", USER, OTHER_USER);
+ jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?)", USER, OTHER_USER);
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java b/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java
new file mode 100644
index 0000000..1ae3560
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/training/experimentreport/teacher/interfaces/rest/TeacherExperimentReportControllerTest.java
@@ -0,0 +1,98 @@
+package com.yau.digitalrmb.training.experimentreport.teacher.interfaces.rest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class TeacherExperimentReportControllerTest {
+ private static final String TEACHER = "00000000-0000-0000-0000-000000000971";
+ private static final String STUDENT_A = "00000000-0000-0000-0000-000000000972";
+ private static final String STUDENT_B = "00000000-0000-0000-0000-000000000973";
+ private static final String SCHOOL = "1071";
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void seed() {
+ clean();
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ TEACHER, "tea-1", "unused", "李老师", "2071", "教师测班1", SCHOOL, "延安大学", 2, TEACHER);
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ STUDENT_A, "stu-a", "unused", "学生甲", "2071", "教师测班1", SCHOOL, "延安大学", 4, STUDENT_A);
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ STUDENT_B, "stu-b", "unused", "学生乙", "2072", "教师测班2", SCHOOL, "延安大学", 4, STUDENT_B);
+
+ jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ + "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ + "VALUES ('r-a','报告A.pdf','/2026/9/29/a.pdf',2048,CURRENT_TIMESTAMP,85.00,?,?,?,"
+ + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)", STUDENT_A, SCHOOL, "2071");
+ jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ + "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ + "VALUES ('r-b','报告B.pdf','/2026/9/29/b.pdf',4096,CURRENT_TIMESTAMP,NULL,?,?,?,"
+ + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)", STUDENT_B, SCHOOL, "2072");
+ }
+
+ @AfterEach
+ void tearDown() {
+ clean();
+ }
+
+ @Test
+ void returnsPagedReportsForSchool() throws Exception {
+ mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("page", "1").param("size", "10").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(2))
+ .andExpect(jsonPath("$.data.records.length()").value(2))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].reportName").value("报告A.pdf"))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].teacherScore").value(85.00))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].classId").value("2072"));
+ }
+
+ @Test
+ void filtersReportsByStudentIdAndClass() throws Exception {
+ mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("studentId", "stu-a").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"));
+
+ mockMvc.perform(get("/api/v1/experiment-reports/teacher").param("classId", "2072").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.records[0].classId").value("2072"));
+ }
+
+ @Test
+ void rejectsAnonymousAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/experiment-reports/teacher")).andExpect(status().isUnauthorized());
+ }
+
+ private RequestPostProcessor teacher() {
+ return jwt().jwt(token -> token.subject(TEACHER).claim("userId", TEACHER)
+ .claim("preferred_username", "tea-1"));
+ }
+
+ private void clean() {
+ jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
+ jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?,?)", TEACHER, STUDENT_A, STUDENT_B);
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java
new file mode 100644
index 0000000..2805b62
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingOverallScoreControllerTest.java
@@ -0,0 +1,119 @@
+package com.yau.digitalrmb.training.score.interfaces.rest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class TrainingOverallScoreControllerTest {
+ private static final String USER = "00000000-0000-0000-0000-000000000951";
+ private static final String SCHOOL = "1051";
+ private static final String MODULE_ONE = "中国人民银行生成数字货币";
+ private static final String MODULE_THREE = "开通数字货币钱包";
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void seed() {
+ clean();
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,'总测班',?,'延安大学',4,0,?)",
+ USER, "overall-a", "unused", "overall-a", "2051", SCHOOL, "overall-a");
+ // 模块一:最高分 98(含一条已删除 30)→ 取 98;进度 50
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "ov-1", MODULE_ONE, 98.00, USER, 1, 50.00, false);
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "ov-2", MODULE_ONE, 30.00, USER, 2, 50.00, true);
+ // 模块三:最高分 70;进度 50
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "ov-3", MODULE_THREE, 70.00, USER, 3, 50.00, false);
+ // 其余 4 个模块无数据,进度/成绩按 0,仍纳入 /6
+ }
+
+ @AfterEach
+ void tearDown() {
+ clean();
+ }
+
+ @Test
+ void overallWithoutReport() throws Exception {
+ // 实训成绩 = 0.30*98 + 0.10*70 = 36.40;实训进度 = (50+0+50+0+0+0)/6 = 16.67
+ // 实训最终 = 16.67/100 * 0.80 * 36.40 = 4.85;无报告 → 报告与总成绩均为 0/4.85
+ mockMvc.perform(get("/api/v1/training-scores/overall").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.items.length()").value(3))
+ .andExpect(jsonPath("$.data.items[0].project").value("实验实训"))
+ .andExpect(jsonPath("$.data.items[0].score").value(36.40))
+ .andExpect(jsonPath("$.data.items[0].weight").value(0.80))
+ .andExpect(jsonPath("$.data.items[0].progress").value(16.67))
+ .andExpect(jsonPath("$.data.items[0].finalScore").value(4.85))
+ .andExpect(jsonPath("$.data.items[1].project").value("实验报告"))
+ .andExpect(jsonPath("$.data.items[1].score").value(0))
+ .andExpect(jsonPath("$.data.items[1].weight").value(0.20))
+ .andExpect(jsonPath("$.data.items[1].progress").value(0))
+ .andExpect(jsonPath("$.data.items[1].finalScore").value(0))
+ .andExpect(jsonPath("$.data.items[2].project").value("总成绩"))
+ .andExpect(jsonPath("$.data.items[2].score").doesNotExist())
+ .andExpect(jsonPath("$.data.items[2].progress").doesNotExist())
+ .andExpect(jsonPath("$.data.items[2].finalScore").value(4.85))
+ .andExpect(jsonPath("$.data.totalFinal").value(4.85));
+ }
+
+ @Test
+ void overallWithGradedReport() throws Exception {
+ // 有报告且教师评分 90 → 报告进度 100,成绩 90,报告最终 = 100/100*0.20*90 = 18.00
+ // 总成绩 = 4.85 + 18.00 = 22.85
+ jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ + "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ + "VALUES ('r-1','报告.pdf','/2026/9/29/r1.pdf',1024,CURRENT_TIMESTAMP,90.00,?,?,?,"
+ + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
+ USER, SCHOOL, "2051");
+ mockMvc.perform(get("/api/v1/training-scores/overall").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items[1].score").value(90.00))
+ .andExpect(jsonPath("$.data.items[1].progress").value(100))
+ .andExpect(jsonPath("$.data.items[1].finalScore").value(18.00))
+ .andExpect(jsonPath("$.data.items[2].finalScore").value(22.85))
+ .andExpect(jsonPath("$.data.totalFinal").value(22.85));
+ }
+
+ @Test
+ void rejectsAnonymousAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/overall")).andExpect(status().isUnauthorized());
+ }
+
+ private RequestPostProcessor currentUser() {
+ return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
+ .claim("preferred_username", "overall-a"));
+ }
+
+ private void clean() {
+ jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=?", USER);
+ jdbc.update("DELETE FROM experiment_report WHERE user_id=?", USER);
+ jdbc.update("DELETE FROM training_module_weight WHERE school_id=?", SCHOOL);
+ jdbc.update("DELETE FROM training_school_score_weight WHERE school_id=?", SCHOOL);
+ jdbc.update("DELETE FROM sys_user WHERE user_id=?", USER);
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java
new file mode 100644
index 0000000..2f683b8
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/training/score/interfaces/rest/TrainingScoreControllerTest.java
@@ -0,0 +1,291 @@
+package com.yau.digitalrmb.training.score.interfaces.rest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class TrainingScoreControllerTest {
+ private static final String USER = "00000000-0000-0000-0000-000000000931";
+ private static final String OTHER_USER = "00000000-0000-0000-0000-000000000932";
+ private static final String SCHOOL = "1001";
+ private static final String OTHER_SCHOOL = "1002";
+ /** 成绩表模块名(模块一:中国人民银行生成数字货币)。 */
+ private static final String MODULE_ONE = "中国人民银行生成数字货币";
+ /** 成绩表模块名(模块三:开通数字货币钱包)。 */
+ private static final String MODULE_THREE = "开通数字货币钱包";
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void seed() {
+ clean();
+ insertUser(USER, "score-a", SCHOOL);
+ insertUser(OTHER_USER, "score-b", OTHER_SCHOOL);
+ // 模块一:2 条有效 + 1 条已删除 → 操作次数 3,当前操作次数 2
+ insertScore("score-a-1", USER, MODULE_ONE, 1, 98.00, false);
+ insertScore("score-a-2", USER, MODULE_ONE, 2, 52.00, false);
+ insertScore("score-a-3", USER, MODULE_ONE, 3, 30.00, true);
+ // 模块三:1 条有效
+ insertScore("score-a-4", USER, MODULE_THREE, 3, 70.00, false);
+ // 其它用户的模块一数据,不应出现在当前用户结果中
+ insertScore("score-b-1", OTHER_USER, MODULE_ONE, 1, 55.00, false);
+ }
+
+ @AfterEach
+ void tearDown() {
+ clean();
+ }
+
+ @Test
+ void returnsSixModulesWithDefaultsWhenNoConfig() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(6))
+ .andExpect(jsonPath("$.data[0].mouduleCode").value(1))
+ .andExpect(jsonPath("$.data[0].moudule").value("中国人民银行生成数字货币"))
+ .andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.5 分"))
+ .andExpect(jsonPath("$.data[0].moduleWeight").value(0.30))
+ .andExpect(jsonPath("$.data[0].operationCount").value(3))
+ .andExpect(jsonPath("$.data[0].currentCount").value(2))
+ .andExpect(jsonPath("$.data[0].maxScore").value(98.00))
+ .andExpect(jsonPath("$.data[0].minScore").value(30.00))
+ .andExpect(jsonPath("$.data[0].avgScore").value(60.00))
+ .andExpect(jsonPath("$.data[0].details.length()").value(2))
+ .andExpect(jsonPath("$.data[1].moduleWeight").value(0.25))
+ .andExpect(jsonPath("$.data[1].operationCount").value(0))
+ .andExpect(jsonPath("$.data[1].details.length()").value(0))
+ .andExpect(jsonPath("$.data[5].mouduleCode").value(6))
+ .andExpect(jsonPath("$.data[5].moduleWeight").value(0.15));
+ // 无配置时成绩查询本身应为本校落库一条默认模块权重配置(含默认扣分)
+ assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
+ assertThat(jdbc.queryForObject(
+ "SELECT deduct_score FROM training_module_weight WHERE school_id=?", BigDecimal.class, SCHOOL))
+ .isEqualByComparingTo("0.50");
+ }
+
+ @Test
+ void returnsSingleModuleWhenModuleProvided() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores")
+ .param("moudule", "开通数字货币钱包").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.length()").value(1))
+ .andExpect(jsonPath("$.data[0].mouduleCode").value(3))
+ .andExpect(jsonPath("$.data[0].operationCount").value(1))
+ .andExpect(jsonPath("$.data[0].currentCount").value(1))
+ .andExpect(jsonPath("$.data[0].maxScore").value(70.00))
+ .andExpect(jsonPath("$.data[0].details[0].id").value("score-a-4"))
+ .andExpect(jsonPath("$.data[0].details[0].userId").value(USER));
+ }
+
+ @Test
+ void hidesOtherUserScoresAndDeletedDetails() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores").with(otherUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].operationCount").value(1))
+ .andExpect(jsonPath("$.data[0].currentCount").value(1))
+ .andExpect(jsonPath("$.data[0].details[0].id").value("score-b-1"));
+ assertThat(jdbc.queryForObject(
+ "SELECT COUNT(*) FROM stu_module_score_details WHERE user_id=? AND moudule=?",
+ Integer.class, USER, MODULE_ONE)).isEqualTo(3);
+ }
+
+ @Test
+ void usesTeacherModuleWeightAndDeductScore() throws Exception {
+ insertModuleWeight(SCHOOL, 0.50, 0.25, 0.05, 0.05, 0.05, 0.10, 1);
+ mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 1 分"))
+ .andExpect(jsonPath("$.data[0].moduleWeight").value(0.50))
+ .andExpect(jsonPath("$.data[1].moduleWeight").value(0.25));
+ mockMvc.perform(get("/api/v1/training-scores").with(otherUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.5 分"))
+ .andExpect(jsonPath("$.data[0].moduleWeight").value(0.30));
+ }
+
+ @Test
+ void firstQuerySeedsDefaultSchoolWeightThenUpdatesById() throws Exception {
+ // 首次查询:库中无数据 → 落库一条默认配置
+ mockMvc.perform(get("/api/v1/training-weights/school").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").isNotEmpty())
+ .andExpect(jsonPath("$.data.trainingWeight").value(0.80))
+ .andExpect(jsonPath("$.data.reportWeight").value(0.20))
+ .andExpect(jsonPath("$.data.configured").value(false));
+ assertThat(countSchoolWeight(SCHOOL)).isEqualTo(1);
+
+ String schoolWeightId = jdbc.queryForObject(
+ "SELECT id FROM training_school_score_weight WHERE school_id=?", String.class, SCHOOL);
+
+ // 再次查询:命中已有配置
+ mockMvc.perform(get("/api/v1/training-weights/school").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(schoolWeightId))
+ .andExpect(jsonPath("$.data.configured").value(true));
+
+ // 按 id 修改
+ mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + schoolWeightId + "\",\"trainingWeight\":0.70,"
+ + "\"reportWeight\":0.30}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.trainingWeight").value(0.70))
+ .andExpect(jsonPath("$.data.reportWeight").value(0.30))
+ .andExpect(jsonPath("$.data.configured").value(true));
+
+ // 两次修改后仍只有一条记录
+ assertThat(countSchoolWeight(SCHOOL)).isEqualTo(1);
+
+ // 权重和不等于 1 → 400
+ mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"" + schoolWeightId + "\",\"trainingWeight\":0.70,"
+ + "\"reportWeight\":0.20}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+
+ // 改别的学校的记录 → 403
+ jdbc.update("INSERT INTO training_school_score_weight (id,school_id,training_weight,report_weight,"
+ + "created_at,updated_at,deleted) VALUES ('w-other',?,0.50,0.50,CURRENT_TIMESTAMP,"
+ + "CURRENT_TIMESTAMP,FALSE)", OTHER_SCHOOL);
+ mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"w-other\",\"trainingWeight\":0.70,\"reportWeight\":0.30}"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(403));
+
+ // id 不存在 → 404
+ mockMvc.perform(post("/api/v1/training-weights/school").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":\"not-exist\",\"trainingWeight\":0.70,\"reportWeight\":0.30}"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(404));
+ }
+
+ @Test
+ void firstQuerySeedsDefaultModuleWeightsThenUpdatesById() throws Exception {
+ mockMvc.perform(get("/api/v1/training-weights/modules").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").isNotEmpty())
+ .andExpect(jsonPath("$.data.deductScore").value(0.50))
+ .andExpect(jsonPath("$.data.configured").value(false))
+ .andExpect(jsonPath("$.data.modules.length()").value(6))
+ .andExpect(jsonPath("$.data.modules[0].mouduleCode").value(1))
+ .andExpect(jsonPath("$.data.modules[0].weight").value(0.30));
+ assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
+
+ String moduleWeightId = jdbc.queryForObject(
+ "SELECT id FROM training_module_weight WHERE school_id=?", String.class, SCHOOL);
+
+ String valid = "{\"id\":\"" + moduleWeightId + "\",\"deductScore\":0.25,\"modules\":["
+ + "{\"moudule\":\"中国人民银行生成数字货币\",\"mouduleCode\":1,\"weight\":0.20},"
+ + "{\"moudule\":\"中国人民银行发行数字货币\",\"mouduleCode\":2,\"weight\":0.20},"
+ + "{\"moudule\":\"开通数字货币钱包\",\"mouduleCode\":3,\"weight\":0.15},"
+ + "{\"moudule\":\"用户兑换数字货币\",\"mouduleCode\":4,\"weight\":0.15},"
+ + "{\"moudule\":\"用户支付数字货币\",\"mouduleCode\":5,\"weight\":0.15},"
+ + "{\"moudule\":\"数字人民币创新应用场景仿真\",\"mouduleCode\":6,\"weight\":0.15}]}";
+ mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON).content(valid))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.deductScore").value(0.25))
+ .andExpect(jsonPath("$.data.configured").value(true))
+ .andExpect(jsonPath("$.data.modules[0].weight").value(0.20))
+ .andExpect(jsonPath("$.data.modules[1].weight").value(0.20));
+ assertThat(countModuleWeight(SCHOOL)).isEqualTo(1);
+
+ String invalidSum = valid.replace("\"mouduleCode\":6,\"weight\":0.15", "\"mouduleCode\":6,\"weight\":0.10");
+ mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON).content(invalidSum))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+
+ String invalidName = valid.replace("\"moudule\":\"用户支付数字货币\"", "\"moudule\":\"支付模块\"");
+ mockMvc.perform(post("/api/v1/training-weights/modules").with(currentUser())
+ .contentType(MediaType.APPLICATION_JSON).content(invalidName))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+
+ // 学生端成绩查询读到新权重与新扣分
+ mockMvc.perform(get("/api/v1/training-scores").with(currentUser()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].moduleWeight").value(0.20))
+ .andExpect(jsonPath("$.data[0].scoringCriteria").value("选错/填错一个扣 0.25 分"));
+ }
+
+ @Test
+ void rejectsAnonymousAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores")).andExpect(status().isUnauthorized());
+ mockMvc.perform(get("/api/v1/training-weights/school")).andExpect(status().isUnauthorized());
+ }
+
+ private RequestPostProcessor currentUser() {
+ return jwt().jwt(token -> token.subject(USER).claim("userId", USER)
+ .claim("preferred_username", "score-a"));
+ }
+
+ private RequestPostProcessor otherUser() {
+ return jwt().jwt(token -> token.subject(OTHER_USER).claim("userId", OTHER_USER)
+ .claim("preferred_username", "score-b"));
+ }
+
+ private void insertUser(String id, String account, String schoolId) {
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,'成绩测试班',?,'延安大学',4,0,?)",
+ id, account, "unused", account, "2001", schoolId, account);
+ }
+
+ private void insertScore(String id, String userId, String module, int serialNumber, double score,
+ boolean deleted) {
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ id, module, score, userId, serialNumber, 50.00, deleted);
+ }
+
+ private int countSchoolWeight(String schoolId) {
+ return jdbc.queryForObject("SELECT COUNT(*) FROM training_school_score_weight WHERE school_id=?",
+ Integer.class, schoolId);
+ }
+
+ private int countModuleWeight(String schoolId) {
+ return jdbc.queryForObject("SELECT COUNT(*) FROM training_module_weight WHERE school_id=?",
+ Integer.class, schoolId);
+ }
+
+ private void insertModuleWeight(String schoolId, double w1, double w2, double w3, double w4, double w5,
+ double w6, double deductScore) {
+ jdbc.update("INSERT INTO training_module_weight (id,school_id,module1_weight,module2_weight,"
+ + "module3_weight,module4_weight,module5_weight,module6_weight,deduct_score,created_at,"
+ + "updated_at,deleted) VALUES ('w-2',?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
+ schoolId, w1, w2, w3, w4, w5, w6, deductScore);
+ }
+
+ private void clean() {
+ jdbc.update("DELETE FROM stu_module_score_details WHERE user_id IN (?,?)", USER, OTHER_USER);
+ jdbc.update("DELETE FROM training_module_weight WHERE school_id IN (?,?)", SCHOOL, OTHER_SCHOOL);
+ jdbc.update("DELETE FROM training_school_score_weight WHERE school_id IN (?,?)", SCHOOL, OTHER_SCHOOL);
+ jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?)", USER, OTHER_USER);
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java b/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java
new file mode 100644
index 0000000..12fc7ea
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/training/score/teacher/interfaces/rest/TrainingScoreTeacherControllerTest.java
@@ -0,0 +1,189 @@
+package com.yau.digitalrmb.training.score.teacher.interfaces.rest;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class TrainingScoreTeacherControllerTest {
+ private static final String TEACHER = "00000000-0000-0000-0000-000000000971";
+ private static final String STUDENT_A = "00000000-0000-0000-0000-000000000972";
+ private static final String STUDENT_B = "00000000-0000-0000-0000-000000000973";
+ private static final String SCHOOL = "1071";
+ private static final String MODULE_ONE = "中国人民银行生成数字货币";
+ private static final String MODULE_THREE = "开通数字货币钱包";
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void seed() {
+ clean();
+ // 教师(登录用户):只需有学校归属,角色不限
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ TEACHER, "tea-1", "unused", "李老师", "2071", "教师测班1", SCHOOL, "延安大学", 2, TEACHER);
+ // 学生 A:模块1最高98(含一条删除30)、模块3=70、进度均50、有报告评分90
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ STUDENT_A, "stu-a", "unused", "学生甲", "2071", "教师测班1", SCHOOL, "延安大学", 4, STUDENT_A);
+ // 学生 B:无任何成绩、无报告
+ jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,"
+ + "school_name,role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,?,0,?)",
+ STUDENT_B, "stu-b", "unused", "学生乙", "2072", "教师测班2", SCHOOL, "延安大学", 4, STUDENT_B);
+
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "t-a-1", MODULE_ONE, 98.00, STUDENT_A, 1, 50.00, false);
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "t-a-2", MODULE_ONE, 30.00, STUDENT_A, 2, 50.00, true);
+ jdbc.update("INSERT INTO stu_module_score_details (id,moudule,learning_projects,assessment_items,"
+ + "scoring_criteria,completion_status,score_project,user_id,total_score,serial_number,schedule,deleted) "
+ + "VALUES (?,?,'实验实训','错误次数','0.5','1',?,?,100.00,?,?,?)",
+ "t-a-3", MODULE_THREE, 70.00, STUDENT_A, 3, 50.00, false);
+
+ jdbc.update("INSERT INTO experiment_report (id,report_name,file_url,file_size,upload_time,teacher_score,"
+ + "user_id,school_id,class_id,created_at,updated_at,deleted) "
+ + "VALUES ('r-t1','报告.pdf','/2026/9/29/r.pdf',1024,CURRENT_TIMESTAMP,90.00,?,?,?,"
+ + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,FALSE)",
+ STUDENT_A, SCHOOL, "2071");
+ }
+
+ @AfterEach
+ void tearDown() {
+ clean();
+ }
+
+ @Test
+ void returnsPagedStudentsWithOverall() throws Exception {
+ // 学生甲:实训成绩 0.30*98+0.10*70=36.40,进度 16.67,实训最终 4.85;报告 90→18.00;总 22.85
+ mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("page", "1").param("size", "10")
+ .with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(2))
+ .andExpect(jsonPath("$.data.records.length()").value(2))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].userName").value("学生甲"))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentTrainingScore").value(36.40))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentTrainingWeight").value(0.80))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentReportScore").value(90.00))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].experimentReportWeight").value(0.20))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].finalScore").value(22.85))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].experimentTrainingScore").value(0.0))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].finalScore").value(0.0));
+ }
+
+ @Test
+ void filtersByStudentIdAndClassId() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("studentId", "stu-a").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"));
+
+ mockMvc.perform(get("/api/v1/training-scores/teacher/overall").param("classId", "2072").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.records[0].classId").value("2072"))
+ .andExpect(jsonPath("$.data.records[0].studentId").value("stu-b"));
+ }
+
+ @Test
+ void returnsPagedModuleDetailScores() throws Exception {
+ // 学生甲:模块1最高98(含一条已删除30,仍取98)、模块3=70,其余模块无成绩为 null;
+ // 综合分数 = 98×0.30 + 70×0.10 = 36.40(默认权重,缺失模块计 0);学生乙无任何成绩 → 综合分数 0
+ // 排序:按综合分数降序 → 学生甲在前
+ mockMvc.perform(get("/api/v1/training-scores/teacher/details").param("page", "1").param("size", "10")
+ .with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(2))
+ .andExpect(jsonPath("$.data.records[0].studentId").value("stu-a"))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].userName").value("学生甲"))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module1Score").value(98.0))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module3Score").value(70.0))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].module2Score")
+ .value(org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.nullValue())))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-a')].totalScore").value(36.40))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].module1Score")
+ .value(org.hamcrest.Matchers.hasItem(org.hamcrest.Matchers.nullValue())))
+ .andExpect(jsonPath("$.data.records[?(@.studentId == 'stu-b')].totalScore").value(0.0));
+ }
+
+ @Test
+ void filtersDetailByStudentId() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/details").param("studentId", "stu-b").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.records[0].studentId").value("stu-b"))
+ .andExpect(jsonPath("$.data.records[0].module6Score").value(org.hamcrest.Matchers.nullValue()))
+ .andExpect(jsonPath("$.data.records[0].totalScore").value(0.0));
+ }
+
+ @Test
+ void exportsDetailsAsXlsx() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/details/export").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Disposition",
+ org.hamcrest.Matchers.containsString("attachment")))
+ .andExpect(content().contentTypeCompatibleWith(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM));
+ }
+
+ @Test
+ void returnsDistinctClasses() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/classes").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(2))
+ .andExpect(jsonPath("$.data[0].classId").value("2071"))
+ .andExpect(jsonPath("$.data[0].className").value("教师测班1"))
+ .andExpect(jsonPath("$.data[1].classId").value("2072"));
+ }
+
+ @Test
+ void rejectsAnonymousAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/overall")).andExpect(status().isUnauthorized());
+ mockMvc.perform(get("/api/v1/training-scores/teacher/details")).andExpect(status().isUnauthorized());
+ mockMvc.perform(get("/api/v1/training-scores/teacher/classes")).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void exportsOverallAsXlsx() throws Exception {
+ mockMvc.perform(get("/api/v1/training-scores/teacher/overall/export").with(teacher()))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Disposition",
+ org.hamcrest.Matchers.containsString("attachment")))
+ .andExpect(content().contentTypeCompatibleWith(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM));
+ }
+
+ private RequestPostProcessor teacher() {
+ return jwt().jwt(token -> token.subject(TEACHER).claim("userId", TEACHER)
+ .claim("preferred_username", "tea-1"));
+ }
+
+ private void clean() {
+ jdbc.update("DELETE FROM stu_module_score_details WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
+ jdbc.update("DELETE FROM experiment_report WHERE user_id IN (?,?)", STUDENT_A, STUDENT_B);
+ jdbc.update("DELETE FROM training_module_weight WHERE school_id=?", SCHOOL);
+ jdbc.update("DELETE FROM training_school_score_weight WHERE school_id=?", SCHOOL);
+ jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?,?)", TEACHER, STUDENT_A, STUDENT_B);
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java b/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java
new file mode 100644
index 0000000..522fc9e
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/walletopening/application/SmartContractServiceTest.java
@@ -0,0 +1,112 @@
+package com.yau.digitalrmb.walletopening.application;
+
+import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
+import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
+import com.yau.digitalrmb.institutionidentity.infrastructure.SmInstitutionIdentityCryptography;
+import com.yau.digitalrmb.walletopening.domain.BankReceivedApplicationRepository;
+import com.yau.digitalrmb.walletopening.domain.CentralWalletActivation;
+import com.yau.digitalrmb.walletopening.domain.CentralWalletActivationRepository;
+import com.yau.digitalrmb.walletopening.domain.CentralWalletRegistrationRepository;
+import com.yau.digitalrmb.walletopening.domain.SmartContractGeneration;
+import com.yau.digitalrmb.walletopening.domain.SmartContractGenerationRepository;
+import com.yau.digitalrmb.walletopening.domain.WalletApplicationRepository;
+import com.yau.digitalrmb.walletopening.domain.WalletIdentifierGenerationRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SmartContractServiceTest {
+
+ private static final InstitutionKeySubject SUBJECT = new InstitutionKeySubject("step4-user", 1001L, 2001L);
+ private static final String ACTIVATE_DIGEST = "ACT_DIGEST_VALUE";
+ private static final String BANK_PUBLIC_KEY = "BANK_SECOND_PUBLIC_KEY";
+
+ @Mock private SmartContractGenerationRepository contractRepository;
+ @Mock private WalletIdentifierGenerationRepository identifierRepository;
+ @Mock private WalletApplicationRepository walletApplicationRepository;
+ @Mock private BankReceivedApplicationRepository bankReceivedRepository;
+ @Mock private CentralWalletRegistrationRepository centralRegistrationRepository;
+ @Mock private CentralWalletActivationRepository activationRepository;
+ @Mock private InstitutionKeyService institutionKeyService;
+ @Mock private SmInstitutionIdentityCryptography cryptography;
+ @Mock private WalletOpeningScoreService scoreService;
+
+ @InjectMocks private SmartContractService service;
+
+ @Test
+ void sendMarksContractSentAndActivatesCentralWallet() {
+ SmartContractGeneration contract = preparedContract();
+ when(contractRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenReturn(Optional.of(contract));
+ when(activationRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenReturn(Optional.empty());
+ when(activationRepository.save(any(CentralWalletActivation.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ when(institutionKeyService.commercialBankSecondPublicKeyOrNull(SUBJECT)).thenReturn(BANK_PUBLIC_KEY);
+ when(cryptography.verify(BANK_PUBLIC_KEY, ACTIVATE_DIGEST, "ACT_SIGNATURE")).thenReturn(true);
+ when(cryptography.sm3(contract.getActivatePlaintext())).thenReturn(ACTIVATE_DIGEST);
+
+ String result = service.send(SUBJECT);
+
+ assertThat(result).isEqualTo("成功");
+ assertThat(contract.isSent()).isTrue();
+ assertThat(contract.getStatus()).isEqualTo(SmartContractGeneration.Status.SENT);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CentralWalletActivation.class);
+ verify(activationRepository).update(captor.capture());
+ CentralWalletActivation activation = captor.getValue();
+ assertThat(activation.isReceived()).isTrue();
+ assertThat(activation.getWalletId()).isEqualTo("WALLET_ID_001");
+ assertThat(activation.getSignVerifyResult()).startsWith("通过");
+ assertThat(activation.getDigestMatchResult()).startsWith("通过");
+ assertThat(activation.isWalletActivated()).isTrue();
+ assertThat(activation.getStatus()).isEqualTo(CentralWalletActivation.Status.ACTIVATED);
+ verify(scoreService).markStepCompleted(SUBJECT.getUserId(), 6);
+ }
+
+ @Test
+ void sendKeepsStepFiveProgressAlreadyReached() {
+ SmartContractGeneration contract = preparedContract();
+ CentralWalletActivation activation = CentralWalletActivation.create(
+ SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId());
+ activation.receive("ACTIVATE_CONFIRM_1", "WALLET_ID_001", "BKCHCNBJ00001", "CTR_1",
+ ACTIVATE_DIGEST, "ACT_SIGNATURE", "20260101010102", "{\"status\": \"ACTIVATED\"}");
+ activation.activateWallet();
+ activation.finalSign("CB_FINAL_WALLET_SIG");
+
+ when(contractRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenReturn(Optional.of(contract));
+ when(activationRepository.findLatest(SUBJECT.getUserId(), SUBJECT.getSchoolId(), SUBJECT.getClassId()))
+ .thenReturn(Optional.of(activation));
+
+ service.send(SUBJECT);
+
+ assertThat(activation.getStatus()).isEqualTo(CentralWalletActivation.Status.FINAL_SIGNED);
+ assertThat(activation.getCbFinalSignature()).isEqualTo("CB_FINAL_WALLET_SIG");
+ }
+
+ private SmartContractGeneration preparedContract() {
+ SmartContractGeneration c = SmartContractGeneration.create(SUBJECT.getUserId(),
+ SUBJECT.getSchoolId(), SUBJECT.getClassId());
+ c.fillContractData("WALLET_ID_001", "一类钱包", "1000", "5000", "100000", "20000", "3年", "20260101010101");
+ c.computeContractDigest("CONTRACT_DIGEST");
+ c.outputContract("CTR_1", "{\"contractId\": \"CTR_1\"}", "20260101010101");
+ c.acquireActivateTimestamp("20260101010102", "BKCHCNBJ00001");
+ c.fillActivatePlaintext("ACTIVATE|WALLET_ID_001|BKCHCNBJ00001|CTR_1|20260101010102");
+ c.computeActivateDigest(ACTIVATE_DIGEST);
+ c.signActivate("商业银行第二私钥", "ACT_SIGNATURE");
+ c.outputConfirm("ACTIVATE_CONFIRM_1", "{\"status\": \"ACTIVATED\"}");
+ return c;
+ }
+}
diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql
index fb43be5..b7ea1d1 100644
--- a/src/test/resources/schema.sql
+++ b/src/test/resources/schema.sql
@@ -1028,3 +1028,47 @@ CREATE TABLE IF NOT EXISTS training_experiment_action (
CONSTRAINT fk_experiment_action_attempt
FOREIGN KEY (attempt_id) REFERENCES training_experiment_attempt(id)
);
+
+-- 教师端总成绩权重配置(实验实训 + 实训报告 = 1),一校一行
+CREATE TABLE IF NOT EXISTS training_school_score_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ training_weight DECIMAL(5, 2) NOT NULL COMMENT '实验实训权重比',
+ report_weight DECIMAL(5, 2) NOT NULL COMMENT '实训报告权重比',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 教师端模块权重配置,一校一行,6 个模块权重横向存储,合计 100%
+CREATE TABLE IF NOT EXISTS training_module_weight (
+ id CHAR(36) PRIMARY KEY,
+ school_id VARCHAR(36) NOT NULL,
+ module1_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行生成数字货币',
+ module2_weight DECIMAL(5, 2) NOT NULL COMMENT '中国人民银行发行数字货币',
+ module3_weight DECIMAL(5, 2) NOT NULL COMMENT '开通数字货币钱包',
+ module4_weight DECIMAL(5, 2) NOT NULL COMMENT '用户兑换数字货币',
+ module5_weight DECIMAL(5, 2) NOT NULL COMMENT '用户支付数字货币',
+ module6_weight DECIMAL(5, 2) NOT NULL COMMENT '数字人民币创新应用场景仿真',
+ deduct_score DECIMAL(5, 2) NOT NULL COMMENT '选错/填错一个扣分',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 实训报告表:与实验模块解耦,一个用户可多次提交,按 user_id 查询
+CREATE TABLE IF NOT EXISTS experiment_report (
+ id CHAR(36) PRIMARY KEY COMMENT '主键',
+ report_name VARCHAR(255) NOT NULL COMMENT '报告名称(上传时的原始文件名)',
+ file_url VARCHAR(512) NOT NULL COMMENT '文件地址:经转换后存储到服务器的 PDF 相对路径',
+ file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
+ upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+ teacher_score DECIMAL(5, 2) NULL COMMENT '教师评分',
+ teacher_comment VARCHAR(500) NULL COMMENT '教师评语',
+ user_id VARCHAR(36) NOT NULL COMMENT '用户ID',
+ school_id VARCHAR(36) NULL COMMENT '学校ID',
+ class_id VARCHAR(36) NULL COMMENT '班级ID',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);