From d69efbd8650fd0cb626792c810708ed00698902a Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 23 Sep 2026 09:39:50 +0800 Subject: [PATCH] feat: add exam settings and platform question bank APIs --- docs/sql/2026-09-22-exam-settings.sql | 7 + .../sql/2026-09-22-platform-question-bank.sql | 11 ++ .../PlatformAdminController.java | 73 +++++++ .../controller/stu/ExamController.java | 8 +- .../linkCommerce/service/ExamService.java | 127 +++++++++++-- .../service/PlatformQuestionBankService.java | 179 ++++++++++++++++++ 6 files changed, 385 insertions(+), 20 deletions(-) create mode 100644 docs/sql/2026-09-22-exam-settings.sql create mode 100644 docs/sql/2026-09-22-platform-question-bank.sql create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/PlatformQuestionBankService.java diff --git a/docs/sql/2026-09-22-exam-settings.sql b/docs/sql/2026-09-22-exam-settings.sql new file mode 100644 index 0000000..c27ef33 --- /dev/null +++ b/docs/sql/2026-09-22-exam-settings.sql @@ -0,0 +1,7 @@ +-- 考试设置扩展。执行前请确认目标数据库与备份策略。 +ALTER TABLE exam_assessment + ADD COLUMN question_order_mode varchar(16) NOT NULL DEFAULT 'FIXED' AFTER allow_rollback, + ADD COLUMN option_order_mode varchar(16) NOT NULL DEFAULT 'FIXED' AFTER question_order_mode, + ADD COLUMN allow_copy_question bit NOT NULL DEFAULT 0 AFTER option_order_mode, + ADD COLUMN allow_paste_answer bit NOT NULL DEFAULT 0 AFTER allow_copy_question, + ADD COLUMN multiple_score_mode varchar(32) NOT NULL DEFAULT 'ALL_OR_NOTHING' AFTER allow_paste_answer; diff --git a/docs/sql/2026-09-22-platform-question-bank.sql b/docs/sql/2026-09-22-platform-question-bank.sql new file mode 100644 index 0000000..39beedd --- /dev/null +++ b/docs/sql/2026-09-22-platform-question-bank.sql @@ -0,0 +1,11 @@ +-- Platform question bank extension. Execute after 2026-09-15-exam.sql. +-- Existing teacher questions remain usable and are classified as TEACHER. +ALTER TABLE exam_question + ADD COLUMN source_type varchar(16) NOT NULL DEFAULT 'TEACHER' COMMENT '题目来源:PLATFORM/TEACHER', + ADD COLUMN task_key varchar(128) NULL COMMENT '归属实训任务,关联 training_task.task_key'; + +UPDATE exam_question SET source_type='TEACHER' WHERE source_type IS NULL OR source_type=''; + +ALTER TABLE exam_question + ADD KEY idx_exam_question_source_task (source_type, task_key), + ADD KEY idx_exam_question_task (task_key); diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/platformadmin/PlatformAdminController.java b/src/main/java/com/sztzjy/linkCommerce/controller/platformadmin/PlatformAdminController.java index 4a9f427..bc0db24 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/platformadmin/PlatformAdminController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/platformadmin/PlatformAdminController.java @@ -10,6 +10,8 @@ import com.sztzjy.linkCommerce.service.PlatformAdminService; import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService; import com.sztzjy.linkCommerce.service.SchoolProductConfigService; import com.sztzjy.linkCommerce.service.UserInfoService; +import com.sztzjy.linkCommerce.service.PlatformQuestionBankService; +import com.sztzjy.linkCommerce.service.ExamService; import com.sztzjy.linkCommerce.util.ResultEntity; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -22,6 +24,8 @@ import javax.servlet.http.HttpServletRequest; import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.Map; +import org.springframework.web.multipart.MultipartFile; @Api(tags = "平台超管管理") @RequestMapping("api/platform-admin") @@ -47,6 +51,71 @@ public class PlatformAdminController { private UserinfoMapper userinfoMapper; @Autowired private TaskAllocationMapper taskAllocationMapper; + @Autowired + private PlatformQuestionBankService platformQuestionBankService; + @Autowired + private ExamService examService; + + @GetMapping("/question-bank/projects") + public ResultEntity>> questionBankProjects(HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "查询成功", platformQuestionBankService.projects(currentUser(request))); + } + + @GetMapping("/question-bank") + public ResultEntity>> questionBank(@RequestParam(required = false) String type, + @RequestParam(required = false) String taskKey, + @RequestParam(required = false) String keyword, + HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "查询成功", platformQuestionBankService.list(currentUser(request), type, taskKey, keyword)); + } + + @GetMapping("/question-bank/{id}") + public ResultEntity> questionBankDetail(@PathVariable long id, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "查询成功", platformQuestionBankService.get(currentUser(request), id)); + } + + @PostMapping("/question-bank") + public ResultEntity> addQuestionBank(@RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "保存成功", platformQuestionBankService.save(currentUser(request), null, payload)); + } + + @PutMapping("/question-bank/{id}") + public ResultEntity> updateQuestionBank(@PathVariable long id, @RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "保存成功", platformQuestionBankService.save(currentUser(request), id, payload)); + } + + @DeleteMapping("/question-bank/{id}") + public ResultEntity deleteQuestionBank(@PathVariable long id, HttpServletRequest request) { + platformQuestionBankService.delete(currentUser(request), id); return new ResultEntity<>(HttpStatus.OK, "删除成功"); + } + + @PostMapping("/question-bank/import") + public ResultEntity>> importQuestionBank(@RequestPart("file") MultipartFile file, + @RequestParam String taskKey, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "导入成功", platformQuestionBankService.importExcel(currentUser(request), file, taskKey)); + } + + @GetMapping("/question-bank/template") + public void questionBankTemplate(@RequestParam(required = false, defaultValue = "word") String format, + HttpServletRequest request, javax.servlet.http.HttpServletResponse response) throws java.io.IOException { + platformAdminService.requirePlatformAdmin(currentUser(request)); + examService.questionTemplate(format, response); + } + + @PostMapping("/question-bank/attachment") + public ResultEntity> questionBankAttachment(@RequestPart("file") MultipartFile file, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "上传成功", platformQuestionBankService.uploadAttachment(currentUser(request), file)); + } + + @PostMapping("/question-bank/ai/preview") + public ResultEntity> questionBankAiPreview(@RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "生成成功", platformQuestionBankService.aiPreview(currentUser(request), payload)); + } + + @PostMapping("/question-bank/ai/confirm") + public ResultEntity>> questionBankAiConfirm(@RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "保存成功", platformQuestionBankService.aiConfirm(currentUser(request), payload)); + } @GetMapping("/schools") @ApiOperation("平台超管-学校列表") @@ -224,6 +293,10 @@ public class PlatformAdminController { platformAdminService.requirePlatformAdmin(user); } + private JwtUser currentUser(HttpServletRequest request) { + return TokenProvider.getJWTUser(request); + } + private School requireSchool(String schoolId) { if (StringUtils.isBlank(schoolId)) { throw new IllegalArgumentException("学校ID不能为空"); diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java index 929f342..c97c157 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java @@ -32,9 +32,15 @@ public class ExamController { @GetMapping("/teacher/questions") public ResultEntity>> questions(@RequestParam(required = false) String type, + @RequestParam(required = false) String taskKey, @RequestParam(required = false) String keyword, HttpServletRequest request) { - return ok(examService.questions(user(request), type, keyword)); + return ok(examService.questions(user(request), type, taskKey, keyword)); + } + + @GetMapping("/teacher/question-projects") + public ResultEntity>> questionProjects(HttpServletRequest request) { + return ok(examService.questionProjects(user(request))); } @PostMapping("/teacher/questions") diff --git a/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java index b33a1fa..ebf3e02 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java @@ -43,6 +43,13 @@ public class ExamService { public static final String MULTIPLE = "MULTIPLE"; public static final String TRUE_FALSE = "TRUE_FALSE"; public static final String SHORT_ANSWER = "SHORT_ANSWER"; + private static final String FIXED_ORDER = "FIXED"; + private static final String RANDOM_ORDER = "RANDOM"; + private static final String ALL_OR_NOTHING = "ALL_OR_NOTHING"; + private static final String PARTIAL_BY_COUNT = "PARTIAL_BY_COUNT"; + private static final String HALF_FOR_PARTIAL = "HALF_FOR_PARTIAL"; + private static final Set ORDER_MODES = new HashSet<>(Arrays.asList(FIXED_ORDER, RANDOM_ORDER)); + private static final Set MULTIPLE_SCORE_MODES = new HashSet<>(Arrays.asList(ALL_OR_NOTHING, PARTIAL_BY_COUNT, HALF_FOR_PARTIAL)); private static final Set TYPES = new LinkedHashSet<>(Arrays.asList(SINGLE, MULTIPLE, TRUE_FALSE, SHORT_ANSWER)); private static final Set ALLOWED_ATTACHMENTS = new HashSet<>(Arrays.asList("pdf", "doc", "docx", "png", "jpg", "jpeg")); private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; @@ -67,22 +74,33 @@ public class ExamService { (rs, n) -> map("id", rs.getString("id"), "name", rs.getString("name")), teacher.getUserId()); } - public List> questions(JwtUser teacher, String type, String keyword) { + public List> questions(JwtUser teacher, String type, String taskKey, String keyword) { requireTeacher(teacher); - StringBuilder sql = new StringBuilder("select * from exam_question where teacher_id=?"); + StringBuilder sql = new StringBuilder("select q.*,t.project_name,t.task_name from exam_question q left join training_task t on t.task_key=q.task_key where (q.source_type='PLATFORM' or (q.source_type='TEACHER' and q.teacher_id=?))"); List args = new ArrayList<>(); args.add(teacher.getUserId()); - if (StringUtils.isNotBlank(type)) { validateType(type); sql.append(" and question_type=?"); args.add(type); } - if (StringUtils.isNotBlank(keyword)) { sql.append(" and stem like ?"); args.add("%" + keyword.trim() + "%"); } - sql.append(" order by update_time desc, id desc"); + if (StringUtils.isNotBlank(type)) { validateType(type); sql.append(" and q.question_type=?"); args.add(type); } + if (StringUtils.isNotBlank(taskKey)) { sql.append(" and q.task_key=?"); args.add(taskKey); } + if (StringUtils.isNotBlank(keyword)) { sql.append(" and q.stem like ?"); args.add("%" + keyword.trim() + "%"); } + sql.append(" order by q.update_time desc, q.id desc"); return jdbc.query(sql.toString(), (rs, n) -> questionRow(rs, true), args.toArray()); } + public List> questions(JwtUser teacher, String type, String keyword) { + return questions(teacher, type, null, keyword); + } + + public List> questionProjects(JwtUser teacher) { + requireTeacher(teacher); + return jdbc.query("select task_key,project_name,task_name from training_task where enabled is null or enabled=1 order by sort,project_name,task_name", + (rs, n) -> map("taskKey", rs.getString("task_key"), "projectName", rs.getString("project_name"), "taskName", rs.getString("task_name"))); + } + public Map createQuestion(JwtUser teacher, Map payload) { requireTeacher(teacher); QuestionData q = validateQuestion(payload); - jdbc.update("insert into exam_question(school_id,teacher_id,question_type,stem,options_json,answer_json,answer_analysis,attachment_name,attachment_url) values(?,?,?,?,?,?,?,?,?)", - teacher.getSchoolId(), teacher.getUserId(), q.type, q.stem, json(q.options), json(q.answer), q.analysis, + jdbc.update("insert into exam_question(school_id,teacher_id,source_type,task_key,question_type,stem,options_json,answer_json,answer_analysis,attachment_name,attachment_url) values(?,?,?,?,?,?,?,?,?,?,?)", + teacher.getSchoolId(), teacher.getUserId(), "TEACHER", text(payload.get("taskKey")), q.type, q.stem, json(q.options), json(q.answer), q.analysis, text(payload.get("attachmentName")), text(payload.get("attachmentUrl"))); Long id = jdbc.queryForObject("select last_insert_id()", Long.class); return getOwnedQuestion(teacher, id); @@ -90,18 +108,20 @@ public class ExamService { public Map updateQuestion(JwtUser teacher, long id, Map payload) { requireTeacher(teacher); - getOwnedQuestion(teacher, id); + Map existing = getOwnedQuestion(teacher, id); + if ("PLATFORM".equals(existing.get("sourceType"))) throw new AccessDeniedException("平台题库题目只读"); QuestionData q = validateQuestion(payload); - jdbc.update("update exam_question set question_type=?,stem=?,options_json=?,answer_json=?,answer_analysis=?,attachment_name=?,attachment_url=? where id=? and teacher_id=?", - q.type, q.stem, json(q.options), json(q.answer), q.analysis, + jdbc.update("update exam_question set task_key=?,question_type=?,stem=?,options_json=?,answer_json=?,answer_analysis=?,attachment_name=?,attachment_url=? where id=? and teacher_id=? and source_type='TEACHER'", + text(payload.get("taskKey")), q.type, q.stem, json(q.options), json(q.answer), q.analysis, text(payload.get("attachmentName")), text(payload.get("attachmentUrl")), id, teacher.getUserId()); return getOwnedQuestion(teacher, id); } public void deleteQuestion(JwtUser teacher, long id) { requireTeacher(teacher); - getOwnedQuestion(teacher, id); - jdbc.update("delete from exam_question where id=? and teacher_id=?", id, teacher.getUserId()); + Map existing = getOwnedQuestion(teacher, id); + if ("PLATFORM".equals(existing.get("sourceType"))) throw new AccessDeniedException("平台题库题目只读"); + jdbc.update("delete from exam_question where id=? and teacher_id=? and source_type='TEACHER'", id, teacher.getUserId()); } public Map uploadQuestionAttachment(JwtUser teacher, MultipartFile file) { @@ -263,13 +283,18 @@ public class ExamService { id == null ? new Object[]{teacher.getSchoolId(), name} : new Object[]{teacher.getSchoolId(), name, id}); if (duplicate > 0) throw new IllegalArgumentException("考试名称已存在"); boolean allowRollback = bool(payload.get("allowRollback"), true); + String questionOrderMode = enumValue(payload.get("questionOrderMode"), FIXED_ORDER, ORDER_MODES, "题目乱序方式无效"); + String optionOrderMode = enumValue(payload.get("optionOrderMode"), FIXED_ORDER, ORDER_MODES, "选项乱序方式无效"); + boolean allowCopyQuestion = bool(payload.get("allowCopyQuestion"), false); + boolean allowPasteAnswer = bool(payload.get("allowPasteAnswer"), false); + String multipleScoreMode = enumValue(payload.get("multipleScoreMode"), ALL_OR_NOTHING, MULTIPLE_SCORE_MODES, "多选题计分方式无效"); long examId; if (id == null) { - jdbc.update("insert into exam_assessment(school_id,teacher_id,exam_name,start_time,end_time,allow_rollback) values(?,?,?,?,?,?)", - teacher.getSchoolId(), teacher.getUserId(), name, start, end, allowRollback); + jdbc.update("insert into exam_assessment(school_id,teacher_id,exam_name,start_time,end_time,allow_rollback,question_order_mode,option_order_mode,allow_copy_question,allow_paste_answer,multiple_score_mode) values(?,?,?,?,?,?,?,?,?,?,?)", + teacher.getSchoolId(), teacher.getUserId(), name, start, end, allowRollback, questionOrderMode, optionOrderMode, allowCopyQuestion, allowPasteAnswer, multipleScoreMode); examId = jdbc.queryForObject("select last_insert_id()", Long.class); } else { - jdbc.update("update exam_assessment set exam_name=?,start_time=?,end_time=?,allow_rollback=? where id=?", name, start, end, allowRollback, id); + jdbc.update("update exam_assessment set exam_name=?,start_time=?,end_time=?,allow_rollback=?,question_order_mode=?,option_order_mode=?,allow_copy_question=?,allow_paste_answer=?,multiple_score_mode=? where id=?", name, start, end, allowRollback, questionOrderMode, optionOrderMode, allowCopyQuestion, allowPasteAnswer, multipleScoreMode, id); jdbc.update("delete from exam_assessment_class where exam_id=?", id); jdbc.update("delete from exam_assessment_question where exam_id=?", id); examId = id; @@ -440,7 +465,10 @@ public class ExamService { public List> studentExams(JwtUser student) { requireStudent(student); autoSubmitExpired(); - return jdbc.query("select distinct e.*,a.id attempt_id,a.attempt_status,a.total_score from exam_assessment e " + + return jdbc.query("select distinct e.*,a.id attempt_id,a.attempt_status,a.total_score," + + "(select u.name from userinfo u where u.user_id=e.teacher_id collate utf8mb4_general_ci limit 1) teacher_name," + + "(select group_concat(distinct s.class_name order by s.class_name separator '、') from exam_assessment_class ec join school_class s on s.school_class_id=ec.teaching_class_id collate utf8mb4_0900_ai_ci where ec.exam_id=e.id) class_names " + + "from exam_assessment e " + "join exam_assessment_class c on c.exam_id=e.id join teaching_class_student t on t.teaching_class_id=c.teaching_class_id and t.status='IN_PROGRESS' " + "left join exam_attempt a on a.exam_id=e.id and a.student_id=? where t.student_user_id=? and e.publication_status='PUBLISHED' order by e.start_time desc", (rs, n) -> { Map m = examSummary(rs); m.put("attemptId", rs.getObject("attempt_id")); m.put("attemptStatus", rs.getString("attempt_status")); @@ -480,13 +508,27 @@ public class ExamService { if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试已结束,不能继续作答"); long attemptId = requireAttempt(student, examId, deviceToken, true); Map question = examQuestion(examId, questionId, true); - Boolean correct = SHORT_ANSWER.equals(question.get("type")) ? null : answersEqual(question.get("answer"), answer); - BigDecimal score = correct == null ? null : (correct ? decimal(question.get("score")) : BigDecimal.ZERO); + String type = String.valueOf(question.get("type")); + Boolean correct = SHORT_ANSWER.equals(type) ? null : answersEqual(question.get("answer"), answer); + BigDecimal score = SHORT_ANSWER.equals(type) ? null : objectiveScore(exam, question, answer); jdbc.update("insert into exam_attempt_answer(attempt_id,question_id,answer_json,is_correct,awarded_score) values(?,?,?,?,?) " + "on duplicate key update answer_json=values(answer_json),is_correct=values(is_correct),awarded_score=values(awarded_score)", attemptId, questionId, json(answer), correct, score); } + private BigDecimal objectiveScore(Map exam, Map question, Object answer) { + BigDecimal full = decimal(question.get("score")); + if (!MULTIPLE.equals(question.get("type"))) return answersEqual(question.get("answer"), answer) ? full : BigDecimal.ZERO; + Set expected = toSet(question.get("answer")); + Set actual = toSet(answer); + if (expected.isEmpty() || actual.isEmpty() || !expected.containsAll(actual)) return BigDecimal.ZERO; + if (expected.equals(actual)) return full; + String mode = String.valueOf(exam.getOrDefault("multipleScoreMode", ALL_OR_NOTHING)); + if (PARTIAL_BY_COUNT.equals(mode)) return full.multiply(BigDecimal.valueOf(actual.size())).divide(BigDecimal.valueOf(expected.size()), 2, BigDecimal.ROUND_HALF_UP); + if (HALF_FOR_PARTIAL.equals(mode)) return full.divide(BigDecimal.valueOf(2), 2, BigDecimal.ROUND_HALF_UP); + return BigDecimal.ZERO; + } + public Map uploadAnswerFile(JwtUser student, long examId, long questionId, String deviceToken, MultipartFile file) { requireStudent(student); validateAttachment(file); Map exam = accessibleStudentExam(student, examId); @@ -561,6 +603,7 @@ public class ExamService { private List> answeredQuestions(long examId, long attemptId, boolean revealAnswer) { List> questions = examQuestions(examId, revealAnswer); + if (!revealAnswer) applyStudentOrdering(examId, attemptId, questions); Map> answers = jdbc.query("select * from exam_attempt_answer where attempt_id=?", (rs, n) -> { Map m = map("questionId", rs.getLong("question_id"), "studentAnswer", parse(rs.getString("answer_json")), "correct", rs.getObject("is_correct") == null ? null : rs.getBoolean("is_correct"), "awardedScore", rs.getBigDecimal("awarded_score"), @@ -570,6 +613,32 @@ public class ExamService { return questions; } + private void applyStudentOrdering(long examId, long attemptId, List> questions) { + Map exam = jdbc.queryForMap("select question_order_mode,option_order_mode from exam_assessment where id=?", examId); + String questionMode = String.valueOf(exam.getOrDefault("question_order_mode", FIXED_ORDER)); + String optionMode = String.valueOf(exam.getOrDefault("option_order_mode", FIXED_ORDER)); + if (RANDOM_ORDER.equals(questionMode)) { + Map>> groups = new LinkedHashMap<>(); + for (Map question : questions) groups.computeIfAbsent(String.valueOf(question.get("type")), key -> new ArrayList<>()).add(question); + List> reordered = new ArrayList<>(); + for (List> group : groups.values()) { + Collections.shuffle(group, new Random(attemptId * 31L + group.hashCode())); + reordered.addAll(group); + } + questions.clear(); questions.addAll(reordered); + for (int i = 0; i < questions.size(); i++) questions.get(i).put("order", i + 1); + } + if (RANDOM_ORDER.equals(optionMode)) { + for (Map question : questions) { + List> options = mapList(question.get("options")); + if (options.size() > 1) { + Collections.shuffle(options, new Random(attemptId * 131L + ((Number) question.get("id")).longValue())); + question.put("options", options); + } + } + } + } + private List> examQuestions(long examId, boolean revealAnswer) { return jdbc.query("select p.question_id id,p.question_type,p.stem,p.options_json,p.answer_json,p.answer_analysis,p.attachment_name,p.attachment_url,p.create_time,p.update_time,p.question_score,p.question_order " + "from exam_assessment_question p where p.exam_id=? order by p.question_order", @@ -583,7 +652,7 @@ public class ExamService { } private Map getOwnedQuestion(JwtUser teacher, long id) { - List> rows = jdbc.query("select * from exam_question where id=? and teacher_id=?", (rs, n) -> questionRow(rs, true), id, teacher.getUserId()); + List> rows = jdbc.query("select q.*,t.project_name,t.task_name from exam_question q left join training_task t on t.task_key=q.task_key where q.id=? and (q.source_type='PLATFORM' or (q.source_type='TEACHER' and q.teacher_id=?))", (rs, n) -> questionRow(rs, true), id, teacher.getUserId()); if (rows.isEmpty()) throw new IllegalArgumentException("题目不存在或无权访问"); return rows.get(0); } @@ -630,6 +699,14 @@ public class ExamService { "startTime", start, "endTime", end, "durationMinutes", Math.max(0, (end.getTime() - start.getTime()) / 60000), "publicationStatus", publication, "status", status(publication, start, end), "allowRollback", rs.getBoolean("allow_rollback"), "exportedAt", getQuiet(rs, "exported_at"), "totalScore", getQuiet(rs, "total_score")); + putQuiet(rs, m, "question_order_mode", "questionOrderMode"); + putQuiet(rs, m, "option_order_mode", "optionOrderMode"); + Object copyQuestion = getQuiet(rs, "allow_copy_question"); if (copyQuestion != null) m.put("allowCopyQuestion", copyQuestion instanceof Boolean ? copyQuestion : rsBoolean(copyQuestion)); + Object pasteAnswer = getQuiet(rs, "allow_paste_answer"); if (pasteAnswer != null) m.put("allowPasteAnswer", pasteAnswer instanceof Boolean ? pasteAnswer : rsBoolean(pasteAnswer)); + putQuiet(rs, m, "multiple_score_mode", "multipleScoreMode"); + m.putIfAbsent("questionOrderMode", FIXED_ORDER); m.putIfAbsent("optionOrderMode", FIXED_ORDER); + m.putIfAbsent("allowCopyQuestion", false); m.putIfAbsent("allowPasteAnswer", false); m.putIfAbsent("multipleScoreMode", ALL_OR_NOTHING); + putQuiet(rs, m, "teacher_name", "teacherName"); putQuiet(rs, m, "class_names", "classNames"); putQuiet(rs, m, "class_count", "classCount"); putQuiet(rs, m, "question_count", "questionCount"); return m; } @@ -666,6 +743,8 @@ public class ExamService { "options", parse(rs.getString("options_json")), "analysis", revealAnswer ? rs.getString("answer_analysis") : null, "attachmentName", rs.getString("attachment_name"), "attachmentUrl", rs.getString("attachment_url"), "createdAt", rs.getTimestamp("create_time"), "updatedAt", rs.getTimestamp("update_time")); + putQuiet(rs, q, "source_type", "sourceType"); putQuiet(rs, q, "task_key", "taskKey"); + putQuiet(rs, q, "project_name", "projectName"); putQuiet(rs, q, "task_name", "taskName"); if (revealAnswer) q.put("answer", parse(rs.getString("answer_json"))); return q; } @@ -1099,12 +1178,22 @@ public class ExamService { private static Number number(Object value) { if (value instanceof Number) return (Number) value; try { return Long.parseLong(text(value)); } catch (Exception e) { throw new IllegalArgumentException("数字参数格式错误"); } } private static BigDecimal decimal(Object value) { if (value == null || text(value).isEmpty()) return BigDecimal.ZERO; return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(text(value)); } private static boolean bool(Object value, boolean fallback) { return value == null ? fallback : Boolean.parseBoolean(text(value)); } + private static String enumValue(Object value, String fallback, Set allowed, String message) { + String normalized = StringUtils.defaultIfBlank(text(value), fallback).trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw new IllegalArgumentException(message); + return normalized; + } private static Timestamp parseTime(Object value) { try { String s = text(value).replace('T', ' '); if (s.length() == 16) s += ":00"; return Timestamp.valueOf(LocalDateTime.parse(s, TIME)); } catch (Exception e) { throw new IllegalArgumentException("考试时间格式不正确"); } } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalArgumentException("数据格式错误"); } } private Object parse(String value) { if (StringUtils.isBlank(value)) return null; try { return objectMapper.readValue(value, Object.class); } catch (Exception e) { return value; } } private List> mapList(Object value) { if (value == null) return new ArrayList<>(); return objectMapper.convertValue(value, new TypeReference>>(){}); } private List stringList(Object value) { if (value == null) return new ArrayList<>(); if (value instanceof Collection) return ((Collection) value).stream().map(String::valueOf).filter(StringUtils::isNotBlank).collect(Collectors.toList()); String s = text(value); return StringUtils.isBlank(s) ? new ArrayList<>() : Collections.singletonList(s); } private static Object getQuiet(ResultSet rs, String column) { try { return rs.getObject(column); } catch (SQLException e) { return null; } } + private static boolean rsBoolean(Object value) { + if (value instanceof byte[]) return ((byte[]) value).length > 0 && ((byte[]) value)[0] != 0; + if (value instanceof Number) return ((Number) value).intValue() != 0; + return Boolean.parseBoolean(String.valueOf(value)); + } private static void putQuiet(ResultSet rs, Map map, String column, String key) { Object value = getQuiet(rs, column); if (value != null) map.put(key, value); } private static Map map(Object... values) { Map m = new LinkedHashMap<>(); for (int i = 0; i < values.length; i += 2) m.put(String.valueOf(values[i]), values[i + 1]); return m; } private static boolean answersEqual(Object expected, Object actual) { if (expected instanceof Collection || actual instanceof Collection) { Set a = toSet(expected), b = toSet(actual); return a.equals(b); } return String.valueOf(expected).equalsIgnoreCase(String.valueOf(actual)); } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/PlatformQuestionBankService.java b/src/main/java/com/sztzjy/linkCommerce/service/PlatformQuestionBankService.java new file mode 100644 index 0000000..0268c29 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/PlatformQuestionBankService.java @@ -0,0 +1,179 @@ +package com.sztzjy.linkCommerce.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sztzjy.linkCommerce.ai.QwenChatClient; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.util.file.IFileUtil; +import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class PlatformQuestionBankService { + private static final String PLATFORM = "PLATFORM"; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final PlatformAdminService platformAdminService; + private final QwenChatClient qwen; + private final IFileUtil fileUtil; + + public PlatformQuestionBankService(JdbcTemplate jdbc, ObjectMapper objectMapper, + PlatformAdminService platformAdminService, QwenChatClient qwen, IFileUtil fileUtil) { + this.jdbc = jdbc; this.objectMapper = objectMapper; this.platformAdminService = platformAdminService; this.qwen = qwen; this.fileUtil = fileUtil; + } + + public Map uploadAttachment(JwtUser user, MultipartFile file) { + platformAdminService.requirePlatformAdmin(user); + if (file == null || file.isEmpty()) throw new IllegalArgumentException("请选择资料文件"); + if (file.getSize() > 20L * 1024 * 1024) throw new IllegalArgumentException("资料不能超过20MB"); + String name = StringUtils.defaultString(file.getOriginalFilename()).replace("\\", "_").replace("/", "_"); + int dot = name.lastIndexOf('.'); String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!Arrays.asList("pdf", "doc", "docx", "png", "jpg", "jpeg").contains(ext)) throw new IllegalArgumentException("资料仅支持PDF、Word和图片格式"); + String path = fileUtil.upload(file); + return map("name", name, "url", "/" + path.replace('\\', '/')); + } + + public List> projects(JwtUser user) { + platformAdminService.requirePlatformAdmin(user); + return jdbc.query("select task_key,project_name,task_name from training_task where enabled is null or enabled=1 order by sort,project_name,task_name", + (rs, n) -> map("taskKey", rs.getString("task_key"), "projectName", rs.getString("project_name"), "taskName", rs.getString("task_name"))); + } + + public List> list(JwtUser user, String type, String taskKey, String keyword) { + platformAdminService.requirePlatformAdmin(user); + StringBuilder sql = new StringBuilder("select q.*,t.project_name,t.task_name from exam_question q left join training_task t on t.task_key=q.task_key where q.source_type='PLATFORM'"); + List args = new ArrayList<>(); + if (StringUtils.isNotBlank(type)) { validateType(type); sql.append(" and q.question_type=?"); args.add(type); } + if (StringUtils.isNotBlank(taskKey)) { sql.append(" and q.task_key=?"); args.add(taskKey); } + if (StringUtils.isNotBlank(keyword)) { sql.append(" and q.stem like ?"); args.add("%" + keyword.trim() + "%"); } + sql.append(" order by q.update_time desc,q.id desc"); + return jdbc.query(sql.toString(), (rs, n) -> row(rs), args.toArray()); + } + + public Map get(JwtUser user, long id) { + platformAdminService.requirePlatformAdmin(user); + List> rows = jdbc.query("select q.*,t.project_name,t.task_name from exam_question q left join training_task t on t.task_key=q.task_key where q.id=? and q.source_type='PLATFORM'", (rs, n) -> row(rs), id); + if (rows.isEmpty()) throw new IllegalArgumentException("题目不存在"); + return rows.get(0); + } + + public Map save(JwtUser user, Long id, Map payload) { + platformAdminService.requirePlatformAdmin(user); + ValidQuestion q = validate(payload); + requireTask(q.taskKey); + if (id == null) { + jdbc.update("insert into exam_question(school_id,teacher_id,source_type,task_key,question_type,stem,options_json,answer_json,answer_analysis,attachment_name,attachment_url) values(?,?,?,?,?,?,?,?,?,?,?)", + PLATFORM, PLATFORM, PLATFORM, q.taskKey, q.type, q.stem, json(q.options), json(q.answer), q.analysis, text(payload.get("attachmentName")), text(payload.get("attachmentUrl"))); + id = jdbc.queryForObject("select last_insert_id()", Long.class); + } else { + get(user, id); + jdbc.update("update exam_question set task_key=?,question_type=?,stem=?,options_json=?,answer_json=?,answer_analysis=?,attachment_name=?,attachment_url=? where id=? and source_type='PLATFORM'", + q.taskKey, q.type, q.stem, json(q.options), json(q.answer), q.analysis, text(payload.get("attachmentName")), text(payload.get("attachmentUrl")), id); + } + return get(user, id); + } + + public void delete(JwtUser user, long id) { + platformAdminService.requirePlatformAdmin(user); get(user, id); + jdbc.update("delete from exam_question where id=? and source_type='PLATFORM'", id); + } + + public Map aiPreview(JwtUser user, Map request) { + platformAdminService.requirePlatformAdmin(user); + String taskKey = required(request, "taskKey", "请选择归属项目"); + requireTask(taskKey); + String type = required(request, "type", "请选择题型"); validateType(type); + int count; + try { count = Integer.parseInt(text(request.get("count"))); } catch (Exception e) { throw new IllegalArgumentException("题目数量必须是数字"); } + if (count < 1 || count > 50) throw new IllegalArgumentException("题目数量需在1到50之间"); + String requirement = StringUtils.defaultString(text(request.get("requirement")), "围绕项目任务设计,题干清晰,答案准确,避免重复。"); + String prompt = "请生成" + count + "道" + typeLabel(type) + "。每道题包含 type,stem,options,answer,analysis。" + + "选择题 options 为 [{label,content}],判断题 answer 为 true/false,简答题 answer 为参考答案字符串。" + requirement; + String raw = qwen.completeJson("你是严谨的考试题库出题助手,只输出JSON对象,格式为{\"questions\":[...]}", prompt); + try { + JsonNode root = objectMapper.readTree(raw); + JsonNode items = root.isArray() ? root : root.path("questions"); + if (!items.isArray()) throw new IllegalArgumentException("AI返回格式不正确"); + List> questions = objectMapper.convertValue(items, new TypeReference>>(){}); + if (questions.size() != count) throw new IllegalArgumentException("AI生成题目数量不符合要求"); + List> normalized = questions.stream().map(item -> { item.put("taskKey", taskKey); item.put("type", StringUtils.defaultIfBlank(text(item.get("type")), type)); return normalize(item); }).collect(Collectors.toList()); + return map("taskKey", taskKey, "questions", normalized); + } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("AI返回内容无法解析"); } + } + + @Transactional + public List> aiConfirm(JwtUser user, Map request) { + platformAdminService.requirePlatformAdmin(user); + List> questions = objectMapper.convertValue(request.get("questions"), new TypeReference>>(){}); + if (questions.isEmpty() || questions.size() > 50) throw new IllegalArgumentException("确认入库的题目数量不正确"); + List> saved = new ArrayList<>(); + for (Map q : questions) saved.add(save(user, null, q)); + return saved; + } + + @Transactional + public List> importExcel(JwtUser user, MultipartFile file, String taskKey) { + platformAdminService.requirePlatformAdmin(user); requireTask(taskKey); + if (file == null || file.isEmpty()) throw new IllegalArgumentException("请选择Word或Excel文件"); + String name = StringUtils.defaultString(file.getOriginalFilename()).toLowerCase(Locale.ROOT); + if (name.endsWith(".docx")) return importWord(user, file, taskKey); + try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) { + Sheet sheet = workbook.getSheetAt(0); DataFormatter formatter = new DataFormatter(); Row header = sheet.getRow(0); + if (header == null) throw new IllegalArgumentException("Excel缺少表头"); + Map columns = new HashMap<>(); for (int i=0;i> result = new ArrayList<>(); + for (int r=1;r<=sheet.getLastRowNum();r++) { Row row=sheet.getRow(r); if (row==null) continue; Map q=new LinkedHashMap<>(); q.put("taskKey",taskKey); q.put("type", excelType(value(row, columns.get("题型"), formatter))); q.put("stem", value(row, columns.get("题干"), formatter)); q.put("answer", value(row, columns.get("正确答案"), formatter)); q.put("analysis", value(row, columns.get("答案解析"), formatter)); List> options=new ArrayList<>(); for(char c='A';c<='H';c++){String v=value(row,columns.get("选项"+c),formatter); if(StringUtils.isNotBlank(v)) options.add(map("label",String.valueOf(c),"content",v));} q.put("options",options); if(StringUtils.isBlank(text(q.get("stem")))) continue; result.add(save(user,null,q)); } + return result; + } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("Excel导入失败,请检查模板"); } + } + + private List> importWord(JwtUser user, MultipartFile file, String taskKey) { + try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) { + List> result = new ArrayList<>(); Map current = null; List> options = new ArrayList<>(); String type = "SHORT_ANSWER"; + for (org.apache.poi.xwpf.usermodel.XWPFParagraph p : doc.getParagraphs()) { + String line = StringUtils.trimToEmpty(p.getText()); if (line.isEmpty()) continue; + String marker = line.replace("[", "").replace("]", "").replace("【", "").replace("】", "").trim(); + if (Arrays.asList("单选题","多选题","判断题","简答题").contains(marker)) { if (current != null) { current.put("options", options); result.add(save(user,null,current)); } type = excelType(marker); current = null; options = new ArrayList<>(); continue; } + if (line.startsWith("题干") || line.startsWith("题目")) { if (current != null) { current.put("options", options); result.add(save(user,null,current)); } current = new LinkedHashMap<>(); current.put("taskKey",taskKey); current.put("type",type); current.put("stem",after(line)); continue; } + if (current == null) continue; + if (line.matches("^[A-H][..、].*")) { options.add(map("label",line.substring(0,1),"content",line.substring(2).trim())); } + else if (line.startsWith("答案")) current.put("answer",after(line)); + else if (line.startsWith("解析")) current.put("analysis",after(line)); + } + if (current != null) { current.put("options", options); result.add(save(user,null,current)); } + if (result.isEmpty()) throw new IllegalArgumentException("Word未读取到题目"); return result; + } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalArgumentException("Word导入失败,请检查模板"); } + } + + private ValidQuestion validate(Map p) { String type=required(p,"type","请选择题型"); validateType(type); String stem=required(p,"stem","题干不能为空"); if(StringUtils.isBlank(plain(stem))) throw new IllegalArgumentException("题干不能为空"); List> options=objectMapper.convertValue(p.getOrDefault("options",Collections.emptyList()),new TypeReference>>(){}); Object answer=p.get("answer"); if(type.equals("SINGLE")||type.equals("MULTIPLE")){ if(options.size()<2) throw new IllegalArgumentException("选择题至少需要两个选项"); Set labels=new LinkedHashSet<>(); List> normalized=new ArrayList<>(); for(Map o:options){String label=text(o.get("label")).trim().toUpperCase();String content=text(o.get("content")).trim();if(!label.matches("[A-H]")||StringUtils.isBlank(plain(content))||!labels.add(label)) throw new IllegalArgumentException("选项格式不正确"); normalized.add(map("label",label,"content",content));} List answers=list(answer); if(type.equals("SINGLE")&&answers.size()!=1) throw new IllegalArgumentException("单选题只能设置一个正确答案"); if(answers.isEmpty()||!labels.containsAll(answers)) throw new IllegalArgumentException("正确答案必须来自题目选项"); return new ValidQuestion(type,stem,normalized,answers,text(p.get("analysis")),required(p,"taskKey","请选择归属项目")); } if(type.equals("TRUE_FALSE")){String v=text(answer);if(!"true".equalsIgnoreCase(v)&&!"false".equalsIgnoreCase(v)&&!"正确".equals(v)&&!"错误".equals(v))throw new IllegalArgumentException("判断题答案格式不正确");return new ValidQuestion(type,stem,Collections.emptyList(),"正确".equals(v)||"true".equalsIgnoreCase(v),text(p.get("analysis")),required(p,"taskKey","请选择归属项目"));} if(StringUtils.isBlank(plain(text(answer)))) throw new IllegalArgumentException("参考答案不能为空"); return new ValidQuestion(type,stem,Collections.emptyList(),text(answer),text(p.get("analysis")),required(p,"taskKey","请选择归属项目")); } + private Map normalize(Map p){ValidQuestion q=validate(p);return map("type",q.type,"stem",q.stem,"options",q.options,"answer",q.answer,"analysis",q.analysis,"taskKey",q.taskKey);} + private void requireTask(String key){if(jdbc.queryForObject("select count(*) from training_task where task_key=?",Integer.class,key)==0)throw new IllegalArgumentException("归属项目不存在");} + private Map row(java.sql.ResultSet rs)throws java.sql.SQLException{return map("id",rs.getLong("id"),"type",rs.getString("question_type"),"stem",rs.getString("stem"),"options",parse(rs.getString("options_json")),"answer",parse(rs.getString("answer_json")),"analysis",rs.getString("answer_analysis"),"attachmentName",rs.getString("attachment_name"),"attachmentUrl",rs.getString("attachment_url"),"sourceType",rs.getString("source_type"),"taskKey",rs.getString("task_key"),"projectName",rs.getString("project_name"),"taskName",rs.getString("task_name"),"updatedAt",rs.getTimestamp("update_time"));} + private Object parse(String s){try{return StringUtils.isBlank(s)?null:objectMapper.readValue(s,Object.class);}catch(Exception e){return s;}} + private String json(Object o){try{return objectMapper.writeValueAsString(o);}catch(Exception e){throw new IllegalArgumentException("数据格式错误");}} + private static String required(Map p,String k,String m){String v=text(p.get(k));if(StringUtils.isBlank(v))throw new IllegalArgumentException(m);return v.trim();} + private static String text(Object v){return v==null?"":String.valueOf(v);} + private static String plain(String value){return value == null ? "" : value.replaceAll("<[^>]+>", "").replace(" ", "").trim();} + private static List list(Object v){if(v instanceof Collection)return ((Collection)v).stream().map(String::valueOf).map(s->s.trim().toUpperCase()).filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());String s=text(v);return StringUtils.isBlank(s)?Collections.emptyList():Arrays.stream(s.split("[,,、 ]")).map(String::trim).map(String::toUpperCase).filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());} + private static void validateType(String t){if(!Arrays.asList("SINGLE","MULTIPLE","TRUE_FALSE","SHORT_ANSWER").contains(t))throw new IllegalArgumentException("不支持的题型");} + private static String typeLabel(String t){return mapOf("SINGLE","单选题","MULTIPLE","多选题","TRUE_FALSE","判断题","SHORT_ANSWER","简答题").get(t);} + private static String excelType(String t){if("单选题".equals(t))return "SINGLE";if("多选题".equals(t))return "MULTIPLE";if("判断题".equals(t))return "TRUE_FALSE";return "SHORT_ANSWER";} + private static String after(String s){int i=Math.max(s.indexOf(':'),s.indexOf(':'));return i<0?"":s.substring(i+1).trim();} + private static String value(Row r,Integer i,DataFormatter f){return i==null?"":f.formatCellValue(r.getCell(i)).trim();} + private static Map map(Object...v){Mapm=new LinkedHashMap<>();for(int i=0;i mapOf(String...v){Mapm=new HashMap<>();for(int i=0;i> options;final Object answer;ValidQuestion(String t,String s,List>o,Object a,String an,String k){type=t;stem=s;options=o;answer=a;analysis=an;taskKey=k;}} +}