Compare commits

..

20 Commits

Author SHA1 Message Date
chenyuan 7f1a3a48f8 fix: 参考答案转换PDF设置文档标题为reference-answer
- Blob URL 预览时浏览器会读取 PDF 文档标题,
  清除 Word 原始二进制标题,避免显示成 yAAAA... 之类的乱码
3 hours ago
chenyuan 9e4decf8cc fix: word转PDF内置宋体资源并显式配置aspose字体源
- 内置 simsun.ttf 资源,首次转换解压到临时字体目录,
  与系统字体目录一起交给 aspose,服务器无中文字体也能正常转换
- 字体配置线程安全(双重检查),按文档设置字体设置
- 学生/教师参考答案预览链路适配
4 hours ago
chenyuan 2ca158d330 fix: word转PDF字体目录补充Windows系统字体目录
- setFontsFolders 会替换 aspose 的默认字体源,原配置只有 Linux 路径
  (/usr/share/fonts),Windows 服务器上 aspose 找不到任何系统字体,
  全部替换为内置 Fanwood(无中文字形),转换出的 PDF 中文全部为方框
- 字体目录加入 C:\Windows\Fonts(保留 Linux 路径兼容),修复后
  PDF 正确嵌入宋体子集,中文正常显示
4 hours ago
chenyuan e2f550de99 feat: 学校默认实训任务初始化默认只开放第一个项目前三项
- initializeSchoolDefault 复制平台基线时按默认开放清单置停用状态,
  其余任务保留完整目录但停用,学校管理员可在默认实训任务配置页放开
- 同步修复脚本说明与测试
5 hours ago
chenyuan d6ebf06afa feat: 参考答案推送与学生实验报告成绩展示
- 任务配置开放参考答案开关(setClassTaskReferenceAnswerEnabled),
  学生任务接口仅在推送后返回参考答案字段
- 学生实验报告任务明细携带AI评分/老师评分与评语
- 新增学生端参考答案预览接口(docx转PDF,推送门控)
- 同步测试
7 hours ago
chenyuan 78dbd4d01a fix: spring-boot 打包含 system 依赖(aspose-words/cells/pdf)
- 这些 jar 走 systemPath 依赖,默认不进 fat jar,只作为资源落在
  BOOT-INF/classes/lib 下,运行时类加载器读不到,
  参考答案转 PDF 预览首次在线上执行即 NoClassDefFoundError: com/aspose/cells/SaveOptions
- includeSystemScope=true 使其正确进入 BOOT-INF/lib
8 hours ago
chenyuan 245dc497ad fix: AI评语改为30-50字(过短自动重写一次),AI评分增加知识检索/模型调用分段计时日志
- 验收:评语按AI评分数据精简到30-50字默认填入评语框,教师可改
- 排查AI评分过慢:实测知识检索32ms、千问模型调用约12s为主体,接口已异步化
8 hours ago
chenyuan 0e9ddeda21 feat: 任务评阅教师触发AI评分与AI评语生成
- 教师可对单个/批量学生异步发起AI评分(复用学生端助评链路),评阅面板轮询展示AI分
- 新增 POST /api/weight/taskReview/ai-comment:基于该生AI评分报告+AI助学报告
  生成10-50字中文教师评语(超长按句子边界截断),供评阅弹窗自动填入
- student_training_answer 新增 teacher_comment 列(启动自动补列),保存评分支持携带评语
- pom 固定 lombok 1.18.32 修复 JDK21 构建兼容
9 hours ago
chenyuan 99c7167a0a fix(sql): 平台/学校默认任务分配 module 存中文任务名导致评阅与学生端解析不到任务的修复脚本
- task_allocation 里 999999999 与 SCHOOL_DEFAULT:* 的 module 存的是中文展示名,
  消费方都用它匹配 training_task.task_key,导致无班级级分配的教学班评阅下拉全空、
  学生列表为 0
- 附判定查询与按学校修复脚本;验收库已手工修复并备份 task_allocation_bak_20261016
9 hours ago
chenyuan 516aef2156 feat: 实训任务配置新增参考答案字段(reference_answer_name/url)
- TrainingTask/TrainingTaskClassConfig 实体与两个 mapper 透传新字段
- TrainingTaskServiceImpl:平台保存未传字段保留原值、默认任务修改同步班级、
  建表语句与 ensureReferenceAnswerColumns 存量自动补列
- 附手工迁移脚本 docs/sql/2026-10-15-training-task-reference-answer.sql
11 hours ago
chenyuan e2ee0b1d42 feat: 教师端成绩列表AI评测成绩列返回真实数据——按教学班任务聚合各任务ai_assessment_score(六项目加权,同教师评分口径,实时读作答表) 2 days ago
chenyuan e5a2bd740f fix: AI评分后教师端成绩列表延迟~24小时才更新——重评claim的快照BINARY长文本比对失效导致重评被静默跳过,移除该条件(快照变化已在Java侧判断);AI评分落库后异步重建本教学班stu_rank当日快照 2 days ago
chenyuan de02726c97 feat: AI助评纳入第一步选择题——评分上下文带题干/选项/标准答案/学生作答,错题在第一评项扣分;校验容错:评项名归一化、忽略模型maxScore按计划值、总分与逐项不一致时以逐项为准 2 days ago
chenyuan 8fd2aabbba fix: 学生/教师列表分页错位与班级院系total丢失;登录密码错误与账号不存在分开提示;AI助评评项名归一化修复误报502 3 days ago
chenyuan b3a9a50fc8 feat: 学生Excel导入取消院系/专业列、班级直接按本校行政班匹配;添加教师账号默认继承操作者学校/院系/专业 4 days ago
chenyuan 179d18e754 feat: 启动时检查数据库连接与AI评估表字符集,Druid固定characterEncoding防按平台默认GBK写库 4 days ago
chenyuan 247669f648 fix: AI助评失败记录不再抛错卡死,PROCESSING超时可重试,报错信息精简 5 days ago
chenyuan 15f1a399df refactor: BI数据源内置表名仅保留新增数据源 6 days ago
chenyuan 0bd56cdeea fix: 用户行为表查询改用selectByExampleWithBLOBs以包含大字段 7 days ago
chenyuan e1effbf35c feat: BI数据源内置表名抽取为常量并新增数据源表 7 days ago

@ -0,0 +1,10 @@
-- 实训任务新增“参考答案”资料位(教师端/平台管理端实训任务管理上传)。
-- 后端 TrainingTaskServiceImpl 启动时会自动补列(ensureReferenceAnswerColumns),
-- 本脚本供手工执行或核对用,重复执行前请先确认列不存在。
ALTER TABLE training_task
ADD COLUMN reference_answer_name varchar(255) NULL AFTER ai_manual_url,
ADD COLUMN reference_answer_url varchar(500) NULL AFTER reference_answer_name;
ALTER TABLE training_task_class_config
ADD COLUMN reference_answer_name varchar(255) NULL AFTER ai_manual_url,
ADD COLUMN reference_answer_url varchar(500) NULL AFTER reference_answer_name;

@ -0,0 +1,53 @@
-- 默认任务开放范围调整:平台默认/学校默认只默认开放第一个项目的前 3 个任务。
--
-- 背景:此前平台默认(class_id='999999999')与学校默认(class_id LIKE 'SCHOOL_DEFAULT:%')
-- 的任务目录默认全部开放,与产品约定不一致——任务分配的默认口径是
-- "教学班未保存发布配置时,仅默认开放第一个项目的前三项,不是全部任务"。
--
-- 影响范围:只调整 disabled_status,不增删任务、不改 module/sort;
-- 第一个项目的前 3 项置为开放(0),其余任务(含综合实训)置为停用(1)。
-- 学校管理员仍可在"默认实训任务配置"页按需放开。
--
-- 前置条件:task_allocation.module 已是英文 task_key,即已执行
-- 2026-10-16-repair-task-allocation-module-keys.sql
-- 若仍有中文任务名残留,JOIN 不上的行保持原值,请先执行修复脚本重建。
--
-- 幂等:可重复执行。执行前建议备份:
-- CREATE TABLE task_allocation_bak_20261016_first3 AS
-- SELECT * FROM task_allocation
-- WHERE class_id='999999999' OR class_id LIKE 'SCHOOL_DEFAULT:%';
-- 1) 第一项目前 3 项开放,其余启用目录内的任务置为停用
UPDATE task_allocation a
JOIN training_task t ON t.task_key = a.module
JOIN (
SELECT task_key,
ROW_NUMBER() OVER (ORDER BY sort ASC, id ASC) AS rank_in_project,
COALESCE(project_name, '其他') AS project_name
FROM training_task
WHERE enabled = 1
AND task_key <> 'comprehensive-case-training'
) ranked ON ranked.task_key = a.module
CROSS JOIN (
SELECT COALESCE(project_name, '其他') AS project_name
FROM training_task
WHERE enabled = 1 AND task_key <> 'comprehensive-case-training'
ORDER BY sort ASC, id ASC
LIMIT 1
) fp
SET a.disabled_status = CASE WHEN ranked.project_name = fp.project_name
AND ranked.rank_in_project <= 3
THEN 0 ELSE 1 END
WHERE a.class_id = '999999999' OR a.class_id LIKE 'SCHOOL_DEFAULT:%';
-- 2) 综合实训始终默认停用(各班发布后才参与)
UPDATE task_allocation
SET disabled_status = 1
WHERE (class_id = '999999999' OR class_id LIKE 'SCHOOL_DEFAULT:%')
AND module = 'comprehensive-case-training';
-- 3) 校验:每个 class_id 下应只有 3 条开放(即第一个项目的前 3 项)
SELECT a.class_id, SUM(a.disabled_status = 0) AS open_count, COUNT(*) AS total
FROM task_allocation a
WHERE a.class_id = '999999999' OR a.class_id LIKE 'SCHOOL_DEFAULT:%'
GROUP BY a.class_id;

@ -0,0 +1,76 @@
-- 修复平台默认/学校默认任务分配的 module 存了中文任务名的问题。
--
-- 现象:task_allocation 里 class_id='999999999'(平台默认)与 class_id LIKE 'SCHOOL_DEFAULT:%'
-- (学校默认,由 initializeSchoolDefault 从平台默认复制而来)的 module 存的是中文任务展示名
-- (如"行业需求分析-蛋糕指数"),而所有消费方(任务评阅 taskReview、学生端实训任务解析等)
-- 都用 module 匹配 training_task.task_key(英文标识),一条都匹配不上。
-- 表现为:没有班级级任务分配的教学班,任务评阅页实训项目/实训任务下拉为空、学生列表为 0,
-- 学生端实训列表为空;有班级级分配的教学班(班级管理/实训任务管理保存过)不受影响。
--
-- 2026-10-16 补充:重建时默认只开放第一个项目的前 3 个任务(disabled_status=0),
-- 其余任务保留完整目录但置为停用(disabled_status=1),与产品约定一致——
-- 教学班未保存发布配置时,学生端默认只开放第一个项目的前三项,不是全部任务。
-- 综合实训(comprehensive-case-training)仍默认停用,由各班发布后参与。
--
-- 执行前先备份:
-- CREATE TABLE task_allocation_bak_20261016 AS
-- SELECT * FROM task_allocation
-- WHERE class_id='999999999' OR class_id LIKE 'SCHOOL_DEFAULT:%';
--
-- 判定是否需要执行:
-- SELECT a.module FROM task_allocation a
-- LEFT JOIN training_task t ON t.task_key = a.module
-- WHERE (a.class_id='999999999' OR a.class_id LIKE 'SCHOOL_DEFAULT:%') AND t.id IS NULL;
-- 查出中文任务名即需要修复。
-- 1) 平台默认:以 training_task 为基线重建(启用任务全量入目录;默认仅第一项目前 3 项开放)
DELETE FROM task_allocation WHERE class_id = '999999999';
INSERT INTO task_allocation (id, class_id, school_id, module, disabled_status, sort)
SELECT UUID(), '999999999', '999999999', tt.task_key,
CASE WHEN tt.project_name = fp.project_name
AND tt.rank_in_project <= 3
AND tt.task_key <> 'comprehensive-case-training'
THEN 0 ELSE 1 END,
tt.sort
FROM (
SELECT t.task_key, t.sort,
COALESCE(t.project_name, '其他') AS project_name,
ROW_NUMBER() OVER (PARTITION BY COALESCE(t.project_name, '其他')
ORDER BY t.sort ASC, t.id ASC) AS rank_in_project
FROM training_task t
WHERE t.enabled = 1
) tt
CROSS JOIN (
SELECT COALESCE(project_name, '其他') AS project_name
FROM training_task
WHERE enabled = 1 AND task_key <> 'comprehensive-case-training'
ORDER BY sort ASC, id ASC
LIMIT 1
) fp;
-- 2) 学校默认:逐个学校执行(把 <SCHOOL_ID> 替换为实际学校ID后运行;
-- 已确认无问题的学校跳过。学校清单:
-- SELECT DISTINCT school_id FROM school_class WHERE class_type='TEACHING';)
-- DELETE FROM task_allocation WHERE class_id = CONCAT('SCHOOL_DEFAULT:', '<SCHOOL_ID>');
-- INSERT INTO task_allocation (id, class_id, school_id, module, disabled_status, sort)
-- SELECT UUID(), CONCAT('SCHOOL_DEFAULT:', '<SCHOOL_ID>'), '<SCHOOL_ID>', tt.task_key,
-- CASE WHEN tt.project_name = fp.project_name
-- AND tt.rank_in_project <= 3
-- AND tt.task_key <> 'comprehensive-case-training'
-- THEN 0 ELSE 1 END,
-- tt.sort
-- FROM (
-- SELECT t.task_key, t.sort,
-- COALESCE(t.project_name, '其他') AS project_name,
-- ROW_NUMBER() OVER (PARTITION BY COALESCE(t.project_name, '其他')
-- ORDER BY t.sort ASC, t.id ASC) AS rank_in_project
-- FROM training_task t
-- WHERE t.enabled = 1
-- ) tt
-- CROSS JOIN (
-- SELECT COALESCE(project_name, '其他') AS project_name
-- FROM training_task
-- WHERE enabled = 1 AND task_key <> 'comprehensive-case-training'
-- ORDER BY sort ASC, id ASC
-- LIMIT 1
-- ) fp;

@ -0,0 +1,3 @@
-- 教师任务评阅评语字段;应用启动时也会由 schema initializer 自动补列。
ALTER TABLE student_training_answer
ADD COLUMN teacher_comment varchar(1000) NULL AFTER teacher_score;

@ -140,6 +140,9 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Spring Boot 2.7.12 manages an older Lombok release that
is incompatible with JDK 21 (JCImport.qualid was removed). -->
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
@ -336,6 +339,11 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- aspose-words/cells/pdf 走 systemPath 依赖,默认不会打进 fat jar,
运行时 NoClassDefFoundError(参考答案转 PDF 预览即此问题),必须包含 -->
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>

@ -0,0 +1,69 @@
package com.sztzjy.linkCommerce.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 启动时检查数据库连接和 AI 评估原始响应列的字符集。
* 字符集不是 utf8mb4 时,写入中文会报 "Incorrect string value";
* 这里只记录告警方便排查,不阻止应用启动。
*/
@Slf4j
@Component
public class DatabaseCharsetChecker {
private static final String SESSION_SQL =
"SELECT @@character_set_client AS client, @@character_set_connection AS conn";
private static final String COLUMN_SQL =
"SELECT CHARACTER_SET_NAME FROM information_schema.COLUMNS "
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_training_evaluation' "
+ "AND COLUMN_NAME = 'assessment_raw_response'";
private final JdbcTemplate jdbcTemplate;
public DatabaseCharsetChecker(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostConstruct
public void check() {
try {
List<String> problems = findProblems();
if (problems.isEmpty()) {
log.info("Database charset check passed: connection and AI evaluation columns use utf8mb4");
return;
}
for (String problem : problems) {
log.warn("Database charset check: {}", problem);
}
} catch (RuntimeException e) {
log.warn("Database charset check skipped: {}", e.getMessage());
}
}
List<String> findProblems() {
List<String> problems = new ArrayList<String>();
Map<String, Object> session = jdbcTemplate.queryForMap(SESSION_SQL);
String client = String.valueOf(session.get("client"));
String connection = String.valueOf(session.get("conn"));
if (!"utf8mb4".equalsIgnoreCase(client)) {
problems.add("character_set_client=" + client + ", expected utf8mb4; Chinese text may fail to save");
}
if (!"utf8mb4".equalsIgnoreCase(connection)) {
problems.add("character_set_connection=" + connection + ", expected utf8mb4; Chinese text may fail to save");
}
List<String> columnCharsets = jdbcTemplate.queryForList(COLUMN_SQL, String.class);
for (String charset : columnCharsets) {
if (charset != null && !"utf8mb4".equalsIgnoreCase(charset)) {
problems.add("ai_training_evaluation.assessment_raw_response charset=" + charset
+ ", expected utf8mb4; AI raw responses may fail to save");
}
}
return problems;
}
}

@ -18,6 +18,7 @@ public class StudentTrainingAnswerTeacherScoreSchemaInitializer {
addColumnIfMissing("teacher_score", "decimal(5,2) NULL");
addColumnIfMissing("teacher_scored_by", "varchar(64) NULL");
addColumnIfMissing("teacher_scored_at", "datetime NULL");
addColumnIfMissing("teacher_comment", "varchar(1000) NULL");
}
private void addColumnIfMissing(String columnName, String definition) {

@ -4,6 +4,8 @@ import com.sztzjy.linkCommerce.config.exception.UnAuthorizedException;
import com.sztzjy.linkCommerce.util.ResultEntity;
import com.sztzjy.linkCommerce.util.ThrowableUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.exceptions.PersistenceException;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
@ -13,6 +15,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.sql.SQLException;
/**
* 全局异常处理
*/
@ -20,6 +24,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final String DATABASE_ERROR_MESSAGE = "系统繁忙,请稍后重试";
@ExceptionHandler(IllegalArgumentException.class)
public ResultEntity<String> illegalArgumentException(IllegalArgumentException e) {
log.error(ThrowableUtil.getStackTrace(e));
@ -61,9 +67,22 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Throwable.class)
public ResultEntity<String> nullPointerException(Throwable e) {
log.error(ThrowableUtil.getStackTrace(e));
if (isDatabaseException(e)) {
// 数据库异常原文包含完整 SQL 和表结构,只记日志,不返回给前端
return new ResultEntity<>(HttpStatus.BAD_REQUEST, DATABASE_ERROR_MESSAGE);
}
return new ResultEntity<>(HttpStatus.BAD_REQUEST, e.getMessage());
}
private static boolean isDatabaseException(Throwable e) {
for (Throwable t = e; t != null; t = t.getCause() == t ? null : t.getCause()) {
if (t instanceof DataAccessException || t instanceof PersistenceException || t instanceof SQLException) {
return true;
}
}
return false;
}
/**
* BadCredentialsException
*/

@ -466,8 +466,6 @@ public class SchoolAdminController {
String name = StringUtils.trimToEmpty(row.getName());
String username = StringUtils.trimToEmpty(row.getUsername());
String facultyName = StringUtils.trimToEmpty(row.getSchoolFacultyName());
String majorName = StringUtils.trimToEmpty(row.getSchoolMajorName());
String className = StringUtils.trimToEmpty(row.getSchoolClassName());
String sex = normalizeImportSex(row.getSex());
@ -486,32 +484,16 @@ public class SchoolAdminController {
} else {
errors.add("第 " + rowNumber + " 行:性别请填写男或女");
}
if (StringUtils.isBlank(facultyName)) {
errors.add("第 " + rowNumber + " 行:所属院系不能为空");
}
if (StringUtils.isBlank(majorName)) {
errors.add("第 " + rowNumber + " 行:所属专业不能为空");
}
if (StringUtils.isBlank(className)) {
errors.add("第 " + rowNumber + " 行:所属班级不能为空");
}
SchoolFaculty faculty = StringUtils.isBlank(facultyName) ? null : findFacultyByName(schoolId, facultyName);
SchoolMajor major = null;
//院系/专业列已取消,班级直接按本校行政班名称匹配
SchoolClass schoolClass = null;
if (StringUtils.isNotBlank(facultyName) && faculty == null) {
errors.add("第 " + rowNumber + " 行:未找到院系“" + facultyName + "”");
}
if (faculty != null && StringUtils.isNotBlank(majorName)) {
major = findMajorByName(faculty.getSchoolFacultyId(), majorName);
if (major == null) {
errors.add("第 " + rowNumber + " 行:院系“" + facultyName + "”下未找到专业“" + majorName + "”");
}
}
if (major != null && StringUtils.isNotBlank(className)) {
schoolClass = findClassByName(schoolId, major.getSchoolMajorId(), className);
if (StringUtils.isNotBlank(className)) {
schoolClass = findAdminClassByName(schoolId, className);
if (schoolClass == null) {
errors.add("第 " + rowNumber + " 行:专业“" + majorName + "”下未找到班级“" + className + "”");
errors.add("第 " + rowNumber + " 行:未找到行政班:" + className);
}
}
@ -524,8 +506,6 @@ public class SchoolAdminController {
student.setSex(sex);
student.setPhone(StringUtils.trimToEmpty(row.getPhone()));
student.setEmail(StringUtils.trimToEmpty(row.getEmail()));
student.setSchoolFacultyId(faculty.getSchoolFacultyId());
student.setSchoolMajorId(major.getSchoolMajorId());
student.setSchoolClassId(schoolClass.getSchoolClassId());
student.setCreateTime(new Date());
student.setCodeFrom("Excel导入");
@ -543,8 +523,6 @@ public class SchoolAdminController {
return StringUtils.isBlank(row.getName())
&& StringUtils.isBlank(row.getUsername())
&& StringUtils.isBlank(row.getSex())
&& StringUtils.isBlank(row.getSchoolFacultyName())
&& StringUtils.isBlank(row.getSchoolMajorName())
&& StringUtils.isBlank(row.getSchoolClassName())
&& StringUtils.isBlank(row.getPhone())
&& StringUtils.isBlank(row.getEmail());
@ -574,30 +552,13 @@ public class SchoolAdminController {
return false;
}
private SchoolFaculty findFacultyByName(String schoolId, String facultyName) {
SchoolFacultyExample example = new SchoolFacultyExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andSchoolFacultyNameEqualTo(facultyName);
List<SchoolFaculty> faculties = schoolFacultyMapper.selectByExample(example);
return faculties.isEmpty() ? null : faculties.get(0);
}
private SchoolMajor findMajorByName(String facultyId, String majorName) {
SchoolMajorExample example = new SchoolMajorExample();
example.createCriteria()
.andSchoolFacultyIdEqualTo(facultyId)
.andSchoolMajorNameEqualTo(majorName);
List<SchoolMajor> majors = schoolMajorMapper.selectByExample(example);
return majors.isEmpty() ? null : majors.get(0);
}
private SchoolClass findClassByName(String schoolId, String majorId, String className) {
//学生账号导入不再区分院系/专业,班级直接按本校行政班名称匹配
private SchoolClass findAdminClassByName(String schoolId, String className) {
SchoolClassExample example = new SchoolClassExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
.andClassNameEqualTo(className)
.andClassTypeEqualTo("ADMIN");
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
}

@ -56,8 +56,10 @@ public class AiTrainingEvaluationController {
@ApiOperation("Generate AI assessment")
public ResultEntity<AiTrainingEvaluationView> assessment(@PathVariable String taskKey, HttpServletRequest request) {
try {
return new ResultEntity<>(HttpStatus.OK, "AI assessment generated",
aiTrainingEvaluationService.generateAssessment(taskKey, currentUser(request)));
AiTrainingEvaluationView view = aiTrainingEvaluationService.startAssessment(taskKey, currentUser(request));
// 兼容旧版服务实现/测试替身;正式实现始终立即返回 PROCESSING 状态。
if (view == null) view = aiTrainingEvaluationService.generateAssessment(taskKey, currentUser(request));
return new ResultEntity<>(HttpStatus.OK, "AI assessment submitted", view);
} catch (ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}

@ -27,6 +27,7 @@ import com.sztzjy.linkCommerce.util.TzApi;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -94,6 +95,9 @@ public class LoginController {
}
Userinfo user = userinfos.get(0);
return new ResultEntity(HttpStatus.OK, buildLoginResultMap(user, token));
}catch (BadCredentialsException e){
// 账号存在但密码不正确;账号不存在时 loadUserByUsername 会抛下标越界,走下方兜底
return new ResultEntity(HttpStatus.BAD_REQUEST, "密码错误");
}catch (Exception e){
return new ResultEntity(HttpStatus.BAD_REQUEST, "账号不存在");
}

@ -14,25 +14,37 @@ import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper;
import com.sztzjy.linkCommerce.mapper.UserinfoMapper;
import com.sztzjy.linkCommerce.mapper.WeightMapper;
import com.sztzjy.linkCommerce.entity.dto.*;
import com.sztzjy.linkCommerce.service.ScoreRankService;
import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService;
import com.sztzjy.linkCommerce.service.TeacherScoreService;
import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService;
import com.sztzjy.linkCommerce.util.ResultEntity;
import com.sztzjy.linkCommerce.util.excel.FilePortUtil;
import com.sztzjy.linkCommerce.util.file.IFileUtil;
import com.sztzjy.linkCommerce.util.pdfUtils.PDFConvertUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.File;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.Collectors;
@ -41,6 +53,8 @@ import java.util.stream.Collectors;
@RequestMapping("api/weight")
@RestController
public class TeaScoreController {
private static final Logger log = LoggerFactory.getLogger(TeaScoreController.class);
@Autowired
WeightMapper weightMapper;
@Autowired
@ -58,6 +72,8 @@ public class TeaScoreController {
@Autowired(required = false)
TrainingTaskMapper trainingTaskMapper;
@Autowired(required = false)
TrainingTaskClassConfigMapper trainingTaskClassConfigMapper;
@Autowired(required = false)
StudentTrainingAnswerMapper studentTrainingAnswerMapper;
@Autowired(required = false)
SchoolDefaultTaskService schoolDefaultTaskService;
@ -65,6 +81,78 @@ public class TeaScoreController {
UserinfoMapper userinfoMapper;
@Autowired
TeacherScoreService teacherScoreService;
@Autowired
IFileUtil fileUtil;
@Autowired
AiTrainingEvaluationService aiTrainingEvaluationService;
@PostMapping("/taskReview/ai-assessment")
@ApiOperation("异步发起单个任务AI评分")
public ResultEntity<AiTrainingEvaluationView> startTaskAiAssessment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireOwnedClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "AI评分任务已提交",
aiTrainingEvaluationService.startAssessmentForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@GetMapping("/taskReview/ai-assessment")
@ApiOperation("查询任务AI评分状态")
public ResultEntity<AiTrainingEvaluationView> getTaskAiAssessment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireAccessibleClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "查询成功",
aiTrainingEvaluationService.getForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@PostMapping("/taskReview/ai-comment")
@ApiOperation("基于AI评分与助学报告生成10-50字教师评语")
public ResultEntity<Map<String, String>> generateTaskAiComment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireAccessibleClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "评语生成成功",
aiTrainingEvaluationService.generateCommentForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@PostMapping("/taskReview/ai-assessment/batch")
@ApiOperation("异步发起批量任务AI评分")
public ResultEntity<List<AiTrainingEvaluationView>> startBatchTaskAiAssessment(@RequestBody BatchTaskAiAssessmentRequest body,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireOwnedClass(teacher, body == null ? null : body.getClassId());
if (body == null || body.getStudentUserIds() == null || body.getStudentUserIds().isEmpty()) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "请选择学生");
}
List<AiTrainingEvaluationView> result = new ArrayList<>();
for (String studentUserId : body.getStudentUserIds()) {
result.add(aiTrainingEvaluationService.startAssessmentForTeacher(body.getClassId(), body.getTaskId(), studentUserId, teacher));
}
return new ResultEntity<>(HttpStatus.OK, "批量AI评分任务已提交", result);
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@GetMapping("/ownedTeachingClasses")
@ApiOperation("教师本人教学班列表")
@ -130,7 +218,8 @@ public class TeaScoreController {
public ResultEntity<?> saveTaskTeacherScore(@RequestParam String classId,
@RequestParam String studentUserId,
@RequestParam String taskId,
@RequestParam BigDecimal teacherScore,
@RequestParam(required = false) BigDecimal teacherScore,
@RequestParam(required = false) String teacherComment,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
@ -148,9 +237,10 @@ public class TeaScoreController {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "教师评分必须为0至100之间的数值");
}
Date scoredAt = new Date();
studentTrainingAnswerMapper.updateTeacherScore(answer.getId(), teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP),
teacher.getUserId(), scoredAt);
studentTrainingAnswerMapper.updateTeacherScoreWithComment(answer.getId(), teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP),
teacher.getUserId(), scoredAt, StringUtils.left(StringUtils.trimToEmpty(teacherComment), 1000));
answer.setTeacherScore(teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP));
answer.setTeacherComment(StringUtils.left(StringUtils.trimToEmpty(teacherComment), 1000));
answer.setTeacherScoredBy(teacher.getUserId());
answer.setTeacherScoredAt(scoredAt);
return new ResultEntity<>(HttpStatus.OK, "任务评分保存成功", answer);
@ -174,6 +264,72 @@ public class TeaScoreController {
}
}
@GetMapping("/taskReview/reference-answer/preview")
@ApiOperation("在线预览任务参考答案")
public ResponseEntity<byte[]> previewTaskReferenceAnswer(@RequestParam String classId,
@RequestParam String taskId,
HttpServletRequest request) {
long startAt = System.currentTimeMillis();
JwtUser operator = currentUser(request);
try {
teacherScoreService.requireAccessibleClass(operator, classId);
TrainingTask task = trainingTaskMapper.selectByPrimaryKey(taskId);
if (task == null) {
log.warn("参考答案预览失败:任务不存在 classId={} taskId={} userId={}", classId, taskId,
operator == null ? null : operator.getUserId());
return ResponseEntity.notFound().build();
}
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
String referenceUrl = classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerUrl())
? classConfig.getReferenceAnswerUrl() : task == null ? null : task.getReferenceAnswerUrl();
String referenceName = classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerName())
? classConfig.getReferenceAnswerName() : task == null ? null : task.getReferenceAnswerName();
if (StringUtils.isBlank(referenceUrl)) {
log.warn("参考答案预览失败:未配置参考答案 classId={} taskId={} taskKey={} userId={}",
classId, taskId, task.getTaskKey(), operator == null ? null : operator.getUserId());
return ResponseEntity.notFound().build();
}
String name = StringUtils.defaultIfBlank(referenceName, referenceUrl);
String path = referenceUrl;
int slash = path.indexOf("/file/");
if (slash >= 0) path = path.substring(slash + 1);
File source = new File(fileUtil.getFullPath(path));
if (!source.exists() || !PDFConvertUtil.isCreatePdf(name)) {
log.warn("参考答案预览失败:文件不存在或类型不支持转换 classId={} taskKey={} userId={} name={} url={} path={} exists={}",
classId, task.getTaskKey(), operator == null ? null : operator.getUserId(),
name, referenceUrl, source.getAbsolutePath(), source.exists());
return ResponseEntity.notFound().build();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (InputStream input = Files.newInputStream(source.toPath())) {
if (!PDFConvertUtil.convertToPDFByFileNameSuffix(name, input, output)) {
log.warn("参考答案预览失败:文档转换未成功 classId={} taskKey={} userId={} name={} path={}",
classId, task.getTaskKey(), operator == null ? null : operator.getUserId(), name, source.getAbsolutePath());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
log.info("参考答案预览成功 classId={} taskKey={} userId={} name={} pdfBytes={} elapsed={}ms",
classId, task.getTaskKey(), operator == null ? null : operator.getUserId(),
name, output.size(), System.currentTimeMillis() - startAt);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header("Content-Disposition", "inline; filename=reference-answer.pdf").body(output.toByteArray());
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
log.warn("参考答案预览失败:权限或参数校验未通过 classId={} taskId={} userId={} status={} reason={}",
classId, taskId, operator == null ? null : operator.getUserId(), e.getCode(), e.getMessage());
return ResponseEntity.status(e.getCode()).build();
} catch (RuntimeException | java.io.IOException e) {
log.warn("参考答案预览失败:转换异常 classId={} taskId={} userId={} name={}",
classId, taskId, operator == null ? null : operator.getUserId(),
StringUtils.defaultIfBlank(currentTaskKey(taskId), "-"), e);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
private String currentTaskKey(String taskId) {
TrainingTask task = trainingTaskMapper == null ? null : trainingTaskMapper.selectByPrimaryKey(taskId);
return task == null ? null : task.getTaskKey();
}
@PostMapping("/batchSaveTaskTeacherScore")
@ApiOperation("批量保存教师任务评分")
@Transactional
@ -189,7 +345,8 @@ public class TeaScoreController {
BigDecimal score = body.getTeacherScore().setScale(2, BigDecimal.ROUND_HALF_UP);
Date scoredAt = new Date();
for (StudentTrainingAnswer answer : answers) {
studentTrainingAnswerMapper.updateTeacherScore(answer.getId(), score, teacher.getUserId(), scoredAt);
studentTrainingAnswerMapper.updateTeacherScoreWithComment(answer.getId(), score, teacher.getUserId(), scoredAt,
StringUtils.left(StringUtils.trimToEmpty(body.getTeacherComment()), 1000));
}
return new ResultEntity<>(HttpStatus.OK, "批量任务评分保存成功", body.getStudentUserIds());
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
@ -441,6 +598,7 @@ public class TeaScoreController {
rank.setCompletedTaskCount(0);
rank.setTrainingProgress(null);
rank.setTeacherScore(null);
rank.setAiAssessmentScore(null);
continue;
}
List<StudentTrainingAnswer> answers = studentTrainingAnswerMapper == null ? Collections.emptyList()
@ -454,6 +612,7 @@ public class TeaScoreController {
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(totalTaskCount), 2, BigDecimal.ROUND_HALF_UP));
rank.setTeacherScore(calculateTeacherScore(classId, allocations, answersByTaskKey));
rank.setAiAssessmentScore(calculateAiAssessmentScore(classId, allocations, answersByTaskKey));
}
}
@ -551,6 +710,48 @@ public class TeaScoreController {
if (!hasTeacherScore) {
return null;
}
return weightedSixModuleScore(classId, scoresByProject, comprehensiveParticipating);
}
/**
* AI评测成绩:与教师评分同一套六项目加权口径,聚合各任务的 ai_assessment_score。
* 直接读 student_training_answer(实时),AI 评分完成即可见于教师端。
*/
private BigDecimal calculateAiAssessmentScore(String classId, List<TaskAllocation> allocations,
Map<String, StudentTrainingAnswer> answersByTaskKey) {
if (teacherScoreService == null || trainingTaskMapper == null) {
return null;
}
Map<String, List<BigDecimal>> scoresByProject = new HashMap<>();
boolean hasAiScore = false;
boolean comprehensiveParticipating = false;
for (TaskAllocation allocation : allocations) {
if (allocation == null || StringUtils.isBlank(allocation.getModule())) {
continue;
}
if ("comprehensive-case-training".equals(allocation.getModule())) {
comprehensiveParticipating = true;
}
TrainingTask task = trainingTaskMapper.selectByTaskKey(allocation.getModule());
if (task == null || StringUtils.isBlank(task.getProjectName())) {
continue;
}
StudentTrainingAnswer answer = answersByTaskKey.get(allocation.getModule());
Integer taskScore = answer == null ? null : answer.getAiAssessmentScore();
if (taskScore != null) {
hasAiScore = true;
}
scoresByProject.computeIfAbsent(task.getProjectName(), key -> new ArrayList<>())
.add(taskScore == null ? BigDecimal.ZERO : BigDecimal.valueOf(taskScore));
}
if (!hasAiScore) {
return null;
}
return weightedSixModuleScore(classId, scoresByProject, comprehensiveParticipating);
}
private BigDecimal weightedSixModuleScore(String classId, Map<String, List<BigDecimal>> scoresByProject,
boolean comprehensiveParticipating) {
TeachingClassScoreWeight weight = teacherScoreService.getOrCreateWeightForCalculation(classId);
if (weight == null) {
return null;
@ -625,7 +826,7 @@ public class TeaScoreController {
if (task == null || StringUtils.isBlank(task.getId()) || !seenTaskIds.add(task.getId())) {
continue;
}
TeacherTaskReviewOptionDTO option = reviewOption(task);
TeacherTaskReviewOptionDTO option = reviewOption(task, classId);
allTasks.add(option);
if (StringUtils.isNotBlank(option.getProjectName())) {
projectNames.add(option.getProjectName());
@ -659,13 +860,19 @@ public class TeaScoreController {
return page;
}
private TeacherTaskReviewOptionDTO reviewOption(TrainingTask task) {
private TeacherTaskReviewOptionDTO reviewOption(TrainingTask task, String classId) {
TeacherTaskReviewOptionDTO option = new TeacherTaskReviewOptionDTO();
option.setTaskId(task.getId());
option.setTaskKey(task.getTaskKey());
option.setProjectName(task.getProjectName());
option.setTaskName(task.getTaskName());
option.setSteps(task.getSteps());
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
option.setReferenceAnswerName(classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerName())
? classConfig.getReferenceAnswerName() : task.getReferenceAnswerName());
option.setReferenceAnswerUrl(classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerUrl())
? classConfig.getReferenceAnswerUrl() : task.getReferenceAnswerUrl());
return option;
}
@ -681,6 +888,7 @@ public class TeaScoreController {
if (answer != null) {
row.setAiAssessmentScore(answer.getAiAssessmentScore());
row.setTeacherScore(answer.getTeacherScore());
row.setTeacherComment(answer.getTeacherComment());
row.setStep1Answer(answer.getStep1Answer());
row.setStep2Answer(answer.getStep2Answer());
row.setStep3Answer(answer.getStep3Answer());

@ -7,12 +7,16 @@ import com.sztzjy.linkCommerce.config.security.TokenProvider;
import com.sztzjy.linkCommerce.entity.TrainingTask;
import com.sztzjy.linkCommerce.entity.importDto.TrainingTaskImportDTO;
import com.sztzjy.linkCommerce.service.TrainingTaskService;
import com.sztzjy.linkCommerce.util.file.IFileUtil;
import com.sztzjy.linkCommerce.util.ResultEntity;
import com.sztzjy.linkCommerce.util.pdfUtils.PDFConvertUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.web.bind.annotation.*;
@ -20,9 +24,12 @@ import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
@ -33,6 +40,9 @@ public class TrainingTaskController {
@Autowired
private TrainingTaskService trainingTaskService;
@Autowired
private IFileUtil fileUtil;
@GetMapping
@ApiOperation("Training task list")
public ResultEntity<List<TrainingTask>> list(@RequestParam(required = false) String projectName,
@ -41,8 +51,10 @@ public class TrainingTaskController {
HttpServletRequest request) {
JwtUser user = TokenProvider.getJWTUser(request);
if (isStudent(user)) {
List<TrainingTask> studentTasks = trainingTaskService.listForStudent(user, enabledOnly);
studentTasks.forEach(this::removeReferenceAnswerForStudent);
return new ResultEntity<>(HttpStatus.OK, "Query success",
trainingTaskService.listForStudent(user, enabledOnly));
studentTasks);
}
return new ResultEntity<>(HttpStatus.OK, "Query success",
trainingTaskService.listDefaults(projectName, taskName, enabledOnly));
@ -69,9 +81,45 @@ public class TrainingTaskController {
if (task == null) {
return new ResultEntity<>(HttpStatus.OK, "Training task not configured", null);
}
if (isStudent(user)) {
removeReferenceAnswerForStudent(task);
}
return new ResultEntity<>(HttpStatus.OK, "Query success", task);
}
@GetMapping("/key/{taskKey}/reference-answer/preview")
@ApiOperation("学生在线查看已推送的参考答案(docx 转 PDF)")
public ResponseEntity<byte[]> previewReferenceAnswerForStudent(@PathVariable String taskKey,
HttpServletRequest request) {
try {
JwtUser user = TokenProvider.getJWTUser(request);
if (!isStudent(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
// getStudentTaskByTaskKey 走班级配置解析;未推送(未开放)时参考答案字段对学生为空
TrainingTask task = trainingTaskService.getStudentTaskByTaskKey(taskKey, user);
if (task == null || StringUtils.isBlank(task.getReferenceAnswerUrl())) {
return ResponseEntity.notFound().build();
}
String name = StringUtils.defaultIfBlank(task.getReferenceAnswerName(), task.getReferenceAnswerUrl());
String path = task.getReferenceAnswerUrl();
int slash = path.indexOf("/file/");
if (slash >= 0) path = path.substring(slash + 1);
File source = new File(fileUtil.getFullPath(path));
if (!source.exists() || !PDFConvertUtil.isCreatePdf(name)) return ResponseEntity.notFound().build();
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (InputStream input = Files.newInputStream(source.toPath())) {
if (!PDFConvertUtil.convertToPDFByFileNameSuffix(name, input, output)) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header("Content-Disposition", "inline; filename=reference-answer.pdf")
.body(output.toByteArray());
} catch (RuntimeException | java.io.IOException e) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
@GetMapping("/classes/{teachingClassId}")
@ApiOperation("Training task list for teaching class")
public ResultEntity<List<TrainingTask>> listForTeachingClass(@PathVariable String teachingClassId,
@ -118,6 +166,22 @@ public class TrainingTaskController {
}
}
@PutMapping("/classes/{teachingClassId}/{taskKey}/reference-answer")
@ApiOperation("Toggle reference answer visibility for a teaching-class task")
public ResultEntity<TrainingTask> setReferenceAnswerVisibility(@PathVariable String teachingClassId,
@PathVariable String taskKey,
@RequestParam Boolean enabled,
HttpServletRequest request) {
JwtUser user = requireTeacher(request);
try {
return new ResultEntity<>(HttpStatus.OK, "Reference answer visibility updated",
trainingTaskService.setClassTaskReferenceAnswerEnabled(
teachingClassId, taskKey, enabled, user.getUserId()));
} catch (IllegalArgumentException e) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
@DeleteMapping("/classes/{teachingClassId}/{taskKey}/override")
@ApiOperation("Restore training task default for teaching class")
public ResultEntity restoreClassTaskDefault(@PathVariable String teachingClassId,
@ -240,6 +304,14 @@ public class TrainingTaskController {
return user != null && user.getRoleId() == 4;
}
/** 参考答案默认对学生隐藏,只有教学班任务配置明确开放时才返回。 */
private void removeReferenceAnswerForStudent(TrainingTask task) {
if (task == null) return;
if (Boolean.TRUE.equals(task.getReferenceAnswerEnabled())) return;
task.setReferenceAnswerName(null);
task.setReferenceAnswerUrl(null);
}
private JwtUser requireAdmin(HttpServletRequest request) {
JwtUser user = TokenProvider.getJWTUser(request);
if (user == null || user.getRoleId() != 1) {

@ -557,32 +557,36 @@ public class UserController {
@PostMapping("/addTeacherAccount")
@ApiOperation("教师管理-添加教师账号")
@AnonymousAccess
public ResultEntity addTeacherAccount(@ApiParam("学校ID") @RequestParam String schoolId,
@ApiParam("学校名称") @RequestParam String schoolName,
@ApiParam("院系ID") @RequestParam String schoolFacultyId,
@ApiParam("专业ID") @RequestParam String schoolMajorId,
@ApiParam("专业名称") @RequestParam String schoolMajorName,
public ResultEntity addTeacherAccount(@ApiParam("学校ID") @RequestParam(required = false) String schoolId,
@ApiParam("学校名称") @RequestParam(required = false) String schoolName,
@ApiParam("院系ID") @RequestParam(required = false) String schoolFacultyId,
@ApiParam("专业ID") @RequestParam(required = false) String schoolMajorId,
@ApiParam("专业名称") @RequestParam(required = false) String schoolMajorName,
@ApiParam("教师名称") @RequestParam String name,
@ApiParam("教师工号") @RequestParam String userName,
@ApiParam("电话号码") @RequestParam(required = false) String phone,
@ApiParam("电子邮箱") @RequestParam(required = false) String email,
@RequestParam(required = false) String operatorId) {
//先根据schoolId进行查询 如果不存在则返回
School school = schoolMapper.selectByPrimaryKey(schoolId);
//学校/院系/专业默认从当前操作用户继承;平台管理员可显式指定学校
Userinfo operator = StringUtils.isBlank(operatorId) ? null : userinfoMapper.selectByPrimaryKey(operatorId);
String effectiveSchoolId = isSchoolTeacher(operator) ? operator.getSchoolId() : StringUtils.trimToNull(schoolId);
if (StringUtils.isBlank(effectiveSchoolId)) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "无法确定教师所属学校");
}
School school = schoolMapper.selectByPrimaryKey(effectiveSchoolId);
if (school == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该学校不存在");
}
if (!canManageSchoolStructure(operatorId, schoolId)) {
if (!canManageSchoolStructure(operatorId, effectiveSchoolId)) {
return noManagePermission();
}
//再根据schoolFacultyId查询 如果不存在则返回
SchoolFaculty schoolFaculty = schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId);
if (schoolFaculty == null) {
String effectiveFacultyId = isSchoolTeacher(operator) ? StringUtils.trimToNull(operator.getSchoolFacultyId()) : StringUtils.trimToNull(schoolFacultyId);
String effectiveMajorId = isSchoolTeacher(operator) ? StringUtils.trimToNull(operator.getSchoolMajorId()) : StringUtils.trimToNull(schoolMajorId);
//院系/专业选填:有值才校验
if (StringUtils.isNotBlank(effectiveFacultyId) && schoolFacultyMapper.selectByPrimaryKey(effectiveFacultyId) == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该院系不存在");
}
//再根据schoolMajorId查询 如果不存在则返回
SchoolMajor schoolMajor = schoolMajorMapper.selectByPrimaryKey(schoolMajorId);
if (schoolMajor == null) {
if (StringUtils.isNotBlank(effectiveMajorId) && schoolMajorMapper.selectByPrimaryKey(effectiveMajorId) == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该专业不存在");
}
//检查userName是否已经存在
@ -599,9 +603,9 @@ public class UserController {
userInfo.setEmail(email);
userInfo.setCreateTime(new Date());
userInfo.setRole(3);
userInfo.setSchoolId(schoolId);
userInfo.setSchoolFacultyId(schoolFacultyId);
userInfo.setSchoolMajorId(schoolMajorId);
userInfo.setSchoolId(effectiveSchoolId);
userInfo.setSchoolFacultyId(effectiveFacultyId);
userInfo.setSchoolMajorId(effectiveMajorId);
userInfo.setSchoolClassId(null);
userInfo.setTeacherAdmin(false);
userInfo.setCodeFrom("添加");
@ -631,7 +635,6 @@ public class UserController {
}
schoolId = operator.getSchoolId();
}
PageHelper.startPage(index, size);
UserinfoExample userInfoExample = new UserinfoExample();
UserinfoExample.Criteria criteria = userInfoExample.createCriteria();
School school = new School();
@ -646,6 +649,8 @@ public class UserController {
criteria.andUsernameEqualTo(userName);
}
criteria.andRoleEqualTo(3);
// startPage 只对紧随其后的第一条查询生效,必须放在学校等辅助查询之后,否则教师主查询不会分页
PageHelper.startPage(index, size);
List<Userinfo> userInfoList = userinfoMapper.selectByExample(userInfoExample);
for (int i = 0; i < userInfoList.size(); i++) {
if (StringUtils.isNotBlank(schoolId)) {
@ -765,15 +770,18 @@ public class UserController {
if (!errorMessages.isEmpty()) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "数据校验失败!", errorMessages);
}
for (TeacherImportDTO teacher : teacherList) {
String schoolId = schoolService.selectSchoolIdBySchoolName(teacher.getSchoolName());
if (!canManageSchoolStructure(operatorId, schoolId)) {
return noManagePermission();
}
// 教师的学校/院系/专业直接取自当前操作用户,不再从Excel的院校名称列解析
Userinfo operator = userinfoMapper.selectByPrimaryKey(operatorId);
String effectiveSchoolId = operator == null ? null : StringUtils.trimToEmpty(operator.getSchoolId());
if (StringUtils.isBlank(effectiveSchoolId)) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "无法确定当前用户所属学校,导入失败");
}
if (!canManageSchoolStructure(operatorId, effectiveSchoolId)) {
return noManagePermission();
}
// 异步处理导入*****
importService.processTeacherImport(teacherList);
importService.processTeacherImport(teacherList, operator);
return new ResultEntity<>(HttpStatus.OK, "导入成功!");
} catch (Exception e) {
@ -815,9 +823,9 @@ public class UserController {
@AnonymousAccess
public ResultEntity addStudentAccount(@ApiParam("学校ID") @RequestParam String schoolId,
@ApiParam("学校名称") @RequestParam String schoolName,
@ApiParam("院系ID") @RequestParam String schoolFacultyId,
@ApiParam("专业ID") @RequestParam String schoolMajorId,
@ApiParam("专业名称") @RequestParam String schoolMajorName,
@ApiParam("院系ID") @RequestParam(required = false) String schoolFacultyId,
@ApiParam("专业ID") @RequestParam(required = false) String schoolMajorId,
@ApiParam("专业名称") @RequestParam(required = false) String schoolMajorName,
@ApiParam("班级名称") @RequestParam String schoolClassName,
@ApiParam("班级Id") @RequestParam String schoolClassId,
@ApiParam("学生姓名") @RequestParam String name,
@ -832,14 +840,11 @@ public class UserController {
if (school == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该学校不存在");
}
//再根据schoolFacultyId查询 如果不存在则返回
SchoolFaculty schoolFaculty = schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId);
if (schoolFaculty == null) {
//院系/专业改为选填:传了才校验,不传直接入库为空
if (StringUtils.isNotBlank(schoolFacultyId) && schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId) == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该院系不存在");
}
//再根据schoolMajorId查询 如果不存在则返回
SchoolMajor schoolMajor = schoolMajorMapper.selectByPrimaryKey(schoolMajorId);
if (schoolMajor == null) {
if (StringUtils.isNotBlank(schoolMajorId) && schoolMajorMapper.selectByPrimaryKey(schoolMajorId) == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该专业不存在");
}
SchoolClass schoolClass = schoolClassMapper.selectByPrimaryKey(schoolClassId);
@ -865,8 +870,8 @@ public class UserController {
userInfo.setRole(4);
userInfo.setTeacherAdmin(false);
userInfo.setSchoolId(effectiveSchoolId);
userInfo.setSchoolFacultyId(schoolFacultyId);
userInfo.setSchoolMajorId(schoolMajorId);
userInfo.setSchoolFacultyId(StringUtils.trimToNull(schoolFacultyId));
userInfo.setSchoolMajorId(StringUtils.trimToNull(schoolMajorId));
String adminClassId = "TEACHING".equals(schoolClass.getClassType())
? resolveAdminClassIdForTeachingClass(schoolClass)
: schoolClassId;
@ -989,7 +994,6 @@ public class UserController {
if (teacherRosterScope) {
schoolId = operator.getSchoolId();
}
PageHelper.startPage(index, size);
UserinfoExample userInfoExample = new UserinfoExample();
UserinfoExample.Criteria criteria = userInfoExample.createCriteria();
School school = new School();
@ -1025,6 +1029,8 @@ public class UserController {
}
}
criteria.andRoleEqualTo(4);
// startPage 只对紧随其后的第一条查询生效,必须放在学校/教学班/花名册等辅助查询之后,否则学生主查询不会分页
PageHelper.startPage(index, size);
List<Userinfo> userInfoList = userinfoMapper.selectByExample(userInfoExample);
for (int i = 0; i < userInfoList.size(); i++) {
if (StringUtils.isNotBlank(schoolId)) {
@ -2129,8 +2135,6 @@ public class UserController {
String name = StringUtils.trimToEmpty(row.getName());
String username = StringUtils.trimToEmpty(row.getUsername());
String facultyName = StringUtils.trimToEmpty(row.getSchoolFacultyName());
String majorName = StringUtils.trimToEmpty(row.getSchoolMajorName());
String className = StringUtils.trimToEmpty(row.getSchoolClassName());
String sex = normalizeTeacherImportSex(row.getSex());
@ -2147,32 +2151,16 @@ public class UserController {
if (StringUtils.isNotBlank(row.getSex()) && sex == null) {
errors.add("第" + rowNumber + "行:性别请填写男或女");
}
if (StringUtils.isBlank(facultyName)) {
errors.add("第" + rowNumber + "行:所属院系不能为空");
}
if (StringUtils.isBlank(majorName)) {
errors.add("第" + rowNumber + "行:所属专业不能为空");
}
if (StringUtils.isBlank(className)) {
errors.add("第" + rowNumber + "行:所属班级不能为空");
}
SchoolFaculty faculty = StringUtils.isBlank(facultyName) ? null : findTeacherImportFaculty(operator.getSchoolId(), facultyName);
SchoolMajor major = null;
//院系/专业列已取消,班级直接按本校行政班名称匹配
SchoolClass schoolClass = null;
if (StringUtils.isNotBlank(facultyName) && faculty == null) {
errors.add("第" + rowNumber + "行:未找到院系“" + facultyName + "”");
}
if (faculty != null && StringUtils.isNotBlank(majorName)) {
major = findTeacherImportMajor(faculty.getSchoolFacultyId(), majorName);
if (major == null) {
errors.add("第" + rowNumber + "行:院系“" + facultyName + "”下未找到专业“" + majorName + "”");
}
}
if (major != null && StringUtils.isNotBlank(className)) {
schoolClass = findTeacherImportClass(operator.getSchoolId(), major.getSchoolMajorId(), className);
if (StringUtils.isNotBlank(className)) {
schoolClass = findTeacherImportAdminClass(operator.getSchoolId(), className);
if (schoolClass == null) {
errors.add("第" + rowNumber + "行:专业“" + majorName + "”下未找到班级“" + className + "”");
errors.add("第" + rowNumber + "行:未找到行政班“" + className + "”");
} else if (!isAdminClass(schoolClass)) {
errors.add("第" + rowNumber + "行:学生账号只能导入行政班");
} else if (!canManageAdminClass(operator, schoolClass)) {
@ -2192,8 +2180,6 @@ public class UserController {
student.setRole(4);
student.setTeacherAdmin(false);
student.setSchoolId(operator.getSchoolId());
student.setSchoolFacultyId(faculty.getSchoolFacultyId());
student.setSchoolMajorId(major.getSchoolMajorId());
student.setSchoolClassId(schoolClass.getSchoolClassId());
student.setCreateTime(new Date());
student.setCodeFrom("Excel导入");
@ -2210,8 +2196,6 @@ public class UserController {
return StringUtils.isBlank(row.getName())
&& StringUtils.isBlank(row.getUsername())
&& StringUtils.isBlank(row.getSex())
&& StringUtils.isBlank(row.getSchoolFacultyName())
&& StringUtils.isBlank(row.getSchoolMajorName())
&& StringUtils.isBlank(row.getSchoolClassName())
&& StringUtils.isBlank(row.getPhone())
&& StringUtils.isBlank(row.getEmail());
@ -2241,30 +2225,23 @@ public class UserController {
return false;
}
private SchoolFaculty findTeacherImportFaculty(String schoolId, String facultyName) {
SchoolFacultyExample example = new SchoolFacultyExample();
private SchoolClass findTeacherImportClass(String schoolId, String majorId, String className) {
SchoolClassExample example = new SchoolClassExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andSchoolFacultyNameEqualTo(facultyName);
List<SchoolFaculty> faculties = schoolFacultyMapper.selectByExample(example);
return faculties.isEmpty() ? null : faculties.get(0);
}
private SchoolMajor findTeacherImportMajor(String facultyId, String majorName) {
SchoolMajorExample example = new SchoolMajorExample();
example.createCriteria()
.andSchoolFacultyIdEqualTo(facultyId)
.andSchoolMajorNameEqualTo(majorName);
List<SchoolMajor> majors = schoolMajorMapper.selectByExample(example);
return majors.isEmpty() ? null : majors.get(0);
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
}
private SchoolClass findTeacherImportClass(String schoolId, String majorId, String className) {
//学生账号导入不再区分院系/专业,班级直接按本校行政班名称匹配
private SchoolClass findTeacherImportAdminClass(String schoolId, String className) {
SchoolClassExample example = new SchoolClassExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
.andClassNameEqualTo(className)
.andClassTypeEqualTo("ADMIN");
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
}

@ -52,6 +52,9 @@ public class StuRank {
@ApiModelProperty(notes = "老师人工评分(查询时从教学班评分表读取)")
private BigDecimal teacherScore;
@ApiModelProperty(notes = "AI评测成绩(查询时按教学班任务聚合各任务ai_assessment_score,不落库)")
private BigDecimal aiAssessmentScore;
@ApiModelProperty(notes = "市场需求挖掘分数")
private BigDecimal scqxwjScore;
@ -200,6 +203,14 @@ public class StuRank {
this.teacherScore = teacherScore;
}
public BigDecimal getAiAssessmentScore() {
return aiAssessmentScore;
}
public void setAiAssessmentScore(BigDecimal aiAssessmentScore) {
this.aiAssessmentScore = aiAssessmentScore;
}
public BigDecimal getScqxwjScore() {
return scqxwjScore;
}

@ -53,6 +53,9 @@ public class StudentTrainingAnswer {
@ApiModelProperty("教师任务评分")
private BigDecimal teacherScore;
@ApiModelProperty("教师评语")
private String teacherComment;
@ApiModelProperty("教师评分人")
private String teacherScoredBy;
@ -197,6 +200,8 @@ public class StudentTrainingAnswer {
public void setTeacherScoredBy(String teacherScoredBy) { this.teacherScoredBy = teacherScoredBy == null ? null : teacherScoredBy.trim(); }
public Date getTeacherScoredAt() { return teacherScoredAt; }
public void setTeacherScoredAt(Date teacherScoredAt) { this.teacherScoredAt = teacherScoredAt; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment == null ? null : teacherComment.trim(); }
public String getSaveAction() {
return saveAction;

@ -59,6 +59,15 @@ public class TrainingTask {
@ApiModelProperty("AI排错指导手册地址")
private String aiManualUrl;
@ApiModelProperty("参考答案名称")
private String referenceAnswerName;
@ApiModelProperty("参考答案地址")
private String referenceAnswerUrl;
@ApiModelProperty("是否向学生开放参考答案")
private Boolean referenceAnswerEnabled;
@ApiModelProperty("排序")
private Integer sort;
@ -218,6 +227,30 @@ public class TrainingTask {
this.aiManualUrl = aiManualUrl == null ? null : aiManualUrl.trim();
}
public String getReferenceAnswerName() {
return referenceAnswerName;
}
public void setReferenceAnswerName(String referenceAnswerName) {
this.referenceAnswerName = referenceAnswerName == null ? null : referenceAnswerName.trim();
}
public String getReferenceAnswerUrl() {
return referenceAnswerUrl;
}
public void setReferenceAnswerUrl(String referenceAnswerUrl) {
this.referenceAnswerUrl = referenceAnswerUrl == null ? null : referenceAnswerUrl.trim();
}
public Boolean getReferenceAnswerEnabled() {
return referenceAnswerEnabled;
}
public void setReferenceAnswerEnabled(Boolean referenceAnswerEnabled) {
this.referenceAnswerEnabled = referenceAnswerEnabled;
}
public Integer getSort() {
return sort;
}

@ -62,6 +62,15 @@ public class TrainingTaskClassConfig {
@ApiModelProperty("AI troubleshooting manual URL")
private String aiManualUrl;
@ApiModelProperty("Reference answer name")
private String referenceAnswerName;
@ApiModelProperty("Reference answer URL")
private String referenceAnswerUrl;
@ApiModelProperty("Whether the reference answer is visible to students")
private Boolean referenceAnswerEnabled;
@ApiModelProperty("Sort order")
private Integer sort;
@ -229,6 +238,30 @@ public class TrainingTaskClassConfig {
this.aiManualUrl = aiManualUrl == null ? null : aiManualUrl.trim();
}
public String getReferenceAnswerName() {
return referenceAnswerName;
}
public void setReferenceAnswerName(String referenceAnswerName) {
this.referenceAnswerName = referenceAnswerName == null ? null : referenceAnswerName.trim();
}
public String getReferenceAnswerUrl() {
return referenceAnswerUrl;
}
public void setReferenceAnswerUrl(String referenceAnswerUrl) {
this.referenceAnswerUrl = referenceAnswerUrl == null ? null : referenceAnswerUrl.trim();
}
public Boolean getReferenceAnswerEnabled() {
return referenceAnswerEnabled;
}
public void setReferenceAnswerEnabled(Boolean referenceAnswerEnabled) {
this.referenceAnswerEnabled = referenceAnswerEnabled;
}
public Integer getSort() {
return sort;
}

@ -9,6 +9,7 @@ public class AiTrainingEvaluationView {
private Date helpCompletedAt;
private String assessmentStatus;
private Integer assessmentScore;
private String assessmentComment;
private String assessmentReportJson;
private Date assessmentCompletedAt;
@ -22,6 +23,8 @@ public class AiTrainingEvaluationView {
public void setAssessmentStatus(String assessmentStatus) { this.assessmentStatus = assessmentStatus; }
public Integer getAssessmentScore() { return assessmentScore; }
public void setAssessmentScore(Integer assessmentScore) { this.assessmentScore = assessmentScore; }
public String getAssessmentComment() { return assessmentComment; }
public void setAssessmentComment(String assessmentComment) { this.assessmentComment = assessmentComment; }
public String getAssessmentReportJson() { return assessmentReportJson; }
public void setAssessmentReportJson(String assessmentReportJson) { this.assessmentReportJson = assessmentReportJson; }
public Date getAssessmentCompletedAt() { return assessmentCompletedAt; }

@ -0,0 +1,15 @@
package com.sztzjy.linkCommerce.entity.dto;
import java.util.List;
public class BatchTaskAiAssessmentRequest {
private String classId;
private String taskId;
private List<String> studentUserIds;
public String getClassId() { return classId; }
public void setClassId(String classId) { this.classId = classId; }
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public List<String> getStudentUserIds() { return studentUserIds; }
public void setStudentUserIds(List<String> studentUserIds) { this.studentUserIds = studentUserIds; }
}

@ -8,6 +8,7 @@ public class BatchTaskTeacherScoreRequest {
private String taskId;
private List<String> studentUserIds;
private BigDecimal teacherScore;
private String teacherComment;
public String getClassId() { return classId; }
public void setClassId(String classId) { this.classId = classId; }
@ -17,4 +18,6 @@ public class BatchTaskTeacherScoreRequest {
public void setStudentUserIds(List<String> studentUserIds) { this.studentUserIds = studentUserIds; }
public BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(BigDecimal teacherScore) { this.teacherScore = teacherScore; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment; }
}

@ -1,4 +1,26 @@
package com.sztzjy.linkCommerce.entity.dto;
import java.math.BigDecimal; import java.util.List;
public class StudentExperimentReportDTO { private String teachingClassId; private Integer rank; private BigDecimal totalScore; private List<StudentExperimentReportModuleDTO> modules; private List<StudentExperimentReportTaskDTO> tasks;
public String getTeachingClassId(){return teachingClassId;} public void setTeachingClassId(String v){teachingClassId=v;} public Integer getRank(){return rank;} public void setRank(Integer v){rank=v;} public BigDecimal getTotalScore(){return totalScore;} public void setTotalScore(BigDecimal v){totalScore=v;} public List<StudentExperimentReportModuleDTO> getModules(){return modules;} public void setModules(List<StudentExperimentReportModuleDTO> v){modules=v;} public List<StudentExperimentReportTaskDTO> getTasks(){return tasks;} public void setTasks(List<StudentExperimentReportTaskDTO> v){tasks=v;} }
public class StudentExperimentReportDTO {
private String teachingClassId;
private Integer rank;
private BigDecimal totalScore;
private BigDecimal aiAssessmentScore;
private BigDecimal teacherScore;
private List<StudentExperimentReportModuleDTO> modules;
private List<StudentExperimentReportTaskDTO> tasks;
public String getTeachingClassId() { return teachingClassId; }
public void setTeachingClassId(String v) { teachingClassId = v; }
public Integer getRank() { return rank; }
public void setRank(Integer v) { rank = v; }
public BigDecimal getTotalScore() { return totalScore; }
public void setTotalScore(BigDecimal v) { totalScore = v; }
public BigDecimal getAiAssessmentScore() { return aiAssessmentScore; }
public void setAiAssessmentScore(BigDecimal v) { aiAssessmentScore = v; }
public BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(BigDecimal v) { teacherScore = v; }
public List<StudentExperimentReportModuleDTO> getModules() { return modules; }
public void setModules(List<StudentExperimentReportModuleDTO> v) { modules = v; }
public List<StudentExperimentReportTaskDTO> getTasks() { return tasks; }
public void setTasks(List<StudentExperimentReportTaskDTO> v) { tasks = v; }
}

@ -1,3 +1,22 @@
package com.sztzjy.linkCommerce.entity.dto;
public class StudentExperimentReportTaskDTO { private String taskName; private String moduleName; private String progressStatus; private Boolean submitted; private Integer aiAssessmentScore;
public String getTaskName(){return taskName;} public void setTaskName(String v){taskName=v;} public String getModuleName(){return moduleName;} public void setModuleName(String v){moduleName=v;} public String getProgressStatus(){return progressStatus;} public void setProgressStatus(String v){progressStatus=v;} public Boolean getSubmitted(){return submitted;} public void setSubmitted(Boolean v){submitted=v;} public Integer getAiAssessmentScore(){return aiAssessmentScore;} public void setAiAssessmentScore(Integer v){aiAssessmentScore=v;} }
public class StudentExperimentReportTaskDTO {
private String taskName;
private String moduleName;
private String progressStatus;
private Boolean submitted;
private Integer aiAssessmentScore;
private java.math.BigDecimal teacherScore;
public String getTaskName() { return taskName; }
public void setTaskName(String v) { taskName = v; }
public String getModuleName() { return moduleName; }
public void setModuleName(String v) { moduleName = v; }
public String getProgressStatus() { return progressStatus; }
public void setProgressStatus(String v) { progressStatus = v; }
public Boolean getSubmitted() { return submitted; }
public void setSubmitted(Boolean v) { submitted = v; }
public Integer getAiAssessmentScore() { return aiAssessmentScore; }
public void setAiAssessmentScore(Integer v) { aiAssessmentScore = v; }
public java.math.BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(java.math.BigDecimal v) { teacherScore = v; }
}

@ -6,6 +6,8 @@ public class TeacherTaskReviewOptionDTO {
private String taskName;
private String taskKey;
private String steps;
private String referenceAnswerName;
private String referenceAnswerUrl;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
@ -17,4 +19,8 @@ public class TeacherTaskReviewOptionDTO {
public void setTaskKey(String taskKey) { this.taskKey = taskKey; }
public String getSteps() { return steps; }
public void setSteps(String steps) { this.steps = steps; }
public String getReferenceAnswerName() { return referenceAnswerName; }
public void setReferenceAnswerName(String referenceAnswerName) { this.referenceAnswerName = referenceAnswerName; }
public String getReferenceAnswerUrl() { return referenceAnswerUrl; }
public void setReferenceAnswerUrl(String referenceAnswerUrl) { this.referenceAnswerUrl = referenceAnswerUrl; }
}

@ -11,6 +11,7 @@ public class TeacherTaskReviewStudentDTO {
private Boolean scorable;
private Integer aiAssessmentScore;
private BigDecimal teacherScore;
private String teacherComment;
private String step1Answer;
private String step2Answer;
private String step3Answer;
@ -33,6 +34,8 @@ public class TeacherTaskReviewStudentDTO {
public void setAiAssessmentScore(Integer aiAssessmentScore) { this.aiAssessmentScore = aiAssessmentScore; }
public BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(BigDecimal teacherScore) { this.teacherScore = teacherScore; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment; }
public String getStep1Answer() { return step1Answer; }
public void setStep1Answer(String step1Answer) { this.step1Answer = step1Answer; }
public String getStep2Answer() { return step2Answer; }

@ -18,14 +18,6 @@ public class SchoolAdminStudentImportDTO {
@ApiModelProperty("性别")
private String sex;
@ExcelProperty("所属院系")
@ApiModelProperty("所属院系")
private String schoolFacultyName;
@ExcelProperty("所属专业")
@ApiModelProperty("所属专业")
private String schoolMajorName;
@ExcelProperty("所属班级")
@ApiModelProperty("所属班级")
private String schoolClassName;

@ -1,28 +1,24 @@
package com.sztzjy.linkCommerce.entity.importDto;
import com.alibaba.excel.annotation.ExcelProperty;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
public class TeacherImportDTO {
@ApiModelProperty("院校名称")
private String schoolName;
@ApiModelProperty("院系名称")
private String schoolFacultyName;
@ApiModelProperty("专业名称")
private String schoolMajorName;
@ExcelProperty("教师姓名")
@ApiModelProperty("教师姓名")
private String teacherName;
@ExcelProperty("工号")
@ApiModelProperty("工号")
private String userName;
@ExcelProperty("电话")
@ApiModelProperty("电话")
private String phone;
@ExcelProperty("邮箱")
@ApiModelProperty("邮箱")
private String email;
}

@ -32,6 +32,12 @@ public interface StudentTrainingAnswerMapper {
@Param("teacherUserId") String teacherUserId,
@Param("scoredAt") Date scoredAt);
int updateTeacherScoreWithComment(@Param("id") String id,
@Param("score") java.math.BigDecimal score,
@Param("teacherUserId") String teacherUserId,
@Param("scoredAt") Date scoredAt,
@Param("teacherComment") String teacherComment);
List<StudentTrainingAnswer> selectByStudentAndTeachingClass(@Param("studentUserId") String studentUserId,
@Param("teachingClassId") String teachingClassId);
}

@ -3,6 +3,8 @@ package com.sztzjy.linkCommerce.service;
import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView;
import java.util.Map;
public interface AiTrainingEvaluationService {
AiTrainingEvaluationView get(String taskKey, JwtUser user);
@ -13,4 +15,15 @@ public interface AiTrainingEvaluationService {
}
AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user);
AiTrainingEvaluationView startAssessment(String taskKey, JwtUser user);
AiTrainingEvaluationView getForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
AiTrainingEvaluationView startAssessmentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
/**
* 教师评阅用:基于该生 AI 评分报告与 AI 助学报告生成 10-50 字中文评语。
* 返回 {comment: 评语};该生尚无 AI 评分数据时抛出 ServiceException。
*/
Map<String, String> generateCommentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
}

@ -1,12 +1,13 @@
package com.sztzjy.linkCommerce.service;
import com.sztzjy.linkCommerce.entity.Userinfo;
import com.sztzjy.linkCommerce.entity.importDto.StudentImportDTO;
import com.sztzjy.linkCommerce.entity.importDto.TeacherImportDTO;
import java.util.List;
public interface ImportService {
void processTeacherImport(List<TeacherImportDTO> teacherList);
void processTeacherImport(List<TeacherImportDTO> teacherList, Userinfo operator);
void processStudentImport(List<StudentImportDTO> studentList);
}

@ -5,6 +5,7 @@ import com.sztzjy.linkCommerce.entity.TrainingTask;
import com.sztzjy.linkCommerce.entity.importDto.TrainingTaskImportDTO;
import java.util.List;
import java.util.Set;
public interface TrainingTaskService {
List<TrainingTask> list(String projectName, String taskName, Boolean enabledOnly);
@ -25,6 +26,11 @@ public interface TrainingTaskService {
void ensureDefaultTasks(String operatorId);
/**
* 教学班从未保存过发布配置时的默认开放任务:第一个项目的前三个任务。
*/
Set<String> getDefaultPublishedTaskKeys();
List<TrainingTask> listDefaults(String projectName, String taskName, Boolean enabledOnly);
List<TrainingTask> listForTeachingClass(String teachingClassId, Boolean enabledOnly, JwtUser operator);
@ -33,6 +39,9 @@ public interface TrainingTaskService {
TrainingTask saveClassTask(String teachingClassId, String taskKey, TrainingTask task, String operatorId);
TrainingTask setClassTaskReferenceAnswerEnabled(String teachingClassId, String taskKey,
Boolean enabled, String operatorId);
void restoreClassTaskDefault(String teachingClassId, String taskKey, String operatorId);
void restoreClassTasksDefaults(String teachingClassId, String operatorId);

@ -3,6 +3,7 @@ package com.sztzjy.linkCommerce.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sztzjy.linkCommerce.ai.QwenChatClient;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException;
@ -19,8 +20,10 @@ import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper;
import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService;
import com.sztzjy.linkCommerce.service.ScoreRankService;
import com.sztzjy.linkCommerce.service.TrainingKnowledgeService;
import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
@ -30,19 +33,29 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationService {
private static final String NOT_STARTED = "NOT_STARTED";
private static final String PROCESSING = "PROCESSING";
private static final String SUCCEEDED = "SUCCEEDED";
private static final String FAILURE_MESSAGE = "AI服务暂时不可用,请稍后重试";
// 与 AiTrainingEvaluationMapper.xml 中 claimHelp/claimAssessment 的 INTERVAL 10 MINUTE 保持一致
private static final long PROCESSING_TIMEOUT_MILLIS = 10 * 60 * 1000L;
private static final int LOGGED_RAW_RESPONSE_LENGTH = 500;
private static final int MAX_ERROR_LENGTH = 1000;
private static final int MAX_HELP_QUESTION_LENGTH = 500;
private static final int MAX_STEP_CONTEXT_LENGTH = 2000;
private static final int MAX_ASSESSMENT_RATIONALE_CHARACTERS = 200;
private static final int MAX_CHOICE_TITLE_LENGTH = 120;
private static final int MAX_CHOICE_OPTION_LENGTH = 80;
private final ObjectMapper objectMapper = new ObjectMapper();
@ -54,6 +67,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
public StudentTrainingAnswerMapper studentTrainingAnswerMapper;
@Autowired
public TeachingClassStudentMapper teachingClassStudentMapper;
@Autowired
public ScoreRankService scoreRankService;
@Autowired(required = false)
public TrainingTaskClassConfigMapper trainingTaskClassConfigMapper;
@Autowired(required = false)
@ -82,7 +97,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String normalizedStepContext = normalizeStepContext(stepContext);
EvaluationContext context = resolveContext(taskKey, user, true);
AiTrainingEvaluation evaluation = getOrCreate(context, user);
if (PROCESSING.equals(evaluation.getHelpStatus())) {
if (PROCESSING.equals(evaluation.getHelpStatus()) && !isProcessingTimedOut(evaluation)) {
return toView(evaluation);
}
if (aiTrainingEvaluationMapper.claimHelp(evaluation.getId(), new Date()) != 1) {
@ -110,7 +125,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed.setAssessmentStatus(evaluation.getAssessmentStatus());
return toView(completed);
} catch (RuntimeException e) {
aiTrainingEvaluationMapper.failHelp(evaluation.getId(), cappedMessage(e), new Date());
recordHelpFailure(evaluation.getId(), e);
throw asServiceException(e);
}
}
@ -119,27 +134,109 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
public AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user) {
EvaluationContext context = resolveContext(taskKey, user, true);
AiTrainingEvaluation evaluation = getOrCreate(context, user);
if (PROCESSING.equals(evaluation.getAssessmentStatus())) {
if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) {
log.info("AI assessment still processing, evaluationId={}", evaluation.getId());
return toView(evaluation);
}
String answerSnapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && answerSnapshot.equals(evaluation.getAssessmentAnswerSnapshot())) {
log.info("AI assessment cached hit, evaluationId={}, answerId={}, snapshotLength={}",
evaluation.getId(), context.answer == null ? null : context.answer.getId(),
answerSnapshot.length());
return toView(evaluation);
}
String taskSnapshot = taskSnapshot(context.effectiveContent);
List<AssessmentCriterionPlan> criterionPlan = assessmentCriterionPlan(context.effectiveContent.getSteps());
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
if (claimed != 1) {
log.info("AI assessment claim skipped, evaluationId={}, reassessment={}, claimed={}",
evaluation.getId(), reassessment, claimed);
return currentView(context, user);
}
return runAssessment(context, user, evaluation);
}
@Override
public AiTrainingEvaluationView startAssessment(String taskKey, JwtUser user) {
EvaluationContext context = resolveContext(taskKey, user, true);
AiTrainingEvaluation evaluation = getOrCreate(context, user);
if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) return toView(evaluation);
String snapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && snapshot.equals(evaluation.getAssessmentAnswerSnapshot())) return toView(evaluation);
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
if (claimed != 1) return currentView(context, user);
CompletableFuture.runAsync(() -> {
try { runAssessment(context, user, evaluation); }
catch (RuntimeException e) { log.warn("Student-triggered AI assessment failed, evaluationId={}", evaluation.getId(), e); }
});
AiTrainingEvaluationView view = toView(evaluation);
view.setAssessmentStatus(PROCESSING);
return view;
}
@Override
public AiTrainingEvaluationView getForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, false);
return toView(getOrCreate(context, studentUserId));
}
@Override
public AiTrainingEvaluationView startAssessmentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, true);
AiTrainingEvaluation evaluation = getOrCreate(context, studentUserId);
if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) {
return toView(evaluation);
}
String answerSnapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && answerSnapshot.equals(evaluation.getAssessmentAnswerSnapshot())) {
return toView(evaluation);
}
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
if (claimed != 1) return currentView(context, studentUserId);
AiTrainingEvaluation processing = new AiTrainingEvaluation();
processing.setId(evaluation.getId());
processing.setHelpStatus(evaluation.getHelpStatus());
processing.setAssessmentStatus(PROCESSING);
CompletableFuture.runAsync(() -> {
try {
runAssessment(context, teacher, evaluation);
} catch (RuntimeException e) {
log.warn("Teacher-triggered AI assessment failed, evaluationId={}", evaluation.getId(), e);
}
});
return toView(processing);
}
private AiTrainingEvaluationView runAssessment(EvaluationContext context, JwtUser actor, AiTrainingEvaluation evaluation) {
long startedAt = System.currentTimeMillis();
String answerSnapshot = answerSnapshot(context.answer);
String taskSnapshot = taskSnapshot(context.effectiveContent);
List<AssessmentCriterionPlan> criterionPlan = assessmentCriterionPlan(context.effectiveContent.getSteps());
String rawResponse = null;
try {
String knowledgeContext = knowledgeContext(context, user, "");
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan), assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext));
long knowledgeStartedAt = System.currentTimeMillis();
String knowledgeContext = knowledgeContext(context, actor, "");
long knowledgeCost = System.currentTimeMillis() - knowledgeStartedAt;
String choiceQuestionContext = choiceQuestionContext(context.effectiveContent, context.answer);
long modelStartedAt = System.currentTimeMillis();
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan),
assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext, choiceQuestionContext));
long modelCost = System.currentTimeMillis() - modelStartedAt;
// AI 评分过慢排查:分段记录知识检索与模型调用耗时(prompt 长度一并输出)
log.info("AI assessment timing, evaluationId={}, knowledgeContext={} ms, modelCall={} ms, total={} ms, "
+ "knowledgeContextChars={}, answerSnapshotChars={}, promptChars={}",
evaluation.getId(), knowledgeCost, modelCost, System.currentTimeMillis() - startedAt,
knowledgeContext.length(), answerSnapshot.length(),
assessmentSystemPrompt(criterionPlan).length()
+ assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext, choiceQuestionContext).length());
JsonNode assessmentReport = object(rawResponse);
int score = validateAssessment(assessmentReport, criterionPlan);
compactAssessmentRationales(assessmentReport);
@ -155,11 +252,12 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed.setAssessmentCompletedAt(completedAt);
completed.setUpdateTime(completedAt);
completeAssessmentAndWriteScore(completed, context.answer.getId(), score, completedAt);
refreshScoreRankAfterAssessment(actor, context.teachingClassId);
completed.setHelpStatus(evaluation.getHelpStatus());
completed.setAssessmentStatus(SUCCEEDED);
return toView(completed);
} catch (RuntimeException e) {
aiTrainingEvaluationMapper.failAssessment(evaluation.getId(), cappedMessage(e), rawResponse, new Date());
recordAssessmentFailure(evaluation.getId(), e, rawResponse);
throw asServiceException(e);
}
}
@ -183,6 +281,88 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
EffectiveTrainingTaskContent.resolve(task, classConfig));
}
private EvaluationContext resolveTeacherContext(String classId, String taskId, String studentUserId,
JwtUser teacher, boolean requireAnswer) {
if (teacher == null || StringUtils.isBlank(teacher.getUserId())) {
throw new ServiceException(HttpStatus.UNAUTHORIZED, "Please login first");
}
if (StringUtils.isAnyBlank(classId, taskId, studentUserId)) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "班级、任务和学生不能为空");
}
TrainingTask task = trainingTaskMapper.selectByPrimaryKey(taskId);
if (task == null) throw new ServiceException(HttpStatus.BAD_REQUEST, "Training task does not exist");
if (teachingClassStudentMapper.countByStudentUserIdAndTeachingClassId(studentUserId, classId) == 0) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生不在当前教学班");
}
StudentTrainingAnswer answer = studentTrainingAnswerMapper.selectByStudentClassAndTask(studentUserId, classId, taskId);
if (requireAnswer && (answer == null || !hasAnyAnswer(answer))) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生尚未完成可评分内容");
}
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
return new EvaluationContext(task, classId, answer, EffectiveTrainingTaskContent.resolve(task, classConfig));
}
@Override
public Map<String, String> generateCommentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, false);
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
studentUserId, context.teachingClassId, context.task.getId());
String assessmentReport = evaluation == null ? "" : StringUtils.trimToEmpty(evaluation.getAssessmentReportJson());
String helpReport = evaluation == null ? "" : StringUtils.trimToEmpty(evaluation.getHelpReportJson());
if (StringUtils.isBlank(assessmentReport)) {
// AI 评分报告是评语的主要依据;助学报告只做补充,没有也能生成
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生还没有AI评分数据,请先执行AI评分");
}
String comment = normalizeTeacherComment(generateCommentOnce(assessmentReport, helpReport, ""));
if (comment.codePointCount(0, comment.length()) < 30) {
// 验收要求 30-50 字;过短时带纠正指令重写一次,取更长的那条
String instruction = " 上一次生成的评语只有" + comment.codePointCount(0, comment.length())
+ "个字,太短;重新生成的评语必须不少于30个字、不超过50个字。";
String retried = normalizeTeacherComment(generateCommentOnce(assessmentReport, helpReport, instruction));
if (retried.codePointCount(0, retried.length()) > comment.codePointCount(0, comment.length())) {
comment = retried;
}
}
return Collections.singletonMap("comment", comment);
}
private String generateCommentOnce(String assessmentReport, String helpReport, String extraInstruction) {
String rawResponse = qwenChatClient.completeJson(commentSystemPrompt(),
commentUserPrompt(assessmentReport, helpReport) + StringUtils.trimToEmpty(extraInstruction));
JsonNode report = object(rawResponse);
JsonNode commentNode = report.get("comment");
if (commentNode == null || !commentNode.isTextual()) {
throw invalidReport();
}
return commentNode.textValue();
}
private String commentSystemPrompt() {
return "You are a vocational school teacher writing feedback for one student's training task. "
+ "Based on the given AI scoring report (and AI tutoring report if present), write ONE Chinese teacher comment. "
+ "Requirements: 30 to 50 Chinese characters; a single short paragraph; objective, specific and encouraging; "
+ "comment on the performance and give one improvement direction; "
+ "no bullet points, no quotes, no salutation, no prefix or suffix such as 评语. "
+ "Return only JSON: {\"comment\":\"...\"}";
}
private String commentUserPrompt(String assessmentReport, String helpReport) {
return "AI评分报告: " + assessmentReport + "\nAI助学报告: " + (StringUtils.isBlank(helpReport) ? "(无)" : helpReport);
}
/** 评语超 50 字时按句子边界截断;首尾引号一并去掉。 */
private String normalizeTeacherComment(String comment) {
String value = StringUtils.trimToEmpty(comment)
.replaceAll("^[\"'“”‘’]+", "")
.replaceAll("[\"'“”‘’]+$", "")
.trim();
if (value.codePointCount(0, value.length()) > 50) {
value = truncateRationaleAtSentenceBoundary(value, 50);
}
return value;
}
private void completeAssessmentAndWriteScore(AiTrainingEvaluation completed,
String answerId,
int score,
@ -199,16 +379,40 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
});
}
/**
* AI 分数落库后立即重建本教学班的 stu_rank 当日快照(与每日凌晨定时重建同一套逻辑),
* 教师端成绩列表马上能看到新分数。异步执行且失败只记日志:排名刷新失败不能影响评分结果。
*/
private void refreshScoreRankAfterAssessment(JwtUser user, String teachingClassId) {
String schoolId = user == null ? null : user.getSchoolId();
if (scoreRankService == null || StringUtils.isAnyBlank(schoolId, teachingClassId)) {
return;
}
CompletableFuture.runAsync(() -> {
try {
if (!scoreRankService.doRankTeachingClass(schoolId, teachingClassId)) {
scoreRankService.doRankOne(schoolId);
}
} catch (RuntimeException e) {
log.warn("AI assessment score rank refresh failed, teachingClassId={}", teachingClassId, e);
}
});
}
private AiTrainingEvaluation getOrCreate(EvaluationContext context, JwtUser user) {
return getOrCreate(context, user.getUserId());
}
private AiTrainingEvaluation getOrCreate(EvaluationContext context, String studentUserId) {
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
user.getUserId(), context.teachingClassId, context.task.getId());
studentUserId, context.teachingClassId, context.task.getId());
if (evaluation != null) {
return evaluation;
}
Date now = new Date();
AiTrainingEvaluation created = new AiTrainingEvaluation();
created.setId(UUID.randomUUID().toString());
created.setStudentUserId(user.getUserId());
created.setStudentUserId(studentUserId);
created.setTeachingClassId(context.teachingClassId);
created.setTaskId(context.task.getId());
created.setTaskKey(context.task.getTaskKey());
@ -218,7 +422,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
created.setUpdateTime(now);
aiTrainingEvaluationMapper.insertIgnore(created);
evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
user.getUserId(), context.teachingClassId, context.task.getId());
studentUserId, context.teachingClassId, context.task.getId());
if (evaluation == null) {
throw new ServiceException(HttpStatus.SERVICE_UNAVAILABLE, "AI evaluation is temporarily unavailable");
}
@ -230,6 +434,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
user.getUserId(), context.teachingClassId, context.task.getId()));
}
private AiTrainingEvaluationView currentView(EvaluationContext context, String studentUserId) {
return toView(aiTrainingEvaluationMapper.findByStudentClassAndTask(
studentUserId, context.teachingClassId, context.task.getId()));
}
private void requireStudent(JwtUser user) {
if (user == null || StringUtils.isBlank(user.getUserId())) {
throw new ServiceException(HttpStatus.UNAUTHORIZED, "Please login first");
@ -310,6 +519,59 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return json(node);
}
/**
* 第一步选择题的评分上下文:题干、选项文字、标准答案与学生作答逐题对照。
* 只用于 AI 助评——助学的提示词面向学生,带标准答案会泄题。
*/
private String choiceQuestionContext(EffectiveTrainingTaskContent content, StudentTrainingAnswer answer) {
ArrayNode result = objectMapper.createArrayNode();
JsonNode questions = parseJsonArray(content.getStepOneQuestions());
JsonNode choiceAnswers = parseChoiceAnswers(answer == null ? null : answer.getStep1Answer());
for (JsonNode question : questions) {
ObjectNode entry = result.addObject();
entry.put("title", StringUtils.abbreviate(question.path("title").asText(), MAX_CHOICE_TITLE_LENGTH));
ArrayNode options = entry.putArray("options");
for (JsonNode option : question.path("options")) {
ObjectNode item = options.addObject();
item.put("key", option.path("key").asText());
item.put("content", StringUtils.abbreviate(option.path("content").asText(), MAX_CHOICE_OPTION_LENGTH));
}
entry.put("correctAnswer", question.path("answer").asText());
entry.put("studentAnswer", choiceAnswers.path(question.path("id").asText()).asText(""));
}
return json(result);
}
private int choiceQuestionEntryCount(String choiceQuestionContext) {
try {
JsonNode node = objectMapper.readTree(StringUtils.trimToEmpty(choiceQuestionContext));
return node.isArray() ? node.size() : 0;
} catch (JsonProcessingException | RuntimeException e) {
return 0;
}
}
private JsonNode parseJsonArray(String value) {
try {
String normalized = StringUtils.trimToEmpty(value);
if (!normalized.startsWith("[")) return objectMapper.createArrayNode();
JsonNode node = objectMapper.readTree(normalized);
return node != null && node.isArray() ? node : objectMapper.createArrayNode();
} catch (JsonProcessingException | RuntimeException e) {
return objectMapper.createArrayNode();
}
}
private JsonNode parseChoiceAnswers(String step1Answer) {
try {
if (StringUtils.isBlank(step1Answer)) return objectMapper.createObjectNode();
JsonNode choiceAnswers = objectMapper.readTree(step1Answer).path("choiceAnswers");
return choiceAnswers.isObject() ? choiceAnswers : objectMapper.createObjectNode();
} catch (JsonProcessingException | RuntimeException e) {
return objectMapper.createObjectNode();
}
}
private String helpAnswerSnapshot(String question, String stepContext, StudentTrainingAnswer answer) {
ObjectNode node = objectMapper.createObjectNode();
node.put("studentQuestion", question);
@ -353,19 +615,22 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
}
requireText(criterion, "name");
requireText(criterion, "rationale");
Integer max = wholeNumber(criterion.get("maxScore"));
Integer earned = wholeNumber(criterion.get("score"));
if (!expected.name.equals(StringUtils.trimToEmpty(criterion.path("name").asText()))
|| max == null || earned == null || max != expected.maxScore || earned < 0 || earned > max) {
// maxScore 以评分计划为准:模型偶发把 maxScore 抄错导致合计≠100,
// 不再校验模型返回的 maxScore,只用计划值判定得分范围。
if (!normalizeCriterionName(expected.name).equals(normalizeCriterionName(criterion.path("name").asText()))
|| earned == null || earned < 0 || earned > expected.maxScore) {
throw invalidReport();
}
maxTotal += max;
maxTotal += expected.maxScore;
earnedTotal += earned;
}
if (maxTotal != 100 || earnedTotal != score) {
throw invalidReport();
// 模型偶发把总分写错(与逐项得分之和不一致):以逐项得分为准并回写报告,
// 避免仅因算术错误整份作废。
if (earnedTotal != score) {
((ObjectNode) root).put("score", earnedTotal);
}
return score;
return earnedTotal;
}
private List<AssessmentCriterionPlan> assessmentCriterionPlan(String steps) {
@ -486,6 +751,16 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return new ServiceException(HttpStatus.BAD_GATEWAY, "AI returned an invalid evaluation report");
}
/**
* 评项名比对前的归一化:去掉模型从提示词的编号列表里照搬回来的枚举前缀
* (如 "1."、"2、"、"(3)"、"Step 1"、"第2步")和所有空白。
* 没有编号+分隔符前缀的纯名字(如 "26电商2")不会被误删。
*/
private String normalizeCriterionName(String value) {
String name = StringUtils.trimToEmpty(value).replaceAll("\\s+", "");
return name.replaceFirst("^(?:(?i:step)\\d{0,3}[.::、))-]*|[((]?第\\d{1,3}步?[)).、::-]*|[((]?\\d{1,3}[)).、::-]+)", "");
}
private String helpSystemPrompt() {
return "You are a training learning assistant. Return only JSON with overallDiagnosis, strengths, improvementAreas, and recommendedActions. Do not include a score.";
}
@ -497,7 +772,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
private String assessmentSystemPrompt(List<AssessmentCriterionPlan> criterionPlan) {
return "You are a training assessor. Return exactly one JSON object, with no Markdown fence or other text. "
+ "Required fields: score (integer total earned from criteria), overallComment (Chinese summary), "
+ "Required fields: score (integer total earned from criteria), overallComment (Chinese summary of 30 to 50 Chinese characters), "
+ "criteria (array of objects with name, maxScore, score, rationale), strengths (array of Chinese strings), "
+ "and improvements (array of Chinese strings). "
+ "Assess each step independently using the task requirements, this student's saved answers and available case/file evidence. "
@ -508,8 +783,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
+ "Determine each criterion score from its evidence first, then sum the scores. Do not choose a target total, default score or invent differences between students. "
+ "Each rationale must identify the actual evidence or omission and explain the awarded points or deduction. "
+ "There are exactly " + criterionPlan.size() + " training steps and their maximum scores sum to 100. "
+ "Return exactly " + criterionPlan.size() + " criteria in this exact order. Each name and maxScore must exactly match this required step plan: "
+ "Return exactly " + criterionPlan.size() + " criteria in this exact order. "
+ "Each criterion's name must be copied verbatim from the quoted name in this required step plan, without adding any numbering or other characters; maxScore must exactly match: "
+ criterionPlanPrompt(criterionPlan) + ". "
+ "The user prompt may include a choiceQuestions array: grade each pick strictly against its correctAnswer; "
+ "every wrong or unanswered pick must lose points within the first criterion's score, and that criterion's rationale must name the wrong question numbers. "
+ "score, maxScore, and each criterion score must be JSON integer numbers (not strings). "
+ "Write one complete concise Chinese sentence for each rationale and end it with Chinese punctuation. "
+ "The combined text length of all criteria.rationale values must not exceed 200 Chinese characters; target no more than "
@ -522,14 +800,17 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
for (int index = 0; index < criterionPlan.size(); index++) {
AssessmentCriterionPlan item = criterionPlan.get(index);
if (index > 0) result.append(", ");
result.append(index + 1).append(". ").append(item.name).append(" (max ").append(item.maxScore).append(")");
result.append(index + 1).append(". \"").append(item.name).append("\" (max ").append(item.maxScore).append(")");
}
return result.append("]").toString();
}
private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext) {
private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext,
String choiceQuestionContext) {
return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot
+ "\nResolved case material and this student's uploaded-file excerpts: " + knowledgeContext
+ "\nchoiceQuestions (step-one single-choice questions): " + choiceQuestionContext
+ " Each entry pairs a question's title and options with its correctAnswer and this student's studentAnswer; an empty studentAnswer means the question was not answered."
+ "\nIf the answer snapshot contains a server-generated calculationCheck object, treat it as verified calculation evidence. "
+ "When calculationCorrect is false, deduct points for the reported incorrectItemCount rather than treating the calculation as correct.";
}
@ -542,22 +823,87 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
view.setHelpReportJson(evaluation.getHelpReportJson());
view.setHelpCompletedAt(evaluation.getHelpCompletedAt());
view.setAssessmentScore(evaluation.getAssessmentScore());
view.setAssessmentComment(assessmentComment(evaluation.getAssessmentReportJson()));
view.setAssessmentReportJson(evaluation.getAssessmentReportJson());
view.setAssessmentCompletedAt(evaluation.getAssessmentCompletedAt());
}
return view;
}
private String cappedMessage(RuntimeException exception) {
String message = StringUtils.defaultIfBlank(exception.getMessage(), "AI evaluation failed");
return StringUtils.left(message, MAX_ERROR_LENGTH);
private String assessmentComment(String reportJson) {
if (StringUtils.isBlank(reportJson)) return "";
try {
JsonNode report = objectMapper.readTree(reportJson);
String comment = StringUtils.trimToEmpty(report.path("overallComment").asText());
if (StringUtils.isBlank(comment)) comment = StringUtils.trimToEmpty(report.path("overallDiagnosis").asText());
if (StringUtils.isBlank(comment)) comment = StringUtils.trimToEmpty(report.path("strengths").path(0).asText());
if (StringUtils.isBlank(comment)) return "";
if (comment.codePointCount(0, comment.length()) < 30 && report.path("criteria").isArray()) {
for (JsonNode criterion : report.path("criteria")) {
String rationale = StringUtils.trimToEmpty(criterion.path("rationale").asText());
if (StringUtils.isNotBlank(rationale)) comment = comment + rationale;
if (comment.codePointCount(0, comment.length()) >= 30) break;
}
}
int count = comment.codePointCount(0, comment.length());
if (count > 50) comment = comment.substring(0, comment.offsetByCodePoints(0, 50));
return comment;
} catch (Exception ignored) {
return "";
}
}
private ServiceException asServiceException(RuntimeException exception) {
if (exception instanceof ServiceException) {
return (ServiceException) exception;
/**
* 落库的失败原因:只保存我们自己抛出的 ServiceException 文案,
* 其余异常(数据库、网络等)的原文只写日志,避免把 SQL 等长文本存进库或带给前端。
*/
private String storedFailureMessage(RuntimeException exception) {
if (exception instanceof ServiceException && StringUtils.isNotBlank(exception.getMessage())) {
return StringUtils.left(exception.getMessage(), MAX_ERROR_LENGTH);
}
return new ServiceException(HttpStatus.BAD_GATEWAY, "AI evaluation failed");
return FAILURE_MESSAGE;
}
/**
* 记录失败状态本身出错时不能再往外抛:否则会盖掉真正的失败原因,
* 并让记录一直停在 PROCESSING,学生无法重试。
*/
private void recordHelpFailure(String evaluationId, RuntimeException cause) {
log.warn("AI help failed, evaluationId={}", evaluationId, cause);
try {
aiTrainingEvaluationMapper.failHelp(evaluationId, storedFailureMessage(cause), new Date());
} catch (RuntimeException recordError) {
log.error("Could not record AI help failure, evaluationId={}", evaluationId, recordError);
}
}
private void recordAssessmentFailure(String evaluationId, RuntimeException cause, String rawResponse) {
log.warn("AI assessment failed, evaluationId={}", evaluationId, cause);
String message = storedFailureMessage(cause);
try {
aiTrainingEvaluationMapper.failAssessment(evaluationId, message, rawResponse, new Date());
return;
} catch (RuntimeException recordError) {
log.warn("Could not record AI assessment failure with the raw response, retrying without it, evaluationId={}, rawResponse={}",
evaluationId, StringUtils.left(rawResponse, LOGGED_RAW_RESPONSE_LENGTH), recordError);
}
try {
aiTrainingEvaluationMapper.failAssessment(evaluationId, message, null, new Date());
} catch (RuntimeException recordError) {
log.error("Could not record AI assessment failure, evaluationId={}", evaluationId, recordError);
}
}
private boolean isProcessingTimedOut(AiTrainingEvaluation evaluation) {
Date updatedAt = evaluation.getUpdateTime();
return updatedAt != null && System.currentTimeMillis() - updatedAt.getTime() > PROCESSING_TIMEOUT_MILLIS;
}
private ServiceException asServiceException(RuntimeException exception) {
HttpStatus status = exception instanceof ServiceException && ((ServiceException) exception).getCode() != null
? ((ServiceException) exception).getCode()
: HttpStatus.BAD_GATEWAY;
return new ServiceException(status, FAILURE_MESSAGE);
}
private static class AssessmentCriterionPlan {

@ -21,7 +21,8 @@ public class ImportServiceImpl implements ImportService {
@Autowired
UserinfoMapper userInfoMapper;
@Override
public void processTeacherImport(List<TeacherImportDTO> teacherList) {
public void processTeacherImport(List<TeacherImportDTO> teacherList, Userinfo operator) {
// 教师的学校/院系/专业直接继承当前操作用户
for (int i = 0; i < teacherList.size(); i++) {
TeacherImportDTO teacherImportDTO = teacherList.get(i);
Userinfo userInfo = new Userinfo();
@ -34,9 +35,9 @@ public class ImportServiceImpl implements ImportService {
userInfo.setCreateTime(new Date());
userInfo.setRole(3);
userInfo.setTeacherAdmin(false);
userInfo.setSchoolId(schoolService.selectSchoolIdBySchoolName(teacherImportDTO.getSchoolName()));
userInfo.setSchoolFacultyId(schoolService.selectFacultyIdBySchoolIdAndFacultyName(userInfo.getSchoolId(),teacherImportDTO.getSchoolFacultyName()));
userInfo.setSchoolMajorId(schoolService.selectMajorIdByFacultyIdAndMajorName(userInfo.getSchoolFacultyId(),teacherImportDTO.getSchoolMajorName()));
userInfo.setSchoolId(operator == null ? null : operator.getSchoolId());
userInfo.setSchoolFacultyId(operator == null ? null : operator.getSchoolFacultyId());
userInfo.setSchoolMajorId(operator == null ? null : operator.getSchoolMajorId());
userInfo.setSchoolClassId(null);
userInfo.setCodeFrom("导入");
userInfoMapper.insert(userInfo);

@ -8,6 +8,7 @@ import com.sztzjy.linkCommerce.mapper.SchoolClassMapper;
import com.sztzjy.linkCommerce.mapper.SchoolDefaultTaskInitializationMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService;
import com.sztzjy.linkCommerce.service.TrainingTaskService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@ -17,6 +18,7 @@ import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@ -30,6 +32,8 @@ public class SchoolDefaultTaskServiceImpl implements SchoolDefaultTaskService {
@Autowired(required = false)
SchoolDefaultTaskInitializationMapper initializationMapper;
@Autowired(required = false)
TrainingTaskService trainingTaskService;
@Autowired(required = false)
@Qualifier("asyncTaskExecutor")
AsyncTaskExecutor taskExecutor;
@ -117,19 +121,37 @@ public class SchoolDefaultTaskServiceImpl implements SchoolDefaultTaskService {
if (platformTasks.isEmpty()) {
throw new IllegalStateException("平台基线实训任务为空");
}
Set<String> defaultOpenTaskKeys = defaultOpenTaskKeys();
for (TaskAllocation source : platformTasks) {
TaskAllocation copy = new TaskAllocation();
copy.setId(UUID.randomUUID().toString());
copy.setClassId(schoolDefaultClassId(schoolId));
copy.setSchoolId(schoolId);
copy.setModule(source.getModule());
copy.setDisabledStatus(source.getDisabledStatus());
copy.setDisabledStatus(resolveInitialDisabledStatus(source, defaultOpenTaskKeys));
copy.setSort(source.getSort());
taskAllocationMapper.insert(copy);
}
markReady(schoolId);
}
/**
* 学校默认任务只默认开放第一个项目的前三个任务(与教学班未配置时的学生端一致),
* 其余任务保留完整目录但置为停用,学校管理员可在默认实训任务配置页手动放开。
*/
private byte resolveInitialDisabledStatus(TaskAllocation source, Set<String> defaultOpenTaskKeys) {
if (defaultOpenTaskKeys.isEmpty() || StringUtils.isBlank(source.getModule())) {
return source.getDisabledStatus() == null ? (byte) 1 : source.getDisabledStatus();
}
return defaultOpenTaskKeys.contains(source.getModule().trim()) ? (byte) 0 : (byte) 1;
}
private Set<String> defaultOpenTaskKeys() {
return trainingTaskService == null
? Collections.emptySet()
: trainingTaskService.getDefaultPublishedTaskKeys();
}
private void initializeInBackground(String schoolId) {
markRunning(schoolId);
try {

@ -74,6 +74,10 @@ public class SchoolServiceImpl implements SchoolService {
}
List<SchoolFaculty> schoolFaculties = schoolFacultyMapper.selectByExample(schoolFacultyExample);
// 基于分页查询结果读取真实总数/页数,映射出的 DTO 列表最后再替换进去;
// 直接包装 DTO 列表会把 total 错算成当页条数
PageInfo<SchoolFaculty> sourcePage = new PageInfo<>(schoolFaculties);
// 将院系信息转换为 DTO
List<SchoolFacultyDto> dtoList = schoolFaculties.stream()
.map(schoolFaculty -> {
@ -86,8 +90,13 @@ public class SchoolServiceImpl implements SchoolService {
})
.collect(Collectors.toList());
// 创建 PageInfo 对象,将 DTO 列表封装成分页结果
return new PageInfo<>(dtoList);
PageInfo<SchoolFacultyDto> pageInfo = new PageInfo<>();
pageInfo.setTotal(sourcePage.getTotal());
pageInfo.setPageNum(sourcePage.getPageNum());
pageInfo.setPageSize(sourcePage.getPageSize());
pageInfo.setPages(sourcePage.getPages());
pageInfo.setList(dtoList);
return pageInfo;
}
@Override
@ -131,6 +140,10 @@ public class SchoolServiceImpl implements SchoolService {
}
List<SchoolClass> schoolClasses = schoolClassMapper.selectByExample(schoolClassExample);
// 基于分页查询结果读取真实总数/页数,映射出的 DTO 列表最后再替换进去;
// 直接包装 DTO 列表会把 total 错算成当页条数
PageInfo<SchoolClass> sourcePage = new PageInfo<>(schoolClasses);
// 将院系信息转换为 DTO
String schoolName = schoolMapper.selectSchoolNameById(schoolId);
List<String> schoolMajorIds = schoolClasses.stream()
@ -201,8 +214,13 @@ public class SchoolServiceImpl implements SchoolService {
})
.collect(Collectors.toList());
// 创建 PageInfo 对象,将 DTO 列表封装成分页结果
return new PageInfo<>(dtoList);
PageInfo<SchoolClassDto> pageInfo = new PageInfo<>();
pageInfo.setTotal(sourcePage.getTotal());
pageInfo.setPageNum(sourcePage.getPageNum());
pageInfo.setPageSize(sourcePage.getPageSize());
pageInfo.setPages(sourcePage.getPages());
pageInfo.setList(dtoList);
return pageInfo;
}
@Override

@ -18,11 +18,25 @@ import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabaseService {
/** BI 数据源管理中的平台内置表,顺序与用户提供的文件夹顺序一致。 */
private static final List<String> BUILT_IN_TABLE_NAMES = Collections.unmodifiableList(Arrays.asList(
"三大品类选品数据",
"三大品类用户评价数据",
"用户RFM数据",
"销量预测数据",
"帕累托ABC分析",
"智能眼镜渠道运营数据",
"智能眼镜用户评论数据",
"智能健身镜产品数据",
"智能美妆镜产品数据",
"智能卫浴镜产品数据",
"用户评论数据"
));
@Autowired
private StuSpendingLevelMapper stuSpendingLevelMapper;
@ -48,25 +62,7 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
@Override
public ResultEntity<StuUserProfileDto> getBaseInfo(String userId) {
List<String> list = new ArrayList<>();
list.add("客户细分数据表");
list.add("销售预测数据表");
list.add("购物车数据表");
list.add("商品评论数据");
list.add("RFM明细数据");
list.add("AARRR传播数据");
list.add("超市销售数据");
list.add("集团商品销售总表");
list.add("用户登录表");
list.add("用户留存分析");
list.add("用户粘性分析");
list.add("AARRR示例数据");
list.add("帕累托ABC分析");
list.add("智能健身镜产品数据");
list.add("智能卫浴镜产品数据");
list.add("智能美妆镜产品数据");
List<String> list = new ArrayList<>(BUILT_IN_TABLE_NAMES);
//用户属性表
StuSpendingLevelExample userAttributeExample = new StuSpendingLevelExample();
@ -176,7 +172,7 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
//用户行为表
StuUserCommentExample userAttributeBehaviorExample = new StuUserCommentExample();
userAttributeBehaviorExample.setOrderByClause("id asc");
List<StuUserComment> userAttributeBehaviorList = stuUserCommentMapper.selectByExample(userAttributeBehaviorExample);
List<StuUserComment> userAttributeBehaviorList = stuUserCommentMapper.selectByExampleWithBLOBs(userAttributeBehaviorExample);
if (userAttributeBehaviorList.isEmpty()) {
throw new ServiceException(HttpStatus.OK, "未查询到用户数据!");
}
@ -186,10 +182,7 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
}
String[] list = {"RFM明细数据","AARRR传播数据","超市销售数据","集团商品销售总表","用户登录表","用户留存分析","用户粘性分析","AARRR示例数据",
"帕累托ABC分析","智能健身镜产品数据","智能卫浴镜产品数据","智能美妆镜产品数据"};
List<String> collect = Arrays.stream(list).collect(Collectors.toList());
if (collect.contains(tableName))
if (BUILT_IN_TABLE_NAMES.contains(tableName))
{
//开启分页
PageHelper.startPage(index, size);

@ -1,11 +1,217 @@
package com.sztzjy.linkCommerce.service.impl;
import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.entity.*; import com.sztzjy.linkCommerce.entity.dto.*; import com.sztzjy.linkCommerce.mapper.*; import com.sztzjy.linkCommerce.service.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;
import java.math.*; import java.util.*;
@Service public class StudentExperimentReportServiceImpl implements StudentExperimentReportService {
@Autowired public StudentTeachingClassResolver studentTeachingClassResolver; @Autowired public SchoolDefaultTaskService schoolDefaultTaskService; @Autowired public TrainingTaskMapper trainingTaskMapper; @Autowired public StudentTrainingAnswerMapper studentTrainingAnswerMapper; @Autowired(required=false) public TeachingClassScoreWeightMapper teachingClassScoreWeightMapper; @Autowired(required=false) public TaskAllocationMapper taskAllocationMapper;
private static final String[] NAMES={"互联网产品开发基础认知","市场洞察与需求分析","产品规划与设计","产品开发与测试验证","产品上线与运营推广","综合实训"};
private static final BigDecimal[] DEFAULTS={new BigDecimal("0.10"),new BigDecimal("0.30"),new BigDecimal("0.10"),new BigDecimal("0.10"),new BigDecimal("0.20"),new BigDecimal("0.20")};
public StudentExperimentReportDTO getCurrentReport(JwtUser user){ String classId=studentTeachingClassResolver.resolveRequired(user); StudentExperimentReportDTO report=new StudentExperimentReportDTO(); report.setTeachingClassId(classId); List<StudentExperimentReportModuleDTO> modules=new ArrayList<>(); Map<String,StudentExperimentReportModuleDTO> index=new HashMap<>(); BigDecimal[] weights=weights(classId); boolean comprehensiveParticipating=hasPublishedComprehensiveTask(classId); for(int i=0;i<NAMES.length;i++){StudentExperimentReportModuleDTO m=new StudentExperimentReportModuleDTO();m.setModuleName(NAMES[i]);m.setWeight(weights[i]);m.setParticipating(i<NAMES.length-1||comprehensiveParticipating);modules.add(m);index.put(NAMES[i],m);} List<StudentExperimentReportTaskDTO> tasks=new ArrayList<>(); for(TaskAllocation a: schoolDefaultTaskService.resolveForTeachingClass(classId)){if(a==null||a.getDisabledStatus()!=null&&a.getDisabledStatus()==1||TaskAllocation.PUBLICATION_MARKER.equals(a.getModule())||("comprehensive-case-training".equals(a.getModule())&&!comprehensiveParticipating))continue; TrainingTask t=trainingTaskMapper.selectByTaskKey(a.getModule()); if(t==null||!index.containsKey(t.getProjectName()))continue; StudentTrainingAnswer ans=studentTrainingAnswerMapper.selectByStudentClassAndTask(user.getUserId(),classId,t.getId()); StudentExperimentReportModuleDTO m=index.get(t.getProjectName());m.setTotalTaskCount(m.getTotalTaskCount()+1);int score=ans==null||ans.getAiAssessmentScore()==null?0:ans.getAiAssessmentScore();m.setScore(m.getScore().add(BigDecimal.valueOf(score)));if(ans!=null&&(Boolean.TRUE.equals(ans.getSubmitted())||"COMPLETED".equals(ans.getProgressStatus())))m.setCompletedTaskCount(m.getCompletedTaskCount()+1);StudentExperimentReportTaskDTO row=new StudentExperimentReportTaskDTO();row.setTaskName(t.getTaskName());row.setModuleName(t.getProjectName());if(ans!=null){row.setProgressStatus(ans.getProgressStatus());row.setSubmitted(ans.getSubmitted());row.setAiAssessmentScore(ans.getAiAssessmentScore());}tasks.add(row);} BigDecimal total=BigDecimal.ZERO;BigDecimal participatingWeight=BigDecimal.ZERO;for(StudentExperimentReportModuleDTO m:modules){BigDecimal score=m.getTotalTaskCount()==0?BigDecimal.ZERO:m.getScore().divide(BigDecimal.valueOf(m.getTotalTaskCount()),2,BigDecimal.ROUND_HALF_UP);m.setScore(score);if(Boolean.TRUE.equals(m.getParticipating())){BigDecimal weight=m.getWeight()==null?BigDecimal.ZERO:m.getWeight();total=total.add(score.multiply(weight));participatingWeight=participatingWeight.add(weight);}}report.setTotalScore(participatingWeight.signum()==0?BigDecimal.ZERO:total.divide(participatingWeight,2,BigDecimal.ROUND_HALF_UP));report.setModules(modules);report.setTasks(tasks);return report; }
private boolean hasPublishedComprehensiveTask(String classId){if(taskAllocationMapper==null)return false;TaskAllocationExample example=new TaskAllocationExample();example.createCriteria().andClassIdEqualTo(classId);List<TaskAllocation> allocations=taskAllocationMapper.selectByExample(example);boolean published=false;boolean enabled=false;for(TaskAllocation allocation:allocations==null?Collections.<TaskAllocation>emptyList():allocations){if(allocation==null)continue;if(TaskAllocation.PUBLICATION_MARKER.equals(allocation.getModule()))published=true;else if("comprehensive-case-training".equals(allocation.getModule())&&(allocation.getDisabledStatus()==null||allocation.getDisabledStatus()==0))enabled=true;}return published&&enabled;}
private BigDecimal[] weights(String id){TeachingClassScoreWeight w=teachingClassScoreWeightMapper==null?null:teachingClassScoreWeightMapper.selectByTeachingClassId(id);if(w==null)return DEFAULTS;return new BigDecimal[]{w.getFoundationWeight(),w.getMarketInsightWeight(),w.getPlanningDesignWeight(),w.getDevelopmentValidationWeight(),w.getLaunchOperationWeight(),w.getComprehensiveTrainingWeight()};}
import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer;
import com.sztzjy.linkCommerce.entity.TaskAllocation;
import com.sztzjy.linkCommerce.entity.TaskAllocationExample;
import com.sztzjy.linkCommerce.entity.TeachingClassScoreWeight;
import com.sztzjy.linkCommerce.entity.TrainingTask;
import com.sztzjy.linkCommerce.entity.dto.StudentExperimentReportDTO;
import com.sztzjy.linkCommerce.entity.dto.StudentExperimentReportModuleDTO;
import com.sztzjy.linkCommerce.entity.dto.StudentExperimentReportTaskDTO;
import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
import com.sztzjy.linkCommerce.mapper.TeachingClassScoreWeightMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper;
import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService;
import com.sztzjy.linkCommerce.service.StudentExperimentReportService;
import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class StudentExperimentReportServiceImpl implements StudentExperimentReportService {
@Autowired
public StudentTeachingClassResolver studentTeachingClassResolver;
@Autowired
public SchoolDefaultTaskService schoolDefaultTaskService;
@Autowired
public TrainingTaskMapper trainingTaskMapper;
@Autowired
public StudentTrainingAnswerMapper studentTrainingAnswerMapper;
@Autowired(required = false)
public TeachingClassScoreWeightMapper teachingClassScoreWeightMapper;
@Autowired(required = false)
public TaskAllocationMapper taskAllocationMapper;
private static final String[] NAMES = {
"互联网产品开发基础认知", "市场洞察与需求分析", "产品规划与设计",
"产品开发与测试验证", "产品上线与运营推广", "综合实训"
};
private static final BigDecimal[] DEFAULTS = {
new BigDecimal("0.10"), new BigDecimal("0.30"), new BigDecimal("0.10"),
new BigDecimal("0.10"), new BigDecimal("0.20"), new BigDecimal("0.20")
};
@Override
public StudentExperimentReportDTO getCurrentReport(JwtUser user) {
String classId = studentTeachingClassResolver.resolveRequired(user);
StudentExperimentReportDTO report = new StudentExperimentReportDTO();
report.setTeachingClassId(classId);
List<StudentExperimentReportModuleDTO> modules = new ArrayList<>();
Map<String, StudentExperimentReportModuleDTO> moduleByName = new HashMap<>();
Map<String, Integer> moduleIndex = new HashMap<>();
BigDecimal[] weights = weights(classId);
boolean comprehensiveParticipating = hasPublishedComprehensiveTask(classId);
for (int i = 0; i < NAMES.length; i++) {
StudentExperimentReportModuleDTO module = new StudentExperimentReportModuleDTO();
module.setModuleName(NAMES[i]);
module.setWeight(weights[i]);
module.setParticipating(i < NAMES.length - 1 || comprehensiveParticipating);
modules.add(module);
moduleByName.put(NAMES[i], module);
moduleIndex.put(NAMES[i], i);
}
BigDecimal[] aiSums = zeroScores();
BigDecimal[] teacherSums = zeroScores();
int[] taskCounts = new int[NAMES.length];
boolean[] hasAiScore = new boolean[NAMES.length];
boolean[] hasTeacherScore = new boolean[NAMES.length];
List<StudentExperimentReportTaskDTO> tasks = new ArrayList<>();
List<TaskAllocation> allocations = schoolDefaultTaskService.resolveForTeachingClass(classId);
for (TaskAllocation allocation : allocations == null
? Collections.<TaskAllocation>emptyList() : allocations) {
if (allocation == null || (allocation.getDisabledStatus() != null && allocation.getDisabledStatus() == 1)
|| TaskAllocation.PUBLICATION_MARKER.equals(allocation.getModule())
|| ("comprehensive-case-training".equals(allocation.getModule()) && !comprehensiveParticipating)) {
continue;
}
TrainingTask task = trainingTaskMapper.selectByTaskKey(allocation.getModule());
Integer index = task == null ? null : moduleIndex.get(task.getProjectName());
if (index == null) {
continue;
}
StudentTrainingAnswer answer = studentTrainingAnswerMapper
.selectByStudentClassAndTask(user.getUserId(), classId, task.getId());
StudentExperimentReportModuleDTO module = moduleByName.get(task.getProjectName());
module.setTotalTaskCount(module.getTotalTaskCount() + 1);
taskCounts[index]++;
StudentExperimentReportTaskDTO row = new StudentExperimentReportTaskDTO();
row.setTaskName(task.getTaskName());
row.setModuleName(task.getProjectName());
if (answer != null) {
row.setProgressStatus(answer.getProgressStatus());
row.setSubmitted(answer.getSubmitted());
row.setAiAssessmentScore(answer.getAiAssessmentScore());
row.setTeacherScore(answer.getTeacherScore());
if (answer.getAiAssessmentScore() != null) {
aiSums[index] = aiSums[index].add(BigDecimal.valueOf(answer.getAiAssessmentScore()));
hasAiScore[index] = true;
}
if (answer.getTeacherScore() != null) {
teacherSums[index] = teacherSums[index].add(answer.getTeacherScore());
hasTeacherScore[index] = true;
}
if (Boolean.TRUE.equals(answer.getSubmitted())
|| "COMPLETED".equals(answer.getProgressStatus())) {
module.setCompletedTaskCount(module.getCompletedTaskCount() + 1);
}
}
tasks.add(row);
}
boolean anyAiScore = false;
boolean anyTeacherScore = false;
for (int i = 0; i < NAMES.length; i++) {
BigDecimal aiAverage = average(aiSums[i], taskCounts[i], hasAiScore[i]);
BigDecimal teacherAverage = average(teacherSums[i], taskCounts[i], hasTeacherScore[i]);
anyAiScore = anyAiScore || aiAverage != null;
anyTeacherScore = anyTeacherScore || teacherAverage != null;
// 教师评分优先;本模块没有教师评分时才使用 AI 评分。
modules.get(i).setScore(teacherAverage != null ? teacherAverage : aiAverage);
}
BigDecimal aiTotal = aggregate(modules, aiSums, taskCounts, hasAiScore);
BigDecimal teacherTotal = aggregate(modules, teacherSums, taskCounts, hasTeacherScore);
report.setAiAssessmentScore(anyAiScore ? aiTotal : null);
report.setTeacherScore(anyTeacherScore ? teacherTotal : null);
// 总成绩同样遵循教师评分优先,且没有任何成绩时返回 null。
report.setTotalScore(anyTeacherScore ? teacherTotal : (anyAiScore ? aiTotal : null));
report.setModules(modules);
report.setTasks(tasks);
return report;
}
private BigDecimal[] zeroScores() {
BigDecimal[] scores = new BigDecimal[NAMES.length];
for (int i = 0; i < scores.length; i++) {
scores[i] = BigDecimal.ZERO;
}
return scores;
}
private BigDecimal average(BigDecimal sum, int count, boolean present) {
if (!present || count <= 0) {
return null;
}
return sum.divide(BigDecimal.valueOf(count), 2, BigDecimal.ROUND_HALF_UP);
}
private BigDecimal aggregate(List<StudentExperimentReportModuleDTO> modules,
BigDecimal[] sums, int[] taskCounts, boolean[] present) {
BigDecimal total = BigDecimal.ZERO;
BigDecimal participatingWeight = BigDecimal.ZERO;
for (int i = 0; i < modules.size(); i++) {
BigDecimal average = average(sums[i], taskCounts[i], present[i]);
if (average == null || !Boolean.TRUE.equals(modules.get(i).getParticipating())) {
continue;
}
BigDecimal weight = modules.get(i).getWeight() == null
? BigDecimal.ZERO : modules.get(i).getWeight();
total = total.add(average.multiply(weight));
participatingWeight = participatingWeight.add(weight);
}
return participatingWeight.signum() == 0
? BigDecimal.ZERO
: total.divide(participatingWeight, 2, BigDecimal.ROUND_HALF_UP);
}
private boolean hasPublishedComprehensiveTask(String classId) {
if (taskAllocationMapper == null) {
return false;
}
TaskAllocationExample example = new TaskAllocationExample();
example.createCriteria().andClassIdEqualTo(classId);
List<TaskAllocation> allocations = taskAllocationMapper.selectByExample(example);
boolean published = false;
boolean enabled = false;
for (TaskAllocation allocation : allocations == null
? Collections.<TaskAllocation>emptyList() : allocations) {
if (allocation == null) {
continue;
}
if (TaskAllocation.PUBLICATION_MARKER.equals(allocation.getModule())) {
published = true;
} else if ("comprehensive-case-training".equals(allocation.getModule())
&& (allocation.getDisabledStatus() == null || allocation.getDisabledStatus() == 0)) {
enabled = true;
}
}
return published && enabled;
}
private BigDecimal[] weights(String id) {
TeachingClassScoreWeight weight = teachingClassScoreWeightMapper == null
? null : teachingClassScoreWeightMapper.selectByTeachingClassId(id);
if (weight == null) {
return DEFAULTS;
}
return new BigDecimal[]{
weight.getFoundationWeight(), weight.getMarketInsightWeight(),
weight.getPlanningDesignWeight(), weight.getDevelopmentValidationWeight(),
weight.getLaunchOperationWeight(), weight.getComprehensiveTrainingWeight()
};
}
}

@ -36,6 +36,7 @@ import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@ -142,6 +143,8 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
String incomingGuideManualUrl = task.getGuideManualUrl();
String incomingAiManualName = task.getAiManualName();
String incomingAiManualUrl = task.getAiManualUrl();
String incomingReferenceAnswerName = task.getReferenceAnswerName();
String incomingReferenceAnswerUrl = task.getReferenceAnswerUrl();
normalizeTask(task);
if (incomingTrainingDataName == null) task.setTrainingDataName(existing.getTrainingDataName());
if (incomingTrainingDataUrl == null) task.setTrainingDataUrl(existing.getTrainingDataUrl());
@ -149,6 +152,8 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
if (incomingGuideManualUrl == null) task.setGuideManualUrl(existing.getGuideManualUrl());
if (incomingAiManualName == null) task.setAiManualName(existing.getAiManualName());
if (incomingAiManualUrl == null) task.setAiManualUrl(existing.getAiManualUrl());
if (incomingReferenceAnswerName == null) task.setReferenceAnswerName(existing.getReferenceAnswerName());
if (incomingReferenceAnswerUrl == null) task.setReferenceAnswerUrl(existing.getReferenceAnswerUrl());
TrainingTask builtInTask = requireBuiltInTask(existing.getTaskKey());
task.setId(id);
task.setTaskKey(existing.getTaskKey());
@ -247,6 +252,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
@Override
public List<TrainingTask> listForTeachingClass(String teachingClassId, Boolean enabledOnly, JwtUser operator) {
requireReadableTeachingClass(teachingClassId, operator);
ensureDefaultTasks(null);
List<TrainingTask> defaults = trainingTaskMapper.selectList(null, null, null);
return filterEnabled(readClassTasks(teachingClassId, defaults), enabledOnly);
}
@ -313,6 +319,26 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
return toTask(effective);
}
@Override
@Transactional(rollbackFor = Exception.class)
public TrainingTask setClassTaskReferenceAnswerEnabled(String teachingClassId, String taskKey,
Boolean enabled, String operatorId) {
requireOwnedTeachingClass(teachingClassId, operatorId);
ensureDefaultTasks(null);
String normalizedTaskKey = StringUtils.trimToEmpty(taskKey);
ensureClassTasksInitialized(teachingClassId, trainingTaskMapper.selectList(null, null, null), operatorId);
TrainingTaskClassConfig config = trainingTaskClassConfigMapper
.selectByTeachingClassAndTaskKey(teachingClassId, normalizedTaskKey);
if (config == null) {
throw new IllegalArgumentException("Training task not found");
}
config.setReferenceAnswerEnabled(Boolean.TRUE.equals(enabled));
config.setUpdateTime(new Date());
trainingTaskClassConfigMapper.updateByPrimaryKeySelective(config);
TrainingTaskClassConfig saved = trainingTaskClassConfigMapper.selectByPrimaryKey(config.getId());
return toTask(saved == null ? config : saved);
}
private void indexCaseMaterial(TrainingTask task, EffectiveTrainingTaskContent content, String teachingClassId) {
if (trainingKnowledgeService == null || task == null) return;
try {
@ -441,12 +467,12 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
}
/**
* @return null when this teaching class has never been configured (default: all tasks),
* otherwise the explicitly published task keys, which may be empty.
* @return the explicitly published task keys, or the first three tasks in the first
* project when this teaching class has never been configured.
*/
private Set<String> getPublishedTaskKeys(String teachingClassId) {
if (taskAllocationMapper == null || StringUtils.isBlank(teachingClassId)) {
return null;
return getDefaultPublishedTaskKeys();
}
TaskAllocationExample example = new TaskAllocationExample();
example.createCriteria().andClassIdEqualTo(teachingClassId);
@ -462,7 +488,35 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
}
}
}
return configured ? taskKeys : null;
return configured ? taskKeys : getDefaultPublishedTaskKeys();
}
@Override
public Set<String> getDefaultPublishedTaskKeys() {
List<TrainingTask> defaults = sortTasks(trainingTaskMapper.selectList(null, null, null));
Set<String> taskKeys = new LinkedHashSet<>();
if (defaults == null) {
return taskKeys;
}
String firstProjectName = null;
for (TrainingTask task : defaults) {
if (task == null || StringUtils.isBlank(task.getTaskKey())
|| requiresExplicitPublication(task.getTaskKey())) {
continue;
}
String projectName = StringUtils.defaultString(task.getProjectName(), "其他");
if (firstProjectName == null) {
firstProjectName = projectName;
}
if (!StringUtils.equals(firstProjectName, projectName)) {
continue;
}
taskKeys.add(task.getTaskKey());
if (taskKeys.size() >= 3) {
break;
}
}
return taskKeys;
}
private String requireStudentTeachingClass(JwtUser student) {
@ -647,6 +701,14 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
update.setAiManualUrl(updatedTask.getAiManualUrl());
changed = true;
}
if (shouldSynchronize(classTask.getReferenceAnswerName(), previousTask.getReferenceAnswerName(), updatedTask.getReferenceAnswerName())) {
update.setReferenceAnswerName(updatedTask.getReferenceAnswerName());
changed = true;
}
if (shouldSynchronize(classTask.getReferenceAnswerUrl(), previousTask.getReferenceAnswerUrl(), updatedTask.getReferenceAnswerUrl())) {
update.setReferenceAnswerUrl(updatedTask.getReferenceAnswerUrl());
changed = true;
}
if (changed) {
update.setUpdateTime(new Date());
trainingTaskClassConfigMapper.updateByPrimaryKeySelective(update);
@ -723,6 +785,9 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
task.setGuideManualUrl(config.getGuideManualUrl());
task.setAiManualName(config.getAiManualName());
task.setAiManualUrl(config.getAiManualUrl());
task.setReferenceAnswerName(config.getReferenceAnswerName());
task.setReferenceAnswerUrl(config.getReferenceAnswerUrl());
task.setReferenceAnswerEnabled(config.getReferenceAnswerEnabled());
task.setSort(config.getSort());
task.setEnabled(config.getEnabled());
task.setCreatedBy(config.getCreatedBy());
@ -751,6 +816,8 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
config.setGuideManualUrl(task.getGuideManualUrl());
config.setAiManualName(task.getAiManualName());
config.setAiManualUrl(task.getAiManualUrl());
config.setReferenceAnswerName(task.getReferenceAnswerName());
config.setReferenceAnswerUrl(task.getReferenceAnswerUrl());
config.setSort(task.getSort());
config.setEnabled(task.getEnabled());
config.setCreatedBy(operatorId);
@ -780,6 +847,8 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
"guide_manual_url varchar(500) NULL COMMENT '实训指导手册地址'," +
"ai_manual_name varchar(255) NULL COMMENT 'AI排错指导手册名称'," +
"ai_manual_url varchar(500) NULL COMMENT 'AI排错指导手册地址'," +
"reference_answer_name varchar(255) NULL COMMENT '参考答案名称'," +
"reference_answer_url varchar(500) NULL COMMENT '参考答案地址'," +
"sort int DEFAULT 0 COMMENT '排序'," +
"enabled bit(1) DEFAULT b'1' COMMENT '是否启用'," +
"created_by varchar(64) NULL COMMENT '创建人'," +
@ -793,6 +862,7 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
jdbcTemplate.execute("ALTER TABLE training_task CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
ensureTextColumn("training_task", "step_one_questions", "第一步选择题JSON数组");
ensureMaterialColumns("training_task");
ensureReferenceAnswerColumns("training_task");
trainingTaskTableChecked = true;
}
@ -820,6 +890,9 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
"guide_manual_url varchar(500) NULL," +
"ai_manual_name varchar(255) NULL," +
"ai_manual_url varchar(500) NULL," +
"reference_answer_name varchar(255) NULL," +
"reference_answer_url varchar(500) NULL," +
"reference_answer_enabled bit(1) NOT NULL DEFAULT b'0' COMMENT '是否向学生开放参考答案'," +
"sort int DEFAULT 0," +
"enabled bit(1) DEFAULT b'1'," +
"created_by varchar(64) NULL," +
@ -832,9 +905,27 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Class scoped training task config'");
ensureTextColumn("training_task_class_config", "step_one_questions", "第一步选择题JSON数组");
ensureMaterialColumns("training_task_class_config");
ensureReferenceAnswerColumns("training_task_class_config");
ensureReferenceAnswerVisibilityColumn("training_task_class_config");
trainingTaskClassConfigTableChecked = true;
}
private void ensureReferenceAnswerVisibilityColumn(String tableName) {
if (jdbcTemplate == null) {
return;
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'reference_answer_enabled'",
Integer.class, tableName);
if (count != null && count > 0) {
return;
}
jdbcTemplate.execute("ALTER TABLE " + tableName
+ " ADD COLUMN reference_answer_enabled bit(1) NOT NULL DEFAULT b'0' "
+ "COMMENT '是否向学生开放参考答案' AFTER reference_answer_url");
}
/** Add the field for deployments that already created the task tables before this feature existed. */
private void ensureTextColumn(String tableName, String columnName, String comment) {
if (jdbcTemplate == null) {
@ -872,6 +963,23 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
+ " ADD COLUMN ai_manual_url varchar(500) NULL AFTER ai_manual_name");
}
/** 补齐参考答案列:已有四类资料列的部署需要追加参考答案两列。 */
private void ensureReferenceAnswerColumns(String tableName) {
if (jdbcTemplate == null) {
return;
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'reference_answer_url'",
Integer.class, tableName);
if (count != null && count > 0) {
return;
}
jdbcTemplate.execute("ALTER TABLE " + tableName
+ " ADD COLUMN reference_answer_name varchar(255) NULL AFTER ai_manual_url,"
+ " ADD COLUMN reference_answer_url varchar(500) NULL AFTER reference_answer_name");
}
TrainingTask buildTask(TrainingTaskImportDTO row, int rowNumber, List<String> errors) {
String projectName = StringUtils.trimToEmpty(row.getProjectName());
String taskKey = StringUtils.trimToEmpty(row.getTaskKey());
@ -952,11 +1060,24 @@ public class TrainingTaskServiceImpl implements TrainingTaskService {
task.setGuideManualUrl(StringUtils.trimToEmpty(task.getGuideManualUrl()));
task.setAiManualName(StringUtils.trimToEmpty(task.getAiManualName()));
task.setAiManualUrl(StringUtils.trimToEmpty(task.getAiManualUrl()));
task.setReferenceAnswerName(StringUtils.trimToEmpty(task.getReferenceAnswerName()));
task.setReferenceAnswerUrl(StringUtils.trimToEmpty(task.getReferenceAnswerUrl()));
validateReferenceAnswerFile(task.getReferenceAnswerName());
if (task.getEnabled() == null) {
task.setEnabled(Boolean.TRUE);
}
}
private void validateReferenceAnswerFile(String fileName) {
if (StringUtils.isBlank(fileName)) {
return;
}
String normalized = fileName.trim().toLowerCase(Locale.ROOT);
if (!normalized.endsWith(".doc") && !normalized.endsWith(".docx")) {
throw new IllegalArgumentException("参考答案仅支持Word文件(.doc或.docx)");
}
}
private TrainingTask requireBuiltInTask(String taskKey) {
TrainingTask builtInTask = getBuiltInTask(taskKey);
if (builtInTask == null) {

@ -38,25 +38,7 @@ public class ValidateServiceImpl implements ValidateService {
TeacherImportDTO teacher = teacherList.get(i);
int rowNum = i + 2; // Excel行号(标题行+1)
// 1. 校验院校名称
if (StringUtils.isBlank(teacher.getSchoolName())) {
errors.add("第" + rowNum + "行: 院校名称不能为空");
}
// 2. 校验院系名称
if (StringUtils.isBlank(teacher.getSchoolFacultyName())) {
errors.add("第" + rowNum + "行: 院系名称不能为空");
}
// 3. 校验专业名称
if (StringUtils.isBlank(teacher.getSchoolMajorName())) {
errors.add("第" + rowNum + "行: 专业名称不能为空");
} else if (!schoolService.existsBySchoolMajorName(teacher.getSchoolName(),teacher.getSchoolMajorName(), teacher.getSchoolFacultyName())) {
errors.add("第" + rowNum + "行: 专业'" + teacher.getSchoolMajorName() +
"'在院系'" + teacher.getSchoolFacultyName() + "'下不存在");
}
// 4. 校验教师姓名
// 1. 校验教师姓名(教师归属的学校/院系/专业直接取自当前操作用户,不再从Excel读取)
if (StringUtils.isBlank(teacher.getTeacherName())) {
errors.add("第" + rowNum + "行: 教师姓名不能为空");
}

@ -15,10 +15,11 @@ public class EffectiveTrainingTaskContent {
private final String steps;
private final String materialName;
private final String materialUrl;
private final String stepOneQuestions;
private EffectiveTrainingTaskContent(String scope, String effectiveConfigId, String knowledge, String background,
String objectives, String requirements, String steps, String materialName,
String materialUrl) {
String materialUrl, String stepOneQuestions) {
this.scope = scope;
this.effectiveConfigId = effectiveConfigId;
this.knowledge = knowledge;
@ -28,6 +29,7 @@ public class EffectiveTrainingTaskContent {
this.steps = steps;
this.materialName = materialName;
this.materialUrl = materialUrl;
this.stepOneQuestions = stepOneQuestions;
}
public static EffectiveTrainingTaskContent resolve(TrainingTask task, TrainingTaskClassConfig classConfig) {
@ -42,7 +44,8 @@ public class EffectiveTrainingTaskContent {
value(useTeacher ? classConfig.getRequirements() : null, task.getRequirements()),
value(useTeacher ? classConfig.getSteps() : null, task.getSteps()),
value(useTeacher ? classConfig.getMaterialName() : null, task.getMaterialName()),
value(useTeacher ? classConfig.getMaterialUrl() : null, task.getMaterialUrl()));
value(useTeacher ? classConfig.getMaterialUrl() : null, task.getMaterialUrl()),
value(useTeacher ? classConfig.getStepOneQuestions() : null, task.getStepOneQuestions()));
}
private static String value(String preferred, String fallback) {
@ -58,4 +61,5 @@ public class EffectiveTrainingTaskContent {
public String getSteps() { return steps; }
public String getMaterialName() { return materialName; }
public String getMaterialUrl() { return materialUrl; }
public String getStepOneQuestions() { return stepOneQuestions; }
}

@ -11,11 +11,17 @@ import com.itextpdf.text.pdf.PdfPTable;
import com.itextpdf.text.pdf.PdfWriter;
import org.apache.poi.hslf.usermodel.*;
import org.apache.poi.xslf.usermodel.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -32,6 +38,10 @@ import static com.sztzjy.linkCommerce.util.pdfUtils.PDFConvertUtil.TypeEnum.*;
*/
public final class PDFConvertUtil {
private static final Logger log = LoggerFactory.getLogger(PDFConvertUtil.class);
private static final String BUNDLED_FONT_RESOURCE = "/simsun.ttf";
private static volatile boolean chineseFontsConfigured;
final private static Map<TypeEnum, List<String>> types = new HashMap<>();
public static enum TypeEnum {
@ -119,27 +129,31 @@ public final class PDFConvertUtil {
* @param outputStream pdf文件输出流
**/
public static boolean wordToPdfByAspose(InputStream inputStream, OutputStream outputStream) {
return convertWordToPdfByAspose(inputStream, outputStream);
}
private static boolean convertWordToPdfByAspose(InputStream inputStream, OutputStream outputStream) {
// 验证License 若不验证则转化出的pdf文档会有水印产生
if (!getLicense()) {
log.warn("word转PDF失败:Aspose License 校验未通过");
return false;
}
try {
//此处处理乱码和小方块
//如果在本地运行,此处报错,请注释这个这是字体,主要是为了解决linux环境下面运行jar时找不到中文字体的问题
//指定文件库内容路径
FontSettings.getDefaultInstance().setFontsFolders(
new String[] {"/usr/share/fonts", "/usr/share/fonts/win"}
, true);
FontSettings fontSettings = configureChineseFonts();
// 将源文件保存在com.aspose.words.Document中,具体的转换格式依靠里面的save方法
com.aspose.words.Document doc = new com.aspose.words.Document(inputStream);
doc.setFontSettings(fontSettings);
// Blob URL 预览时浏览器会读取 PDF 的文档标题。清除 Word 原始标题,
// 避免某些文档的二进制标题被显示成 yAAAA... 之类的乱码。
doc.getBuiltInDocumentProperties().setTitle("reference-answer");
// 全面支持DOC, DOCX, OOXML, RTF HTML, OpenDocument, PDF,EPUB, XPS, SWF 相互转换
doc.save(outputStream, SaveFormat.PDF);
System.out.println("word转换完毕");
} catch (Exception e) {
e.printStackTrace();
log.warn("word转PDF异常", e);
return false;
}finally {
closeOutput(outputStream);
@ -148,6 +162,67 @@ public final class PDFConvertUtil {
return true;
}
/**
* 配置 Word 转 PDF 使用的中文字体。
*
* 服务器通常没有 Windows 的宋体,单纯扫描系统字体目录会导致 DOC/DOCX
* 中的中文被替换成方框或乱码。项目内置 simsun.ttf,首次转换时解压到
* 临时字体目录,并和常见系统字体目录一起交给 Aspose。
*/
private static FontSettings configureChineseFonts() {
FontSettings settings = FontSettings.getDefaultInstance();
if (chineseFontsConfigured) {
return settings;
}
synchronized (PDFConvertUtil.class) {
if (chineseFontsConfigured) {
return settings;
}
List<String> fontFolders = new ArrayList<>();
addExistingFontFolder(fontFolders, "C:\\Windows\\Fonts");
addExistingFontFolder(fontFolders, "/usr/share/fonts");
addExistingFontFolder(fontFolders, "/usr/local/share/fonts");
addExistingFontFolder(fontFolders, "/usr/share/fonts/chinese");
addExistingFontFolder(fontFolders, "/usr/share/fonts/win");
try {
Path bundledFontFolder = Paths.get(System.getProperty("java.io.tmpdir"), "link-commerce-fonts");
Files.createDirectories(bundledFontFolder);
Path bundledFont = bundledFontFolder.resolve("simsun.ttf");
if (!Files.exists(bundledFont) || Files.size(bundledFont) == 0) {
try (InputStream input = PDFConvertUtil.class.getResourceAsStream(BUNDLED_FONT_RESOURCE)) {
if (input == null) {
throw new FileNotFoundException("Missing bundled font " + BUNDLED_FONT_RESOURCE);
}
Files.copy(input, bundledFont, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
addExistingFontFolder(fontFolders, bundledFontFolder.toString());
} catch (IOException | SecurityException e) {
log.warn("内置中文字体加载失败,将继续使用系统字体目录", e);
}
if (!fontFolders.isEmpty()) {
settings.setFontsFolders(fontFolders.toArray(new String[0]), true);
}
settings.setDefaultFontName("SimSun");
settings.setFontSubstitutes("宋体", "SimSun", "Noto Sans CJK SC", "Microsoft YaHei");
settings.setFontSubstitutes("等线", "SimSun", "Noto Sans CJK SC", "Microsoft YaHei");
settings.setFontSubstitutes("微软雅黑", "SimSun", "Noto Sans CJK SC", "Microsoft YaHei");
settings.setFontSubstitutes("黑体", "SimSun", "Noto Sans CJK SC", "Microsoft YaHei");
settings.setFontSubstitutes("仿宋", "SimSun", "Noto Sans CJK SC", "Microsoft YaHei");
chineseFontsConfigured = true;
return settings;
}
}
private static void addExistingFontFolder(List<String> folders, String folder) {
if (folder != null && Files.isDirectory(Paths.get(folder))) {
folders.add(folder);
}
}
/**
* 关闭输出流
* @param outputStream
@ -208,7 +283,7 @@ public final class PDFConvertUtil {
aposeLic.setLicense(is);
result = true;
} catch (Exception e) {
e.printStackTrace();
log.warn("Aspose Words License 加载异常", e);
}
return result;
}
@ -225,8 +300,11 @@ public final class PDFConvertUtil {
//此处处理乱码和小方块
//如果在本地运行,此处报错,请注释这个这是字体,主要是为了解决linux环境下面运行jar时找不到中文字体的问题
//指定文件库内容路径
// 字体目录同时包含 Windows 系统字体与 Linux 常见目录:
// setFontsFolders 会替换 aspose 的默认字体源,漏掉 Windows 字体目录时
// 宋体等中文字体不可见,转换出的 PDF 中文全部变成方框
FontSettings.getDefaultInstance().setFontsFolders(
new String[] {"/usr/share/fonts", "/usr/share/fonts/win"}
new String[] {"C:\\Windows\\Fonts", "/usr/share/fonts", "/usr/share/fonts/win"}
, true);
// 原始excel路径
com.aspose.cells.Workbook wb = new com.aspose.cells.Workbook(inputStream);
@ -329,8 +407,11 @@ public final class PDFConvertUtil {
//此处处理乱码和小方块
//如果在本地运行,此处报错,请注释这个这是字体,主要是为了解决linux环境下面运行jar时找不到中文字体的问题
//指定文件库内容路径
// 字体目录同时包含 Windows 系统字体与 Linux 常见目录:
// setFontsFolders 会替换 aspose 的默认字体源,漏掉 Windows 字体目录时
// 宋体等中文字体不可见,转换出的 PDF 中文全部变成方框
FontSettings.getDefaultInstance().setFontsFolders(
new String[] {"/usr/share/fonts", "/usr/share/fonts/win"}
new String[] {"C:\\Windows\\Fonts", "/usr/share/fonts", "/usr/share/fonts/win"}
, true);
hslfSlideShow = new HSLFSlideShow(inputStream);
@ -411,8 +492,11 @@ public final class PDFConvertUtil {
//此处处理乱码和小方块
//如果在本地运行,此处报错,请注释这个这是字体,主要是为了解决linux环境下面运行jar时找不到中文字体的问题
//指定文件库内容路径
// 字体目录同时包含 Windows 系统字体与 Linux 常见目录:
// setFontsFolders 会替换 aspose 的默认字体源,漏掉 Windows 字体目录时
// 宋体等中文字体不可见,转换出的 PDF 中文全部变成方框
FontSettings.getDefaultInstance().setFontsFolders(
new String[] {"/usr/share/fonts", "/usr/share/fonts/win"}
new String[] {"C:\\Windows\\Fonts", "/usr/share/fonts", "/usr/share/fonts/win"}
, true);
slideShow = new XMLSlideShow(inputStream);

@ -63,6 +63,9 @@ spring:
validation-query: SELECT 1
validation-query-timeout: 3
keep-alive: true
# 固定驱动端编码:这里的参数优先于连接串,避免生产环境私有配置里的连接串
# 缺少或写错 characterEncoding 时,驱动按平台默认编码(GBK)发送中文
connection-properties: characterEncoding=utf8;connectionCollation=utf8mb4_unicode_ci
connection-init-sqls:
- SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
# 配置监控统计

@ -61,14 +61,16 @@
UPDATE ai_training_evaluation
SET help_status = 'PROCESSING', help_error_message = NULL, update_time = #{updateTime,jdbcType=TIMESTAMP}
WHERE id = #{id,jdbcType=VARCHAR}
AND help_status IN ('NOT_STARTED', 'FAILED', 'SUCCEEDED')
AND (help_status IN ('NOT_STARTED', 'FAILED', 'SUCCEEDED')
OR (help_status = 'PROCESSING' AND update_time &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
</update>
<update id="claimAssessment">
UPDATE ai_training_evaluation
SET assessment_status = 'PROCESSING', assessment_error_message = NULL, update_time = #{updateTime,jdbcType=TIMESTAMP}
WHERE id = #{id,jdbcType=VARCHAR}
AND assessment_status IN ('NOT_STARTED', 'FAILED')
AND (assessment_status IN ('NOT_STARTED', 'FAILED')
OR (assessment_status = 'PROCESSING' AND update_time &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
</update>
<update id="claimReassessment">
@ -76,7 +78,6 @@
SET assessment_status = 'PROCESSING', assessment_error_message = NULL, update_time = #{updateTime,jdbcType=TIMESTAMP}
WHERE id = #{id,jdbcType=VARCHAR}
AND assessment_status = 'SUCCEEDED'
AND (BINARY assessment_answer_snapshot &lt;=&gt; BINARY #{expectedAnswerSnapshot,jdbcType=LONGVARCHAR})
</update>
<update id="completeHelp" parameterType="com.sztzjy.linkCommerce.entity.AiTrainingEvaluation">

@ -18,6 +18,7 @@
<result column="submitted" jdbcType="BIT" property="submitted" />
<result column="ai_assessment_score" jdbcType="INTEGER" property="aiAssessmentScore" />
<result column="teacher_score" jdbcType="DECIMAL" property="teacherScore" />
<result column="teacher_comment" jdbcType="VARCHAR" property="teacherComment" />
<result column="teacher_scored_by" jdbcType="VARCHAR" property="teacherScoredBy" />
<result column="teacher_scored_at" jdbcType="TIMESTAMP" property="teacherScoredAt" />
<result column="submit_time" jdbcType="TIMESTAMP" property="submitTime" />
@ -27,7 +28,7 @@
<sql id="Base_Column_List">
id, student_user_id, teaching_class_id, task_id, task_key, task_name, step1_answer, step2_answer,
step3_answer, step4_answer, dynamic_step_answers, current_step, progress_status, submitted, ai_assessment_score, teacher_score, teacher_scored_by, teacher_scored_at, submit_time, create_time, update_time
step3_answer, step4_answer, dynamic_step_answers, current_step, progress_status, submitted, ai_assessment_score, teacher_score, teacher_comment, teacher_scored_by, teacher_scored_at, submit_time, create_time, update_time
</sql>
<select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap">
@ -79,6 +80,7 @@
<if test="submitted != null">submitted,</if>
<if test="aiAssessmentScore != null">ai_assessment_score,</if>
<if test="teacherScore != null">teacher_score,</if>
<if test="teacherComment != null">teacher_comment,</if>
<if test="teacherScoredBy != null">teacher_scored_by,</if>
<if test="teacherScoredAt != null">teacher_scored_at,</if>
<if test="submitTime != null">submit_time,</if>
@ -102,6 +104,7 @@
<if test="submitted != null">#{submitted,jdbcType=BIT},</if>
<if test="aiAssessmentScore != null">#{aiAssessmentScore,jdbcType=INTEGER},</if>
<if test="teacherScore != null">#{teacherScore,jdbcType=DECIMAL},</if>
<if test="teacherComment != null">#{teacherComment,jdbcType=VARCHAR},</if>
<if test="teacherScoredBy != null">#{teacherScoredBy,jdbcType=VARCHAR},</if>
<if test="teacherScoredAt != null">#{teacherScoredAt,jdbcType=TIMESTAMP},</if>
<if test="submitTime != null">#{submitTime,jdbcType=TIMESTAMP},</if>
@ -128,6 +131,7 @@
<if test="submitted != null">submitted = #{submitted,jdbcType=BIT},</if>
<if test="aiAssessmentScore != null">ai_assessment_score = #{aiAssessmentScore,jdbcType=INTEGER},</if>
<if test="teacherScore != null">teacher_score = #{teacherScore,jdbcType=DECIMAL},</if>
<if test="teacherComment != null">teacher_comment = #{teacherComment,jdbcType=VARCHAR},</if>
<if test="teacherScoredBy != null">teacher_scored_by = #{teacherScoredBy,jdbcType=VARCHAR},</if>
<if test="teacherScoredAt != null">teacher_scored_at = #{teacherScoredAt,jdbcType=TIMESTAMP},</if>
<if test="submitTime != null">submit_time = #{submitTime,jdbcType=TIMESTAMP},</if>
@ -152,4 +156,13 @@
update_time = #{scoredAt,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=VARCHAR}
</update>
<update id="updateTeacherScoreWithComment">
update student_training_answer
set teacher_score = #{score,jdbcType=DECIMAL},
teacher_comment = #{teacherComment,jdbcType=VARCHAR},
teacher_scored_by = #{teacherUserId,jdbcType=VARCHAR},
teacher_scored_at = #{scoredAt,jdbcType=TIMESTAMP},
update_time = #{scoredAt,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=VARCHAR}
</update>
</mapper>

@ -21,6 +21,9 @@
<result column="guide_manual_url" jdbcType="VARCHAR" property="guideManualUrl" />
<result column="ai_manual_name" jdbcType="VARCHAR" property="aiManualName" />
<result column="ai_manual_url" jdbcType="VARCHAR" property="aiManualUrl" />
<result column="reference_answer_name" jdbcType="VARCHAR" property="referenceAnswerName" />
<result column="reference_answer_url" jdbcType="VARCHAR" property="referenceAnswerUrl" />
<result column="reference_answer_enabled" jdbcType="BIT" property="referenceAnswerEnabled" />
<result column="sort" jdbcType="INTEGER" property="sort" />
<result column="enabled" jdbcType="BIT" property="enabled" />
<result column="created_by" jdbcType="VARCHAR" property="createdBy" />
@ -32,6 +35,7 @@
id, teaching_class_id, task_key, project_name, task_name, knowledge, background, objectives, requirements,
steps, step_one_questions, material_name, material_url,
training_data_name, training_data_url, guide_manual_name, guide_manual_url, ai_manual_name, ai_manual_url,
reference_answer_name, reference_answer_url, reference_answer_enabled,
sort, enabled, created_by, create_time, update_time
</sql>
@ -98,6 +102,9 @@
<if test="guideManualUrl != null">guide_manual_url,</if>
<if test="aiManualName != null">ai_manual_name,</if>
<if test="aiManualUrl != null">ai_manual_url,</if>
<if test="referenceAnswerName != null">reference_answer_name,</if>
<if test="referenceAnswerUrl != null">reference_answer_url,</if>
<if test="referenceAnswerEnabled != null">reference_answer_enabled,</if>
<if test="sort != null">sort,</if>
<if test="enabled != null">enabled,</if>
<if test="createdBy != null">created_by,</if>
@ -124,6 +131,9 @@
<if test="guideManualUrl != null">#{guideManualUrl,jdbcType=VARCHAR},</if>
<if test="aiManualName != null">#{aiManualName,jdbcType=VARCHAR},</if>
<if test="aiManualUrl != null">#{aiManualUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerName != null">#{referenceAnswerName,jdbcType=VARCHAR},</if>
<if test="referenceAnswerUrl != null">#{referenceAnswerUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerEnabled != null">#{referenceAnswerEnabled,jdbcType=BIT},</if>
<if test="sort != null">#{sort,jdbcType=INTEGER},</if>
<if test="enabled != null">#{enabled,jdbcType=BIT},</if>
<if test="createdBy != null">#{createdBy,jdbcType=VARCHAR},</if>
@ -151,6 +161,9 @@
<if test="guideManualUrl != null">guide_manual_url = #{guideManualUrl,jdbcType=VARCHAR},</if>
<if test="aiManualName != null">ai_manual_name = #{aiManualName,jdbcType=VARCHAR},</if>
<if test="aiManualUrl != null">ai_manual_url = #{aiManualUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerName != null">reference_answer_name = #{referenceAnswerName,jdbcType=VARCHAR},</if>
<if test="referenceAnswerUrl != null">reference_answer_url = #{referenceAnswerUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerEnabled != null">reference_answer_enabled = #{referenceAnswerEnabled,jdbcType=BIT},</if>
<if test="sort != null">sort = #{sort,jdbcType=INTEGER},</if>
<if test="enabled != null">enabled = #{enabled,jdbcType=BIT},</if>
<if test="updateTime != null">update_time = #{updateTime,jdbcType=TIMESTAMP},</if>

@ -20,6 +20,8 @@
<result column="guide_manual_url" jdbcType="VARCHAR" property="guideManualUrl" />
<result column="ai_manual_name" jdbcType="VARCHAR" property="aiManualName" />
<result column="ai_manual_url" jdbcType="VARCHAR" property="aiManualUrl" />
<result column="reference_answer_name" jdbcType="VARCHAR" property="referenceAnswerName" />
<result column="reference_answer_url" jdbcType="VARCHAR" property="referenceAnswerUrl" />
<result column="sort" jdbcType="INTEGER" property="sort" />
<result column="enabled" jdbcType="BIT" property="enabled" />
<result column="created_by" jdbcType="VARCHAR" property="createdBy" />
@ -31,6 +33,7 @@
id, task_key, project_name, task_name, knowledge, background, objectives, requirements,
steps, step_one_questions, material_name, material_url,
training_data_name, training_data_url, guide_manual_name, guide_manual_url, ai_manual_name, ai_manual_url,
reference_answer_name, reference_answer_url,
sort, enabled, created_by, create_time, update_time
</sql>
@ -93,6 +96,8 @@
<if test="guideManualUrl != null">guide_manual_url,</if>
<if test="aiManualName != null">ai_manual_name,</if>
<if test="aiManualUrl != null">ai_manual_url,</if>
<if test="referenceAnswerName != null">reference_answer_name,</if>
<if test="referenceAnswerUrl != null">reference_answer_url,</if>
<if test="sort != null">sort,</if>
<if test="enabled != null">enabled,</if>
<if test="createdBy != null">created_by,</if>
@ -118,6 +123,8 @@
<if test="guideManualUrl != null">#{guideManualUrl,jdbcType=VARCHAR},</if>
<if test="aiManualName != null">#{aiManualName,jdbcType=VARCHAR},</if>
<if test="aiManualUrl != null">#{aiManualUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerName != null">#{referenceAnswerName,jdbcType=VARCHAR},</if>
<if test="referenceAnswerUrl != null">#{referenceAnswerUrl,jdbcType=VARCHAR},</if>
<if test="sort != null">#{sort,jdbcType=INTEGER},</if>
<if test="enabled != null">#{enabled,jdbcType=BIT},</if>
<if test="createdBy != null">#{createdBy,jdbcType=VARCHAR},</if>
@ -146,6 +153,8 @@
<if test="guideManualUrl != null">guide_manual_url = #{guideManualUrl,jdbcType=VARCHAR},</if>
<if test="aiManualName != null">ai_manual_name = #{aiManualName,jdbcType=VARCHAR},</if>
<if test="aiManualUrl != null">ai_manual_url = #{aiManualUrl,jdbcType=VARCHAR},</if>
<if test="referenceAnswerName != null">reference_answer_name = #{referenceAnswerName,jdbcType=VARCHAR},</if>
<if test="referenceAnswerUrl != null">reference_answer_url = #{referenceAnswerUrl,jdbcType=VARCHAR},</if>
<if test="sort != null">sort = #{sort,jdbcType=INTEGER},</if>
<if test="enabled != null">enabled = #{enabled,jdbcType=BIT},</if>
<if test="createdBy != null">created_by = #{createdBy,jdbcType=VARCHAR},</if>

@ -0,0 +1,75 @@
package com.sztzjy.linkCommerce.config;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DatabaseCharsetCheckerTest {
@Test
void reportsNothingWhenConnectionAndColumnAreUtf8mb4() {
DatabaseCharsetChecker checker = checker("utf8mb4", "utf8mb4", Collections.singletonList("utf8mb4"));
assertTrue(checker.findProblems().isEmpty());
}
@Test
void reportsConnectionCharsetThatIsNotUtf8mb4() {
DatabaseCharsetChecker checker = checker("gbk", "gbk", Collections.singletonList("utf8mb4"));
List<String> problems = checker.findProblems();
assertEquals(2, problems.size());
assertTrue(problems.get(0).contains("character_set_client=gbk"));
assertTrue(problems.get(1).contains("character_set_connection=gbk"));
}
@Test
void reportsRawResponseColumnThatIsNotUtf8mb4() {
DatabaseCharsetChecker checker = checker("utf8mb4", "utf8mb4", Collections.singletonList("latin1"));
List<String> problems = checker.findProblems();
assertEquals(1, problems.size());
assertTrue(problems.get(0).contains("assessment_raw_response"));
assertTrue(problems.get(0).contains("latin1"));
}
@Test
void skipsColumnCheckWhenTableDoesNotExistYet() {
DatabaseCharsetChecker checker = checker("utf8mb4", "utf8mb4", Collections.<String>emptyList());
assertTrue(checker.findProblems().isEmpty());
}
@Test
void neverBlocksStartupWhenDatabaseIsUnavailable() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.queryForMap(anyString())).thenThrow(new DataAccessResourceFailureException("db down"));
assertDoesNotThrow(() -> new DatabaseCharsetChecker(jdbcTemplate).check());
}
private static DatabaseCharsetChecker checker(String client, String connection, List<String> columnCharsets) {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
Map<String, Object> session = new HashMap<String, Object>();
session.put("client", client);
session.put("conn", connection);
when(jdbcTemplate.queryForMap(anyString())).thenReturn(session);
when(jdbcTemplate.queryForList(anyString(), org.mockito.ArgumentMatchers.<Class<String>>any()))
.thenReturn(columnCharsets);
return new DatabaseCharsetChecker(jdbcTemplate);
}
}

@ -0,0 +1,59 @@
package com.sztzjy.linkCommerce.config.exception.handler;
import com.sztzjy.linkCommerce.util.ResultEntity;
import org.apache.ibatis.exceptions.PersistenceException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.BadSqlGrammarException;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void databaseExceptionsReturnShortMessageWithoutSql() {
PersistenceException e = new PersistenceException(
"\n### Error updating database. Cause: java.sql.SQLException: Incorrect string value"
+ "\n### SQL: UPDATE ai_training_evaluation SET assessment_raw_response = ?");
ResultEntity<String> result = handler.nullPointerException(e);
assertShortAndClean(result.getBody().getMsg());
}
@Test
void springJdbcExceptionsReturnShortMessageWithoutSql() {
BadSqlGrammarException e = new BadSqlGrammarException("PreparedStatementCallback",
"select p.question from platform_question p", new SQLException("syntax error near 'x'"));
ResultEntity<String> result = handler.nullPointerException(e);
assertShortAndClean(result.getBody().getMsg());
}
@Test
void wrappedSqlExceptionIsAlsoShortened() {
RuntimeException e = new RuntimeException("### Could not execute: select * from ai_training_evaluation",
new SQLException("Incorrect string value: '\\xC9'"));
ResultEntity<String> result = handler.nullPointerException(e);
assertShortAndClean(result.getBody().getMsg());
}
@Test
void ordinaryBusinessMessagesAreUnchanged() {
ResultEntity<String> result = handler.nullPointerException(new RuntimeException("考试不存在或无权访问"));
assertEquals("考试不存在或无权访问", result.getBody().getMsg());
}
private static void assertShortAndClean(String message) {
assertTrue(message != null && message.length() <= 30, "message: " + message);
assertTrue(!message.contains("###") && !message.contains("SQL") && !message.contains("select "),
"internal detail leaked: " + message);
}
}

@ -46,6 +46,17 @@ class TeaScoreControllerTaskReviewTest {
"taskReview", String.class, String.class, String.class, HttpServletRequest.class));
}
@Test
void taskScoreRejectsMissingTeacherScoreWithBusinessMessage() {
TeaScoreController controller = controllerWithCompletedAnswer();
ResultEntity<?> result = controller.saveTaskTeacherScore(
"class-1", "student-1", "task-1", null, "评语", new MockHttpServletRequest());
assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode());
assertEquals("教师评分必须为0至100之间的数值", result.getBody().getMsg());
}
@Test
void batchScoreRejectsTaskThatIsNotAllocatedToTheClassBeforeWritingAnyScore() {
TeaScoreController controller = controllerWithCompletedAnswer();

@ -1304,14 +1304,12 @@ class UserControllerTeacherAdminTest {
adminClass.setClassType("ADMIN");
adminClass.setCreatedBy("teacher-1");
when(controller.userinfoMapper.selectByPrimaryKey("teacher-1")).thenReturn(teacher("teacher-1", true));
when(controller.schoolFacultyMapper.selectByExample(any())).thenReturn(Collections.singletonList(faculty("faculty-1", "院系")));
when(controller.schoolMajorMapper.selectByExample(any())).thenReturn(Collections.singletonList(major("major-1", "faculty-1", "专业")));
when(controller.schoolClassMapper.selectByExample(any())).thenReturn(Collections.singletonList(adminClass));
when(controller.userInfoService.existsByUserName("20260001")).thenReturn(false);
when(controller.userinfoMapper.insertSelective(any(Userinfo.class))).thenReturn(1);
ResultEntity result = controller.importTeacherStudentRows(
Collections.singletonList(studentAccountRow("张三", "20260001", "男", "院系", "专业", "行政一班")),
Collections.singletonList(studentAccountRow("张三", "20260001", "男", "行政一班")),
"teacher-1");
ArgumentCaptor<Userinfo> captor = ArgumentCaptor.forClass(Userinfo.class);
@ -1335,14 +1333,12 @@ class UserControllerTeacherAdminTest {
adminClass.setClassType("ADMIN");
adminClass.setCreatedBy("teacher-2");
when(controller.userinfoMapper.selectByPrimaryKey("teacher-1")).thenReturn(teacher("teacher-1", true));
when(controller.schoolFacultyMapper.selectByExample(any())).thenReturn(Collections.singletonList(faculty("faculty-1", "院系")));
when(controller.schoolMajorMapper.selectByExample(any())).thenReturn(Collections.singletonList(major("major-1", "faculty-1", "专业")));
when(controller.schoolClassMapper.selectByExample(any())).thenReturn(Collections.singletonList(adminClass));
when(controller.userInfoService.existsByUserName("20260001")).thenReturn(false);
when(controller.userinfoMapper.insertSelective(any(Userinfo.class))).thenReturn(1);
ResultEntity result = controller.importTeacherStudentRows(
Collections.singletonList(studentAccountRow("张三", "20260001", "男", "院系", "专业", "行政一班")),
Collections.singletonList(studentAccountRow("张三", "20260001", "男", "行政一班")),
"teacher-1");
assertEquals(HttpStatus.OK, result.getStatusCode());
@ -1464,15 +1460,11 @@ class UserControllerTeacherAdminTest {
private SchoolAdminStudentImportDTO studentAccountRow(String name,
String username,
String sex,
String facultyName,
String majorName,
String className) {
SchoolAdminStudentImportDTO row = new SchoolAdminStudentImportDTO();
row.setName(name);
row.setUsername(username);
row.setSex(sex);
row.setSchoolFacultyName(facultyName);
row.setSchoolMajorName(majorName);
row.setSchoolClassName(className);
row.setPhone("13800000000");
row.setEmail("student@example.com");

@ -501,6 +501,124 @@ class AiTrainingEvaluationServiceImplTest {
verify(service.qwenChatClient, never()).completeJson(any(), any());
}
@Test
void recentProcessingAssessmentIsNotReclaimed() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "PROCESSING");
evaluation.setUpdateTime(minutesAgo(1));
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation);
AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student());
assertEquals("PROCESSING", view.getAssessmentStatus());
verify(service.aiTrainingEvaluationMapper, never()).claimAssessment(any(), any(Date.class));
verify(service.qwenChatClient, never()).completeJson(any(), any());
}
@Test
void staleProcessingAssessmentIsReclaimedSoStudentIsNotStuckForever() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "PROCESSING");
evaluation.setUpdateTime(minutesAgo(11));
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation);
when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1);
when(service.qwenChatClient.completeJson(any(), any())).thenReturn(assessmentJson());
when(service.aiTrainingEvaluationMapper.completeAssessment(any(AiTrainingEvaluation.class))).thenReturn(1);
when(service.studentTrainingAnswerMapper.updateAiAssessmentScore(eq("answer-1"), eq(86), any(Date.class))).thenReturn(1);
AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student());
assertEquals(Integer.valueOf(86), view.getAssessmentScore());
verify(service.aiTrainingEvaluationMapper).claimAssessment(eq("evaluation-1"), any(Date.class));
}
@Test
void failureRecordingErrorFallsBackToShortRecordAndKeepsStudentRetryable() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED");
String invalidResponse = assessmentJson().replace("\"maxScore\":25", "\"maxScore\":24");
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1"))
.thenReturn(null, evaluation);
when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1);
when(service.qwenChatClient.completeJson(any(), any())).thenReturn(invalidResponse);
when(service.aiTrainingEvaluationMapper.failAssessment(eq("evaluation-1"), any(), eq(invalidResponse), any(Date.class)))
.thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage()));
when(service.aiTrainingEvaluationMapper.failAssessment(eq("evaluation-1"), any(), isNull(), any(Date.class))).thenReturn(1);
ServiceException thrown = assertThrows(ServiceException.class,
() -> service.generateAssessment("new-product-survey", student()));
assertShortFriendlyMessage(thrown.getMessage());
verify(service.aiTrainingEvaluationMapper).failAssessment(eq("evaluation-1"), any(), isNull(), any(Date.class));
}
@Test
void failureRecordingBothAttemptsFailingStillReturnsShortError() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED");
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1"))
.thenReturn(null, evaluation);
when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1);
when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException("upstream"));
when(service.aiTrainingEvaluationMapper.failAssessment(any(), any(), any(), any(Date.class)))
.thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage()));
ServiceException thrown = assertThrows(ServiceException.class,
() -> service.generateAssessment("new-product-survey", student()));
assertShortFriendlyMessage(thrown.getMessage());
}
@Test
void helpFailureRecordingErrorStillReturnsShortError() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED");
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1"))
.thenReturn(null, evaluation);
when(service.aiTrainingEvaluationMapper.claimHelp(eq("evaluation-1"), any(Date.class))).thenReturn(1);
when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException("upstream"));
when(service.aiTrainingEvaluationMapper.failHelp(any(), any(), any(Date.class)))
.thenThrow(new org.apache.ibatis.exceptions.PersistenceException(longDatabaseMessage()));
ServiceException thrown = assertThrows(ServiceException.class,
() -> service.generateHelp("new-product-survey", student(), "如何完善调研证据?"));
assertShortFriendlyMessage(thrown.getMessage());
}
@Test
void storedFailureMessageNeverContainsInternalExceptionText() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));
AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED");
when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1"))
.thenReturn(null, evaluation);
when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1);
when(service.qwenChatClient.completeJson(any(), any())).thenThrow(new IllegalStateException(longDatabaseMessage()));
when(service.aiTrainingEvaluationMapper.failAssessment(any(), any(), any(), any(Date.class))).thenReturn(1);
assertThrows(ServiceException.class, () -> service.generateAssessment("new-product-survey", student()));
ArgumentCaptor<String> stored = ArgumentCaptor.forClass(String.class);
verify(service.aiTrainingEvaluationMapper).failAssessment(eq("evaluation-1"), stored.capture(), any(), any(Date.class));
assertShortFriendlyMessage(stored.getValue());
}
private static String longDatabaseMessage() {
return "\n### Error updating database. Cause: java.sql.SQLException: Incorrect string value"
+ "\n### SQL: UPDATE ai_training_evaluation SET assessment_raw_response = ? WHERE id = ?";
}
private static void assertShortFriendlyMessage(String message) {
assertNotNull(message);
assertTrue(message.length() <= 60, "message too long: " + message);
assertTrue(!message.contains("###") && !message.contains("SQL") && !message.contains("Exception"),
"internal detail leaked: " + message);
}
private static Date minutesAgo(int minutes) {
return new Date(System.currentTimeMillis() - minutes * 60_000L);
}
@Test
void rejectsNonStudentJwtUser() {
AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null));

@ -3,37 +3,65 @@ package com.sztzjy.linkCommerce.service.impl;
import com.sztzjy.linkCommerce.entity.Userinfo;
import com.sztzjy.linkCommerce.entity.importDto.TeacherImportDTO;
import com.sztzjy.linkCommerce.mapper.UserinfoMapper;
import com.sztzjy.linkCommerce.service.SchoolService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ImportServiceImplTest {
@Test
void teacherImportCreatesOrdinaryTeacher() {
ImportServiceImpl service = new ImportServiceImpl();
service.schoolService = mock(SchoolService.class);
service.userInfoMapper = mock(UserinfoMapper.class);
when(service.schoolService.selectSchoolIdBySchoolName(any())).thenReturn("school-1");
when(service.schoolService.selectFacultyIdBySchoolIdAndFacultyName(any(), any())).thenReturn("faculty-1");
when(service.schoolService.selectMajorIdByFacultyIdAndMajorName(any(), any())).thenReturn("major-1");
// 操作用户的学校/院系/专业应直接继承给导入的教师
Userinfo operator = new Userinfo();
operator.setUserId("operator-1");
operator.setSchoolId("school-1");
operator.setSchoolFacultyId("faculty-1");
operator.setSchoolMajorId("major-1");
TeacherImportDTO row = new TeacherImportDTO();
row.setTeacherName("张老师");
row.setUserName("t001");
service.processTeacherImport(Collections.singletonList(row));
service.processTeacherImport(Collections.singletonList(row), operator);
ArgumentCaptor<Userinfo> captor = ArgumentCaptor.forClass(Userinfo.class);
verify(service.userInfoMapper).insert(captor.capture());
assertFalse(captor.getValue().getTeacherAdmin());
assertEquals("school-1", captor.getValue().getSchoolId());
assertEquals("faculty-1", captor.getValue().getSchoolFacultyId());
assertEquals("major-1", captor.getValue().getSchoolMajorId());
}
@Test
void teacherImportWithoutOperatorOrganizationLeavesFieldsEmpty() {
ImportServiceImpl service = new ImportServiceImpl();
service.userInfoMapper = mock(UserinfoMapper.class);
Userinfo operator = new Userinfo();
operator.setUserId("operator-2");
operator.setSchoolId("school-1");
TeacherImportDTO row = new TeacherImportDTO();
row.setTeacherName("李老师");
row.setUserName("t002");
service.processTeacherImport(Collections.singletonList(row), operator);
ArgumentCaptor<Userinfo> captor = ArgumentCaptor.forClass(Userinfo.class);
verify(service.userInfoMapper).insert(captor.capture());
assertEquals("school-1", captor.getValue().getSchoolId());
assertNull(captor.getValue().getSchoolFacultyId());
assertNull(captor.getValue().getSchoolMajorId());
}
}

@ -5,12 +5,17 @@ import com.sztzjy.linkCommerce.entity.TaskAllocation;
import com.sztzjy.linkCommerce.mapper.SchoolClassMapper;
import com.sztzjy.linkCommerce.mapper.SchoolDefaultTaskInitializationMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
import com.sztzjy.linkCommerce.service.TrainingTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.core.task.AsyncTaskExecutor;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
@ -68,6 +73,63 @@ class SchoolDefaultTaskServiceImplTest {
verify(executor).execute(any(Runnable.class));
}
@Test
void initializationOpensOnlyFirstProjectFirstThreeTasksAndDisablesRest() {
TaskAllocationMapper taskMapper = mock(TaskAllocationMapper.class);
TrainingTaskService trainingTaskService = mock(TrainingTaskService.class);
SchoolDefaultTaskServiceImpl service = new SchoolDefaultTaskServiceImpl();
ReflectionTestUtils.setField(service, "taskAllocationMapper", taskMapper);
ReflectionTestUtils.setField(service, "trainingTaskService", trainingTaskService);
when(taskMapper.countByExample(any())).thenReturn(0L);
when(taskMapper.selectByExample(any())).thenReturn(Arrays.asList(
baselineTask("task-1", (byte) 0),
baselineTask("task-2", (byte) 0),
baselineTask("task-3", (byte) 0),
baselineTask("task-4", (byte) 0),
baselineTask("task-5", (byte) 0),
baselineTask("comprehensive-case-training", (byte) 1)));
Set<String> defaultOpen = new LinkedHashSet<>(Arrays.asList("task-1", "task-2", "task-3"));
when(trainingTaskService.getDefaultPublishedTaskKeys()).thenReturn(defaultOpen);
service.initializeSchoolDefault("school-1");
ArgumentCaptor<TaskAllocation> captor = ArgumentCaptor.forClass(TaskAllocation.class);
verify(taskMapper, times(6)).insert(captor.capture());
for (TaskAllocation inserted : captor.getAllValues()) {
assertEquals("SCHOOL_DEFAULT:school-1", inserted.getClassId());
if (defaultOpen.contains(inserted.getModule())) {
assertEquals((byte) 0, inserted.getDisabledStatus(), inserted.getModule() + " 应默认开放");
} else {
assertEquals((byte) 1, inserted.getDisabledStatus(), inserted.getModule() + " 应默认停用");
}
}
}
@Test
void initializationFallsBackToBaselineStatusWhenDefaultOpenKeysUnavailable() {
TaskAllocationMapper taskMapper = mock(TaskAllocationMapper.class);
SchoolDefaultTaskServiceImpl service = new SchoolDefaultTaskServiceImpl();
ReflectionTestUtils.setField(service, "taskAllocationMapper", taskMapper);
when(taskMapper.countByExample(any())).thenReturn(0L);
when(taskMapper.selectByExample(any())).thenReturn(Collections.singletonList(
baselineTask("task-1", (byte) 0)));
service.initializeSchoolDefault("school-1");
ArgumentCaptor<TaskAllocation> captor = ArgumentCaptor.forClass(TaskAllocation.class);
verify(taskMapper).insert(captor.capture());
assertEquals((byte) 0, captor.getValue().getDisabledStatus());
}
private TaskAllocation baselineTask(String module, byte disabledStatus) {
TaskAllocation task = new TaskAllocation();
task.setModule(module);
task.setDisabledStatus(disabledStatus);
return task;
}
private TaskAllocation task(String module) {
TaskAllocation task = new TaskAllocation();
task.setModule(module);

@ -128,6 +128,20 @@ class TrainingTaskServiceImplTest {
assertFalse(task.getSteps().contains("Step 3"));
}
@Test
void referenceAnswerRejectsNonWordFiles() {
TrainingTaskServiceImpl service = new TrainingTaskServiceImpl();
service.trainingTaskMapper = mock(TrainingTaskMapper.class);
TrainingTask task = storedTask(null, "new-product-survey");
task.setReferenceAnswerName("reference-answer.pdf");
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> service.create(task, "teacher-1"));
assertEquals("参考答案仅支持Word文件(.doc或.docx)", error.getMessage());
verify(service.trainingTaskMapper, never()).insertSelective(any(TrainingTask.class));
}
@Test
void importTaskRejectsUnknownTaskKeyBecauseTaskCountIsFixed() {
TrainingTaskServiceImpl service = new TrainingTaskServiceImpl();
@ -649,6 +663,8 @@ class TrainingTaskServiceImplTest {
payload.setTaskName("Class Name");
payload.setKnowledge("Class Knowledge");
payload.setMaterialName("Class Material");
payload.setReferenceAnswerName("参考答案.docx");
payload.setReferenceAnswerUrl("/file/reference-answer.docx");
payload.setEnabled(Boolean.TRUE);
service.saveClassTask("class-1", "new-product-survey", payload, "teacher-1");
@ -665,6 +681,8 @@ class TrainingTaskServiceImplTest {
assertEquals(Integer.valueOf(3), savedOverride.getSort());
assertEquals("Class Knowledge", savedOverride.getKnowledge());
assertEquals("Class Material", savedOverride.getMaterialName());
assertEquals("参考答案.docx", savedOverride.getReferenceAnswerName());
assertEquals("/file/reference-answer.docx", savedOverride.getReferenceAnswerUrl());
}
@Test

Loading…
Cancel
Save