Compare commits

..

No commits in common. 'main' and 'fix/exam-question-access-material-excel-delete-draft' have entirely different histories.

@ -1,69 +0,0 @@
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;
}
}

@ -4,8 +4,6 @@ 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;
@ -15,8 +13,6 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.sql.SQLException;
/**
* 全局异常处理
*/
@ -24,8 +20,6 @@ import java.sql.SQLException;
@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));
@ -67,22 +61,9 @@ 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,6 +466,8 @@ 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());
@ -484,16 +486,32 @@ 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(className)) {
schoolClass = findAdminClassByName(schoolId, className);
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 (schoolClass == null) {
errors.add("第 " + rowNumber + " 行:未找到行政班:" + className);
errors.add("第 " + rowNumber + " 行:专业“" + majorName + "”下未找到班级“" + className + "”");
}
}
@ -506,6 +524,8 @@ 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导入");
@ -523,6 +543,8 @@ 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());
@ -552,13 +574,30 @@ public class SchoolAdminController {
return false;
}
//学生账号导入不再区分院系/专业,班级直接按本校行政班名称匹配
private SchoolClass findAdminClassByName(String schoolId, String className) {
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) {
SchoolClassExample example = new SchoolClassExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andClassNameEqualTo(className)
.andClassTypeEqualTo("ADMIN");
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
}

@ -27,7 +27,6 @@ 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;
@ -95,9 +94,6 @@ 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, "账号不存在");
}

@ -441,7 +441,6 @@ public class TeaScoreController {
rank.setCompletedTaskCount(0);
rank.setTrainingProgress(null);
rank.setTeacherScore(null);
rank.setAiAssessmentScore(null);
continue;
}
List<StudentTrainingAnswer> answers = studentTrainingAnswerMapper == null ? Collections.emptyList()
@ -455,7 +454,6 @@ 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));
}
}
@ -553,48 +551,6 @@ 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;

@ -557,36 +557,32 @@ public class UserController {
@PostMapping("/addTeacherAccount")
@ApiOperation("教师管理-添加教师账号")
@AnonymousAccess
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,
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,
@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) {
//学校/院系/专业默认从当前操作用户继承;平台管理员可显式指定学校
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);
//先根据schoolId进行查询 如果不存在则返回
School school = schoolMapper.selectByPrimaryKey(schoolId);
if (school == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该学校不存在");
}
if (!canManageSchoolStructure(operatorId, effectiveSchoolId)) {
if (!canManageSchoolStructure(operatorId, schoolId)) {
return noManagePermission();
}
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) {
//再根据schoolFacultyId查询 如果不存在则返回
SchoolFaculty schoolFaculty = schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId);
if (schoolFaculty == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该院系不存在");
}
if (StringUtils.isNotBlank(effectiveMajorId) && schoolMajorMapper.selectByPrimaryKey(effectiveMajorId) == null) {
//再根据schoolMajorId查询 如果不存在则返回
SchoolMajor schoolMajor = schoolMajorMapper.selectByPrimaryKey(schoolMajorId);
if (schoolMajor == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该专业不存在");
}
//检查userName是否已经存在
@ -603,9 +599,9 @@ public class UserController {
userInfo.setEmail(email);
userInfo.setCreateTime(new Date());
userInfo.setRole(3);
userInfo.setSchoolId(effectiveSchoolId);
userInfo.setSchoolFacultyId(effectiveFacultyId);
userInfo.setSchoolMajorId(effectiveMajorId);
userInfo.setSchoolId(schoolId);
userInfo.setSchoolFacultyId(schoolFacultyId);
userInfo.setSchoolMajorId(schoolMajorId);
userInfo.setSchoolClassId(null);
userInfo.setTeacherAdmin(false);
userInfo.setCodeFrom("添加");
@ -635,6 +631,7 @@ public class UserController {
}
schoolId = operator.getSchoolId();
}
PageHelper.startPage(index, size);
UserinfoExample userInfoExample = new UserinfoExample();
UserinfoExample.Criteria criteria = userInfoExample.createCriteria();
School school = new School();
@ -649,8 +646,6 @@ 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)) {
@ -770,18 +765,15 @@ public class UserController {
if (!errorMessages.isEmpty()) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "数据校验失败!", errorMessages);
}
// 教师的学校/院系/专业直接取自当前操作用户,不再从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();
for (TeacherImportDTO teacher : teacherList) {
String schoolId = schoolService.selectSchoolIdBySchoolName(teacher.getSchoolName());
if (!canManageSchoolStructure(operatorId, schoolId)) {
return noManagePermission();
}
}
// 异步处理导入*****
importService.processTeacherImport(teacherList, operator);
importService.processTeacherImport(teacherList);
return new ResultEntity<>(HttpStatus.OK, "导入成功!");
} catch (Exception e) {
@ -823,9 +815,9 @@ public class UserController {
@AnonymousAccess
public ResultEntity addStudentAccount(@ApiParam("学校ID") @RequestParam String schoolId,
@ApiParam("学校名称") @RequestParam String schoolName,
@ApiParam("院系ID") @RequestParam(required = false) String schoolFacultyId,
@ApiParam("专业ID") @RequestParam(required = false) String schoolMajorId,
@ApiParam("专业名称") @RequestParam(required = false) String schoolMajorName,
@ApiParam("院系ID") @RequestParam String schoolFacultyId,
@ApiParam("专业ID") @RequestParam String schoolMajorId,
@ApiParam("专业名称") @RequestParam String schoolMajorName,
@ApiParam("班级名称") @RequestParam String schoolClassName,
@ApiParam("班级Id") @RequestParam String schoolClassId,
@ApiParam("学生姓名") @RequestParam String name,
@ -840,11 +832,14 @@ public class UserController {
if (school == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该学校不存在");
}
//院系/专业改为选填:传了才校验,不传直接入库为空
if (StringUtils.isNotBlank(schoolFacultyId) && schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId) == null) {
//再根据schoolFacultyId查询 如果不存在则返回
SchoolFaculty schoolFaculty = schoolFacultyMapper.selectByPrimaryKey(schoolFacultyId);
if (schoolFaculty == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该院系不存在");
}
if (StringUtils.isNotBlank(schoolMajorId) && schoolMajorMapper.selectByPrimaryKey(schoolMajorId) == null) {
//再根据schoolMajorId查询 如果不存在则返回
SchoolMajor schoolMajor = schoolMajorMapper.selectByPrimaryKey(schoolMajorId);
if (schoolMajor == null) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "该专业不存在");
}
SchoolClass schoolClass = schoolClassMapper.selectByPrimaryKey(schoolClassId);
@ -870,8 +865,8 @@ public class UserController {
userInfo.setRole(4);
userInfo.setTeacherAdmin(false);
userInfo.setSchoolId(effectiveSchoolId);
userInfo.setSchoolFacultyId(StringUtils.trimToNull(schoolFacultyId));
userInfo.setSchoolMajorId(StringUtils.trimToNull(schoolMajorId));
userInfo.setSchoolFacultyId(schoolFacultyId);
userInfo.setSchoolMajorId(schoolMajorId);
String adminClassId = "TEACHING".equals(schoolClass.getClassType())
? resolveAdminClassIdForTeachingClass(schoolClass)
: schoolClassId;
@ -994,6 +989,7 @@ 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();
@ -1029,8 +1025,6 @@ 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)) {
@ -2135,6 +2129,8 @@ 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());
@ -2151,16 +2147,32 @@ 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(className)) {
schoolClass = findTeacherImportAdminClass(operator.getSchoolId(), className);
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 (schoolClass == null) {
errors.add("第" + rowNumber + "行:未找到行政班“" + className + "”");
errors.add("第" + rowNumber + "行:专业“" + majorName + "”下未找到班级“" + className + "”");
} else if (!isAdminClass(schoolClass)) {
errors.add("第" + rowNumber + "行:学生账号只能导入行政班");
} else if (!canManageAdminClass(operator, schoolClass)) {
@ -2180,6 +2192,8 @@ 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导入");
@ -2196,6 +2210,8 @@ 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());
@ -2225,23 +2241,30 @@ public class UserController {
return false;
}
private SchoolClass findTeacherImportClass(String schoolId, String majorId, String className) {
SchoolClassExample example = new SchoolClassExample();
private SchoolFaculty findTeacherImportFaculty(String schoolId, String facultyName) {
SchoolFacultyExample example = new SchoolFacultyExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
.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);
}
//学生账号导入不再区分院系/专业,班级直接按本校行政班名称匹配
private SchoolClass findTeacherImportAdminClass(String schoolId, String className) {
private SchoolClass findTeacherImportClass(String schoolId, String majorId, String className) {
SchoolClassExample example = new SchoolClassExample();
example.createCriteria()
.andSchoolIdEqualTo(schoolId)
.andClassNameEqualTo(className)
.andClassTypeEqualTo("ADMIN");
.andSchoolMajorIdEqualTo(majorId)
.andClassNameEqualTo(className);
List<SchoolClass> classes = schoolClassMapper.selectByExample(example);
return classes.isEmpty() ? null : classes.get(0);
}

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

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

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

@ -1,13 +1,12 @@
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, Userinfo operator);
void processTeacherImport(List<TeacherImportDTO> teacherList);
void processStudentImport(List<StudentImportDTO> studentList);
}

@ -3,7 +3,6 @@ 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;
@ -20,10 +19,8 @@ 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;
@ -36,24 +33,16 @@ import java.util.ArrayList;
import java.util.Date;
import java.util.List;
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();
@ -65,8 +54,6 @@ 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)
@ -95,7 +82,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()) && !isProcessingTimedOut(evaluation)) {
if (PROCESSING.equals(evaluation.getHelpStatus())) {
return toView(evaluation);
}
if (aiTrainingEvaluationMapper.claimHelp(evaluation.getId(), new Date()) != 1) {
@ -123,7 +110,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed.setAssessmentStatus(evaluation.getAssessmentStatus());
return toView(completed);
} catch (RuntimeException e) {
recordHelpFailure(evaluation.getId(), e);
aiTrainingEvaluationMapper.failHelp(evaluation.getId(), cappedMessage(e), new Date());
throw asServiceException(e);
}
}
@ -132,16 +119,12 @@ 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()) && !isProcessingTimedOut(evaluation)) {
log.info("AI assessment still processing, evaluationId={}", evaluation.getId());
if (PROCESSING.equals(evaluation.getAssessmentStatus())) {
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);
@ -150,19 +133,13 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
? 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);
}
String rawResponse = null;
try {
String knowledgeContext = knowledgeContext(context, user, "");
String choiceQuestionContext = choiceQuestionContext(context.effectiveContent, context.answer);
log.info("AI assessment choice question context prepared, evaluationId={}, entries={}",
evaluation.getId(), choiceQuestionEntryCount(choiceQuestionContext));
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan),
assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext, choiceQuestionContext));
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan), assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext));
JsonNode assessmentReport = object(rawResponse);
int score = validateAssessment(assessmentReport, criterionPlan);
compactAssessmentRationales(assessmentReport);
@ -178,14 +155,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed.setAssessmentCompletedAt(completedAt);
completed.setUpdateTime(completedAt);
completeAssessmentAndWriteScore(completed, context.answer.getId(), score, completedAt);
// 教师端成绩列表读的是 stu_rank 每日快照(凌晨1点定时重建),
// 不在这里立即刷新本班快照,新 AI 分数要等次日才会出现在教师端。
refreshScoreRankAfterAssessment(user, context.teachingClassId);
completed.setHelpStatus(evaluation.getHelpStatus());
completed.setAssessmentStatus(SUCCEEDED);
return toView(completed);
} catch (RuntimeException e) {
recordAssessmentFailure(evaluation.getId(), e, rawResponse);
aiTrainingEvaluationMapper.failAssessment(evaluation.getId(), cappedMessage(e), rawResponse, new Date());
throw asServiceException(e);
}
}
@ -225,26 +199,6 @@ 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) {
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
user.getUserId(), context.teachingClassId, context.task.getId());
@ -356,59 +310,6 @@ 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);
@ -452,22 +353,19 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
}
requireText(criterion, "name");
requireText(criterion, "rationale");
Integer max = wholeNumber(criterion.get("maxScore"));
Integer earned = wholeNumber(criterion.get("score"));
// maxScore 以评分计划为准:模型偶发把 maxScore 抄错导致合计≠100,
// 不再校验模型返回的 maxScore,只用计划值判定得分范围。
if (!normalizeCriterionName(expected.name).equals(normalizeCriterionName(criterion.path("name").asText()))
|| earned == null || earned < 0 || earned > expected.maxScore) {
if (!expected.name.equals(StringUtils.trimToEmpty(criterion.path("name").asText()))
|| max == null || earned == null || max != expected.maxScore || earned < 0 || earned > max) {
throw invalidReport();
}
maxTotal += expected.maxScore;
maxTotal += max;
earnedTotal += earned;
}
// 模型偶发把总分写错(与逐项得分之和不一致):以逐项得分为准并回写报告,
// 避免仅因算术错误整份作废。
if (earnedTotal != score) {
((ObjectNode) root).put("score", earnedTotal);
if (maxTotal != 100 || earnedTotal != score) {
throw invalidReport();
}
return earnedTotal;
return score;
}
private List<AssessmentCriterionPlan> assessmentCriterionPlan(String steps) {
@ -588,16 +486,6 @@ 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.";
}
@ -620,11 +508,8 @@ 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 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: "
+ "Return exactly " + criterionPlan.size() + " criteria in this exact order. Each name and maxScore must exactly match this required step plan: "
+ 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 "
@ -637,17 +522,14 @@ 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,
String choiceQuestionContext) {
private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext) {
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.";
}
@ -666,57 +548,16 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return view;
}
/**
* 落库的失败原因:只保存我们自己抛出的 ServiceException 文案,
* 其余异常(数据库、网络等)的原文只写日志,避免把 SQL 等长文本存进库或带给前端。
*/
private String storedFailureMessage(RuntimeException exception) {
if (exception instanceof ServiceException && StringUtils.isNotBlank(exception.getMessage())) {
return StringUtils.left(exception.getMessage(), MAX_ERROR_LENGTH);
}
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 String cappedMessage(RuntimeException exception) {
String message = StringUtils.defaultIfBlank(exception.getMessage(), "AI evaluation failed");
return StringUtils.left(message, MAX_ERROR_LENGTH);
}
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);
if (exception instanceof ServiceException) {
return (ServiceException) exception;
}
return new ServiceException(HttpStatus.BAD_GATEWAY, "AI evaluation failed");
}
private static class AssessmentCriterionPlan {

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

@ -74,10 +74,6 @@ 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 -> {
@ -90,13 +86,8 @@ public class SchoolServiceImpl implements SchoolService {
})
.collect(Collectors.toList());
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;
// 创建 PageInfo 对象,将 DTO 列表封装成分页结果
return new PageInfo<>(dtoList);
}
@Override
@ -140,10 +131,6 @@ 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()
@ -214,13 +201,8 @@ public class SchoolServiceImpl implements SchoolService {
})
.collect(Collectors.toList());
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;
// 创建 PageInfo 对象,将 DTO 列表封装成分页结果
return new PageInfo<>(dtoList);
}
@Override

@ -18,25 +18,11 @@ 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;
@ -62,7 +48,25 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
@Override
public ResultEntity<StuUserProfileDto> getBaseInfo(String userId) {
List<String> list = new ArrayList<>(BUILT_IN_TABLE_NAMES);
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("智能美妆镜产品数据");
//用户属性表
StuSpendingLevelExample userAttributeExample = new StuSpendingLevelExample();
@ -172,7 +176,7 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
//用户行为表
StuUserCommentExample userAttributeBehaviorExample = new StuUserCommentExample();
userAttributeBehaviorExample.setOrderByClause("id asc");
List<StuUserComment> userAttributeBehaviorList = stuUserCommentMapper.selectByExampleWithBLOBs(userAttributeBehaviorExample);
List<StuUserComment> userAttributeBehaviorList = stuUserCommentMapper.selectByExample(userAttributeBehaviorExample);
if (userAttributeBehaviorList.isEmpty()) {
throw new ServiceException(HttpStatus.OK, "未查询到用户数据!");
}
@ -182,7 +186,10 @@ public class StuUserProfileDatabaseServiceImpl implements StuUserProfileDatabase
}
if (BUILT_IN_TABLE_NAMES.contains(tableName))
String[] list = {"RFM明细数据","AARRR传播数据","超市销售数据","集团商品销售总表","用户登录表","用户留存分析","用户粘性分析","AARRR示例数据",
"帕累托ABC分析","智能健身镜产品数据","智能卫浴镜产品数据","智能美妆镜产品数据"};
List<String> collect = Arrays.stream(list).collect(Collectors.toList());
if (collect.contains(tableName))
{
//开启分页
PageHelper.startPage(index, size);

@ -38,7 +38,25 @@ public class ValidateServiceImpl implements ValidateService {
TeacherImportDTO teacher = teacherList.get(i);
int rowNum = i + 2; // Excel行号(标题行+1)
// 1. 校验教师姓名(教师归属的学校/院系/专业直接取自当前操作用户,不再从Excel读取)
// 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. 校验教师姓名
if (StringUtils.isBlank(teacher.getTeacherName())) {
errors.add("第" + rowNum + "行: 教师姓名不能为空");
}

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

@ -63,9 +63,6 @@ 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,16 +61,14 @@
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')
OR (help_status = 'PROCESSING' AND update_time &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
AND help_status IN ('NOT_STARTED', 'FAILED', 'SUCCEEDED')
</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')
OR (assessment_status = 'PROCESSING' AND update_time &lt; DATE_SUB(#{updateTime,jdbcType=TIMESTAMP}, INTERVAL 10 MINUTE)))
AND assessment_status IN ('NOT_STARTED', 'FAILED')
</update>
<update id="claimReassessment">
@ -78,6 +76,7 @@
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">

@ -1,75 +0,0 @@
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);
}
}

@ -1,59 +0,0 @@
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);
}
}

@ -1304,12 +1304,14 @@ 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);
@ -1333,12 +1335,14 @@ 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());
@ -1460,11 +1464,15 @@ 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,124 +501,6 @@ 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,65 +3,37 @@ 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);
// 操作用户的学校/院系/专业应直接继承给导入的教师
Userinfo operator = new Userinfo();
operator.setUserId("operator-1");
operator.setSchoolId("school-1");
operator.setSchoolFacultyId("faculty-1");
operator.setSchoolMajorId("major-1");
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");
TeacherImportDTO row = new TeacherImportDTO();
row.setTeacherName("张老师");
row.setUserName("t001");
service.processTeacherImport(Collections.singletonList(row), operator);
service.processTeacherImport(Collections.singletonList(row));
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());
}
}

Loading…
Cancel
Save