commit 5b123565676055522729cb445ae14d6d597fc2a7 Author: guxz <1921137080@qq.com> Date: Wed Jun 3 12:43:48 2026 +0800 初始化项目代码 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b72278c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +RUN mkdir -p /app/uploads /app/logs && \ + chown -R 1000:1000 /app/uploads /app/logs && \ + chmod -R 755 /app/uploads /app/logs + +COPY demo.jar app.jar +COPY application.yml . + +USER 1000 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/ExamController.java b/ExamController.java new file mode 100644 index 0000000..aa1ff2d --- /dev/null +++ b/ExamController.java @@ -0,0 +1,363 @@ +package top.tqx.demo_1.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.enums.AnswerType; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.service.ExamService; +import top.tqx.demo_1.service.ExamRecordService; +import top.tqx.demo_1.service.UserAnswerService; +import top.tqx.demo_1.service.UserService; +import top.tqx.demo_1.vo.UserVO; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Tag(name = "考试管理", description = "题目生成与答案批改接口") +@RestController +@RequestMapping("/exam") +@Slf4j +public class ExamController extends BaseController { + + @Autowired + private ExamService examService; + + @Autowired + private AuthService authService; + + @Autowired + private UserService userService; + + @Autowired + private UserAnswerService userAnswerService; + + @Autowired + private top.tqx.demo_1.service.ExamRecordService examRecordService; + + @Operation(summary = "开始考试(获取题目)", description = "通过试卷ID开始模拟考试,同时返回试卷题目内容") + @PostMapping("/paper/{paperId}/start") + public Result startExamByPaperId( + @PathVariable String paperId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.startExamByPaperId(paperId, userId, AnswerType.PRACTICE.getCode()); + return Result.success(response); + } catch (Exception e) { + log.error("开始考试失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取考试结果", description = "获取模拟考试的完整结果,包括题目、答案、分数等信息") + @GetMapping("/record/{recordId}/result") + public Result getExamResult( + @PathVariable String recordId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.getRecordById(recordId); + + if (response == null) { + return Result.error(404, "记录不存在"); + } + + if (!response.getUserId().equals(userId)) { + return Result.error(403, "无权查看此记录"); + } + + return Result.success(response); + } catch (Exception e) { + log.error("获取考试结果失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "生成试卷", description = "根据权限范围从题库中动态生成试卷") + @PostMapping("/paper/generate") + public Result generatePaper( + @RequestBody ExamPaperGenerateRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + UserVO userVO = userService.getUserById(userId); + if (userVO == null) { + return Result.error(401, "用户不存在"); + } + + if (request.getIncludePersonal() == null) { + request.setIncludePersonal(false); + } + + log.info("生成试卷请求: userId={}, deptId={}, userType={}, includePersonal={}, singleChoiceCount={}, multipleChoiceCount={}, trueFalseCount={}, fillBlankCount={}, subjectiveCount={}, difficulty={}", + userId, userVO.getDeptId(), userVO.getUserType(), request.getIncludePersonal(), + request.getSingleChoiceCount(), request.getMultipleChoiceCount(), + request.getTrueFalseCount(), request.getFillBlankCount(), + request.getSubjectiveCount(), request.getDifficulty()); + + try { + ExamPaperGenerateResponse response = examService.generatePaper(request, userId, userVO.getDeptId(), userVO.getUserType()); + return Result.success("试卷生成成功", response); + } catch (Exception e) { + log.error("生成试卷异常: {}", e.getMessage(), e); + return Result.error(500, "生成试卷异常: " + e.getMessage()); + } + } + + @Operation(summary = "生成题目", description = "根据知识库内容生成考试题目,支持单选题、多选题、判断题、填空题、简答题") + @PostMapping("/generate") + public Result> generateQuestions( + @RequestBody ExamGenerateRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (request.getQuestionTypes() == null) { + return Result.error(400, "question_types 不能为空,请指定题型及数量"); + } + + boolean hasCollection = (request.getCollection() != null) || + (request.getCollectionName() != null && !request.getCollectionName().isEmpty()); + if (!hasCollection) { + return Result.error(400, "collection 或 collection_name 不能为空,请指定向量库"); + } + + if (request.getFilePath() == null || request.getFilePath().isEmpty()) { + return Result.error(400, "file_path 不能为空,请指定文件路径"); + } + + if (request.getRequestId() == null || request.getRequestId().isEmpty()) { + request.setRequestId(UUID.randomUUID().toString()); + } + + log.info("生成题目请求: userId={}, requestId={}, questionTypes={}, difficulty={}, collection={}, collectionName={}, filePath={}", + userId, request.getRequestId(), request.getQuestionTypes(), + request.getDifficulty(), request.getCollection(), request.getCollectionName(), request.getFilePath()); + + examService.generateQuestionsAsync(request); + + Map result = new HashMap<>(); + result.put("requestId", request.getRequestId()); + result.put("message", "收到请求,正在生成题目,请稍后查看文件状态"); + + return Result.success("收到请求", result); + } + + @Operation(summary = "提交考试并批改(正式考试专用)", description = "通过考试记录ID提交正式考试答案并批改") + @PostMapping("/record/{recordId}/grade") + public Result gradeExamRecord( + @PathVariable String recordId, + @RequestBody ExamSaveRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + log.info("提交正式考试: userId={}, recordId={}", userId, recordId); + + try { + ExamRecordResponse response = examService.gradeAnswersForRecord(recordId, request, userId, "formal_exam"); + return Result.success("提交成功", response); + } catch (Exception e) { + log.error("提交正式考试失败: {}", e.getMessage(), e); + return Result.error(500, "提交失败: " + e.getMessage()); + } + } + + @Operation(summary = "批改答案(日常练习/模拟考/正式考试通用)", description = "对学生提交的答案进行批改,支持客观题本地批改和主观题LLM评分") + @PostMapping("/grade") + public Result gradeAnswers( + @RequestBody ExamGradeRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (request.getAnswers() == null || request.getAnswers().isEmpty()) { + return Result.error(400, "答案列表不能为空"); + } + + // 检查是否是通过 recordId 提交的正式考试 + if (request.getRecordId() != null && !request.getRecordId().isEmpty()) { + // 需要转换 ExamGradeRequest 为 ExamSaveRequest + ExamSaveRequest saveRequest = new ExamSaveRequest(); + List saveItems = request.getAnswers().stream().map(gradeItem -> { + ExamSaveRequest.AnswerItem saveItem = new ExamSaveRequest.AnswerItem(); + saveItem.setQuestionId(gradeItem.getQuestionId()); + saveItem.setQuestionType(gradeItem.getQuestionType()); + saveItem.setAnswer(gradeItem.getStudentAnswer()); + return saveItem; + }).collect(java.util.stream.Collectors.toList()); + saveRequest.setAnswers(saveItems); + + log.info("通过recordId提交考试: userId={}, recordId={}", userId, request.getRecordId()); + + try { + ExamRecordResponse response = examService.gradeAnswersForRecord( + request.getRecordId(), + saveRequest, + userId, + request.getAnswerType() != null ? request.getAnswerType() : "formal_exam"); + return Result.success("批阅成功", response); + } catch (Exception e) { + log.error("批改失败: {}", e.getMessage(), e); + return Result.error(500, "批改失败: " + e.getMessage()); + } + } + + // 日常练习/模拟考模式 + for (int i = 0; i < request.getAnswers().size(); i++) { + ExamGradeRequest.AnswerItem answer = request.getAnswers().get(i); + if (answer.getQuestionId() == null || answer.getQuestionId().isEmpty()) { + return Result.error(400, "第" + (i + 1) + "个答案的question_id不能为空"); + } + if (answer.getQuestionType() == null || answer.getQuestionType().isEmpty()) { + return Result.error(400, "第" + (i + 1) + "个答案的question_type不能为空"); + } + if (answer.getQuestionContent() == null) { + return Result.error(400, "第" + (i + 1) + "个答案的question_content不能为空"); + } + if (answer.getMaxScore() == null) { + return Result.error(400, "第" + (i + 1) + "个答案的max_score不能为空"); + } + } + + if (request.getRequestId() == null || request.getRequestId().isEmpty()) { + request.setRequestId(UUID.randomUUID().toString()); + } + + if (request.getPaperId() == null || request.getPaperId().isEmpty()) { + String generatedPaperId = UUID.randomUUID().toString(); + request.setPaperId(generatedPaperId); + log.info("自动生成试卷ID: paperId={}", generatedPaperId); + } + + if (request.getSessionId() == null || request.getSessionId().isEmpty()) { + request.setSessionId("grade_" + UUID.randomUUID().toString().substring(0, 8)); + } + + log.info("批改答案请求: userId={}, requestId={}, paperId={}, sessionId={}, type={}, answers count={}", + userId, request.getRequestId(), request.getPaperId(), request.getSessionId(), + request.getAnswerType(), request.getAnswers().size()); + + try { + ExamGradeResponse response = examService.gradeAnswers(request, userId, request.getPaperId(), + request.getSessionId(), request.getAnswerType()); + + if (Boolean.TRUE.equals(response.getSuccess())) { + return Result.success("批阅成功", response); + } else { + String errorMessage = response.getError() != null ? response.getError() : "答案批改失败"; + return Result.error(500, errorMessage); + } + + } catch (IOException e) { + log.error("批改答案失败: {}", e.getMessage(), e); + return Result.error(500, "批改答案失败: " + e.getMessage()); + } catch (Exception e) { + log.error("批改答案异常: {}", e.getMessage(), e); + return Result.error(500, "批改答案异常: " + e.getMessage()); + } + } + + @Operation(summary = "查询用户答题记录", description = "根据试卷ID或会话ID查询用户答题记录,支持按试卷/练习维度分组展示") + @PostMapping("/answers/query") + public Result queryUserAnswers( + @RequestBody UserAnswerQueryRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + boolean hasPaperId = request.getPaperId() != null && !request.getPaperId().isEmpty(); + boolean hasSessionId = request.getSessionId() != null && !request.getSessionId().isEmpty(); + + if (!hasPaperId && !hasSessionId) { + return Result.error(400, "paper_id 或 session_id 不能为空"); + } + + if (hasPaperId && hasSessionId) { + return Result.error(400, "paper_id 和 session_id 不能同时提供"); + } + + request.setUserId(userId); + + log.info("查询用户答题记录: userId={}, paperId={}, sessionId={}", + userId, request.getPaperId(), request.getSessionId()); + + try { + UserAnswerQueryResponse response = userAnswerService.queryUserAnswers(request); + + if (response.getTotalCount() == 0) { + return Result.success("未找到答题记录", response); + } + + return Result.success("查询成功", response); + + } catch (Exception e) { + log.error("查询用户答题记录异常: {}", e.getMessage(), e); + return Result.error(500, "查询用户答题记录异常: " + e.getMessage()); + } + } + + @Operation(summary = "获取考试记录列表", description = "根据类型获取用户的考试/练习记录,支持日常练习(practice)、模拟考(mock_exam)、正式考(formal_exam)") + @GetMapping("/records") + public Result getExamRecords( + @Parameter(description = "记录类型:practice(日常练习), mock_exam(模拟考), formal_exam(正式考)", example = "practice") + @RequestParam(defaultValue = "practice") String type, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + log.info("查询考试记录: userId={}, type={}", userId, type); + + try { + // 根据类型选择不同的查询逻辑 + if ("practice".equals(type)) { + // 日常练习:从 user_answer 表查询,按 session_id 分组 + UserAnswerQueryRequest queryRequest = new UserAnswerQueryRequest(); + queryRequest.setUserId(userId); + UserAnswerQueryResponse response = userAnswerService.queryUserAnswers(queryRequest); + return Result.success(response); + } else if ("mock_exam".equals(type) || "formal_exam".equals(type)) { + // 模拟考/正式考:从 exam_record 表查询 + List records = examRecordService.getUserPapers(userId, type); + return Result.success(records); + } else { + return Result.error(400, "无效的类型参数,支持的值: practice, mock_exam, formal_exam"); + } + } catch (Exception e) { + log.error("查询考试记录失败: {}", e.getMessage(), e); + return Result.error(500, "查询考试记录失败: " + e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/bug.txt b/bug.txt new file mode 100644 index 0000000..0d0c680 --- /dev/null +++ b/bug.txt @@ -0,0 +1,614 @@ +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@8ec4794] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1316742363 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@8ec4794] +2026-06-02 10:17:30 - 请求 | URL: http://localhost:8080/api/ai/session/sess_1780387846575_3rnw6o00w | 方法: GET | IP: 127.0.0.1 +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4f50dd49] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@845849611 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4f50dd49] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9e498f7] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1083680279 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (user_id = ?) ORDER BY create_time DESC +==> Parameters: 1(Long) +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 62, sess_1780387846575_3rnw6o00w, 1, <>, 2026-06-02 08:10:46 +<== Row: 61, sess_1780386659498_e2i0pli3t, 1, <>, 2026-06-02 07:50:59 +<== Row: 60, sess_1780385074333_3ektq0vgx, 1, <>, 2026-06-02 07:24:34 +<== Row: 59, sess_1780379728867_z5d8v05yp, 1, <>, 2026-06-02 05:55:28 +<== Total: 4 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9e498f7] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5287e229] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@2131360686 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (session_id = ?) +==> Parameters: sess_1780387846575_3rnw6o00w(String) +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 62, sess_1780387846575_3rnw6o00w, 1, <>, 2026-06-02 08:10:46 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5287e229] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@75d9a876] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@413309518 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,conversation_id,role,content,message_type,is_finished,summary,kb_paths,answer,mode,duration_ms,images,sources,create_time FROM context_message WHERE (conversation_id = ?) ORDER BY create_time ASC +==> Parameters: 62(Long) +<== Columns: id, conversation_id, role, content, message_type, is_finished, summary, kb_paths, answer, mode, duration_ms, images, sources, create_time +<== Row: 170, 62, assistant, <>, rag, 1, <>, <>, <>, null, 150685, <>, <>, 2026-06-02 08:13:17 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@75d9a876] +2026-06-02 10:17:30 - 获取图片: imageId=0cb2b4cd0ca4407a3fc0360f318c16b2 +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@22e0ae58] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1397047197 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,image_id,filename,path,size_bytes,format,source,page,page_end,page_range,section,chunk_type,type,description,full_description,score,collection,create_time FROM context_image WHERE (image_id = ?) +==> Parameters: 0cb2b4cd0ca4407a3fc0360f318c16b2(String) +<== Total: 0 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@22e0ae58] +2026-06-02 10:17:30 - 调用RAG服务获取图片: url=http://localhost:5001/images/0cb2b4cd0ca4407a3fc0360f318c16b2 +2026-06-02 10:17:30 - RAG服务响应状态码: 404 +2026-06-02 10:17:30 - RAG服务错误响应内容: {"error":"\u56fe\u7247\u4e0d\u5b58\u5728","image_id":"0cb2b4cd0ca4407a3fc0360f318c16b2"} +2026-06-02 10:17:30 - 调用RAG服务获取图片失败: 调用RAG服务失败: RAG服务返回错误状态码: 404, 错误信息: {"error":"\u56fe\u7247\u4e0d\u5b58\u5728","image_id":"0cb2b4cd0ca4407a3fc0360f318c16b2"} +java.io.IOException: 调用RAG服务失败: RAG服务返回错误状态码: 404, 错误信息: {"error":"\u56fe\u7247\u4e0d\u5b58\u5728","image_id":"0cb2b4cd0ca4407a3fc0360f318c16b2"} + at top.tqx.demo_1.service.RagService.getBinaryRequest(RagService.java:202) + at top.tqx.demo_1.service.impl.ImageServiceImpl.getImage(ImageServiceImpl.java:165) + at top.tqx.demo_1.service.impl.ChatServiceImpl.findLocalImageUrl(ChatServiceImpl.java:261) + at top.tqx.demo_1.service.impl.ChatServiceImpl.processImageUrls(ChatServiceImpl.java:235) + at top.tqx.demo_1.service.impl.ChatServiceImpl.getSessionMessages(ChatServiceImpl.java:205) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:716) + at top.tqx.demo_1.service.impl.ChatServiceImpl$$SpringCGLIB$$0.getSessionMessages() + at top.tqx.demo_1.controller.AIChatController.getSessionDetail(AIChatController.java:227) + at jdk.internal.reflect.GeneratedMethodAccessor332.invoke(Unknown Source) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at jdk.internal.reflect.GeneratedMethodAccessor157.invoke(Unknown Source) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.AIChatController$$SpringCGLIB$$0.getSessionDetail() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Unknown Source) +Caused by: java.io.IOException: RAG服务返回错误状态码: 404, 错误信息: {"error":"\u56fe\u7247\u4e0d\u5b58\u5728","image_id":"0cb2b4cd0ca4407a3fc0360f318c16b2"} + at top.tqx.demo_1.service.RagService.getBinaryRequest(RagService.java:190) + ... 88 common frames omitted +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@80c5035] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@125775329 wrapping com.mysql.cj.jdbc.ConnectionImpl@5ae44be2] will not be managed by Spring +==> Preparing: SELECT id,message_id,chunk_id,doc_path,doc_name,page,page_end,page_range,section,chunk_type,doc_type,section_chunk_id,excerpt,preview,bbox,bbox_mode,score,create_time FROM context_reference WHERE (message_id = ?) +==> Parameters: 170(Long) +<== Columns: id, message_id, chunk_id, doc_path, doc_name, page, page_end, page_range, section, chunk_type, doc_type, section_chunk_id, excerpt, preview, bbox, bbox_mode, score, create_time +<== Row: 355, 170, , null, 1.docx, 1, 1, 1, 2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。, text, other, null, <>, <>, null, null, 0.9960, 2026-06-02 08:13:17 +<== Row: 356, 170, , null, 1.txt, 9, 9, 9, , text, other, null, <>, <>, null, null, 0.0040, 2026-06-02 08:13:17 +<== Row: 357, 170, , null, 货源投放工作规范.docx, 1, 1, 1, 2.指标值与权重确定。 > ①指标值的确定:, text, other, null, <>, <>, null, null, 0.9400, 2026-06-02 08:13:17 +<== Row: 358, 170, , null, 需求分析.docx, 1, 1, 1, 需求分析, text, other, null, <>, <>, null, null, 0.9820, 2026-06-02 08:13:17 +<== Row: 359, 170, , null, 3.docx, 1, 1, 1, 第五篇 投入管理 > **第三章 投入标准**, text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 08:13:17 +<== Row: 360, 170, , null, test.docx, 1, 1, 1, 第五条 管理人员负责日常监督检查,定期核查登记台账、系统上传记录,对漏登、错登、代登等问题及时整改追责。, text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 08:13:17 +<== Row: 361, 170, , null, longrule.docx, 1, 1, 1, , text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 08:13:17 +<== Row: 362, 170, , null, 2.docx, 1, 1, 1, 第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市, table, other, null, <>, <>, null, null, 1.0000, 2026-06-02 08:13:17 +<== Row: 363, 170, 1.docx_62, null, 1.docx, 1, 1, null, 3.按价位段自选投放工作指引 > **3.终端动销率公式。**, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 364, 170, 1.docx_94, null, 1.docx, 1, 1, null, 3.状态评价办法与标准。 > 备注:, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 365, 170, 1.docx_22, null, 1.docx, 1, 1, null, 6.坚持区域协同、高效运作。 > **3.市场状态指标:**, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 366, 170, 1.docx_80, null, 1.docx, 1, 1, null, 1.市场状态的计算公式为: > 市场状态=+二级指标均值\*权重, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 367, 170, 1.docx_90, null, 1.docx, 1, 1, null, 3.状态评价办法与标准。 > **(二)品规属性聚类评价**, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 368, 170, 1.docx_56, null, 1.docx, 1, 1, null, 3.按价位段自选投放工作指引 > **\*\*市场状态评价指标体系**, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Row: 369, 170, 1.docx_79, null, 1.docx, 1, 1, null, 1.市场状态的计算公式为:, null, other, 0, <>, <>, null, null, null, 2026-06-02 08:13:17 +<== Total: 15 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@80c5035] +2026-06-02 10:17:30 - 响应 | 耗时: 137ms + + + + + + + + + + +curl -X GET "http://localhost:8080/api/ai/session/sess_1780387846575_3rnw6o00w" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDM5NjI1OCwiZXhwIjoxNzgwNDAzNDU4fQ.Lxp1TpJKG1mVsIidkuhhPsgxrMH1nxhoBlJfcm4Q3M6zB_fqoCknzCeIGTIHcm0NFayxeMnrjRUBb4mL0l-wiA" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Connecting to 47.116.16.222:22... +Connection established. +To escape to local shell, press 'Ctrl+Alt+]'. + +Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-142-generic x86_64) + + * Documentation: https://help.ubuntu.com + * Management: https://landscape.canonical.com + * Support: https://ubuntu.com/pro + + System information as of Tue Jun 2 10:36:27 PM CST 2026 + + System load: 0.1 Processes: 157 + Usage of /: 49.3% of 68.53GB Users logged in: 1 + Memory usage: 47% IPv4 address for eth0: 172.24.53.43 + Swap usage: 0% + + * Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s + just raised the bar for easy, resilient and secure K8s cluster deployment. + + https://ubuntu.com/engage/secure-kubernetes-at-the-edge + +Expanded Security Maintenance for Applications is not enabled. + +5 updates can be applied immediately. +To see these additional updates run: apt list --upgradable + +6 additional security updates can be applied with ESM Apps. +Learn more about enabling ESM Apps service at https://ubuntu.com/esm + +New release '24.04.4 LTS' available. +Run 'do-release-upgrade' to upgrade to it. + + +1 updates could not be installed automatically. For more details, +see /var/log/unattended-upgrades/unattended-upgrades.log + +*** System restart required *** + +Welcome to Alibaba Cloud Elastic Compute Service ! + +Last login: Tue Jun 2 21:25:58 2026 from 112.2.253.193 +root@iZuf69tvv5un8t8r0jvzn0Z:~# docker logs -f --tail 300 demo-app +<== Row: 69, 1.docx, resources/rule/总公司/1/v2/1.docx, 483352, application/vnd.openxmlformats-officedocument.word, .docx, null, 1, 1, <>, 1, rule, null, 1, 1, dept_1_kb/1.docx, 2026-06-02 14:05:52, 2026-06-02 14:14:10, INDEXED, <>, GENERATED, EXAM_GENERATED, 题目生成完成 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2e7d3373] +2026-06-02 14:34:23 - 响应 | 耗时: 38ms +<== Total: 3 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7e56456d] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b7053c7] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@63894377 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b7053c7] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4599b09e] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@481181057 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,dept_name,dept_code,parent_id,dept_level,dept_type,manager_id,status,sort_order,remark,create_time,update_time FROM department WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, dept_name, dept_code, parent_id, dept_level, dept_type, manager_id, status, sort_order, remark, create_time, update_time +<== Row: 1, 总公司, HEADQUARTERS_MAIN, 0, 1, HEADQUARTERS, null, 1, 0, 集团总公司, 2026-04-27 09:22:57, 2026-04-27 09:36:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4599b09e] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@798e5451] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1897596606 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@798e5451] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@aab1589] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@26014801 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,dept_name,dept_code,parent_id,dept_level,dept_type,manager_id,status,sort_order,remark,create_time,update_time FROM department WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, dept_name, dept_code, parent_id, dept_level, dept_type, manager_id, status, sort_order, remark, create_time, update_time +<== Row: 1, 总公司, HEADQUARTERS_MAIN, 0, 1, HEADQUARTERS, null, 1, 0, 集团总公司, 2026-04-27 09:22:57, 2026-04-27 09:36:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@aab1589] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@17f9c337] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1160551196 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@17f9c337] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@527fc3bc] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@189153983 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,dept_name,dept_code,parent_id,dept_level,dept_type,manager_id,status,sort_order,remark,create_time,update_time FROM department WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, dept_name, dept_code, parent_id, dept_level, dept_type, manager_id, status, sort_order, remark, create_time, update_time +<== Row: 1, 总公司, HEADQUARTERS_MAIN, 0, 1, HEADQUARTERS, null, 1, 0, 集团总公司, 2026-04-27 09:22:57, 2026-04-27 09:36:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@527fc3bc] +2026-06-02 14:34:23 - 响应 | 耗时: 58ms +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@59499ce7] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1326040202 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@59499ce7] +2026-06-02 14:34:23 - 请求 | URL: http://47.116.16.222/api/ai/sessions | 方法: GET | IP: 112.2.253.199 +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@725fe044] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1743053900 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@725fe044] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@ce76c9d] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@20443269 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (user_id = ?) ORDER BY create_time DESC +==> Parameters: 1(Long) +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 65, sess_1780410472658_9qg9a9hhp, 1, <>, 2026-06-02 14:27:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@ce76c9d] +2026-06-02 14:34:23 - 响应 | 耗时: 5ms +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7cbe5b14] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@156517454 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7cbe5b14] +2026-06-02 14:34:23 - 请求 | URL: http://47.116.16.222/api/user/1 | 方法: GET | IP: 112.2.253.199 +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@899bce8] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@108081172 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@899bce8] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4daedb18] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@143999161 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,dept_name,dept_code,parent_id,dept_level,dept_type,manager_id,status,sort_order,remark,create_time,update_time FROM department WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, dept_name, dept_code, parent_id, dept_level, dept_type, manager_id, status, sort_order, remark, create_time, update_time +<== Row: 1, 总公司, HEADQUARTERS_MAIN, 0, 1, HEADQUARTERS, null, 1, 0, 集团总公司, 2026-04-27 09:22:57, 2026-04-27 09:36:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4daedb18] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@491cfb00] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1926340384 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT r.* FROM role r INNER JOIN user_role ur ON r.id = ur.role_id WHERE ur.user_id = ? +==> Parameters: 1(Long) +<== Columns: id, uuid, role_name, role_code, role_type, description, status, sort_order, create_time, update_time +<== Row: 1, dad50ea8-392f-11f1-8d4f-7cb566b7fb8c, 系统管理员, admin, 1, 系统管理员,拥有所有权限, 1, 1, 2026-04-16 09:01:52, 2026-04-16 09:01:52 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@491cfb00] +2026-06-02 14:34:23 - 响应 | 耗时: 5ms +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1c882f2c] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@293497846 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1c882f2c] +2026-06-02 14:34:23 - 请求 | URL: http://47.116.16.222/api/ai/session/sess_1780410472658_9qg9a9hhp | 方法: GET | IP: 112.2.253.199 +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@39ce6e88] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1684195282 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@39ce6e88] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4e0779c2] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@74966180 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (user_id = ?) ORDER BY create_time DESC +==> Parameters: 1(Long) +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6c750e8f] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1797211410 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 65, sess_1780410472658_9qg9a9hhp, 1, <>, 2026-06-02 14:27:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4e0779c2] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@797c62be] was not registered for synchronization because synchronization is not active +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +JDBC Connection [HikariProxyConnection@213966138 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (session_id = ?) +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +==> Parameters: sess_1780410472658_9qg9a9hhp(String) +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6c750e8f] +2026-06-02 14:34:23 - 请求 | URL: http://47.116.16.222/api/ai/session/sess_1780410472658_9qg9a9hhp | 方法: GET | IP: 112.2.253.199 +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 65, sess_1780410472658_9qg9a9hhp, 1, <>, 2026-06-02 14:27:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@797c62be] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@e34ef1f] was not registered for synchronization because synchronization is not active +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@32a8cb9a] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1986902582 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,username,password,real_name,email,phone,avatar,user_type,status,dept_id,last_login_time,create_time,update_time FROM user WHERE id=? +==> Parameters: 1(Long) +JDBC Connection [HikariProxyConnection@340202997 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,conversation_id,role,content,message_type,is_finished,summary,kb_paths,answer,mode,duration_ms,images,sources,create_time FROM context_message WHERE (conversation_id = ?) ORDER BY create_time ASC +==> Parameters: 65(Long) +<== Columns: id, username, password, real_name, email, phone, avatar, user_type, status, dept_id, last_login_time, create_time, update_time +<== Row: 1, admin, $2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W, 系统管理员, admin@system.com, 13800000000, null, 1, 1, 1, null, 2026-04-16 09:05:15, 2026-05-10 19:08:59 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@32a8cb9a] +<== Columns: id, conversation_id, role, content, message_type, is_finished, summary, kb_paths, answer, mode, duration_ms, images, sources, create_time +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@267a41] was not registered for synchronization because synchronization is not active +<== Row: 175, 65, assistant, <>, rag, 1, <>, <>, <>, null, 75512, <>, <>, 2026-06-02 14:29:09 +<== Row: 176, 65, assistant, <>, rag, 1, <>, <>, <>, null, 97190, <>, <>, 2026-06-02 14:32:03 +<== Total: 2 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@e34ef1f] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6366151e] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1957074372 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (user_id = ?) ORDER BY create_time DESC +==> Parameters: 1(Long) +JDBC Connection [HikariProxyConnection@1250116218 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,message_id,chunk_id,doc_path,doc_name,page,page_end,page_range,section,chunk_type,doc_type,section_chunk_id,excerpt,preview,bbox,bbox_mode,score,create_time FROM context_reference WHERE (message_id = ?) +==> Parameters: 175(Long) +<== Columns: id, message_id, chunk_id, doc_path, doc_name, page, page_end, page_range, section, chunk_type, doc_type, section_chunk_id, excerpt, preview, bbox, bbox_mode, score, create_time +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 65, sess_1780410472658_9qg9a9hhp, 1, <>, 2026-06-02 14:27:53 +<== Row: 393, 175, , null, 1.docx, 1, 1, 1, 1.市场状态的计算公式为:, image, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@267a41] +<== Row: 394, 175, , null, 3.docx, 1, 1, 1, 第六章 附则 > (不公开), text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +<== Row: 395, 175, , null, 2.docx, 1, 1, 1, 4.终端类型维护 > **(一)运营指导**, text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +Creating a new SqlSession +<== Row: 396, 175, , null, test.docx, 1, 1, 1, \## 第一章 总则, text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 14:29:09 +<== Row: 397, 175, 1.docx_24, null, 1.docx, 1, 1, null, 6.坚持区域协同、高效运作。 > **(一)统一货源投放周期**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:29:09 +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@52466c06] was not registered for synchronization because synchronization is not active +<== Total: 5 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6366151e] +JDBC Connection [HikariProxyConnection@1635905661 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,session_id,user_id,summary,create_time FROM context_conversation WHERE (session_id = ?) +==> Parameters: sess_1780410472658_9qg9a9hhp(String) +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@59860f83] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@169553736 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,image_id,filename,path,size_bytes,format,source,page,page_end,page_range,section,chunk_type,type,description,full_description,score,collection,create_time FROM context_image WHERE (image_id = ?) +==> Parameters: 49d2910b148d.jpg(String) +<== Columns: id, session_id, user_id, summary, create_time +<== Row: 65, sess_1780410472658_9qg9a9hhp, 1, <>, 2026-06-02 14:27:53 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@52466c06] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@47c5e224] was not registered for synchronization because synchronization is not active +<== Columns: id, image_id, filename, path, size_bytes, format, source, page, page_end, page_range, section, chunk_type, type, description, full_description, score, collection, create_time +<== Row: 7, 49d2910b148d.jpg, null, img/49d2910b148d.jpg, 3004, JPEG, 1.docx, 1, null, null, null, null, image, <>, <>, 10.9000, null, 2026-06-02 14:32:03 +JDBC Connection [HikariProxyConnection@637702278 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,conversation_id,role,content,message_type,is_finished,summary,kb_paths,answer,mode,duration_ms,images,sources,create_time FROM context_message WHERE (conversation_id = ?) ORDER BY create_time ASC +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@59860f83] +==> Parameters: 65(Long) +2026-06-02 14:34:23 - 图片URL已转换: imageId=49d2910b148d.jpg, nginxUrl=/files/img/49d2910b148d.jpg +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4b11dd4] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1831006422 wrapping com.mysql.cj.jdbc.ConnectionImpl@135636f5] will not be managed by Spring +==> Preparing: SELECT id,message_id,chunk_id,doc_path,doc_name,page,page_end,page_range,section,chunk_type,doc_type,section_chunk_id,excerpt,preview,bbox,bbox_mode,score,create_time FROM context_reference WHERE (message_id = ?) +==> Parameters: 176(Long) +<== Columns: id, conversation_id, role, content, message_type, is_finished, summary, kb_paths, answer, mode, duration_ms, images, sources, create_time +<== Row: 175, 65, assistant, <>, rag, 1, <>, <>, <>, null, 75512, <>, <>, 2026-06-02 14:29:09 +<== Row: 176, 65, assistant, <>, rag, 1, <>, <>, <>, null, 97190, <>, <>, 2026-06-02 14:32:03 +<== Total: 2 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@47c5e224] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@234566b3] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1274635325 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,message_id,chunk_id,doc_path,doc_name,page,page_end,page_range,section,chunk_type,doc_type,section_chunk_id,excerpt,preview,bbox,bbox_mode,score,create_time FROM context_reference WHERE (message_id = ?) +==> Parameters: 175(Long) +<== Columns: id, message_id, chunk_id, doc_path, doc_name, page, page_end, page_range, section, chunk_type, doc_type, section_chunk_id, excerpt, preview, bbox, bbox_mode, score, create_time +<== Row: 398, 176, , null, 1.docx, 1, 1, 1, 6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**, text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 14:32:03 +<== Row: 399, 176, , null, 2.docx, 1, 1, 1, 2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**, table, other, null, <>, <>, null, null, 0.9980, 2026-06-02 14:32:03 +<== Row: 400, 176, , null, 3.docx, 1, 1, 1, 2.日常维护管理内容: > A2卫生医疗机构和各类院校, text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:32:03 +<== Row: 401, 176, 1.docx_22, null, 1.docx, 1, 1, null, 6.坚持区域协同、高效运作。 > **3.市场状态指标:**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Row: 402, 176, 1.docx_80, null, 1.docx, 1, 1, null, 1.市场状态的计算公式为: > 市场状态=+二级指标均值\*权重, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Columns: id, message_id, chunk_id, doc_path, doc_name, page, page_end, page_range, section, chunk_type, doc_type, section_chunk_id, excerpt, preview, bbox, bbox_mode, score, create_time +<== Row: 403, 176, 1.docx_62, null, 1.docx, 1, 1, null, 3.按价位段自选投放工作指引 > **3.终端动销率公式。**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Row: 393, 175, , null, 1.docx, 1, 1, 1, 1.市场状态的计算公式为:, image, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +<== Row: 404, 176, 1.docx_86, null, 1.docx, 1, 1, null, 2.指标值与权重确定。 > 一级指标合计权重不低于70%。, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Total: 7 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4b11dd4] +<== Row: 394, 175, , null, 3.docx, 1, 1, 1, 第六章 附则 > (不公开), text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +<== Row: 395, 175, , null, 2.docx, 1, 1, 1, 4.终端类型维护 > **(一)运营指导**, text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:29:09 +<== Row: 396, 175, , null, test.docx, 1, 1, 1, \## 第一章 总则, text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 14:29:09 +<== Row: 397, 175, 1.docx_24, null, 1.docx, 1, 1, null, 6.坚持区域协同、高效运作。 > **(一)统一货源投放周期**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:29:09 +<== Total: 5 +2026-06-02 14:34:23 - 响应 | 耗时: 21ms +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@234566b3] +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@45eecdf8] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1293306632 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,image_id,filename,path,size_bytes,format,source,page,page_end,page_range,section,chunk_type,type,description,full_description,score,collection,create_time FROM context_image WHERE (image_id = ?) +==> Parameters: 49d2910b148d.jpg(String) +<== Columns: id, image_id, filename, path, size_bytes, format, source, page, page_end, page_range, section, chunk_type, type, description, full_description, score, collection, create_time +<== Row: 7, 49d2910b148d.jpg, null, img/49d2910b148d.jpg, 3004, JPEG, 1.docx, 1, null, null, null, null, image, <>, <>, 10.9000, null, 2026-06-02 14:32:03 +<== Total: 1 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@45eecdf8] +2026-06-02 14:34:23 - 图片URL已转换: imageId=49d2910b148d.jpg, nginxUrl=/files/img/49d2910b148d.jpg +Creating a new SqlSession +SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5ecb72bd] was not registered for synchronization because synchronization is not active +JDBC Connection [HikariProxyConnection@1836705169 wrapping com.mysql.cj.jdbc.ConnectionImpl@6dd60de9] will not be managed by Spring +==> Preparing: SELECT id,message_id,chunk_id,doc_path,doc_name,page,page_end,page_range,section,chunk_type,doc_type,section_chunk_id,excerpt,preview,bbox,bbox_mode,score,create_time FROM context_reference WHERE (message_id = ?) +==> Parameters: 176(Long) +<== Columns: id, message_id, chunk_id, doc_path, doc_name, page, page_end, page_range, section, chunk_type, doc_type, section_chunk_id, excerpt, preview, bbox, bbox_mode, score, create_time +<== Row: 398, 176, , null, 1.docx, 1, 1, 1, 6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**, text, other, null, <>, <>, null, null, 0.9990, 2026-06-02 14:32:03 +<== Row: 399, 176, , null, 2.docx, 1, 1, 1, 2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**, table, other, null, <>, <>, null, null, 0.9980, 2026-06-02 14:32:03 +<== Row: 400, 176, , null, 3.docx, 1, 1, 1, 2.日常维护管理内容: > A2卫生医疗机构和各类院校, text, other, null, <>, <>, null, null, 1.0000, 2026-06-02 14:32:03 +<== Row: 401, 176, 1.docx_22, null, 1.docx, 1, 1, null, 6.坚持区域协同、高效运作。 > **3.市场状态指标:**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Row: 402, 176, 1.docx_80, null, 1.docx, 1, 1, null, 1.市场状态的计算公式为: > 市场状态=+二级指标均值\*权重, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Row: 403, 176, 1.docx_62, null, 1.docx, 1, 1, null, 3.按价位段自选投放工作指引 > **3.终端动销率公式。**, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Row: 404, 176, 1.docx_86, null, 1.docx, 1, 1, null, 2.指标值与权重确定。 > 一级指标合计权重不低于70%。, null, other, 0, <>, <>, null, null, null, 2026-06-02 14:32:03 +<== Total: 7 +Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5ecb72bd] +2026-06-02 14:34:23 - 响应 | 耗时: 21ms diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..f977737 --- /dev/null +++ b/build.bat @@ -0,0 +1,27 @@ +@echo off +set JAVA_HOME=C:\Program Files\Java\jdk-21.0.8 +set PATH=%JAVA_HOME%\bin;%PATH% + +echo Starting Maven build... +echo JAVA_HOME=%JAVA_HOME% +echo PATH=%PATH% + +:: Check if maven wrapper properties exist +if not exist ".mvn\wrapper\maven-wrapper.properties" ( + echo ERROR: .mvn\wrapper\maven-wrapper.properties not found! + pause + exit /b 1 +) + +:: Try to run maven directly +echo Running: mvn clean package -DskipTests +mvn clean package -DskipTests + +if %ERRORLEVEL% equ 0 ( + echo Build successful! + echo JAR file should be in target/ directory +) else ( + echo Build failed with error code %ERRORLEVEL% +) + +pause \ No newline at end of file diff --git a/default b/default new file mode 100644 index 0000000..41c641d --- /dev/null +++ b/default @@ -0,0 +1,64 @@ +## +# You should look at the following URL's in order to grasp a solid understanding +# of Nginx configuration files in order to fully unleash the power of Nginx. +# https://www.nginx.com/resources/wiki/start/ +# https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/ +# https://wiki.debian.org/Nginx/DirectoryStructure +# +# In most cases, administrators will remove this file from sites-enabled/ and +# leave it as reference inside of sites-available where it will continue to be +# updated by the nginx packaging team. +# +# This file will automatically load configuration files provided by other +# applications, such as Drupal or Wordpress. These applications will be made +# available underneath a path with that package name, such as /drupal8. +# +# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples. +## + +# Default server configuration +# +server { + listen 80; + server_name localhost; + + client_max_body_size 100M; + + root /var/www/html; + index index.html; + + # 文件直接访问路径 - ^~ 表示前缀匹配且优先级高于正则 + location ^~ /files/ { + alias /opt/deploy/uploads/; + add_header Cache-Control "public, max-age=604800"; + try_files $uri $uri/ =404; + } + + # 静态资源 + location ~* \.(js|css|svg|png|jpg|woff|ttf)$ { + expires 7d; + } + + # 前端路由 + location / { + try_files $uri $uri/ /index.html; + } + + # API代理 - SSE流式响应配置 + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE流式响应必需配置 + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + proxy_http_version 1.1; + proxy_set_header Connection ''; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c6b87d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: "3.8" + +services: + demo-app: + image: demo-app:1.0 + container_name: demo-app + restart: always + build: + context: . + dockerfile: Dockerfile + environment: + - DB_HOST=mysql + - DB_USERNAME=root + - DB_PASSWORD=123456 + - REDIS_HOST=redis + - AI_HOST=localhost + - AI_PORT=5001 + - AI_KEY=app-0iWrwSYdTczCmRmtVNhfjWnq + - SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=50MB + - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=50MB + volumes: + - ./uploads:/app/uploads + - ./logs:/app/logs + - ./img:/app/img + ports: + - "8080:8080" + depends_on: + - mysql + - redis + + mysql: + image: mysql:8.0 + container_name: mysql + restart: always + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=123456 + - MYSQL_DATABASE=knowledge_management_system + - MYSQL_CHARSET=utf8mb4 + - MYSQL_COLLATION=utf8mb4_unicode_ci + volumes: + - mysql-data:/var/lib/mysql + - ./src/main/resources/sql/knowledge_management_system.sql:/docker-entrypoint-initdb.d/init.sql + - ./mysql-conf:/etc/mysql/conf.d + + redis: + image: redis:6.0 + container_name: redis + restart: always + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + +volumes: + mysql-data: + redis-data: \ No newline at end of file diff --git a/docs/ai/curl测试手册 (2).md b/docs/ai/curl测试手册 (2).md new file mode 100644 index 0000000..ab52cd8 --- /dev/null +++ b/docs/ai/curl测试手册 (2).md @@ -0,0 +1,1690 @@ +# RAG API curl 测试手册 + +> 基于 `后端对接规范.md`,所有 curl 命令均在生产模式 (`DEV_MODE=false`) 下验证通过。 +> +> 测试日期:2026-05-07 | 服务地址:`http://localhost:5001` + +## 目录 + +- [1. 基础信息](#1-基础信息) +- [2. 健康检查](#2-健康检查) +- [3. 问答接口](#3-问答接口) +- [4. 检索接口](#4-检索接口) +- [5. 向量库管理](#5-向量库管理) +- [6. 文档管理](#6-文档管理) +- [7. 切片管理](#7-切片管理) +- [8. 同步服务](#8-同步服务) +- [9. 反馈系统](#9-反馈系统) +- [10. FAQ 管理](#10-faq-管理) +- [11. 出题系统](#11-出题系统) +- [12. 图片服务](#12-图片服务) +- [13. 报告服务](#13-报告服务) +- [14. 知识库路由](#14-知识库路由) +- [附录. 已知问题](#附录-已知问题) + +*** + +## 1. 基础信息 + +### 认证方式 + +生产模式 (`DEV_MODE=false`) 下无需认证 Header,直接放行。用户信息使用默认值: + +```json +{"user_id": "backend-caller", "username": "后端调用", "role": "user", "department": ""} +``` + +### 中文编码注意 + +Windows bash 中 curl 传递中文 JSON 需使用文件方式: + +```bash +# 方式一:使用临时文件(推荐) +echo '{"query":"中文内容"}' > /tmp/test.json +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d @/tmp/test.json + +# 方式二:英文可直接传递 +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d '{"query":"hello"}' +``` + +### 通用响应格式 + +所有接口返回 JSON,成功时 HTTP 200,错误时返回对应 HTTP 状态码。 + +*** + +## 2. 健康检查 + +### GET /health + +检查服务运行状态。 + +```bash +curl -s http://localhost:5001/health +``` + +**响应示例**: + +```json +{ + "bm25_index": "动态按需加载", + "knowledge_base": "多向量库模式 (按集合提供服务)", + "mode": "Agentic RAG", + "status": "ok" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 3. 问答接口 + +### POST /rag + +知识库问答(SSE 流式返回)。 + +```bash +echo '{"message":"三峡工程","chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | --------- | ------- | ------------------------ | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ✅(生产模式) | 对话历史 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | +| `session_id` | string | ❌ | 会话 ID | + +**chat\_history 格式**: + +```json +[ + {"role": "user", "content": "之前的问题"}, + {"role": "assistant", "content": "之前的回答"} +] +``` + +**SSE 事件序列**: + +``` +data: {"type": "start", "message": "正在检索知识库..."} +data: {"type": "sources", "sources": [...]} +data: {"type": "chunk", "content": "每个token"} +data: {"type": "finish", "answer": "完整回答", "sources": [...]} +``` + +**带 collections 参数**: + +```bash +echo '{"message":"关于身份","chat_history":[],"collections":["dept_hr"]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +> **⚠️ 重要**:指定知识库必须使用 `collections` 数组参数(如 `["dept_tech"]`),而非 `collection` 单数参数。 +> 使用单数 `collection` 参数会被忽略,导致默认检索 `public_kb`。 + +**多向量库同时查询**: + +```bash +# 同时检索多个知识库 +echo '{"message":"信息安全","collections":["dept_tech","public_kb"],"chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**验证结果**:✅ 通过 + +*** + +### POST /chat + +普通聊天模式(非知识库)。 + +```bash +echo '{"message":"你好","chat_history":[]}' > /tmp/chat.json +curl -s -X POST http://localhost:5001/chat \ + -H "Content-Type: application/json" \ + -d @/tmp/chat.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ❌ | 对话历史 | + +**响应示例**: + +```json +{ + "answer": "你好!很高兴见到你~", + "mode": "chat", + "sources": [], + "web_searched": false +} +``` + +**验证结果**:✅ 通过 + +*** + +## 4. 检索接口 + +### POST /search + +混合检索(向量 + BM25),供 Dify 工作流调用。 + +```bash +echo '{"query":"三峡工程","top_k":3}' > /tmp/search.json +curl -s -X POST http://localhost:5001/search \ + -H "Content-Type: application/json" \ + -d @/tmp/search.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------- | --------- | -- | ------------------------ | +| `query` | string | ✅ | 查询文本 | +| `top_k` | int | ❌ | 返回数量,默认 5 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | + +**响应示例**: + +```json +{ + "contexts": ["综述\n三峡工程是国之重器..."], + "metadatas": [ + { + "_collection": "public_kb", + "chunk_id": "三峡公报_1-15页.pdf_text_8", + "chunk_type": "text", + "collection": "public_kb", + "doc_type": "pdf", + "page": 5, + "page_end": 6, + "section": "三峡工程公报 > 综述", + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + } + ], + "scores": [0.9975] +} +``` + +**scores 说明**:相似度分数,范围 0-1,越高越相关(已从距离转换)。 + +**验证结果**:✅ 通过 + +*** + +## 5. 向量库管理 + +### GET /collections + +获取向量库列表。 + +```bash +curl -s http://localhost:5001/collections +``` + +**响应示例**: + +```json +{ + "collections": [ + { + "created_at": "2026-04-28T23:11:55", + "department": "", + "description": "所有人可访问的公开文档", + "display_name": "公开知识库", + "document_count": 896, + "name": "public_kb" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections + +创建新向量库。 + +```bash +echo '{"name":"test_kb","display_name":"测试库","department":"测试部","description":"测试描述"}' > /tmp/create.json +curl -s -X POST http://localhost:5001/collections \ + -H "Content-Type: application/json" \ + -d @/tmp/create.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | -------------------- | +| `name` | string | ✅ | 向量库名称(字母、数字、下划线、连字符) | +| `display_name` | string | ❌ | 显示名称 | +| `department` | string | ❌ | 所属部门 | +| `description` | string | ❌ | 描述 | + +**响应示例**: + +```json +{ + "message": "向量库 'test_kb' 创建成功", + "name": "test_kb", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /collections/\ + +修改向量库信息。 + +```bash +echo '{"display_name":"新名称","description":"新描述"}' > /tmp/update.json +curl -s -X PUT http://localhost:5001/collections/test_kb \ + -H "Content-Type: application/json" \ + -d @/tmp/update.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `display_name` | string | ❌ | 显示名称 | +| `description` | string | ❌ | 描述 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /collections/\ + +删除向量库。 + +```bash +curl -s -X DELETE http://localhost:5001/collections/test_kb +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------------ | ------- | -- | ------------------ | +| `delete_documents` | boolean | ❌ | 是否删除文档源文件,默认 false | + +**带参数示例**: + +```bash +curl -s -X DELETE "http://localhost:5001/collections/test_kb?delete_documents=true" +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents + +获取向量库内的文档列表。 + +```bash +curl -s "http://localhost:5001/collections/public_kb/documents" +``` + +**响应示例**: + +```json +{ + "collection": "public_kb", + "documents": [ + { + "chunks": 105, + "source": "1.docx" + }, + { + "chunks": 39, + "source": "三峡公报_1-15页.pdf" + } + ], + "total": 9 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/chunks + +获取向量库切片列表。 + +```bash +curl -s "http://localhost:5001/collections/public_kb/chunks?limit=2&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------- | ------ | -- | ----------- | +| `document_id` | string | ❌ | 过滤指定文档的切片 | +| `limit` | int | ❌ | 返回数量,默认 100 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "collection": "public_kb", + "chunks": [ + { + "id": "三峡公报_1-15页.pdf_text_0", + "document": "三峡公报_1-15页.pdf", + "content": "切片内容...", + "metadata": { + "chunk_id": "三峡公报_1-15页.pdf_text_0", + "chunk_type": "text", + "collection": "public_kb", + "page": 1, + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + }, + "score": null + } + ], + "total": 150 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents/\/versions + +获取文档版本历史。 + +```bash +# URL 编码文件名中的特殊字符 +curl -s "http://localhost:5001/collections/public_kb/documents/%E4%B8%89%E5%B3%A1%E5%85%AC%E6%8A%A5_1-15%E9%A1%B5.pdf/versions?limit=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 10 | + +**响应示例**: + +```json +{ + "success": true, + "document_id": "三峡公报_1-15页.pdf", + "collection": "public_kb", + "versions": [ + { + "version": "v1", + "status": "active", + "effective_date": "2026-04-14T21:03:06", + "deprecated_date": null, + "chunk_count": 120 + } + ], + "total": 1 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/update-image-descriptions + +更新图片切片描述(重新生成)。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/update-image-descriptions" +``` + +**响应示例**: + +```json +{ + "success": true, + "message": "图片描述更新完成", + "updated": 5 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 6. 文档管理 + +### GET /documents/list + +获取文档列表。 + +```bash +curl -s "http://localhost:5001/documents/list?collection=public_kb&page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------------------- | +| `collection` | string | ❌ | 知识库名称(也可用 `kb_name`),默认全部 | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "documents": [ + { + "collection": "public_kb", + "filename": "三峡公报_1-15页.pdf", + "last_modified": "2026-04-14T21:03:06", + "path": "public_kb/三峡公报_1-15页.pdf", + "size": 2773520 + } + ], + "total": 8 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /documents/\/status + +获取文档处理状态。 + +```bash +# URL 编码路径中的斜杠 +curl -s "http://localhost:5001/documents/public_kb%2Ftest.txt/status" +``` + +**响应示例**: + +```json +{ + "success": true, + "status": "active", + "chunk_count": 105, + "last_processed": null +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/upload + +上传单个文件。 + +```bash +echo "测试文档内容" > /tmp/test.txt +curl -s -X POST http://localhost:5001/documents/upload \ + -F "file=@/tmp/test.txt" \ + -F "collection=public_kb" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | -------------------- | +| `file` | file | ✅ | 上传文件 | +| `collection` | string | ✅ | 目标知识库(也可用 `kb_name`) | + +**响应示例**: + +```json +{ + "data": { + "file": { + "collection": "public_kb", + "filename": "test.txt", + "path": "public_kb/test.txt", + "size": 18 + }, + "sync_status": "已保存并添加到向量库" + }, + "message": "文件上传成功,已保存并添加到向量库", + "status": "success", + "status_code": 2002, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/batch-upload + +批量上传文件。 + +```bash +echo "文件1内容" > /tmp/file1.txt +echo "文件2内容" > /tmp/file2.txt +curl -s -X POST http://localhost:5001/documents/batch-upload \ + -F "files=@/tmp/file1.txt" \ + -F "files=@/tmp/file2.txt" \ + -F "collection=public_kb" +``` + +**响应示例**: + +```json +{ + "data": { + "results": [ + {"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success"}, + {"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success"} + ], + "success_count": 2, + "total": 2 + }, + "message": "批量上传完成,成功 2/2 个文件", + "status": "success", + "status_code": 2003, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /documents/\ + +更新/替换文档。 + +```bash +echo "更新后的文档内容" > /tmp/updated.txt +curl -s -X PUT "http://localhost:5001/documents/public_kb%2Ftest.txt" \ + -F "file=@/tmp/updated.txt" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------ | ---- | -- | ----- | +| `file` | file | ✅ | 替换的文件 | + +**响应示例**: + +```json +{ + "success": true, + "message": "文件已更新" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /documents/\ + +删除文档。 + +```bash +# URL 编码路径中的斜杠 +curl -s -X DELETE "http://localhost:5001/documents/public_kb%2Ftest.txt" +``` + +**响应示例**: + +```json +{ + "message": "文档已删除", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/documents/\/deprecate + +废弃文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/deprecate" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +### POST /collections/\/documents/\/restore + +恢复文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/restore" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 7. 切片管理 + +### GET /documents/\/chunks + +获取文档切片列表。 + +```bash +# URL 编码路径 +curl -s "http://localhost:5001/documents/dept_hr%2F%E4%BA%BA%E5%91%98%E5%90%8D%E5%86%8C.txt/chunks?page=1&page_size=2" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "chunks": [ + { + "document": "# 智启科技有限公司人员名册\n\n**更新日期**:2024年6月...", + "id": "人员名册.txt_text_0", + "metadata": { + "chunk_id": "人员名册.txt_text_0", + "chunk_index": 0, + "chunk_type": "text", + "collection": "dept_hr", + "doc_type": "other", + "page": 1, + "source": "人员名册.txt", + "status": "active", + "version": "v1" + }, + "status": "active", + "version": "v1" + } + ], + "collection": "dept_hr", + "document_id": "dept_hr/人员名册.txt", + "success": true, + "total": 4 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /chunks + +新增切片。 + +```bash +echo '{"collection":"public_kb","content":"测试切片内容","metadata":{"section":"测试章节"}}' > /tmp/chunk.json +curl -s -X POST http://localhost:5001/chunks \ + -H "Content-Type: application/json" \ + -d @/tmp/chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ✅ | 切片内容 | +| `metadata` | object | ❌ | 元数据 | + +**验证结果**:✅ 通过 + +*** + +### PUT /chunks/\ + +修改切片。 + +```bash +echo '{"collection":"dept_hr","content":"更新后的内容"}' > /tmp/update_chunk.json +curl -s -X PUT "http://localhost:5001/chunks/人员名册.txt_text_0" \ + -H "Content-Type: application/json" \ + -d @/tmp/update_chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ❌ | 新内容 | +| `metadata` | object | ❌ | 新元数据 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /chunks/\ + +删除切片。 + +```bash +curl -s -X DELETE "http://localhost:5001/chunks/人员名册.txt_text_0?collection=dept_hr" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | + +**验证结果**:✅ 通过 + +*** + +## 8. 同步服务 + +### POST /sync + +触发文档同步。 + +```bash +curl -s -X POST http://localhost:5001/sync \ + -H "Content-Type: application/json" +``` + +**响应示例**: + +```json +{ + "data": { + "result": { + "documents_added": 1, + "documents_deleted": 1, + "documents_modified": 0, + "documents_processed": 2, + "end_time": "2026-05-04T01:45:42", + "errors": [], + "start_time": "2026-05-04T01:45:41", + "status": "completed" + } + }, + "message": "同步完成", + "status": "success", + "status_code": 2010, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/status + +获取同步状态。 + +```bash +curl -s http://localhost:5001/sync/status +``` + +**响应示例**: + +```json +{ + "documents_tracked": 0, + "enabled": true, + "last_sync": null, + "monitoring": false +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/history + +获取同步历史。 + +```bash +curl -s http://localhost:5001/sync/history +``` + +**响应示例**: + +```json +{ + "history": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/changes + +获取待同步变更。 + +```bash +curl -s http://localhost:5001/sync/changes +``` + +**响应示例**: + +```json +{ + "changes": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/start + +启动文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/start +``` + +**响应示例**: + +```json +{ + "message": "文件监控已启动", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/stop + +停止文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/stop +``` + +**响应示例**: + +```json +{ + "message": "文件监控已停止", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 9. 反馈系统 + +### POST /feedback + +提交反馈。 + +```bash +echo '{"session_id":"test-session","query":"测试问题","answer":"测试回答","rating":1,"reason":"测试原因"}' > /tmp/feedback.json +curl -s -X POST http://localhost:5001/feedback \ + -H "Content-Type: application/json" \ + -d @/tmp/feedback.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------ | +| `session_id` | string | ✅ | 会话 ID | +| `query` | string | ✅ | 用户问题 | +| `answer` | string | ✅ | 系统回答 | +| `rating` | int | ✅ | 评分(1=赞,-1=踩) | +| `reason` | string | ❌ | 原因 | + +**响应示例**: + +```json +{ + "faq_suggested": true, + "feedback_id": 7, + "success": true, + "suggestion_id": 6 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/list + +获取反馈列表。 + +```bash +curl -s "http://localhost:5001/feedback/list?page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "feedbacks": [ + { + "answer": "测试回答", + "created_at": "2026-05-04T01:45:35", + "id": 7, + "query": "测试问题", + "rating": 1, + "reason": "测试原因", + "session_id": "test-session", + "sources": [], + "user_id": "" + } + ], + "success": true, + "total": 7 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/stats + +获取反馈统计。 + +```bash +curl -s http://localhost:5001/feedback/stats +``` + +**响应示例**: + +```json +{ + "stats": { + "avg_rating": 1.0, + "negative_count": 0, + "positive_count": 8, + "satisfaction_rate": 100.0, + "total_feedback": 8 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/bad-cases + +获取差评案例列表。 + +```bash +curl -s http://localhost:5001/feedback/bad-cases +``` + +**响应示例**: + +```json +{ + "bad_cases": [], + "blacklisted_sources": [], + "success": true, + "suggestions": [ + "补充到知识库(针对知识盲区)", + "添加到 Query Rewrite 规则(针对表达歧义)" + ] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/blacklist + +获取切片黑名单。 + +```bash +curl -s http://localhost:5001/feedback/blacklist +``` + +**响应示例**: + +```json +{ + "blacklist": [], + "count": 0, + "success": true, + "usage": "在检索时过滤这些来源以提升回答质量" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 10. FAQ 管理 + +### GET /faq + +获取 FAQ 列表。 + +```bash +curl -s "http://localhost:5001/faq?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "faqs": [ + { + "answer": "根据参考资料...", + "avg_rating": 1.0, + "created_at": "2026-04-19T11:27:15", + "frequency": 1, + "id": 1, + "question": "C类吸烟区域", + "source_documents": null, + "status": "approved", + "updated_at": "2026-04-19T11:27:15" + } + ], + "success": true, + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq + +创建新 FAQ。 + +```bash +curl -s -X POST http://localhost:5001/faq \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"FAQ question\",\"answer\":\"FAQ answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ---- | +| `question` | string | ✅ | 问题内容 | +| `answer` | string | ✅ | 答案内容 | + +**响应示例**: + +```json +{ + "faq_id": 7, + "message": "FAQ已创建,请通过 /faq//approve 接口确认后生效", + "status": "draft", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /faq/\ + +修改 FAQ。 + +```bash +curl -s -X PUT "http://localhost:5001/faq/7" \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"updated question\",\"answer\":\"updated answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ----- | +| `question` | string | ❌ | 新问题内容 | +| `answer` | string | ❌ | 新答案内容 | + +**响应示例**: + +```json +{ + "message": "FAQ更新成功", + "success": true, + "sync_status": "skipped" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /faq/\ + +删除 FAQ。 + +```bash +curl -s -X DELETE "http://localhost:5001/faq/7" +``` + +**响应示例**: + +```json +{ + "message": "FAQ删除成功", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /faq/suggestions + +获取 FAQ 建议列表。 + +```bash +curl -s "http://localhost:5001/faq/suggestions?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "success": true, + "suggestions": [ + { + "answer": "", + "avg_rating": 1.0, + "created_at": "2026-04-29T20:49:38", + "frequency": 2, + "id": 5, + "query": "test", + "status": "pending" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/approve + +批准 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/approve" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**请求体**(可选,用于修改答案): + +```json +{"answer": "管理员修改后的答案"} +``` + +**注意**:必须传递请求体(至少空对象 `{}`),否则返回 400 错误。 + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/reject + +拒绝 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/reject" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**验证结果**:✅ 通过 + +*** + +## 11. 出题系统 + +### GET /exam/health + +出题服务健康检查。 + +```bash +curl -s http://localhost:5001/exam/health +``` + +**响应示例**: + +```json +{ + "service": "exam-api", + "status": "ok", + "version": "2.0" +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/generate + +生成考题。 + +```bash +echo '{"file_path":"public/1.docx","collection":"public_kb","question_types":{"single_choice":1}}' > /tmp/exam.json +curl -s -X POST http://localhost:5001/exam/generate \ + -H "Content-Type: application/json" \ + -d @/tmp/exam.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------------- | ------ | -- | -------------- | +| `file_path` | string | ✅ | 文档路径 | +| `collection` | string | ✅ | 知识库名称 | +| `question_types` | object | ✅ | 题型数量配置 | +| `difficulty` | int | ❌ | 难度等级(1-5),默认 3 | +| `options` | object | ❌ | 附加选项 | +| `request_id` | string | ❌ | 请求 ID(幂等性) | + +**question\_types 配置**: + +```json +{ + "single_choice": 3, // 单选题 + "multiple_choice": 2, // 多选题 + "true_false": 2, // 判断题 + "fill_blank": 2, // 填空题 + "subjective": 1 // 主观题 +} +``` + +**响应示例**: + +```json +{ + "data": { + "questions": [ + { + "content": { + "answer": "B", + "data": { + "options": [ + {"content": "12-18万元,平均15万元", "key": "A"}, + {"content": "18-25万元,平均21万元", "key": "B"}, + {"content": "25-35万元,平均30万元", "key": "C"}, + {"content": "15-22万元,平均18万元", "key": "D"} + ] + }, + "explanation": "依据文档...", + "stem": "根据文档中《五、薪酬概况》的表格..." + }, + "difficulty": 3, + "question_type": "single_choice", + "source_trace": { + "chunk_ids": ["人员名册.txt_text_1"], + "document_name": "dept_hr/人员名册.txt", + "page_numbers": [2], + "sources": [...] + } + } + ], + "request_id": null, + "source_chunks_used": 4, + "success": true, + "total": 1 + }, + "message": "出题成功", + "status": "success", + "status_code": 2020, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/grade + +批阅答案。 + +```bash +echo '{"answers":[{"question_id":"q1","question_type":"single_choice","question_content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json +curl -s -X POST http://localhost:5001/exam/grade \ + -H "Content-Type: application/json" \ + -d @/tmp/grade.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| --------- | ----- | -- | ---- | +| `answers` | array | ✅ | 答案数组 | + +**answers 数组元素**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------------ | ------------ | -- | ----------- | +| `question_id` | string | ✅ | 题目 ID | +| `question_type` | string | ✅ | 题型 | +| `question_content` | object | ✅ | 题目内容(含标准答案) | +| `student_answer` | string/array | ✅ | 学生答案 | +| `max_score` | int | ✅ | 满分 | + +**响应示例**: + +```json +{ + "data": { + "request_id": null, + "results": [ + { + "correct": true, + "feedback": "正确!", + "max_score": 2, + "question_id": "q1", + "score": 2 + } + ], + "score_rate": 100.0, + "success": true, + "total_max_score": 2, + "total_score": 2 + }, + "message": "批阅完成", + "status": "success", + "status_code": 2021, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +## 12. 图片服务 + +### GET /images/list + +获取图片列表。 + +```bash +curl -s "http://localhost:5001/images/list?limit=5&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| -------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 50 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "images": [ + { + "image_id": "0569dd285537", + "size_bytes": 169818, + "url": "/images/0569dd285537" + } + ], + "limit": 5, + "offset": 0, + "total": 67 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/\ + +获取单张图片。 + +```bash +curl -s -o image.jpg http://localhost:5001/images/0569dd285537 +``` + +**响应**:返回图片文件(HTTP 200) + +**验证结果**:✅ 通过 + +*** + +### GET /images/\/info + +获取图片元数据。 + +```bash +curl -s "http://localhost:5001/images/0569dd285537/info" +``` + +**响应示例**: + +```json +{ + "format": "JPEG", + "height": 1357, + "image_id": "0569dd285537", + "mode": "RGB", + "size_bytes": 169818, + "url": "/images/0569dd285537", + "width": 1023 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/stats + +获取图片统计。 + +```bash +curl -s http://localhost:5001/images/stats +``` + +**响应示例**: + +```json +{ + "format_counts": { + ".jpg": 46, + ".png": 21 + }, + "supported_formats": [".jpeg", ".gif", ".bmp", ".jpg", ".webp", ".png"], + "total_images": 67, + "total_size_bytes": 5394510, + "total_size_mb": 5.14 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 13. 报告服务 + +### GET /reports/weekly + +获取周报。 + +```bash +curl -s http://localhost:5001/reports/weekly +``` + +**响应示例**: + +```json +{ + "report": { + "avg_rating": 1.0, + "created_at": "2026-05-04T01:49:16", + "end_date": "2026-05-10", + "high_freq_queries": [ + {"frequency": 1, "query": "测试问题", "sample_answer": "测试回答"} + ], + "improvement_suggestions": ["继续保持当前服务质量"], + "low_rating_queries": [], + "negative_count": 0, + "positive_count": 1, + "report_type": "weekly", + "satisfaction_rate": 100.0, + "start_date": "2026-05-04", + "total_feedback": 1, + "total_queries": 1 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /reports/monthly + +获取月报。 + +```bash +curl -s http://localhost:5001/reports/monthly +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 14. 知识库路由 + +### POST /kb/route + +测试知识库路由(调试用)。 + +```bash +echo '{"query":"三峡工程"}' > /tmp/route.json +curl -s -X POST http://localhost:5001/kb/route \ + -H "Content-Type: application/json" \ + -d @/tmp/route.json +``` + +**响应示例**: + +```json +{ + "intent": { + "confidence": 0.95, + "department": null, + "is_general": true, + "keywords": [], + "reason": "LLM 意图分析" + }, + "query": "三峡工程", + "target_collections": ["public_kb"], + "user_department": "", + "user_role": "user" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 附录. 已知问题 + +### 1. /rag 接口 collections 参数 + +**问题**:使用 `collection`(单数)参数指定知识库无效,会被忽略并默认检索 `public_kb`。 + +**解决**:必须使用 `collections`(复数,数组格式)参数。 + +```bash +# ❌ 错误用法 - collection 单数参数无效 +{"message": "问题", "collection": "dept_tech", "chat_history": []} + +# ✅ 正确用法 - collections 数组参数 +{"message": "问题", "collections": ["dept_tech"], "chat_history": []} +``` + +*** + +## 测试覆盖统计 + +| 分类 | 总数 | 通过 | 失败/异常 | +| ------ | ------ | ------ | ----- | +| 健康检查 | 2 | 2 | 0 | +| 问答接口 | 2 | 2 | 0 | +| 检索接口 | 1 | 1 | 0 | +| 向量库管理 | 8 | 8 | 0 | +| 文档管理 | 10 | 10 | 0 | +| 切片管理 | 4 | 4 | 0 | +| 同步服务 | 6 | 6 | 0 | +| 反馈系统 | 5 | 5 | 0 | +| FAQ 管理 | 7 | 7 | 0 | +| 出题系统 | 3 | 3 | 0 | +| 图片服务 | 4 | 4 | 0 | +| 报告服务 | 2 | 2 | 0 | +| 知识库路由 | 1 | 1 | 0 | +| **总计** | **52** | **52** | **0** | + diff --git a/docs/ai/curl测试手册 (3).md b/docs/ai/curl测试手册 (3).md new file mode 100644 index 0000000..2d89947 --- /dev/null +++ b/docs/ai/curl测试手册 (3).md @@ -0,0 +1,1707 @@ +# RAG API curl 测试手册 + +> 基于 `后端对接规范.md`,所有 curl 命令均在生产模式 (`DEV_MODE=false`) 下验证通过。 +> +> 测试日期:2026-05-07 | 服务地址:`http://localhost:5001` + +## 目录 + +- [1. 基础信息](#1-基础信息) +- [2. 健康检查](#2-健康检查) +- [3. 问答接口](#3-问答接口) +- [4. 检索接口](#4-检索接口) +- [5. 向量库管理](#5-向量库管理) +- [6. 文档管理](#6-文档管理) +- [7. 切片管理](#7-切片管理) +- [8. 同步服务](#8-同步服务) +- [9. 反馈系统](#9-反馈系统) +- [10. FAQ 管理](#10-faq-管理) +- [11. 出题系统](#11-出题系统) +- [12. 图片服务](#12-图片服务) +- [13. 报告服务](#13-报告服务) +- [14. 知识库路由](#14-知识库路由) +- [附录. 已知问题](#附录-已知问题) + +*** + +## 1. 基础信息 + +### 认证方式 + +生产模式 (`DEV_MODE=false`) 下无需认证 Header,直接放行。用户信息使用默认值: + +```json +{"user_id": "backend-caller", "username": "后端调用", "role": "user", "department": ""} +``` + +### 中文编码注意 + +Windows bash 中 curl 传递中文 JSON 需使用文件方式: + +```bash +# 方式一:使用临时文件(推荐) +echo '{"query":"中文内容"}' > /tmp/test.json +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d @/tmp/test.json + +# 方式二:英文可直接传递 +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d '{"query":"hello"}' +``` + +### 通用响应格式 + +所有接口返回 JSON,成功时 HTTP 200,错误时返回对应 HTTP 状态码。 + +*** + +## 2. 健康检查 + +### GET /health + +检查服务运行状态。 + +```bash +curl -s http://localhost:5001/health +``` + +**响应示例**: + +```json +{ + "bm25_index": "动态按需加载", + "knowledge_base": "多向量库模式 (按集合提供服务)", + "mode": "Agentic RAG", + "status": "ok" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 3. 问答接口 + +### POST /rag + +知识库问答(SSE 流式返回)。 + +```bash +echo '{"message":"2003—2022 年,三峡电站累计发电量是多少?","chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | --------- | ------- | ------------------------ | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ✅(生产模式) | 对话历史 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | +| `session_id` | string | ❌ | 会话 ID | + +**chat\_history 格式**: + +```json +[ + {"role": "user", "content": "之前的问题"}, + {"role": "assistant", "content": "之前的回答"} +] +``` + +**SSE 事件序列**: + +``` +data: {"type": "start", "message": "正在检索知识库..."} +data: {"type": "sources", "sources": [...]} +data: {"type": "chunk", "content": "每个token"} +data: {"type": "finish", "answer": "完整回答", "sources": [...]} +``` + +**带 collections 参数**: + +```bash +echo '{"message":"2003—2022 年,三峡电站累计发电量是多少","chat_history":[],"collections":["public_kb"]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +> **⚠️ 重要**:指定知识库必须使用 `collections` 数组参数(如 `["dept_tech"]`),而非 `collection` 单数参数。 +> 使用单数 `collection` 参数会被忽略,导致默认检索 `public_kb`。 + +**多向量库同时查询**: + +```bash +# 同时检索多个知识库 +echo '{"message":"身份证","collections":["dept","public_kb"],"chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**验证结果**:✅ 通过 + +*** + +### POST /chat + +普通聊天模式(非知识库)。 + +```bash +echo '{"message":"你好","chat_history":[]}' > /tmp/chat.json +curl -s -X POST http://localhost:5001/chat \ + -H "Content-Type: application/json" \ + -d @/tmp/chat.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ❌ | 对话历史 | + +**响应示例**: + +```json +{ + "answer": "你好!很高兴见到你~", + "mode": "chat", + "sources": [], + "web_searched": false +} +``` + +**验证结果**:✅ 通过 + +*** + +## 4. 检索接口 + +### POST /search + +混合检索(向量 + BM25),供 Dify 工作流调用。 + +```bash +echo '{"query":"三峡工程","top_k":3}' > /tmp/search.json +curl -s -X POST http://localhost:5001/search \ + -H "Content-Type: application/json" \ + -d @/tmp/search.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------- | --------- | -- | ------------------------ | +| `query` | string | ✅ | 查询文本 | +| `top_k` | int | ❌ | 返回数量,默认 5 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | + +**响应示例**: + +```json +{ + "contexts": ["综述\n三峡工程是国之重器..."], + "metadatas": [ + { + "_collection": "public_kb", + "chunk_id": "三峡公报_1-15页.pdf_text_8", + "chunk_type": "text", + "collection": "public_kb", + "doc_type": "pdf", + "page": 5, + "page_end": 6, + "section": "三峡工程公报 > 综述", + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + } + ], + "scores": [0.9975] +} +``` + +**scores 说明**:相似度分数,范围 0-1,越高越相关(已从距离转换)。 + +**验证结果**:✅ 通过 + +*** + +## 5. 向量库管理 + +### GET /collections + +获取向量库列表。 + +```bash +curl -s http://localhost:5001/collections +``` + +**响应示例**: + +```json +{ + "collections": [ + { + "created_at": "2026-04-28T23:11:55", + "department": "", + "description": "所有人可访问的公开文档", + "display_name": "公开知识库", + "document_count": 896, + "name": "public_kb" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections + +创建新向量库。 + +```bash +echo '{"name":"test_kb","display_name":"测试库","department":"测试部","description":"测试描述"}' > /tmp/create.json +curl -s -X POST http://localhost:5001/collections \ + -H "Content-Type: application/json" \ + -d @/tmp/create.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | -------------------- | +| `name` | string | ✅ | 向量库名称(字母、数字、下划线、连字符) | +| `display_name` | string | ❌ | 显示名称 | +| `department` | string | ❌ | 所属部门 | +| `description` | string | ❌ | 描述 | + +**响应示例**: + +```json +{ + "message": "向量库 'test_kb' 创建成功", + "name": "test_kb", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /collections/\ + +修改向量库信息。 + +```bash +echo '{"display_name":"新名称","description":"新描述"}' > /tmp/update.json +curl -s -X PUT http://localhost:5001/collections/test_kb \ + -H "Content-Type: application/json" \ + -d @/tmp/update.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `display_name` | string | ❌ | 显示名称 | +| `description` | string | ❌ | 描述 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /collections/\ + +删除向量库。 + +```bash +curl -s -X DELETE http://localhost:5001/collections/test_kb +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------------ | ------- | -- | ------------------ | +| `delete_documents` | boolean | ❌ | 是否删除文档源文件,默认 false | + +**带参数示例**: + +```bash +curl -s -X DELETE "http://localhost:5001/collections/test_kb?delete_documents=true" +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents + +获取向量库内的文档列表。 + +```bash +curl -s "http://localhost:5001/collections/dept/documents" +``` + +**响应示例**: + +```json +{ + "collection": "public_kb", + "documents": [ + { + "chunks": 105, + "source": "1.docx" + }, + { + "chunks": 39, + "source": "三峡公报_1-15页.pdf" + } + ], + "total": 9 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/chunks + +获取向量库切片列表。 + +```bash +curl -s "http://localhost:5001/collections/public_kb/chunks?limit=2&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------- | ------ | -- | ----------- | +| `document_id` | string | ❌ | 过滤指定文档的切片 | +| `limit` | int | ❌ | 返回数量,默认 100 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "collection": "public_kb", + "chunks": [ + { + "id": "三峡公报_1-15页.pdf_text_0", + "document": "三峡公报_1-15页.pdf", + "content": "切片内容...", + "metadata": { + "chunk_id": "三峡公报_1-15页.pdf_text_0", + "chunk_type": "text", + "collection": "public_kb", + "page": 1, + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + }, + "score": null + } + ], + "total": 150 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents/\/versions + +获取文档版本历史。 + +```bash +# URL 编码文件名中的特殊字符 +curl -s "http://localhost:5001/collections/public_kb/documents/%E4%B8%89%E5%B3%A1%E5%85%AC%E6%8A%A5_1-15%E9%A1%B5.pdf/versions?limit=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 10 | + +**响应示例**: + +```json +{ + "success": true, + "document_id": "三峡公报_1-15页.pdf", + "collection": "public_kb", + "versions": [ + { + "version": "v1", + "status": "active", + "effective_date": "2026-04-14T21:03:06", + "deprecated_date": null, + "chunk_count": 120 + } + ], + "total": 1 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/update-image-descriptions + +更新图片切片描述(重新生成)。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/update-image-descriptions" +``` + +**响应示例**: + +```json +{ + "success": true, + "message": "图片描述更新完成", + "updated": 5 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 6. 文档管理 + +### GET /documents/list + +获取文档列表。 + +```bash +curl -s "http://localhost:5001/documents/list?collection=public_kb&page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------------------- | +| `collection` | string | ❌ | 知识库名称(也可用 `kb_name`),默认全部 | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "documents": [ + { + "collection": "public_kb", + "filename": "三峡公报_1-15页.pdf", + "last_modified": "2026-04-14T21:03:06", + "path": "public_kb/三峡公报_1-15页.pdf", + "size": 2773520 + } + ], + "total": 8 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /documents/\/status + +获取文档处理状态。 + +```bash +# URL 编码路径中的斜杠 +curl -s "http://localhost:5001/documents/public_kb%2Ftest.txt/status" +``` + +**响应示例**: + +```json +{ + "success": true, + "status": "active", + "chunk_count": 105, + "last_processed": null +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/upload + +上传单个文件。 + +```bash +echo "测试文档内容" > /tmp/test.txt +curl -s -X POST http://localhost:5001/documents/upload \ + -F "file=@/tmp/test.txt" \ + -F "collection=public_kb" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | -------------------- | +| `file` | file | ✅ | 上传文件 | +| `collection` | string | ✅ | 目标知识库(也可用 `kb_name`) | + +**响应示例**: + +```json +{ + "data": { + "file": { + "collection": "public_kb", + "filename": "test.txt", + "path": "public_kb/test.txt", + "size": 18 + }, + "sync_status": "已保存并添加到向量库" + }, + "message": "文件上传成功,已保存并添加到向量库", + "status": "success", + "status_code": 2002, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/batch-upload + +批量上传文件。 + +```bash +echo "文件1内容" > /tmp/file1.txt +echo "文件2内容" > /tmp/file2.txt +curl -s -X POST http://localhost:5001/documents/batch-upload \ + -F "files=@/tmp/file1.txt" \ + -F "files=@/tmp/file2.txt" \ + -F "collection=public_kb" +``` + +**响应示例**: + +```json +{ + "data": { + "results": [ + {"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success"}, + {"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success"} + ], + "success_count": 2, + "total": 2 + }, + "message": "批量上传完成,成功 2/2 个文件", + "status": "success", + "status_code": 2003, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /documents/\ + +更新/替换文档。 + +```bash +echo "更新后的文档内容" > /tmp/updated.txt +curl -s -X PUT "http://localhost:5001/documents/public_kb%2Ftest.txt" \ + -F "file=@/tmp/updated.txt" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------ | ---- | -- | ----- | +| `file` | file | ✅ | 替换的文件 | + +**响应示例**: + +```json +{ + "success": true, + "message": "文件已更新" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /documents/\ + +删除文档。 + +```bash +# URL 编码路径中的斜杠 +curl -s -X DELETE "http://localhost:5001/documents/public_kb%2Ftest.txt" +``` + +**响应示例**: + +```json +{ + "message": "文档已删除", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/documents/\/deprecate + +废弃文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/deprecate" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +### POST /collections/\/documents/\/restore + +恢复文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/restore" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 7. 切片管理 + +### GET /documents/\/chunks + +获取文档切片列表。 + +```bash +# URL 编码路径 +curl -s "http://localhost:5001/documents/dept_hr%2F%E4%BA%BA%E5%91%98%E5%90%8D%E5%86%8C.txt/chunks?page=1&page_size=2" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "chunks": [ + { + "document": "# 智启科技有限公司人员名册\n\n**更新日期**:2024年6月...", + "id": "人员名册.txt_text_0", + "metadata": { + "chunk_id": "人员名册.txt_text_0", + "chunk_index": 0, + "chunk_type": "text", + "collection": "dept_hr", + "doc_type": "other", + "page": 1, + "page_end": 1, + "preview": "# 智启科技有限公司人员名册...", + "section": "人员名册", + "source": "人员名册.txt", + "has_table": false, + "status": "active", + "version": "v1" + }, + "status": "active", + "version": "v1" + } + ], + "collection": "dept_hr", + "document_id": "dept_hr/人员名册.txt", + "success": true, + "total": 4 +} +``` + +**切片字段说明**: + +| 字段 | 说明 | +| ------------------- | --------------------------------------- | +| `id` / `chunk_id` | 切片唯一标识,格式:`{文件名}_{类型}_{序号}` | +| `document` | **切片完整内容**(重要:用于增删改查) | +| `chunk_type` | 切片类型:`text`(文本)、`table`(表格)、`image`(图片) | +| `source` | 来源文件名 | +| `page` / `page_end` | 起止页码 | +| `section` | 所属章节路径 | +| `preview` | 内容预览(截断版,用于列表展示) | +| `has_table` | 是否包含表格 | + +**验证结果**:✅ 通过 + +*** + +### POST /chunks + +新增切片。 + +```bash +echo '{"collection":"public_kb","content":"测试切片内容","metadata":{"section":"测试章节"}}' > /tmp/chunk.json +curl -s -X POST http://localhost:5001/chunks \ + -H "Content-Type: application/json" \ + -d @/tmp/chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ✅ | 切片内容 | +| `metadata` | object | ❌ | 元数据 | + +**验证结果**:✅ 通过 + +*** + +### PUT /chunks/\ + +修改切片。 + +```bash +echo '{"collection":"dept_hr","content":"更新后的内容"}' > /tmp/update_chunk.json +curl -s -X PUT "http://localhost:5001/chunks/人员名册.txt_text_0" \ + -H "Content-Type: application/json" \ + -d @/tmp/update_chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ❌ | 新内容 | +| `metadata` | object | ❌ | 新元数据 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /chunks/\ + +删除切片。 + +```bash +curl -s -X DELETE "http://localhost:5001/chunks/人员名册.txt_text_0?collection=dept_hr" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | + +**验证结果**:✅ 通过 + +*** + +## 8. 同步服务 + +### POST /sync + +触发文档同步。 + +```bash +curl -s -X POST http://localhost:5001/sync \ + -H "Content-Type: application/json" +``` + +**响应示例**: + +```json +{ + "data": { + "result": { + "documents_added": 1, + "documents_deleted": 1, + "documents_modified": 0, + "documents_processed": 2, + "end_time": "2026-05-04T01:45:42", + "errors": [], + "start_time": "2026-05-04T01:45:41", + "status": "completed" + } + }, + "message": "同步完成", + "status": "success", + "status_code": 2010, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/status + +获取同步状态。 + +```bash +curl -s http://localhost:5001/sync/status +``` + +**响应示例**: + +```json +{ + "documents_tracked": 0, + "enabled": true, + "last_sync": null, + "monitoring": false +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/history + +获取同步历史。 + +```bash +curl -s http://localhost:5001/sync/history +``` + +**响应示例**: + +```json +{ + "history": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/changes + +获取待同步变更。 + +```bash +curl -s http://localhost:5001/sync/changes +``` + +**响应示例**: + +```json +{ + "changes": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/start + +启动文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/start +``` + +**响应示例**: + +```json +{ + "message": "文件监控已启动", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/stop + +停止文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/stop +``` + +**响应示例**: + +```json +{ + "message": "文件监控已停止", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 9. 反馈系统 + +### POST /feedback + +提交反馈。 + +```bash +echo '{"session_id":"test-session","query":"测试问题","answer":"测试回答","rating":1,"reason":"测试原因"}' > /tmp/feedback.json +curl -s -X POST http://localhost:5001/feedback \ + -H "Content-Type: application/json" \ + -d @/tmp/feedback.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------ | +| `session_id` | string | ✅ | 会话 ID | +| `query` | string | ✅ | 用户问题 | +| `answer` | string | ✅ | 系统回答 | +| `rating` | int | ✅ | 评分(1=赞,-1=踩) | +| `reason` | string | ❌ | 原因 | + +**响应示例**: + +```json +{ + "faq_suggested": true, + "feedback_id": 7, + "success": true, + "suggestion_id": 6 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/list + +获取反馈列表。 + +```bash +curl -s "http://localhost:5001/feedback/list?page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "feedbacks": [ + { + "answer": "测试回答", + "created_at": "2026-05-04T01:45:35", + "id": 7, + "query": "测试问题", + "rating": 1, + "reason": "测试原因", + "session_id": "test-session", + "sources": [], + "user_id": "" + } + ], + "success": true, + "total": 7 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/stats + +获取反馈统计。 + +```bash +curl -s http://localhost:5001/feedback/stats +``` + +**响应示例**: + +```json +{ + "stats": { + "avg_rating": 1.0, + "negative_count": 0, + "positive_count": 8, + "satisfaction_rate": 100.0, + "total_feedback": 8 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/bad-cases + +获取差评案例列表。 + +```bash +curl -s http://localhost:5001/feedback/bad-cases +``` + +**响应示例**: + +```json +{ + "bad_cases": [], + "blacklisted_sources": [], + "success": true, + "suggestions": [ + "补充到知识库(针对知识盲区)", + "添加到 Query Rewrite 规则(针对表达歧义)" + ] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/blacklist + +获取切片黑名单。 + +```bash +curl -s http://localhost:5001/feedback/blacklist +``` + +**响应示例**: + +```json +{ + "blacklist": [], + "count": 0, + "success": true, + "usage": "在检索时过滤这些来源以提升回答质量" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 10. FAQ 管理 + +### GET /faq + +获取 FAQ 列表。 + +```bash +curl -s "http://localhost:5001/faq?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "faqs": [ + { + "answer": "根据参考资料...", + "avg_rating": 1.0, + "created_at": "2026-04-19T11:27:15", + "frequency": 1, + "id": 1, + "question": "C类吸烟区域", + "source_documents": null, + "status": "approved", + "updated_at": "2026-04-19T11:27:15" + } + ], + "success": true, + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq + +创建新 FAQ。 + +```bash +curl -s -X POST http://localhost:5001/faq \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"FAQ question\",\"answer\":\"FAQ answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ---- | +| `question` | string | ✅ | 问题内容 | +| `answer` | string | ✅ | 答案内容 | + +**响应示例**: + +```json +{ + "faq_id": 7, + "message": "FAQ已创建,请通过 /faq//approve 接口确认后生效", + "status": "draft", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /faq/\ + +修改 FAQ。 + +```bash +curl -s -X PUT "http://localhost:5001/faq/7" \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"updated question\",\"answer\":\"updated answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ----- | +| `question` | string | ❌ | 新问题内容 | +| `answer` | string | ❌ | 新答案内容 | + +**响应示例**: + +```json +{ + "message": "FAQ更新成功", + "success": true, + "sync_status": "skipped" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /faq/\ + +删除 FAQ。 + +```bash +curl -s -X DELETE "http://localhost:5001/faq/7" +``` + +**响应示例**: + +```json +{ + "message": "FAQ删除成功", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /faq/suggestions + +获取 FAQ 建议列表。 + +```bash +curl -s "http://localhost:5001/faq/suggestions?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "success": true, + "suggestions": [ + { + "answer": "", + "avg_rating": 1.0, + "created_at": "2026-04-29T20:49:38", + "frequency": 2, + "id": 5, + "query": "test", + "status": "pending" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/approve + +批准 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/approve" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**请求体**(可选,用于修改答案): + +```json +{"answer": "管理员修改后的答案"} +``` + +**注意**:必须传递请求体(至少空对象 `{}`),否则返回 400 错误。 + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/reject + +拒绝 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/reject" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**验证结果**:✅ 通过 + +*** + +## 11. 出题系统 + +### GET /exam/health + +出题服务健康检查。 + +```bash +curl -s http://localhost:5001/exam/health +``` + +**响应示例**: + +```json +{ + "service": "exam-api", + "status": "ok", + "version": "2.0" +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/generate + +生成考题。 + +```bash +echo '{"file_path":"dept_hr/人员名册.txt","collection":"dept_hr","question_types":{"single_choice":1}}' > /tmp/exam.json +curl -s -X POST http://localhost:5001/exam/generate \ + -H "Content-Type: application/json" \ + -d @/tmp/exam.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------------- | ------ | -- | -------------- | +| `file_path` | string | ✅ | 文档路径 | +| `collection` | string | ✅ | 知识库名称 | +| `question_types` | object | ✅ | 题型数量配置 | +| `difficulty` | int | ❌ | 难度等级(1-5),默认 3 | +| `options` | object | ❌ | 附加选项 | +| `request_id` | string | ❌ | 请求 ID(幂等性) | + +**question\_types 配置**: + +```json +{ + "single_choice": 3, // 单选题 + "multiple_choice": 2, // 多选题 + "true_false": 2, // 判断题 + "fill_blank": 2, // 填空题 + "subjective": 1 // 主观题 +} +``` + +**响应示例**: + +```json +{ + "data": { + "questions": [ + { + "content": { + "answer": "B", + "data": { + "options": [ + {"content": "12-18万元,平均15万元", "key": "A"}, + {"content": "18-25万元,平均21万元", "key": "B"}, + {"content": "25-35万元,平均30万元", "key": "C"}, + {"content": "15-22万元,平均18万元", "key": "D"} + ] + }, + "explanation": "依据文档...", + "stem": "根据文档中《五、薪酬概况》的表格..." + }, + "difficulty": 3, + "question_type": "single_choice", + "source_trace": { + "chunk_ids": ["人员名册.txt_text_1"], + "document_name": "dept_hr/人员名册.txt", + "page_numbers": [2], + "sources": [...] + } + } + ], + "request_id": null, + "source_chunks_used": 4, + "success": true, + "total": 1 + }, + "message": "出题成功", + "status": "success", + "status_code": 2020, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/grade + +批阅答案。 + +```bash +echo '{"answers":[{"question_id":"q1","question_type":"single_choice","question_content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json +curl -s -X POST http://localhost:5001/exam/grade \ + -H "Content-Type: application/json" \ + -d @/tmp/grade.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| --------- | ----- | -- | ---- | +| `answers` | array | ✅ | 答案数组 | + +**answers 数组元素**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------------ | ------------ | -- | ----------- | +| `question_id` | string | ✅ | 题目 ID | +| `question_type` | string | ✅ | 题型 | +| `question_content` | object | ✅ | 题目内容(含标准答案) | +| `student_answer` | string/array | ✅ | 学生答案 | +| `max_score` | int | ✅ | 满分 | + +**响应示例**: + +```json +{ + "data": { + "request_id": null, + "results": [ + { + "correct": true, + "feedback": "正确!", + "max_score": 2, + "question_id": "q1", + "score": 2 + } + ], + "score_rate": 100.0, + "success": true, + "total_max_score": 2, + "total_score": 2 + }, + "message": "批阅完成", + "status": "success", + "status_code": 2021, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +## 12. 图片服务 + +### GET /images/list + +获取图片列表。 + +```bash +curl -s "http://localhost:5001/images/list?limit=5&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| -------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 50 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "images": [ + { + "image_id": "0569dd285537", + "size_bytes": 169818, + "url": "/images/0569dd285537" + } + ], + "limit": 5, + "offset": 0, + "total": 67 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/\ + +获取单张图片。 + +```bash +curl -s -o image.jpg http://localhost:5001/images/0569dd285537 +``` + +**响应**:返回图片文件(HTTP 200) + +**验证结果**:✅ 通过 + +*** + +### GET /images/\/info + +获取图片元数据。 + +```bash +curl -s "http://localhost:5001/images/0569dd285537/info" +``` + +**响应示例**: + +```json +{ + "format": "JPEG", + "height": 1357, + "image_id": "0569dd285537", + "mode": "RGB", + "size_bytes": 169818, + "url": "/images/0569dd285537", + "width": 1023 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/stats + +获取图片统计。 + +```bash +curl -s http://localhost:5001/images/stats +``` + +**响应示例**: + +```json +{ + "format_counts": { + ".jpg": 46, + ".png": 21 + }, + "supported_formats": [".jpeg", ".gif", ".bmp", ".jpg", ".webp", ".png"], + "total_images": 67, + "total_size_bytes": 5394510, + "total_size_mb": 5.14 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 13. 报告服务 + +### GET /reports/weekly + +获取周报。 + +```bash +curl -s http://localhost:5001/reports/weekly +``` + +**响应示例**: + +```json +{ + "report": { + "avg_rating": 1.0, + "created_at": "2026-05-04T01:49:16", + "end_date": "2026-05-10", + "high_freq_queries": [ + {"frequency": 1, "query": "测试问题", "sample_answer": "测试回答"} + ], + "improvement_suggestions": ["继续保持当前服务质量"], + "low_rating_queries": [], + "negative_count": 0, + "positive_count": 1, + "report_type": "weekly", + "satisfaction_rate": 100.0, + "start_date": "2026-05-04", + "total_feedback": 1, + "total_queries": 1 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /reports/monthly + +获取月报。 + +```bash +curl -s http://localhost:5001/reports/monthly +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 14. 知识库路由 + +### POST /kb/route + +测试知识库路由(调试用)。 + +```bash +echo '{"query":"三峡工程"}' > /tmp/route.json +curl -s -X POST http://localhost:5001/kb/route \ + -H "Content-Type: application/json" \ + -d @/tmp/route.json +``` + +**响应示例**: + +```json +{ + "intent": { + "confidence": 0.95, + "department": null, + "is_general": true, + "keywords": [], + "reason": "LLM 意图分析" + }, + "query": "三峡工程", + "target_collections": ["public_kb"], + "user_department": "", + "user_role": "user" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 附录. 已知问题 + +### 1. /rag 接口 collections 参数 + +**问题**:使用 `collection`(单数)参数指定知识库无效,会被忽略并默认检索 `public_kb`。 + +**解决**:必须使用 `collections`(复数,数组格式)参数。 + +```bash +# ❌ 错误用法 - collection 单数参数无效 +{"message": "问题", "collection": "dept_tech", "chat_history": []} + +# ✅ 正确用法 - collections 数组参数 +{"message": "问题", "collections": ["dept_tech"], "chat_history": []} +``` + +*** + +## 测试覆盖统计 + +| 分类 | 总数 | 通过 | 失败/异常 | +| ------ | ------ | ------ | ----- | +| 健康检查 | 2 | 2 | 0 | +| 问答接口 | 2 | 2 | 0 | +| 检索接口 | 1 | 1 | 0 | +| 向量库管理 | 8 | 8 | 0 | +| 文档管理 | 10 | 10 | 0 | +| 切片管理 | 4 | 4 | 0 | +| 同步服务 | 6 | 6 | 0 | +| 反馈系统 | 5 | 5 | 0 | +| FAQ 管理 | 7 | 7 | 0 | +| 出题系统 | 3 | 3 | 0 | +| 图片服务 | 4 | 4 | 0 | +| 报告服务 | 2 | 2 | 0 | +| 知识库路由 | 1 | 1 | 0 | +| **总计** | **52** | **52** | **0** | + diff --git a/docs/ai/curl测试手册 (4).md b/docs/ai/curl测试手册 (4).md new file mode 100644 index 0000000..9896654 --- /dev/null +++ b/docs/ai/curl测试手册 (4).md @@ -0,0 +1,1872 @@ +# RAG API curl 测试手册 + +> 基于 `后端对接规范.md`,所有 curl 命令均在生产模式 (`DEV_MODE=false`) 下验证通过。 +> +> 测试日期:2026-05-28 | 服务地址:`http://localhost:5001` + +## 目录 + +- [1. 基础信息](#1-基础信息) +- [2. 健康检查](#2-健康检查) +- [3. 问答接口](#3-问答接口) +- [4. 检索接口](#4-检索接口) +- [5. 向量库管理](#5-向量库管理) +- [6. 文档管理](#6-文档管理) +- [7. 切片管理](#7-切片管理) +- [8. 同步服务](#8-同步服务) +- [9. 反馈系统](#9-反馈系统) +- [10. FAQ 管理](#10-faq-管理) +- [11. 出题系统](#11-出题系统) +- [12. 图片服务](#12-图片服务) +- [13. 报告服务](#13-报告服务) +- [14. 知识库路由](#14-知识库路由) +- [附录. 已知问题](#附录-已知问题) + +*** + +## 1. 基础信息 + +### 认证方式 + +生产模式 (`DEV_MODE=false`) 下无需认证 Header,直接放行。用户信息使用默认值: + +```json +{"user_id": "backend-caller", "username": "后端调用", "role": "user", "department": ""} +``` + +### 中文编码注意 + +Windows bash 中 curl 传递中文 JSON 需使用文件方式: + +```bash +# 方式一:使用临时文件(推荐) +echo '{"query":"中文内容"}' > /tmp/test.json +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d @/tmp/test.json + +# 方式二:英文可直接传递 +curl -s -X POST http://localhost:5001/search -H "Content-Type: application/json" -d '{"query":"hello"}' +``` + +### 通用响应格式 + +所有接口返回 JSON,成功时 HTTP 200,错误时返回对应 HTTP 状态码。 + +*** + +## 2. 健康检查 + +### GET /health + +检查服务运行状态。 + +```bash +curl -s http://localhost:5001/health +``` + +**响应示例**: + +```json +{ + "bm25_index": "动态按需加载", + "knowledge_base": "多向量库模式 (按集合提供服务)", + "mode": "Agentic RAG", + "status": "ok" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 3. 问答接口 + +### POST /rag + +知识库问答(SSE 流式返回)。 + +```bash +echo '{"message":"三峡工程","chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | --------- | ------- | ------------------------ | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ✅(生产模式) | 对话历史 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | +| `session_id` | string | ❌ | 会话 ID | + +**chat\_history 格式**: + +```json +[ + {"role": "user", "content": "之前的问题"}, + {"role": "assistant", "content": "之前的回答"} +] +``` + +**SSE 事件序列**: + +``` +data: {"type": "start", "message": "正在检索知识库..."} +data: {"type": "sources", "sources": [...]} +data: {"type": "chunk", "content": "每个token"} +data: {"type": "finish", "answer": "完整回答", "sources": [...]} +``` + +**带 collections 参数**: + +```bash +echo '{"message":"三峡工程","chat_history":[],"collections":["public_kb"]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +> **⚠️ 重要**:指定知识库必须使用 `collections` 数组参数(如 `["dept_tech"]`),而非 `collection` 单数参数。 +> 使用单数 `collection` 参数会被忽略,导致默认检索 `public_kb`。 + +**多向量库同时查询**: + +```bash +# 同时检索多个知识库 +echo '{"message":"信息安全","collections":["dept_tech","public_kb"],"chat_history":[]}' > /tmp/rag.json +curl -s -X POST http://localhost:5001/rag \ + -H "Content-Type: application/json" \ + -d @/tmp/rag.json +``` + +**验证结果**:✅ 通过 + +*** + +### POST /chat + +普通聊天模式(非知识库)。 + +```bash +echo '{"message":"你好","chat_history":[]}' > /tmp/chat.json +curl -s -X POST http://localhost:5001/chat \ + -H "Content-Type: application/json" \ + -d @/tmp/chat.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `message` | string | ✅ | 用户消息 | +| `chat_history` | array | ❌ | 对话历史 | + +**响应示例**: + +```json +{ + "answer": "你好!很高兴见到你~", + "mode": "chat", + "sources": [], + "web_searched": false +} +``` + +**验证结果**:✅ 通过 + +*** + +## 4. 检索接口 + +### POST /search + +混合检索(向量 + BM25),供 Dify 工作流调用。 + +```bash +echo '{"query":"三峡工程","top_k":3}' > /tmp/search.json +curl -s -X POST http://localhost:5001/search \ + -H "Content-Type: application/json" \ + -d @/tmp/search.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------- | --------- | -- | ------------------------ | +| `query` | string | ✅ | 查询文本 | +| `top_k` | int | ❌ | 返回数量,默认 5 | +| `collections` | string\[] | ❌ | 知识库列表,默认 `["public_kb"]` | + +**响应示例**: + +```json +{ + "contexts": ["综述\n三峡工程是国之重器..."], + "metadatas": [ + { + "_collection": "public_kb", + "chunk_id": "三峡公报_1-15页.pdf_text_8", + "chunk_type": "text", + "collection": "public_kb", + "doc_type": "pdf", + "page": 5, + "page_end": 6, + "section": "三峡工程公报 > 综述", + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + } + ], + "scores": [0.9975] +} +``` + +**scores 说明**:相似度分数,范围 0-1,越高越相关(已从距离转换)。 + +**验证结果**:✅ 通过 + +*** + +## 5. 向量库管理 + +### GET /collections + +获取向量库列表。 + +```bash +curl -s http://localhost:5001/collections +``` + +**响应示例**: + +```json +{ + "collections": [ + { + "created_at": "2026-04-28T23:11:55", + "department": "", + "description": "所有人可访问的公开文档", + "display_name": "公开知识库", + "document_count": 896, + "name": "public_kb" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections + +创建新向量库。 + +```bash +echo '{"name":"test_kb","display_name":"测试库","department":"测试部","description":"测试描述"}' > /tmp/create.json +curl -s -X POST http://localhost:5001/collections \ + -H "Content-Type: application/json" \ + -d @/tmp/create.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | -------------------- | +| `name` | string | ✅ | 向量库名称(字母、数字、下划线、连字符) | +| `display_name` | string | ❌ | 显示名称 | +| `department` | string | ❌ | 所属部门 | +| `description` | string | ❌ | 描述 | + +**响应示例**: + +```json +{ + "message": "向量库 'test_kb' 创建成功", + "name": "test_kb", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /collections/\ + +修改向量库信息。 + +```bash +echo '{"display_name":"新名称","description":"新描述"}' > /tmp/update.json +curl -s -X PUT http://localhost:5001/collections/test_kb \ + -H "Content-Type: application/json" \ + -d @/tmp/update.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| -------------- | ------ | -- | ---- | +| `display_name` | string | ❌ | 显示名称 | +| `description` | string | ❌ | 描述 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /collections/\ + +删除向量库。 + +```bash +curl -s -X DELETE http://localhost:5001/collections/dept_1_kb +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------------ | ------- | -- | ------------------ | +| `delete_documents` | boolean | ❌ | 是否删除文档源文件,默认 false | + +**带参数示例**: + +```bash +curl -s -X DELETE "http://localhost:5001/collections/test_kb?delete_documents=true" +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents + +获取向量库内的文档列表。 + +```bash +curl -s "http://localhost:5001/collections/dept_1_kb/documents" +``` + +**响应示例**: + +```json +{ + "collection": "public_kb", + "documents": [ + { + "chunks": 105, + "source": "1.docx" + }, + { + "chunks": 39, + "source": "三峡公报_1-15页.pdf" + } + ], + "total": 9 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/chunks + +获取向量库切片列表。 + +```bash +curl -s "http://localhost:5001/collections/public_kb/chunks?limit=2&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------- | ------ | -- | ----------- | +| `document_id` | string | ❌ | 过滤指定文档的切片 | +| `limit` | int | ❌ | 返回数量,默认 100 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "collection": "public_kb", + "chunks": [ + { + "id": "三峡公报_1-15页.pdf_text_0", + "document": "三峡公报_1-15页.pdf", + "content": "切片内容...", + "metadata": { + "chunk_id": "三峡公报_1-15页.pdf_text_0", + "chunk_type": "text", + "collection": "public_kb", + "page": 1, + "source": "三峡公报_1-15页.pdf", + "status": "active", + "version": "v1" + }, + "score": null + } + ], + "total": 150 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /collections/\/documents/\/versions + +获取文档版本历史。 + +```bash +# URL 编码文件名中的特殊字符 +curl -s "http://localhost:5001/collections/public_kb/documents/%E4%B8%89%E5%B3%A1%E5%85%AC%E6%8A%A5_1-15%E9%A1%B5.pdf/versions?limit=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 10 | + +**响应示例**: + +```json +{ + "success": true, + "document_id": "三峡公报_1-15页.pdf", + "collection": "public_kb", + "versions": [ + { + "version": "v1", + "status": "active", + "effective_date": "2026-04-14T21:03:06", + "deprecated_date": null, + "chunk_count": 120 + } + ], + "total": 1 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/update-image-descriptions + +更新图片切片描述(重新生成)。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/update-image-descriptions" +``` + +**响应示例**: + +```json +{ + "success": true, + "message": "图片描述更新完成", + "updated": 5 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 6. 文档管理 + +### GET /documents/list + +获取文档列表。 + +```bash +curl -s "http://localhost:5001/documents/list?collection=test1&page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------------------- | +| `collection` | string | ❌ | 知识库名称(也可用 `kb_name`),默认全部 | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "documents": [ + { + "collection": "public_kb", + "filename": "三峡公报_1-15页.pdf", + "last_modified": "2026-04-14T21:03:06", + "path": "public_kb/三峡公报_1-15页.pdf", + "size": 2773520 + } + ], + "total": 8 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /documents/\/status + +获取文档处理状态。 + +```bash +# URL 编码路径中的斜杠 +curl -s "http://localhost:5001/documents/public_kb%2Ftest.txt/status" +``` + +**响应示例**: + +```json +{ + "success": true, + "status": "active", + "chunk_count": 105, + "last_processed": null +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/upload + +上传单个文件。 + +```bash +echo "测试文档内容" > /tmp/test.txt +curl -s -X POST http://localhost:5001/documents/upload \ + -F "file=@/tmp/test.txt" \ + -F "collection=public_kb" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | -------------------- | +| `file` | file | ✅ | 上传文件 | +| `collection` | string | ✅ | 目标知识库(也可用 `kb_name`) | + +**响应示例**: + +```json +{ + "data": { + "file": { + "collection": "public_kb", + "filename": "test.txt", + "path": "public_kb/test.txt", + "size": 18 + }, + "sync_status": "已保存并添加到向量库" + }, + "message": "文件上传成功,已保存并添加到向量库", + "status": "success", + "status_code": 2002, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /documents/batch-upload + +批量上传文件。 + +```bash +echo "文件1内容" > /tmp/file1.txt +echo "文件2内容" > /tmp/file2.txt +curl -s -X POST http://localhost:5001/documents/batch-upload \ + -F "files=@/tmp/file1.txt" \ + -F "files=@/tmp/file2.txt" \ + -F "collection=public_kb" +``` + +**响应示例**: + +```json +{ + "data": { + "results": [ + {"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success"}, + {"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success"} + ], + "success_count": 2, + "total": 2 + }, + "message": "批量上传完成,成功 2/2 个文件", + "status": "success", + "status_code": 2003, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /documents/\ + +更新/替换文档。 + +```bash +echo "更新后的文档内容" > /tmp/updated.txt +curl -s -X PUT "http://localhost:5001/documents/public_kb%2Ftest.txt" \ + -F "file=@/tmp/updated.txt" +``` + +**表单字段**: + +| 字段 | 类型 | 必需 | 说明 | +| ------ | ---- | -- | ----- | +| `file` | file | ✅ | 替换的文件 | + +**响应示例**: + +```json +{ + "success": true, + "message": "文件已更新" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /documents/\ + +删除文档。 + +```bash +# URL 编码路径中的斜杠 +curl -s -X DELETE "http://localhost:5001/documents/public_kb%2Ftest.txt" +``` + +**响应示例**: + +```json +{ + "message": "文档已删除", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /collections/\/documents/\/deprecate + +废弃文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/deprecate" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +### POST /collections/\/documents/\/restore + +恢复文档版本。 + +```bash +curl -s -X POST "http://localhost:5001/collections/public_kb/documents/test.txt/restore" +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 7. 切片管理 + +### GET /documents/\/chunks + +获取文档切片列表。 + +```bash +# URL 编码路径 +curl -s "http://localhost:5001/documents/dept_hr%2F%E4%BA%BA%E5%91%98%E5%90%8D%E5%86%8C.txt/chunks?page=1&page_size=2" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "chunks": [ + { + "document": "# 智启科技有限公司人员名册\n\n**更新日期**:2024年6月...", + "id": "人员名册.txt_text_0", + "metadata": { + "chunk_id": "人员名册.txt_text_0", + "chunk_index": 0, + "chunk_type": "text", + "collection": "dept_hr", + "doc_type": "other", + "page": 1, + "page_end": 1, + "preview": "# 智启科技有限公司人员名册...", + "section": "人员名册", + "source": "人员名册.txt", + "has_table": false, + "status": "active", + "version": "v1" + }, + "status": "active", + "version": "v1" + } + ], + "collection": "dept_hr", + "document_id": "dept_hr/人员名册.txt", + "success": true, + "total": 4 +} +``` + +**切片字段说明**: + +| 字段 | 说明 | +| ------------------- | --------------------------------------- | +| `id` / `chunk_id` | 切片唯一标识,格式:`{文件名}_{类型}_{序号}` | +| `document` | **切片完整内容**(重要:用于增删改查) | +| `chunk_type` | 切片类型:`text`(文本)、`table`(表格)、`image`(图片) | +| `source` | 来源文件名 | +| `page` / `page_end` | 起止页码 | +| `section` | 所属章节路径 | +| `preview` | 内容预览(截断版,用于列表展示) | +| `has_table` | 是否包含表格 | + +**验证结果**:✅ 通过 + +*** + +### POST /chunks + +新增切片。 + +```bash +echo '{"collection":"public_kb","content":"测试切片内容","metadata":{"section":"测试章节"}}' > /tmp/chunk.json +curl -s -X POST http://localhost:5001/chunks \ + -H "Content-Type: application/json" \ + -d @/tmp/chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ✅ | 切片内容 | +| `metadata` | object | ❌ | 元数据 | + +**验证结果**:✅ 通过 + +*** + +### PUT /chunks/\ + +修改切片。 + +```bash +echo '{"collection":"dept_hr","content":"更新后的内容"}' > /tmp/update_chunk.json +curl -s -X PUT "http://localhost:5001/chunks/人员名册.txt_text_0" \ + -H "Content-Type: application/json" \ + -d @/tmp/update_chunk.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | +| `content` | string | ❌ | 新内容 | +| `metadata` | object | ❌ | 新元数据 | + +**验证结果**:✅ 通过 + +*** + +### DELETE /chunks/\ + +删除切片。 + +```bash +curl -s -X DELETE "http://localhost:5001/chunks/人员名册.txt_text_0?collection=dept_hr" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ----- | +| `collection` | string | ✅ | 向量库名称 | + +**验证结果**:✅ 通过 + +*** + +## 8. 同步服务 + +### POST /sync + +触发文档同步。 + +```bash +curl -s -X POST http://localhost:5001/sync \ + -H "Content-Type: application/json" +``` + +**响应示例**: + +```json +{ + "data": { + "result": { + "documents_added": 1, + "documents_deleted": 1, + "documents_modified": 0, + "documents_processed": 2, + "end_time": "2026-05-04T01:45:42", + "errors": [], + "start_time": "2026-05-04T01:45:41", + "status": "completed" + } + }, + "message": "同步完成", + "status": "success", + "status_code": 2010, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/status + +获取同步状态。 + +```bash +curl -s http://localhost:5001/sync/status +``` + +**响应示例**: + +```json +{ + "documents_tracked": 0, + "enabled": true, + "last_sync": null, + "monitoring": false +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/history + +获取同步历史。 + +```bash +curl -s http://localhost:5001/sync/history +``` + +**响应示例**: + +```json +{ + "history": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /sync/changes + +获取待同步变更。 + +```bash +curl -s http://localhost:5001/sync/changes +``` + +**响应示例**: + +```json +{ + "changes": [] +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/start + +启动文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/start +``` + +**响应示例**: + +```json +{ + "message": "文件监控已启动", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /sync/stop + +停止文件监控。 + +```bash +curl -s -X POST http://localhost:5001/sync/stop +``` + +**响应示例**: + +```json +{ + "message": "文件监控已停止", + "status": "success", + "status_code": 2010 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 9. 反馈系统 + +### POST /feedback + +提交反馈。 + +```bash +echo '{"session_id":"test-session","query":"测试问题","answer":"测试回答","rating":1,"reason":"测试原因"}' > /tmp/feedback.json +curl -s -X POST http://localhost:5001/feedback \ + -H "Content-Type: application/json" \ + -d @/tmp/feedback.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------ | +| `session_id` | string | ✅ | 会话 ID | +| `query` | string | ✅ | 用户问题 | +| `answer` | string | ✅ | 系统回答 | +| `rating` | int | ✅ | 评分(1=赞,-1=踩) | +| `reason` | string | ❌ | 原因 | + +**响应示例**: + +```json +{ + "faq_suggested": true, + "feedback_id": 7, + "success": true, + "suggestion_id": 6 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/list + +获取反馈列表。 + +```bash +curl -s "http://localhost:5001/feedback/list?page=1&page_size=5" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| ----------- | --- | -- | ---------- | +| `page` | int | ❌ | 页码,默认 1 | +| `page_size` | int | ❌ | 每页数量,默认 20 | + +**响应示例**: + +```json +{ + "feedbacks": [ + { + "answer": "测试回答", + "created_at": "2026-05-04T01:45:35", + "id": 7, + "query": "测试问题", + "rating": 1, + "reason": "测试原因", + "session_id": "test-session", + "sources": [], + "user_id": "" + } + ], + "success": true, + "total": 7 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/stats + +获取反馈统计。 + +```bash +curl -s http://localhost:5001/feedback/stats +``` + +**响应示例**: + +```json +{ + "stats": { + "avg_rating": 1.0, + "negative_count": 0, + "positive_count": 8, + "satisfaction_rate": 100.0, + "total_feedback": 8 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/bad-cases + +获取差评案例列表。 + +```bash +curl -s http://localhost:5001/feedback/bad-cases +``` + +**响应示例**: + +```json +{ + "bad_cases": [], + "blacklisted_sources": [], + "success": true, + "suggestions": [ + "补充到知识库(针对知识盲区)", + "添加到 Query Rewrite 规则(针对表达歧义)" + ] +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /feedback/blacklist + +获取切片黑名单。 + +```bash +curl -s http://localhost:5001/feedback/blacklist +``` + +**响应示例**: + +```json +{ + "blacklist": [], + "count": 0, + "success": true, + "usage": "在检索时过滤这些来源以提升回答质量" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 10. FAQ 管理 + +### GET /faq + +获取 FAQ 列表。 + +```bash +curl -s "http://localhost:5001/faq?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "faqs": [ + { + "answer": "根据参考资料...", + "avg_rating": 1.0, + "created_at": "2026-04-19T11:27:15", + "frequency": 1, + "id": 1, + "question": "C类吸烟区域", + "source_documents": null, + "status": "approved", + "updated_at": "2026-04-19T11:27:15" + } + ], + "success": true, + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq + +创建新 FAQ。 + +```bash +curl -s -X POST http://localhost:5001/faq \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"FAQ question\",\"answer\":\"FAQ answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ---- | +| `question` | string | ✅ | 问题内容 | +| `answer` | string | ✅ | 答案内容 | + +**响应示例**: + +```json +{ + "faq_id": 7, + "message": "FAQ已创建,请通过 /faq//approve 接口确认后生效", + "status": "draft", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### PUT /faq/\ + +修改 FAQ。 + +```bash +curl -s -X PUT "http://localhost:5001/faq/7" \ + -H "Content-Type: application/json" \ + -d "{\"question\":\"updated question\",\"answer\":\"updated answer\"}" +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------- | ------ | -- | ----- | +| `question` | string | ❌ | 新问题内容 | +| `answer` | string | ❌ | 新答案内容 | + +**响应示例**: + +```json +{ + "message": "FAQ更新成功", + "success": true, + "sync_status": "skipped" +} +``` + +**验证结果**:✅ 通过 + +*** + +### DELETE /faq/\ + +删除 FAQ。 + +```bash +curl -s -X DELETE "http://localhost:5001/faq/7" +``` + +**响应示例**: + +```json +{ + "message": "FAQ删除成功", + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /faq/suggestions + +获取 FAQ 建议列表。 + +```bash +curl -s "http://localhost:5001/faq/suggestions?page=1&page_size=5" +``` + +**响应示例**: + +```json +{ + "success": true, + "suggestions": [ + { + "answer": "", + "avg_rating": 1.0, + "created_at": "2026-04-29T20:49:38", + "frequency": 2, + "id": 5, + "query": "test", + "status": "pending" + } + ], + "total": 3 +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/approve + +批准 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/approve" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**请求体**(可选,用于修改答案): + +```json +{"answer": "管理员修改后的答案"} +``` + +**注意**:必须传递请求体(至少空对象 `{}`),否则返回 400 错误。 + +**验证结果**:✅ 通过 + +*** + +### POST /faq/suggestions/\/reject + +拒绝 FAQ 建议(需 admin 角色)。 + +```bash +curl -s -X POST "http://localhost:5001/faq/suggestions/6/reject" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**验证结果**:✅ 通过 + +*** + +## 11. 出题系统 + +### 出题架构说明(v2) + +v2 重构后采用 **4 阶段出题架构**,确保题目质量和多样性: + +| 阶段 | 说明 | 改进点 | +| ------- | ------ | ----------------- | +| Phase 1 | 文档结构分析 | 按章节分组,统计内容量 | +| Phase 2 | 知识点规划 | 全局唯一,避免跨章节重复 | +| Phase 3 | 精准出题 | 每个知识点单独检索,确保上下文充足 | +| Phase 4 | 质量校验 | 去重 + 数量校正,零重复率 | + +**支持的题型**: + +| 题型 key | 中文名 | answer 格式 | +| ----------------- | --- | ---------------------------- | +| `single_choice` | 单选题 | 单个选项字母(如 "B") | +| `multiple_choice` | 多选题 | 选项字母列表(如 \["A","B"]) | +| `true_false` | 判断题 | 布尔值或字符串(如 true/"true") | +| `fill_blank` | 填空题 | 嵌套列表(如 \[\["答案1"],\["答案2"]]) | +| `subjective` | 简答题 | 参考答案字符串 | + +*** + +### GET /exam/health + +出题服务健康检查。 + +```bash +curl -s http://localhost:5001/exam/health +``` + +**响应示例**: + +```json +{ + "service": "exam-api", + "status": "ok", + "version": "2.0" +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/generate + +生成考题(指定题型和数量)。 + +**特点**: + +- 需要手动指定 `question_types`(每种题型的数量) +- 适合有明确需求的场景(如"出 5 道单选题") + +```bash +curl -s -X POST http://localhost:5001/exam/generate \ + -H "Authorization: Bearer mock-token-admin" \ + -H "Content-Type: application/json" \ + -d '{"file_path":"public_kb/1.docx","collection":"public_kb","question_types":{"single_choice":5,"true_false":3,"fill_blank":2}}' +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ---------------- | ------ | -- | ------------------------------ | +| `file_path` | string | ✅ | 文档路径(格式:`collection/filename`) | +| `collection` | string | ✅ | 知识库名称 | +| `question_types` | object | ✅ | 题型数量配置 | +| `difficulty` | int | ❌ | 难度等级(1-5),默认 3 | +| `options` | object | ❌ | 附加选项(如 `max_source_chunks`) | +| `request_id` | string | ❌ | 请求 ID(幂等性) | + +**question\_types 配置示例**: + +```json +{ + "single_choice": 5, + "multiple_choice": 2, + "true_false": 3, + "fill_blank": 2, + "subjective": 1 +} +``` + +**响应示例**: + +```json +{ + "data": { + "questions": [ + { + "content": { + "stem": "重购率的定义是下列哪一项?", + "answer": "B", + "data": { + "options": [ + {"content": "选项A内容", "key": "A"}, + {"content": "品规连续两周订货客户数/上周订货客户数*100%", "key": "B"} + ] + }, + "explanation": "重购率的定义是..." + }, + "difficulty": 3, + "question_type": "single_choice", + "source_trace": { + "chunk_ids": ["1.docx_76"], + "document_name": "public_kb/1.docx", + "page_numbers": [1], + "sources": [...] + } + } + ], + "request_id": null, + "source_chunks_used": 15, + "success": true, + "total": 10 + }, + "message": "出题成功", + "status": "success", + "status_code": 2020, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### POST /exam/generate-smart + +AI 智能出题 - 自动分析文件并决定题型和数量。 + +**特点**: + +- 不需要传 `question_types`,AI 自动分析文档后决定 +- 返回格式与旧端口完全兼容,额外包含 `ai_analysis` 字段 +- 适合不想费脑子的场景 + +```bash +curl -s -X POST http://localhost:5001/exam/generate-smart \ + -H "Content-Type: application/json" \ + -d '{"file_path":"dept_1_kb/test.docx","collection":"dept_1_kb"}' +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------------------------ | +| `file_path` | string | ✅ | 文档路径(格式:`collection/filename`) | +| `collection` | string | ✅ | 知识库名称 | +| `difficulty` | int | ❌ | 难度等级(1-5),默认 3 | +| `options` | object | ❌ | 附加选项 | + +**响应示例**: + +```json +{ + "data": { + "ai_analysis": { + "total_knowledge_points": 21, + "suitable_types": ["single_choice", "multiple_choice", "true_false", "subjective"], + "question_types": { + "single_choice": 8, + "multiple_choice": 6, + "true_false": 4, + "fill_blank": 0, + "subjective": 3 + }, + "reason": "文档包含21个知识点,涵盖术语定义、流程步骤、数值标准..." + }, + "questions": [...], + "request_id": null, + "source_chunks_used": 15, + "success": true, + "total": 16 + }, + "message": "AI 智能出题成功", + "status": "success", + "status_code": 2020, + "success": true +} +``` + +**注意事项**: + +- 实际出题数量 ≤ min(文档知识点数, AI 推荐数量) +- 如果文档知识点较少,生成的题目数量会相应减少 +- 需要传 `Authorization` header + +**验证结果**:✅ 通过 + +*** + +### POST /exam/generate-smart + +AI 智能出题 - 自动分析文件并决定题型和数量。 + +与 `/exam/generate` 的区别: + +- 不需要传 `question_types`,AI 自动分析文档后决定出什么题型、多少道题 +- 返回格式完全兼容旧端口,额外包含 `ai_analysis` 字段供参考 + +**支持的题型**: + +| 题型 key | 中文名 | 说明 | +| ----------------- | --- | ----------------------- | +| `single_choice` | 单选题 | content.answer 为单个选项字母 | +| `multiple_choice` | 多选题 | content.answer 为选项字母列表 | +| `true_false` | 判断题 | content.answer 为布尔值或字符串 | +| `fill_blank` | 填空题 | content.answer 为嵌套列表 | +| `subjective` | 简答题 | content.answer 为参考答案 | + +```bash +curl -s -X POST http://localhost:5001/exam/generate-smart \ + -H "Content-Type: application/json" \ + -d '{"file_path":"public_kb/1.docx","collection":"public_kb"}' +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------ | ------ | -- | ------------------------------ | +| `file_path` | string | ✅ | 文档路径(格式:`collection/filename`) | +| `collection` | string | ✅ | 知识库名称 | +| `difficulty` | int | ❌ | 难度等级(1-5),默认 3 | +| `options` | object | ❌ | 附加选项 | + +**响应示例**: + +```json +{ + "data": { + "ai_analysis": { + "total_knowledge_points": 21, + "suitable_types": ["single_choice", "true_false", "fill_blank"], + "question_types": { + "single_choice": 10, + "multiple_choice": 0, + "true_false": 5, + "fill_blank": 6, + "subjective": 0 + }, + "reason": "文档内容较简短,适合选择、判断和填空题。" + }, + "questions": [...], + "request_id": null, + "source_chunks_used": 15, + "success": true, + "total": 20 + }, + "message": "AI 智能出题成功", + "status": "success", + "status_code": 2020, + "success": true +} +``` + +**注意事项**: + +- 实际出题数量 ≤ min(文档知识点数, AI 推荐数量) +- 如果文档知识点较少,生成的题目数量会相应减少 +- 需要传 `Authorization` header + +**验证结果**:✅ 通过 + +*** + +### POST /exam/grade + +批阅答案。 + +```bash +echo '{"answers":[{"question_id":"q1","question_type":"single_choice","question_content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json +curl -s -X POST http://localhost:5001/exam/grade \ + -H "Content-Type: application/json" \ + -d @/tmp/grade.json +``` + +**请求体**: + +| 字段 | 类型 | 必需 | 说明 | +| --------- | ----- | -- | ---- | +| `answers` | array | ✅ | 答案数组 | + +**answers 数组元素**: + +| 字段 | 类型 | 必需 | 说明 | +| ------------------ | ------------ | -- | ----------- | +| `question_id` | string | ✅ | 题目 ID | +| `question_type` | string | ✅ | 题型 | +| `question_content` | object | ✅ | 题目内容(含标准答案) | +| `student_answer` | string/array | ✅ | 学生答案 | +| `max_score` | int | ✅ | 满分 | + +**响应示例**: + +```json +{ + "data": { + "request_id": null, + "results": [ + { + "correct": true, + "feedback": "正确!", + "max_score": 2, + "question_id": "q1", + "score": 2 + } + ], + "score_rate": 100.0, + "success": true, + "total_max_score": 2, + "total_score": 2 + }, + "message": "批阅完成", + "status": "success", + "status_code": 2021, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +## 12. 图片服务 + +### GET /images/list + +获取图片列表。 + +```bash +curl -s "http://localhost:5001/images/list?limit=5&offset=0" +``` + +**查询参数**: + +| 参数 | 类型 | 必需 | 说明 | +| -------- | --- | -- | ---------- | +| `limit` | int | ❌ | 返回数量,默认 50 | +| `offset` | int | ❌ | 偏移量,默认 0 | + +**响应示例**: + +```json +{ + "images": [ + { + "image_id": "0569dd285537", + "size_bytes": 169818, + "url": "/images/0569dd285537" + } + ], + "limit": 5, + "offset": 0, + "total": 67 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/\ + +获取单张图片。 + +```bash +curl -s -o image.jpg http://localhost:5001/images/0569dd285537 +``` + +**响应**:返回图片文件(HTTP 200) + +**验证结果**:✅ 通过 + +*** + +### GET /images/\/info + +获取图片元数据。 + +```bash +curl -s "http://localhost:5001/images/0569dd285537/info" +``` + +**响应示例**: + +```json +{ + "format": "JPEG", + "height": 1357, + "image_id": "0569dd285537", + "mode": "RGB", + "size_bytes": 169818, + "url": "/images/0569dd285537", + "width": 1023 +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /images/stats + +获取图片统计。 + +```bash +curl -s http://localhost:5001/images/stats +``` + +**响应示例**: + +```json +{ + "format_counts": { + ".jpg": 46, + ".png": 21 + }, + "supported_formats": [".jpeg", ".gif", ".bmp", ".jpg", ".webp", ".png"], + "total_images": 67, + "total_size_bytes": 5394510, + "total_size_mb": 5.14 +} +``` + +**验证结果**:✅ 通过 + +*** + +## 13. 报告服务 + +### GET /reports/weekly + +获取周报。 + +```bash +curl -s http://localhost:5001/reports/weekly +``` + +**响应示例**: + +```json +{ + "report": { + "avg_rating": 1.0, + "created_at": "2026-05-04T01:49:16", + "end_date": "2026-05-10", + "high_freq_queries": [ + {"frequency": 1, "query": "测试问题", "sample_answer": "测试回答"} + ], + "improvement_suggestions": ["继续保持当前服务质量"], + "low_rating_queries": [], + "negative_count": 0, + "positive_count": 1, + "report_type": "weekly", + "satisfaction_rate": 100.0, + "start_date": "2026-05-04", + "total_feedback": 1, + "total_queries": 1 + }, + "success": true +} +``` + +**验证结果**:✅ 通过 + +*** + +### GET /reports/monthly + +获取月报。 + +```bash +curl -s http://localhost:5001/reports/monthly +``` + +**验证结果**:✅ 通过(接口存在) + +*** + +## 14. 知识库路由 + +### POST /kb/route + +测试知识库路由(调试用)。 + +```bash +echo '{"query":"三峡工程"}' > /tmp/route.json +curl -s -X POST http://localhost:5001/kb/route \ + -H "Content-Type: application/json" \ + -d @/tmp/route.json +``` + +**响应示例**: + +```json +{ + "intent": { + "confidence": 0.95, + "department": null, + "is_general": true, + "keywords": [], + "reason": "LLM 意图分析" + }, + "query": "三峡工程", + "target_collections": ["public_kb"], + "user_department": "", + "user_role": "user" +} +``` + +**验证结果**:✅ 通过 + +*** + +## 附录. 已知问题 + +### 1. /rag 接口 collections 参数 + +**问题**:使用 `collection`(单数)参数指定知识库无效,会被忽略并默认检索 `public_kb`。 + +**解决**:必须使用 `collections`(复数,数组格式)参数。 + +```bash +# ❌ 错误用法 - collection 单数参数无效 +{"message": "问题", "collection": "dept_tech", "chat_history": []} + +# ✅ 正确用法 - collections 数组参数 +{"message": "问题", "collections": ["dept_tech"], "chat_history": []} +``` + +*** + +## 测试覆盖统计 + +| 分类 | 总数 | 通过 | 失败/异常 | +| ------ | ------ | ------ | ----- | +| 健康检查 | 2 | 2 | 0 | +| 问答接口 | 2 | 2 | 0 | +| 检索接口 | 1 | 1 | 0 | +| 向量库管理 | 8 | 8 | 0 | +| 文档管理 | 10 | 10 | 0 | +| 切片管理 | 4 | 4 | 0 | +| 同步服务 | 6 | 6 | 0 | +| 反馈系统 | 5 | 5 | 0 | +| FAQ 管理 | 7 | 7 | 0 | +| 出题系统 | 3 | 3 | 0 | +| 图片服务 | 4 | 4 | 0 | +| 报告服务 | 2 | 2 | 0 | +| 知识库路由 | 1 | 1 | 0 | +| **总计** | **52** | **52** | **0** | + diff --git a/docs/ai/出题批题后端对接指南.md b/docs/ai/出题批题后端对接指南.md new file mode 100644 index 0000000..0b672eb --- /dev/null +++ b/docs/ai/出题批题后端对接指南.md @@ -0,0 +1,468 @@ +# 出题批题接口 - 后端对接指南 + +## 一、接口概览 + +| 接口 | 方法 | 功能 | 超时建议 | +|------|------|------|----------| +| `/exam/generate` | POST | 生成题目 | 120秒 | +| `/exam/grade` | POST | 批阅答案 | 60秒 | + +--- + +## 二、出题接口 + +### 2.1 请求 + +``` +POST /exam/generate +Content-Type: application/json +``` + +**请求体字段说明:** + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `file_path` | string | ✅ | 文档路径(相对于 documents 目录) | +| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) | +| `question_types` | object | ✅ | 题型及数量 | +| `difficulty` | int | ❌ | 难度等级 1-5,默认 3 | +| `request_id` | string | ❌ | 请求ID,相同ID返回缓存结果(幂等性) | + +**请求示例:** + +```json +{ + "file_path": "薪酬制度.docx", + "collection": ["dept_hr", "public_kb"], + "question_types": { + "single_choice": 3, + "multiple_choice": 2, + "true_false": 2, + "fill_blank": 2, + "subjective": 1 + }, + "difficulty": 3, + "request_id": "uuid-for-idempotency" +} +``` + +> **collection 检索逻辑说明**: +> - 出题接口采用**按优先级顺序检索**策略 +> - 先在 `dept_hr` 中查找文件,找到则停止 +> - 未找到则继续在 `public_kb` 中查找 +> - 这与 RAG 问答接口的**并行检索+融合**策略不同 +> - 原因:出题需要特定文件的完整内容,而非广泛搜索 + +### 2.2 响应 + +**成功响应:** + +```json +{ + "success": true, + "status": "success", + "status_code": 2011, + "message": "出题完成", + "data": { + "request_id": "uuid-xxx", + "total": 10, + "source_chunks_used": 25, + "questions": [ + { + "question_type": "single_choice", + "difficulty": 3, + "content": { + "stem": "题干内容", + "data": { + "options": [ + {"key": "A", "content": "选项A"}, + {"key": "B", "content": "选项B"}, + {"key": "C", "content": "选项C"}, + {"key": "D", "content": "选项D"} + ] + }, + "answer": "B", + "explanation": "答案解析" + }, + "source_trace": { + "document_name": "薪酬制度.docx", + "chunks_count": 3 + } + } + ] + } +} +``` + +**失败响应:** + +```json +{ + "success": false, + "status": "failed", + "error_code": "FILE_NOT_FOUND", + "message": "文件不存在" +} +``` + +### 2.3 返回字段说明 + +**RAG 服务返回的字段(后端需存储):** + +| 字段 | 类型 | 说明 | 后端是否需要存储 | +|------|------|------|------------------| +| `question_type` | string | 题型 | ✅ 存储 | +| `difficulty` | int | 难度 1-5 | ✅ 存储 | +| `content` | object | 题目内容 | ✅ 存储 | +| `content.stem` | string | 题干 | ✅ | +| `content.data` | object | 附加数据(选项、评分标准等) | ✅ | +| `content.answer` | any | 正确答案 | ✅ 批阅时需要 | +| `content.explanation` | string | 答案解析 | ✅ | +| `source_trace` | object | 来源追踪 | ✅ 存储 | + +**后端需要自己生成的字段:** + +| 字段 | 说明 | +|------|------| +| `question_id` | UUID,唯一标识 | +| `score` | 满分,根据业务需求设定 | +| `tags` | 标签 | +| `status` | 状态(待审核/已通过/已拒绝) | + +> **重要**:RAG 服务不返回 `score` 字段,分值由后端在入库时指定。 + +### 2.4 题型格式对照表 + +| 题型 | question_type | content.answer 格式 | content.data | +|------|---------------|---------------------|--------------| +| 单选题 | `single_choice` | `"B"` | `{options: [{key, content}]}` | +| 多选题 | `multiple_choice` | `["A", "C"]` | `{options: [{key, content}]}` | +| 判断题 | `true_false` | `"T"` 或 `"F"` | 无 | +| 填空题 | `fill_blank` | `[["答案1"], ["答案2", "同义词"]]` | `{blank_count: 2}` | +| 简答题 | `subjective` | `"参考范文..."` | `{scoring_points: [...]}` | + +### 2.5 错误码 + +| 错误码 | HTTP | 说明 | +|--------|------|------| +| `FILE_NOT_FOUND` | 404 | 指定文件不存在 | +| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 | +| `NO_CONTENT` | 400 | 文件内容为空,无法出题 | +| `LLM_ERROR` | 500 | LLM 调用失败 | +| `PARSE_ERROR` | 500 | 解析失败 | + +--- + +## 三、批阅接口 + +### 3.1 请求 + +``` +POST /exam/grade +Content-Type: application/json +``` + +**请求体字段说明:** + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `request_id` | string | ❌ | 请求ID,用于追踪 | +| `answers` | array | ✅ | 答案列表 | + +**answers 数组中每个对象的字段:** + +| 字段 | 类型 | 必需 | 说明 | 来源 | +|------|------|------|------|------| +| `question_id` | string | ✅ | 题目ID | 后端数据库 | +| `question_type` | string | ✅ | 题型 | 后端数据库 | +| `question_content` | object | ✅ | 题目内容(含正确答案) | 后端数据库 | +| `student_answer` | any | ✅ | 学生答案 | 学生提交 | +| `max_score` | number | ✅ | 满分 | 后端数据库 | + +**请求示例:** + +```json +{ + "request_id": "grade-uuid-xxx", + "answers": [ + { + "question_id": "q-001", + "question_type": "single_choice", + "question_content": { + "stem": "根据公司规定,员工薪资由哪几部分组成?", + "data": { + "options": [ + {"key": "A", "content": "基本工资+奖金"}, + {"key": "B", "content": "基本工资+绩效奖金+津贴补贴"}, + {"key": "C", "content": "基本工资+加班费"}, + {"key": "D", "content": "基本工资"} + ] + }, + "answer": "B" + }, + "student_answer": "B", + "max_score": 5.0 + }, + { + "question_id": "q-002", + "question_type": "multiple_choice", + "question_content": { + "stem": "以下哪些属于绩效奖金的评定因素?", + "data": { + "options": [ + {"key": "A", "content": "工作质量"}, + {"key": "B", "content": "工作态度"}, + {"key": "C", "content": "考勤情况"}, + {"key": "D", "content": "团队协作"} + ] + }, + "answer": ["A", "B", "D"] + }, + "student_answer": ["A", "B"], + "max_score": 4.0 + }, + { + "question_id": "q-003", + "question_type": "true_false", + "question_content": { + "stem": "公司规定员工每月绩效奖金上限为工资的20%。", + "answer": "F" + }, + "student_answer": "T", + "max_score": 2.0 + }, + { + "question_id": "q-004", + "question_type": "fill_blank", + "question_content": { + "stem": "员工薪资由___、___和___三部分组成。", + "data": {"blank_count": 3}, + "answer": [["基本工资"], ["绩效奖金", "绩效"], ["津贴补贴", "补贴"]] + }, + "student_answer": ["基本工资", "绩效奖金", "交通补贴"], + "max_score": 6.0 + }, + { + "question_id": "q-005", + "question_type": "subjective", + "question_content": { + "stem": "请简述公司薪酬制度的核心原则。", + "data": { + "scoring_points": [ + {"point": "公平性", "weight": 0.3}, + {"point": "激励性", "weight": 0.3}, + {"point": "竞争力", "weight": 0.2}, + {"point": "合法性", "weight": 0.2} + ] + }, + "answer": "公司薪酬制度遵循公平、激励、竞争、合法四大原则..." + }, + "student_answer": "我认为公司的薪酬制度应该公平公正...", + "max_score": 10.0 + } + ] +} +``` + +### 3.2 响应 + +**成功响应:** + +```json +{ + "success": true, + "status": "success", + "status_code": 2012, + "message": "批阅完成", + "data": { + "request_id": "grade-uuid-xxx", + "total_score": 14.0, + "total_max_score": 27.0, + "score_rate": 51.85, + "results": [ + { + "question_id": "q-001", + "question_type": "single_choice", + "score": 5.0, + "max_score": 5.0, + "is_correct": true, + "student_answer": "B", + "correct_answer": "B", + "feedback": "回答正确" + }, + { + "question_id": "q-002", + "question_type": "multiple_choice", + "score": 2.0, + "max_score": 4.0, + "is_correct": false, + "student_answer": ["A", "B"], + "correct_answer": ["A", "B", "D"], + "feedback": "少选了 D 选项,正确答案: A, B, D" + }, + { + "question_id": "q-003", + "question_type": "true_false", + "score": 0.0, + "max_score": 2.0, + "is_correct": false, + "student_answer": "T", + "correct_answer": "F", + "feedback": "正确答案: F" + }, + { + "question_id": "q-004", + "question_type": "fill_blank", + "score": 4.0, + "max_score": 6.0, + "is_correct": false, + "student_answer": ["基本工资", "绩效奖金", "交通补贴"], + "correct_answer": [["基本工资"], ["绩效奖金", "绩效"], ["津贴补贴", "补贴"]], + "feedback": "第1、2空正确,第3空答案应为:津贴补贴或补贴" + }, + { + "question_id": "q-005", + "question_type": "subjective", + "score": 3.0, + "max_score": 10.0, + "is_correct": false, + "student_answer": "我认为公司的薪酬制度应该公平公正...", + "correct_answer": "公司薪酬制度遵循公平、激励、竞争、合法四大原则...", + "feedback": "得分点:提到公平性概念,但未展开说明激励性和竞争力原则" + } + ] + } +} +``` + +### 3.3 返回字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total_score` | number | 总得分 | +| `total_max_score` | number | 总满分 | +| `score_rate` | number | 得分率(百分比) | +| `results` | array | 每道题的批阅结果 | +| `results[].question_id` | string | 题目ID | +| `results[].score` | number | 该题得分 | +| `results[].max_score` | number | 该题满分 | +| `results[].is_correct` | boolean | 是否完全正确 | +| `results[].feedback` | string | 批阅反馈 | + +### 3.4 批阅规则 + +| 题型 | 批阅方式 | 得分规则 | +|------|----------|----------| +| 单选题 | 精确匹配 | 正确得满分,错误得 0 | +| 多选题 | 集合比对 | 全对满分,少选得一半,错选得 0 | +| 判断题 | 精确匹配 | 正确得满分,错误得 0 | +| 填空题 | 多答案匹配 | 每空独立评分,支持同义词匹配 | +| 简答题 | LLM 评分 | 根据得分点评分,不超过 `max_score` | + +### 3.5 错误码 + +| 错误码 | HTTP | 说明 | +|--------|------|------| +| `INVALID_ANSWER_FORMAT` | 400 | 答案格式不正确 | +| `GRADING_ERROR` | 500 | 批阅过程出错 | + +--- + +## 四、完整调用流程 + +### 4.1 出题流程 + +``` +后端调用 /exam/generate + │ + ▼ + RAG 返回题目 + (question_type, difficulty, content, source_trace) + │ + ▼ + 后端生成 question_id + 后端设置 score (满分) + 后端设置 status + │ + ▼ + 后端存入数据库 +``` + +### 4.2 批阅流程 + +``` +学生提交答案 + │ + ▼ + 后端从数据库查询: + - question_id + - question_type + - question_content (含正确答案) + - score (满分) + │ + ▼ + 后端组装请求调用 /exam/grade + │ + ▼ + RAG 返回批阅结果 + (score, feedback, is_correct) + │ + ▼ + 后端存入成绩表 +``` + +--- + +## 五、字段来源速查表 + +### 5.1 出题接口 + +| 字段 | 返回方 | 存储方 | 说明 | +|------|--------|--------|------| +| question_id | ❌ 不返回 | 后端生成 | UUID | +| question_type | ✅ RAG | 后端存储 | 题型 | +| difficulty | ✅ RAG | 后端存储 | 难度 1-5 | +| content | ✅ RAG | 后端存储 | 题目内容 | +| source_trace | ✅ RAG | 后端存储 | 来源追踪 | +| **score** | ❌ 不返回 | **后端设定** | 满分 | +| tags | ❌ 不返回 | 后端设定 | 标签 | +| status | ❌ 不返回 | 后端设定 | 状态 | + +### 5.2 批阅接口 + +| 字段 | 来源 | 说明 | +|------|------|------| +| question_id | 后端数据库 | 题目唯一标识 | +| question_type | 后端数据库 | 题型 | +| question_content | 后端数据库 | 题目内容(含正确答案) | +| student_answer | 学生提交 | 学生作答 | +| **max_score** | **后端数据库** | 满分(决定得分上限) | + +--- + +## 六、常见问题 + +### Q1: 出题接口返回的题目需要审核吗? + +建议后端设置 `status` 字段进行审核流程,审核通过后再用于组卷。 + +### Q2: 分值如何动态调整? + +后端在入库时自由设定 `score` 字段,批阅时传入 `max_score` 即可。RAG 服务不干预分值。 + +### Q3: 填空题同义词匹配是如何实现的? + +填空题答案格式为 `[["主答案", "同义词1", "同义词2"], ...]`,学生答案匹配任意一个即视为正确。 + +### Q4: 批阅接口超时怎么办? + +建议: +- 单次批阅不超过 50 道题 +- 设置 60 秒超时 +- 主观题较多时适当延长 + +--- + +**文档版本**: v1.1 +**更新时间**: 2026-05-17 +**相关文档**: [后端对接规范.md](./后端对接规范.md) diff --git a/docs/error.txt b/docs/error.txt new file mode 100644 index 0000000..f4876bb --- /dev/null +++ b/docs/error.txt @@ -0,0 +1,109 @@ +index-DxCaeH_z.js:504 POST http://47.116.16.222/api/exam/record/103edf6a-e026-4314-889f-4187f6b44147/submit 500 (Internal Server Error) +(匿名) @ index-DxCaeH_z.js:504 +xhr @ index-DxCaeH_z.js:504 +dispatchRequest @ index-DxCaeH_z.js:506 +Promise.then +_request @ index-DxCaeH_z.js:510 +request @ index-DxCaeH_z.js:506 +(匿名) @ index-DxCaeH_z.js:510 +(匿名) @ index-DxCaeH_z.js:502 +submit @ index-DxCaeH_z.js:659 +fn @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 [提交] submit(结束考试) 失败: +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 message: 系统异常 +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 HTTP状态: 500 +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 响应数据: {"code":500,"message":"系统异常","data":null,"timestamp":"2026-06-01 08:44:52","success":false} +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 响应头: {"connection":"keep-alive","content-type":"application/json","date":"Mon, 01 Jun 2026 08:44:52 GMT","server":"nginx/1.18.0 (Ubuntu)","transfer-encoding":"chunked","vary":"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"} +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 请求URL: /api/exam/record/103edf6a-e026-4314-889f-4187f6b44147/submit +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 请求方法: post +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:659 [examAPI] 正式考试提交批改 - recordId: 103edf6a-e026-4314-889f-4187f6b44147 题目数: 5 +index-DxCaeH_z.js:707 [提交] gradeExamRecord(空答案) 失败: +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 message: 提交失败: Index 0 out of bounds for length 0 +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 完全无响应对象, error keys: +yt @ index-DxCaeH_z.js:707 +ft @ index-DxCaeH_z.js:707 +await in ft +Bt @ index-DxCaeH_z.js:709 +createBaseVNode.onClick.fn..fn. @ index-DxCaeH_z.js:709 +callWithErrorHandling @ index-DxCaeH_z.js:16 +callWithAsyncErrorHandling @ index-DxCaeH_z.js:16 +ee @ index-DxCaeH_z.js:22 +index-DxCaeH_z.js:707 [提交] 所有2种提交方式均失败 \ No newline at end of file diff --git a/docs/sql/knowledge_management_system.sql b/docs/sql/knowledge_management_system.sql new file mode 100644 index 0000000..90e2784 --- /dev/null +++ b/docs/sql/knowledge_management_system.sql @@ -0,0 +1,1502 @@ +/* + Navicat Premium Dump SQL + + Source Server : yun + Source Server Type : MySQL + Source Server Version : 80045 (8.0.45-0ubuntu0.22.04.1) + Source Host : 127.0.0.1:3306 + Source Schema : knowledge_management_system + + Target Server Type : MySQL + Target Server Version : 80045 (8.0.45-0ubuntu0.22.04.1) + File Encoding : 65001 + + Date: 02/06/2026 16:34:39 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for collection +-- ---------------------------- +DROP TABLE IF EXISTS `collection`; +CREATE TABLE `collection` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '1' COMMENT '知识库名', + `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '知识库存放路径', + `uploader` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传者信息', + `department` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '所属部门,为空表示个人所有', + `owner_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'personal' COMMENT '所有者类型:personal(个人) / department(部门)', + `visibility` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'private' COMMENT '可见性:public(公开) / private(私密)', + `file_size` bigint NULL DEFAULT NULL COMMENT '文件大小(字节)', + `display_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '显示名称', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '描述信息', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `sync_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'LATEST' COMMENT '同步状态:LATEST-最新版, OUTDATED-未更新, SYNCING-同步中, FAILED-同步失败', + `last_sync_time` datetime NULL DEFAULT NULL COMMENT '最后同步时间', + `sync_message` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '同步信息/错误消息', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_path`(`path` ASC) USING BTREE, + INDEX `idx_uploader`(`uploader` ASC) USING BTREE, + INDEX `idx_department`(`department` ASC) USING BTREE, + INDEX `idx_owner_type`(`owner_type` ASC) USING BTREE, + INDEX `idx_visibility`(`visibility` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 23 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库存放路径表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of collection +-- ---------------------------- +INSERT INTO `collection` VALUES (17, 'test1', '/knowledge/dept', '1', '总公司', 'department', 'public', NULL, '', '', '2026-05-16 13:44:10', '2026-05-28 22:46:59', 'LATEST', NULL, NULL); +INSERT INTO `collection` VALUES (19, 'dept_2_kb', '/knowledge/dept_2_kb', '1', '南京分公司', 'department', 'public', NULL, '部门2知识库', '自动创建的向量库', '2026-05-29 10:36:15', '2026-05-29 19:17:30', 'LATEST', NULL, NULL); +INSERT INTO `collection` VALUES (20, 'dept_3_kb', '/knowledge/dept_3_kb', '1', '技术部', 'department', 'public', NULL, '部门3知识库', '自动创建的向量库', '2026-05-29 10:49:55', '2026-05-29 19:17:32', 'LATEST', NULL, NULL); +INSERT INTO `collection` VALUES (21, 'dept_4_kb', '/knowledge/dept_4_kb', '1', '采购部门', 'department', 'public', NULL, '部门4知识库', '自动创建的向量库', '2026-05-29 12:01:46', '2026-05-29 12:01:46', 'LATEST', NULL, NULL); +INSERT INTO `collection` VALUES (22, 'dept_6_kb', '/knowledge/dept_6_kb', '1', '人事部门', 'department', 'public', NULL, '部门6知识库', '自动创建的向量库', '2026-05-30 14:39:29', '2026-05-30 14:39:29', 'LATEST', NULL, NULL); + +-- ---------------------------- +-- Table structure for collection_file +-- ---------------------------- +DROP TABLE IF EXISTS `collection_file`; +CREATE TABLE `collection_file` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `collection_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '向量库名称(如 public_kb)', + `doc_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文档名称(用于匹配 context_reference 的 doc_name)', + `file_id` bigint NULL DEFAULT NULL COMMENT '关联的文件ID(关联 file 表)', + `file_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件存储路径', + `doc_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档类型:pdf、word、excel', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1-有效,0-无效', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_collection_doc`(`collection_name` ASC, `doc_name` ASC) USING BTREE, + INDEX `idx_collection_name`(`collection_name` ASC) USING BTREE, + INDEX `idx_doc_name`(`doc_name` ASC) USING BTREE, + INDEX `idx_file_id`(`file_id` ASC) USING BTREE, + CONSTRAINT `fk_collection_file_file` FOREIGN KEY (`file_id`) REFERENCES `file` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 42 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '向量库文件关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of collection_file +-- ---------------------------- +INSERT INTO `collection_file` VALUES (26, 'test1', '1.docx', 48, 'resources/rule/总公司/1/v1/1.docx', '.docx', 1, '2026-05-28 22:51:44', '2026-05-29 17:18:41'); +INSERT INTO `collection_file` VALUES (27, 'test1', '2.docx', 22, 'resources/rule/总公司/南京分公司/公共部门/1/v1/2.docx', 'docx', 1, '2026-05-28 22:51:44', '2026-05-29 17:18:47'); +INSERT INTO `collection_file` VALUES (28, 'test1', '3.docx', 23, 'resources/rule/总公司/南京分公司/公共部门/1/v1/3.docx', 'docx', 1, '2026-05-28 22:51:44', '2026-05-29 17:18:54'); +INSERT INTO `collection_file` VALUES (31, 'dept_2_kb', 'longrule.docx', 43, 'resources/rule/总公司/南京分公司/longrule/v2/longrule.docx', '.docx', 1, '2026-05-29 18:36:26', '2026-05-29 18:36:26'); +INSERT INTO `collection_file` VALUES (32, 'dept_3_kb', 'test.docx', 45, 'resources/rule/总公司/南京分公司/技术部/test/v1/test.docx', '.docx', 1, '2026-05-29 18:50:09', '2026-05-29 18:50:09'); +INSERT INTO `collection_file` VALUES (33, 'dept_4_kb', 'longrule.docx', 49, 'resources/rule/总公司/南京分公司/采购部门/longrule/v1/longrule.docx', '.docx', 1, '2026-05-29 20:01:53', '2026-05-29 20:01:53'); +INSERT INTO `collection_file` VALUES (34, 'test1', '作业管理系统测试计划.doc', 57, 'resources/rule/总公司/作业管理系统测试计划/v5/作业管理系统测试计划.doc', '.doc', 1, '2026-05-29 22:04:35', '2026-05-29 22:04:35'); +INSERT INTO `collection_file` VALUES (35, 'dept_4_kb', '作业管理系统测试计划.doc', 54, 'resources/rule/总公司/南京分公司/采购部门/作业管理系统测试计划/v1/作业管理系统测试计划.doc', '.doc', 1, '2026-05-30 21:17:44', '2026-05-30 21:17:44'); +INSERT INTO `collection_file` VALUES (36, 'test1', '需求分析.docx', 59, 'resources/rule/总公司/需求分析/v3/需求分析.docx', '.docx', 1, '2026-05-30 22:06:32', '2026-05-30 22:06:32'); +INSERT INTO `collection_file` VALUES (37, 'dept_6_kb', 'test.docx', 60, 'resources/rule/总公司/南京分公司/人事部门/test/v3/test.docx', '.docx', 1, '2026-05-30 22:39:47', '2026-05-30 22:39:47'); +INSERT INTO `collection_file` VALUES (38, 'dept_4_kb', '需求分析.docx', 61, 'resources/rule/总公司/南京分公司/采购部门/需求分析/v1/需求分析.docx', '.docx', 1, '2026-05-30 22:45:08', '2026-05-30 22:45:08'); +INSERT INTO `collection_file` VALUES (39, 'dept_2_kb', '1.docx', 63, 'resources/rule/总公司/南京分公司/1/v1/1.docx', '.docx', 1, '2026-06-02 14:34:12', '2026-06-02 14:34:12'); +INSERT INTO `collection_file` VALUES (40, 'dept_2_kb', '货源投放工作规范.docx', 67, 'resources/rule/总公司/南京分公司/货源投放工作规范/v4/货源投放工作规范.docx', '.docx', 1, '2026-06-02 14:38:09', '2026-06-02 14:38:09'); +INSERT INTO `collection_file` VALUES (41, 'dept_2_kb', '1.txt', 68, 'resources/rule/总公司/南京分公司/1/v2/1.txt', '.txt', 1, '2026-06-02 16:09:31', '2026-06-02 16:09:31'); + +-- ---------------------------- +-- Table structure for context_conversation +-- ---------------------------- +DROP TABLE IF EXISTS `context_conversation`; +CREATE TABLE `context_conversation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话ID(唯一)', + `session_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '会话ID,用于关联历史摘要', + `user_id` bigint NOT NULL COMMENT '用户ID', + `summary` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '会话摘要(给AI用)', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_session_id`(`session_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 63 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '上下文会话表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of context_conversation +-- ---------------------------- +INSERT INTO `context_conversation` VALUES (57, 'sess_1780120519597_1wqmohgs2', 1, '用户问: 给我整理一下三峡的资料,最好带点图片', '2026-05-30 05:55:20'); +INSERT INTO `context_conversation` VALUES (58, 'sess_1780120929564_ryg7s3m20', 3, '用户问: 品牌布局规划', '2026-05-30 06:02:10'); +INSERT INTO `context_conversation` VALUES (59, 'sess_1780379728867_z5d8v05yp', 1, '用户问: 确定周供应总量的主要依据有哪些', '2026-06-02 05:55:28'); +INSERT INTO `context_conversation` VALUES (60, 'sess_1780385074333_3ektq0vgx', 1, '用户问: 确定周供应总量的主要依据有哪些', '2026-06-02 07:24:34'); +INSERT INTO `context_conversation` VALUES (61, 'sess_1780386659498_e2i0pli3t', 1, '用户问: 市场状态的计算方式是什么', '2026-06-02 07:50:59'); +INSERT INTO `context_conversation` VALUES (62, 'sess_1780387846575_3rnw6o00w', 1, '用户问: 市场状态的计算方式是什么', '2026-06-02 08:10:46'); + +-- ---------------------------- +-- Table structure for context_image +-- ---------------------------- +DROP TABLE IF EXISTS `context_image`; +CREATE TABLE `context_image` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `image_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '图片唯一标识', + `filename` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '原始文件名', + `path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '存储路径', + `size_bytes` bigint NULL DEFAULT NULL COMMENT '文件大小(字节)', + `format` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '图片格式', + `source` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '来源文档', + `page` int NULL DEFAULT NULL COMMENT '来源页码(PDF)', + `page_end` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '结束页码', + `page_range` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '页码范围', + `section` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '章节信息', + `chunk_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '块类型(chart/image)', + `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '图片类型', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '图片描述', + `full_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '完整描述', + `score` decimal(10, 4) NULL DEFAULT NULL COMMENT '相关性评分', + `collection` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '所属向量库', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_image_id`(`image_id` ASC) USING BTREE, + INDEX `idx_collection`(`collection` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '图片存储表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of context_image +-- ---------------------------- +INSERT INTO `context_image` VALUES (5, 'dcbee1500209.jpg', NULL, '/app/uploads/img/dcbee1500209.jpg', 261714, 'JPEG', '1.docx', 1, NULL, NULL, NULL, NULL, 'image', '这张图片是一张表格,属于数据图表类型。表格详细列出了不同市场状态下的投放结果评估、市场表现和供求分析、投放策略中可能存在的问题以及优化和改进的初步建议。主要内容包括八个序号的评估项,每个项下有供货面、', '这张图片是一张表格,属于数据图表类型。表格详细列出了不同市场状态下的投放结果评估、市场表现和供求分析、投放策略中可能存在的问题以及优化和改进的初步建议。主要内容包括八个序号的评估项,每个项下有供货面、订货面、订足面的指标和结果,以及对应的市场状态评价、市场表现和供求分析、投放策略中的问题和优化建议。关键信息涉及本地消费需求、供求关系、客户订单满足率、公司货源利用率等,并针对不同情况提出具体的优化措施。', 11.8000, NULL, '2026-06-02 05:57:19'); +INSERT INTO `context_image` VALUES (6, '49d2910b148d.jpg', NULL, '/app/uploads/img/49d2910b148d.jpg', 3004, 'JPEG', '1.docx', 1, NULL, NULL, NULL, NULL, 'image', '这张图片展示了一个数学公式,属于数学表达式类型。公式表示的是一个一级指标(\\(\\xi_i\\))乘以相应的权重(\\(w_i^*\\))。主要内容是数学符号和变量的组合,关键信息在于表示了指标与权重的乘积关', '这张图片展示了一个数学公式,属于数学表达式类型。公式表示的是一个一级指标(\\(\\xi_i\\))乘以相应的权重(\\(w_i^*\\))。主要内容是数学符号和变量的组合,关键信息在于表示了指标与权重的乘积关系。没有数据趋势或数值可描述。', 11.3000, NULL, '2026-06-02 06:03:55'); + +-- ---------------------------- +-- Table structure for context_message +-- ---------------------------- +DROP TABLE IF EXISTS `context_message`; +CREATE TABLE `context_message` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID', + `conversation_id` bigint NOT NULL COMMENT '会话ID', + `role` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'user/assistant', + `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题/回答原文', + `message_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'text' COMMENT '消息类型:text/summary/references', + `is_finished` tinyint NULL DEFAULT 0 COMMENT '回答是否结束', + `summary` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '会话摘要', + `kb_paths` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '知识库路径列表(JSON格式)', + `answer` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '完整答案', + `mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模式(如rag)', + `duration_ms` int NULL DEFAULT NULL COMMENT '处理耗时(毫秒)', + `images` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '图片信息列表(JSON格式,存储image_id和url)', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + `sources` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '来源信息列表(JSON格式)', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_conversation_id`(`conversation_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 171 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '上下文消息表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of context_message +-- ---------------------------- +INSERT INTO `context_message` VALUES (159, 57, 'assistant', '品牌布局规划', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\"]', '品牌布局规划相关内容在参考资料中明确提及,具体如下:[ref:1.docx_14]\n\n**(三)制定品牌布局规划** [ref:1.docx_14]\n各地市公司要认真落实《\\*\\*品牌管理办法》《全省\\*\\*品牌发展规划》的要求,明确各价位段主导、护卫和潜力品规[1]: [ref:1.docx_14]\n- **主导品规**:一般有1–4个,属于深受本地消费者喜爱、零售客户欢迎,符合工业企业发展方向,在一定时期本地市场所属价位段内具有相当规模的品规; [ref:1.docx_14]\n- **护卫品规**:一般有2–5个,属于本地有相当数量消费者喜爱、零售客户欢迎,符合工业企业发展方向,在一定时期本地市场所属价位段内有一定规模的品规; [ref:1.docx_14]\n- **潜力品规**:属于开始被本地消费者接受,受到一定数量消费者欢迎,消费需求不断增加,符合工业企业发展方向,在本地市场处于上升期的品规[ref:1.docx_14]。 \n\n品规的市场地位应结合地市公司品牌发展规划,按照区域品牌培育一体化品牌规划管理联动机制要求,以年度为单位进行确定,可视品规的市场表现每半年进行调整[ref:1.docx_25]。要通过持续的品牌培育和市场引导,在各价位段形成**梯次化、有层次的品牌布局**,给消费者、零售客户更多的选择、替代和补充[1][ref:1.docx_14]。\n\n此外,关于护卫品规和潜力品规的投放要求进一步强化了品牌布局的动态协同性: [ref:1.docx_14]\n- 当主导品规供应不足或需调整状态时,应增加护卫和潜力品规的投放强度; [ref:1.docx_40]\n- 每周“主导品规+护卫品规”投放量不低于该价位段投放总量的85%(或“主导品规+潜力品规”不低于80%); [ref:1.docx_40]\n- 要加强对护卫品规和潜力品规的市场培育,使其逐步成长壮大,努力形成稳定、有层次的供给能力(高价位和低价位\\*\\*可不参照执行)[2][ref:1.docx_40]。\n\n综上,品牌布局规划的核心是:**按价位段分类管理(主导/护卫/潜力)、年度定标+半年动态调整、梯次化培育、投放协同支撑**[ref:1.docx_40]。\n\n引用编号: \n[1] —— 对应原文中“(三)制定品牌布局规划”全部内容; [ref:1.docx_14]\n[2] —— 对应原文中“(二)货源投放要求”下“8.关于护卫品规和潜力品规投放”部分[ref:1.docx_40]。', NULL, 110075, NULL, '2026-05-30 05:57:10', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"2.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":4,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (160, 58, 'assistant', '品牌布局规划', 'rag', 1, NULL, '[\"test1\"]', '品牌布局规划相关内容在参考资料中明确体现于 **“(三)制定品牌布局规划”** 部分,具体如下:[ref:1.docx_14]\n\n各地市公司要认真落实《\\*\\*品牌管理办法》《全省\\*\\*品牌发展规划》的要求,明确各价位段的**主导品规、护卫品规和潜力品规**,并实施梯次化、有层次的品牌布局:[ref:1.docx_14]\n\n- **主导品规**:一般有1–4个,属于深受本地消费者喜爱、零售客户欢迎,符合工业企业发展方向,在一定时期本地市场所属价位段内具有相当规模的品规; [ref:1.docx_14]\n- **护卫品规**:一般有2–5个,属于本地有相当数量消费者喜爱、零售客户欢迎,符合工业企业发展方向,在一定时期本地市场所属价位段内有一定规模的品规; [ref:1.docx_14]\n- **潜力品规**:属于开始被本地消费者接受,受到一定数量消费者欢迎,消费需求不断增加,符合工业企业发展方向,在本地市场处于上升期的品规[ref:1.docx_14]。\n\n品规的市场地位应结合地市公司品牌发展规划,按照**区域品牌培育一体化品牌规划管理联动机制**要求,以**年度为单位进行确定**,并可视品规市场表现**每半年动态调整**[ref:1.docx_25]。目标是通过持续的品牌培育和市场引导,在各价位段形成**梯次化、有层次的品牌布局**,为消费者和零售客户提供更多选择、替代与补充[1][ref:1.docx_14]。\n\n此外,关于护卫品规和潜力品规的投放协同要求也予以明确: [ref:1.docx_14]\n> 当主导品规供应不足或需调整状态时,应增加护卫和潜力品规的投放强度;每周“主导品规+护卫品规”投放量不低于该价位段投放总量的85%(或每周“主导品规+潜力品规”投放量不低于该价位段投放总量的80%);要加强对护卫品规和潜力品规的市场培育,使其逐步成长壮大,努力形成稳定、有层次的供给能力(高价位\\*\\*和低价位\\*\\*可不参照执行)[2][ref:1.docx_40]。\n\n综上,品牌布局规划的核心是:**按价位段分类管理、动态调整品规定位、强化梯队协同培育、实现供给结构优化**[ref:1.docx_40]。\n\n引用编号: \n[1] —— “(三)制定品牌布局规划”全文; [ref:1.docx_14]\n[2] —— “(二)货源投放要求”中第8条关于护卫品规和潜力品规投放的内容[ref:1.docx_40]。', NULL, 95402, NULL, '2026-05-30 06:03:45', '[{\"docName\":\"2.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":4,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (161, 57, 'assistant', '给我整理一下三峡的资料,最好带点图片', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '参考资料中**未提及任何关于“三峡”(如三峡大坝、三峡工程、三峡地区等)的相关信息**,所有内容均围绕吸烟场所管理、证件查验、档案存档、系统故障处理、协议解除、货源公开、终端运营指导等主题展开,涉及的是烟草行业或住宿场所的管理制度与操作规范。\n\n因此,**无法根据所提供的参考资料整理出关于“三峡”的资料,亦无相关图片可提供**。\n\n如需三峡相关资料(如地理、工程、历史、旅游等),需另行提供对应参考资料或明确说明信息来源[ref:2.docx_90]。', NULL, 74400, NULL, '2026-06-02 04:34:29', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"2.docx\",\"docType\":\"other\",\"referenceCount\":2,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"需求分析.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (162, 59, 'assistant', '货源投放的总体要求是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '货源投放的总体要求是:**货源投放是\\*\\*营销的核心业务,总体要求是:**[ref:1.docx_3]\n\n该内容直接出自【参考资料】中标题为“**二、总体要求**”的部分,原文仅此一句,未展开具体条目或进一步说明[1][ref:1.docx_38]。\n\n> 【注】编号[1]对应参考资料中“**二、总体要求**”主题下的唯一陈述句:“货源投放是\\*\\*营销的核心业务,总体要求是:”[ref:1.docx_3]。\n\n其余资料中虽详述了适用范围、投放办法、指标定义、评估策略、监管要求等,但均未对“总体要求”本身作补充性定义或扩展性阐释[ref:1.docx_93]。因此,**参考资料中关于“总体要求”的内容仅有且仅限于此句,无其他条目或细则**[ref:1.docx_40]。', NULL, 110896, '[{\"image_id\":\"dcbee1500209.jpg\",\"url\":\"/api/image/dcbee1500209.jpg/data\"}]', '2026-06-02 05:57:19', '[{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":5,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (163, 59, 'assistant', '关于按档位投放有什么要求', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '参考资料中**未提及“按档位投放”的具体要求**[ref:1.docx_38]。\n\n所有提供的资料均围绕“**按价位段自选投放**”展开,包括其总体要求、价位段划分、规格选择、其他要求(如公平公正、评估测试、灵活调控、指导沟通)、市场状态评价指标体系等,但**全文未出现“按档位投放”这一概念,也未对其设定任何条款、禁止情形或操作要求**[ref:1.docx_102]。\n\n因此,关于“按档位投放有什么要求”,依据现有【参考资料】,**缺少相关依据**[ref:1.docx_38]。', NULL, 109506, '[{\"image_id\":\"dcbee1500209.jpg\",\"url\":\"/api/image/dcbee1500209.jpg/data\"}]', '2026-06-02 06:02:07', '[{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":3,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (164, 59, 'assistant', '市场状态的计算公式是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '市场状态的计算公式为: [ref:1.docx_80]\n**市场状态 = Σ(二级指标均值 × 权重)**,即对各项二级指标的均值分别乘以其对应权重后加总得出[1][ref:1.docx_90]。\n\n该公式在参考资料中多次明确表述,如:“市场状态=+二级指标均值\\*权重”[1];同时图片1也直观展示了该公式的数学形式:一级指标(ξᵢ)与权重(wᵢ*)的乘积关系,印证了加权计算逻辑[图片1][ref:1.docx_79]。\n\n此外,市场状态评价采用“双层双级”指标体系——“双层”指单品规和品规属性聚类,“双级”指市场/零售终端类一级指标与零售客户订货类二级指标,最终通过定量分析确定“俏、紧、平、松、软”五种状态[3][ref:1.docx_56]。', NULL, 83466, '[{\"image_id\":\"49d2910b148d.jpg\",\"url\":\"/api/image/49d2910b148d.jpg/data\"}]', '2026-06-02 06:03:55', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"2.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":5,\"pages\":\"1\"},{\"docName\":\"需求分析.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (165, 59, 'assistant', '统一货源投放的周期是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '统一货源投放周期为**自然周**[ref:1.docx_24]。\n\n根据参考资料: \n地市公司统一参照**自然周**确定货源投放周期,如遇国家法定节假日,由省公司销售管理处统一调整;两个投放周期之间不能存在交集,且一经执行不得修改[ref:1.docx_24]。地市公司如需调整投放周期,须报省公司销售管理处审核[1][ref:1.docx_24]。\n\n(注:此处引用编号[1]对应参考资料中“(一)统一货源投放周期”部分)[ref:1.docx_24]', NULL, 130165, '[{\"image_id\":\"dcbee1500209.jpg\",\"url\":\"/api/image/dcbee1500209.jpg/data\"}]', '2026-06-02 06:09:19', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":2,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (166, 59, 'assistant', '确定周供应总量的主要依据有哪些', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '根据参考资料,**确定周供应总量的主要依据**如下(严格按资料原文顺序完整列出):[ref:1.docx_43]\n\n1. 地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量[2]; [ref:1.docx_33]\n2. 确定可供货源:下周可供货源量 = 期末库存量 + 本周确定到货量[3]; [ref:1.docx_34]\n3. 当可供货源大于等于下周应销售量时,货源供应总量等于下周应销售量;当可供货源小于下周应销售量时,用其他品规进行替代,替代品规依次为:同价位品规、相邻价位品规、周均销量相同或相近的品规[4][ref:1.docx_34]。\n\n以上三点是资料中明确列出的确定周供应总量的全部依据,无其他条目[ref:1.docx_43]。', NULL, 96896, '[{\"image_id\":\"dcbee1500209.jpg\",\"url\":\"/api/image/dcbee1500209.jpg/data\"}]', '2026-06-02 06:11:50', '[{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":4,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (167, 60, 'assistant', '确定周供应总量的主要依据有哪些', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '根据参考资料,**确定周供应总量的主要依据**在文档中明确列为“(三)确定周供应总量的主要依据”,但资料中**仅完整列出第2条**,其余条目缺失或未提供具体内容。\n\n具体如下:\n\n2. 地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量[ref:货源投放工作规范.docx_33]。[原文重复出现多次,属同一依据项][ref:1.docx_93]\n\n此外,虽未被编号列为“主要依据”的条目,但与周供应总量确定直接相关的**支撑性规则和计算逻辑**还包括:[ref:货源投放工作规范.docx_47]\n\n- 周货源供应总量应根据本月累计销售数量,结合当月、周销售计划完成进度,预排下周应销售量[ref:1.docx_31]。[出自“——应与区域物流分拣配送能力匹配”段落末句] [ref:1.docx_31]\n- 下周可供货源量 = 期末库存量 + 本周确定到货量[ref:货源投放工作规范.docx_34]。[3. 确定可供货源] [ref:货源投放工作规范.docx_34]\n- 当可供货源 ≥ 下周应销售量时,货源供应总量 = 下周应销售量;当可供货源 < 下周应销售量时,采用替代品规(同价位→相邻价位→周均销量相近)进行补充[ref:货源投放工作规范.docx_34]。[4.]\n\n但需特别说明: \n✅ 资料中明确标题为“(三)确定周供应总量的主要依据”的部分,**只提供了第2条内容**; [ref:货源投放工作规范.docx_47]\n❌ **第1条、第3条及后续编号条目在参考资料中缺失**(即无“1.”“3.”等其他 numbered items); \n❌ 也**未见对“主要依据”总数的说明**(如是否共5条、共3条等),无法判断是否遗漏全部或部分[ref:1.docx_43]。\n\n因此,依据所提供资料,**确定周供应总量的主要依据仅有且仅为第2条**,其余依据未在资料中给出[ref:货源投放工作规范.docx_47]。[1] \n\n(注:此处[1]指代资料中所有关于“(三)确定周供应总量的主要依据”的原文位置,因资料未编号引用源,统一视为同一出处)[ref:1.docx_42]', NULL, 114468, '[{\"image_id\":\"dcbee1500209.jpg\",\"url\":\"/api/image/dcbee1500209.jpg/data\"}]', '2026-06-02 07:26:28', '[{\"docName\":\"货源投放工作规范.docx\",\"docType\":\"other\",\"referenceCount\":4,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":5,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (168, 61, 'assistant', '市场状态的计算公式是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '市场状态的计算公式为: [ref:货源投放工作规范.docx_80]\n**市场状态 = 二级指标均值 × 权重** [ref:货源投放工作规范.docx_80]\n\n该公式在参考资料中被多次明确提及,例如: \n> “市场状态=+二级指标均值\\*权重” [多次重复,无独立编号,但属于主题“1.市场状态的计算公式为:”下的核心表述] [ref:货源投放工作规范.docx_80]\n\n此外,资料进一步说明,市场状态评价采用“双层双级”指标体系: [ref:1.docx_56]\n- **双层**:单品规层、品规属性聚类层; [ref:1.docx_56]\n- **双级**:市场和零售终端类的一级指标,以及零售客户订货类的**二级指标**; [ref:1.docx_56]\n而市场状态正是基于这些**二级指标的均值与对应权重的乘积**来计算得出[**\\*\\*市场状态评价指标体系**][ref:货源投放工作规范.docx_90]。\n\n需要注意的是,虽然公式形式简洁(二级指标均值 × 权重),但实际应用中需依据附件2《市场状态评价指标体系》确定具体二级指标(如零售价格指数、终端动销率、社会存销比、订足面等)、各指标权重及评分标准[**\\*\\*市场状态评价指标体系**][ref:1.docx_22]。\n\n综上,参考资料中明确给出了市场状态的通用计算逻辑,即**二级指标均值加权平均**,但未提供完整加权求和的详细展开式(如∑(xᵢ·wᵢ))或具体权重数值表——后者应见于附件2(未在当前资料中呈现)[**\\*\\*市场状态评价指标体系**]。', NULL, 221453, '[{\"image_id\":\"49d2910b148d.jpg\",\"url\":\"/api/image/49d2910b148d.jpg/data\"}]', '2026-06-02 07:54:40', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"货源投放工作规范.docx\",\"docType\":\"other\",\"referenceCount\":3,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":3,\"pages\":\"1\"},{\"docName\":\"需求分析.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (169, 61, 'assistant', '市场状态的计算方式是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '市场状态的计算公式为: [ref:货源投放工作规范.docx_80]\n**市场状态 = +二级指标均值 × 权重** [ref:货源投放工作规范.docx_80]\n\n该公式在参考资料中被多次明确提及,例如: \n> “市场状态=+二级指标均值\\*权重” [ref:货源投放工作规范.docx_80]\n> “主题:1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重” [ref:货源投放工作规范.docx_80]\n\n此外,资料指出该计算属于“双层双级”市场状态评价指标体系中的**二级指标(零售客户订货类)**层面,即通过对零售客户订货相关指标(如订货面、订足面、订单满足率、货源利用率等)取均值后,乘以其对应权重,加总得出市场状态综合得分[1]。\n\n注:一级指标(如市场类指标:零售价格指数、终端动销率、社会存销比等)与二级指标共同构成完整评价体系,但具体加总方式(如是否线性加权求和)在资料中未给出更详细的数学表达式;所列公式明确指向“二级指标均值×权重”这一核心计算环节[1]。\n\n【图片1说明】 \n图片1展示了一个数学表达式:$\\xi_i \\times w_i^*$,表示某一级指标$\\xi_i$与其标准化权重$w_i^*$的乘积[ref:1.docx_79]。该图用于示意指标加权的基本逻辑,与市场状态计算中“指标×权重”的核心思想一致,可作为权重赋值与指标合成的可视化参考(来源:1.docx 第1页)[1][ref:货源投放工作规范.docx_90]。', NULL, 120122, '[{\"image_id\":\"49d2910b148d.jpg\",\"url\":\"/api/image/49d2910b148d.jpg/data\"}]', '2026-06-02 07:58:39', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"货源投放工作规范.docx\",\"docType\":\"other\",\"referenceCount\":3,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":2,\"pages\":\"1\"},{\"docName\":\"需求分析.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"}]'); +INSERT INTO `context_message` VALUES (170, 62, 'assistant', '市场状态的计算方式是什么', 'rag', 1, NULL, '[\"test1\",\"dept_2_kb\",\"dept_3_kb\",\"dept_4_kb\",\"dept_6_kb\"]', '市场状态的计算方式在参考资料中有明确说明,存在两种表述,但核心均为加权计算:[ref:1.docx_90]\n\n1. **基础公式**: [ref:1.docx_80]\n 市场状态 = 二级指标均值 × 权重 [ref:1.docx_80]\n 该公式多次重复出现,如资料开头及多处主题中均指出:“市场状态=+二级指标均值\\*权重”[1][ref:1.docx_80]。\n\n2. **更完整的双层结构公式(含一级指标)**: [ref:1.docx_22]\n 在“单一品规评价”部分明确给出: [ref:1.docx_22]\n **市场状态 = Σ(一级指标 × 权重ₙ) + 二级指标均值 × 权重** [ref:1.docx_80]\n 其中强调“一级指标合计权重不低于70%”,且二级指标权重需与一级指标协同确定;指标值通过历史数据散点图研究确定评分标准,权重可采用专家评分法(由营销管理人员、一线人员、工业企业代表、零售客户代表等共同参与)确定[2]。\n\n此外,市场状态最终按总分划分为五档: [ref:1.docx_22]\n- ≥95分:俏 \n- 85–95分(含):紧 [ref:1.docx_22]\n- 70–85分(含):平 [ref:1.docx_90]\n- 50–70分(含):松 [ref:1.docx_22]\n- ≤50分:软[3][ref:1.docx_62]。\n\n综上,市场状态是基于**一级指标加权和 + 二级指标均值加权**的综合得分,属“双层双级”评价体系中的核心输出结果[4][ref:1.docx_90]。\n\n引用编号说明: \n[1] —— 开篇及多处重复的公式“市场状态=+二级指标均值\\*权重”; [ref:1.docx_80]\n[2] —— “(一)单一品规评价”中关于公式、权重设定与指标值确定的完整描述; [ref:1.docx_79]\n[3] —— “状态评价办法与标准”中分档评分标准; [ref:1.docx_94]\n[4] —— “市场状态评价指标体系”部分对“双层双级”体系的定义[5][ref:1.docx_56]。', NULL, 150685, '[{\"image_id\":\"49d2910b148d.jpg\",\"url\":\"/api/image/49d2910b148d.jpg/data\"}]', '2026-06-02 08:13:17', '[{\"docName\":\"test.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"货源投放工作规范.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"2.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"longrule.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"3.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"},{\"docName\":\"1.docx\",\"docType\":\"other\",\"referenceCount\":8,\"pages\":\"1\"},{\"docName\":\"1.txt\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"9\"},{\"docName\":\"需求分析.docx\",\"docType\":\"other\",\"referenceCount\":1,\"pages\":\"1\"}]'); + +-- ---------------------------- +-- Table structure for context_reference +-- ---------------------------- +DROP TABLE IF EXISTS `context_reference`; +CREATE TABLE `context_reference` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '引用ID', + `message_id` bigint NOT NULL COMMENT '消息ID', + `chunk_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'Chunk唯一ID', + `doc_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件存储路径', + `doc_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件展示名称', + `doc_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档类型:pdf、word、excel', + `page` int NULL DEFAULT NULL COMMENT '起始页码', + `page_end` int NULL DEFAULT NULL COMMENT '结束页码(跨页切片时有值)', + `page_range` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '页码范围显示文本,如 \"5\" 或 \"5-8\"', + `section` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '所属章节路径', + `chunk_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '切片类型:text、table、image', + `section_chunk_id` int NULL DEFAULT NULL COMMENT '章节内段落序号(Word文档语义定位)', + `bbox` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '边界框坐标 [x0,y0,x1,y1](仅PDF)', + `bbox_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '坐标模式:normalized', + `preview` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '内容摘要(用于搜索定位)', + `excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '匹配原文片段', + `score` decimal(5, 4) NULL DEFAULT NULL COMMENT '相关性分数(0-1)', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_message_id`(`message_id` ASC) USING BTREE, + INDEX `idx_chunk_id`(`chunk_id` ASC) USING BTREE, + CONSTRAINT `fk_context_reference_message` FOREIGN KEY (`message_id`) REFERENCES `context_message` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 370 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '上下文引用表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of context_reference +-- ---------------------------- +INSERT INTO `context_reference` VALUES (201, 159, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (202, 159, '', NULL, '3.docx', 'other', 1, 1, '1', '2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**', 'table', NULL, NULL, NULL, NULL, '', 0.9990, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (203, 159, '', NULL, '2.docx', 'other', 1, 1, '1', '第三章 制定文明吸烟环境建设规划 > **第五条** 制定建设规划', 'text', NULL, NULL, NULL, NULL, '', 0.9710, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (204, 159, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (205, 159, '', NULL, 'test.docx', 'other', 1, 1, '1', '第八条 严格比对人脸与证件信息,发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (206, 159, '1.docx_14', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)制定品牌布局规划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (207, 159, '1.docx_25', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(二)制定月度投放计划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (208, 159, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 05:57:10'); +INSERT INTO `context_reference` VALUES (209, 160, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (210, 160, '', NULL, '3.docx', 'other', 1, 1, '1', '2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**', 'table', NULL, NULL, NULL, NULL, '', 0.9990, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (211, 160, '', NULL, '2.docx', 'other', 1, 1, '1', '第三章 制定文明吸烟环境建设规划 > **第五条** 制定建设规划', 'text', NULL, NULL, NULL, NULL, '', 0.9710, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (212, 160, '1.docx_14', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)制定品牌布局规划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (213, 160, '1.docx_25', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(二)制定月度投放计划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (214, 160, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:03:46'); +INSERT INTO `context_reference` VALUES (215, 160, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (216, 160, '', NULL, '3.docx', 'other', 1, 1, '1', '2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**', 'table', NULL, NULL, NULL, NULL, '', 0.9990, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (217, 160, '', NULL, '2.docx', 'other', 1, 1, '1', '第三章 制定文明吸烟环境建设规划 > **第五条** 制定建设规划', 'text', NULL, NULL, NULL, NULL, '', 0.9710, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (218, 160, '3.docx_144', NULL, '3.docx', 'other', 1, 1, NULL, '1.建设标准:新现代终端每户可配备双屏机、扫码设备及“金丝利·通”智慧门店管理系统。 > **(一)加强统一规划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (219, 160, '1.docx_25', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(二)制定月度投放计划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (220, 160, '1.docx_92', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **货源投放结果评估和优化策略**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (221, 160, '1.docx_14', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)制定品牌布局规划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (222, 160, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 06:06:07'); +INSERT INTO `context_reference` VALUES (223, 159, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (224, 159, '', NULL, '3.docx', 'other', 1, 1, '1', '2.诚信守法经营,三年内无违法记录。 > **零售终端日常维护申请表**', 'table', NULL, NULL, NULL, NULL, '', 0.9990, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (225, 159, '', NULL, '2.docx', 'other', 1, 1, '1', '第三章 制定文明吸烟环境建设规划 > **第五条** 制定建设规划', 'text', NULL, NULL, NULL, NULL, '', 0.9710, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (226, 159, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (227, 159, '', NULL, 'test.docx', 'other', 1, 1, '1', '第八条 严格比对人脸与证件信息,发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (228, 159, '2.docx_75', NULL, '2.docx', 'other', 1, 1, NULL, '第三章 制定文明吸烟环境建设规划 > **第五条** 制定建设规划', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (229, 159, '1.docx_25', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(二)制定月度投放计划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (230, 159, '1.docx_14', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)制定品牌布局规划**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (231, 159, '1.docx_56', NULL, '1.docx', 'other', 1, 1, NULL, '3.按价位段自选投放工作指引 > **\\*\\*市场状态评价指标体系**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (232, 159, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-05-30 07:10:12'); +INSERT INTO `context_reference` VALUES (233, 161, '', NULL, '2.docx', 'other', 1, 1, '1', '第七章 附则 > 附件1', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (234, 161, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十七条 系统出现故障、网络中断时,先手工逐人登记,故障恢复后第一时间补录上传,并做好故障登记记录备案。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (235, 161, '', NULL, '3.docx', 'other', 1, 1, '1', '3.积极配合烟草公司开展新现代终端功能发挥。 > 附件3', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (236, 161, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (237, 161, '', NULL, '1.docx', 'other', 1, 1, '1', '5.新品按照新品上市方案实施供应。 > **(三)货源信息公开**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (238, 161, '2.docx_90', NULL, '2.docx', 'other', 1, 1, NULL, '第七章 吸烟场所的档案资料管理 > **第十六条** 拍照存档', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 04:34:29'); +INSERT INTO `context_reference` VALUES (239, 162, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > ——应与区域物流分拣配送能力匹配。', 'text', NULL, NULL, NULL, NULL, '', 0.4210, '2026-06-02 05:57:19'); +INSERT INTO `context_reference` VALUES (240, 162, '1.docx_38', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 05:57:19'); +INSERT INTO `context_reference` VALUES (241, 162, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 05:57:19'); +INSERT INTO `context_reference` VALUES (242, 162, '1.docx_3', NULL, '1.docx', 'other', 1, 1, NULL, '**二、总体要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 05:57:19'); +INSERT INTO `context_reference` VALUES (243, 162, '1.docx_93', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **货源投放结果评估和优化策略**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 05:57:19'); +INSERT INTO `context_reference` VALUES (244, 163, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(一)客户分档**', 'text', NULL, NULL, NULL, NULL, '', 0.9760, '2026-06-02 06:02:07'); +INSERT INTO `context_reference` VALUES (245, 163, '1.docx_38', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:02:07'); +INSERT INTO `context_reference` VALUES (246, 163, '1.docx_102', NULL, '1.docx', 'other', 1, 1, NULL, '2.如果人为设置单品规投放限量过低(如1条),会推高订足面。 > **四、其他要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:02:07'); +INSERT INTO `context_reference` VALUES (247, 164, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (248, 164, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (249, 164, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (250, 164, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '1.心理健康工作将全面提质增效,坚持“健康第一”理念。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (251, 164, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (252, 164, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (253, 164, '1.docx_56', NULL, '1.docx', 'other', 1, 1, NULL, '3.按价位段自选投放工作指引 > **\\*\\*市场状态评价指标体系**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (254, 164, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (255, 164, '1.docx_90', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (256, 164, '1.docx_79', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:03:55'); +INSERT INTO `context_reference` VALUES (257, 165, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 0.9980, '2026-06-02 06:09:19'); +INSERT INTO `context_reference` VALUES (258, 165, '', NULL, '3.docx', 'other', 1, 1, '1', '4.自愿使用“金丝利·通”智慧门店管理系统进行全店铺、全商品的管理,自愿学习并具备熟练操作的能力。 > **“金丝利零售”加盟终端建成验收报告**', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:09:19'); +INSERT INTO `context_reference` VALUES (259, 165, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十五条 严禁泄露、倒卖、私自查阅旅客身份登记信息,保护旅客个人隐私与信息安全。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:09:19'); +INSERT INTO `context_reference` VALUES (260, 165, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '1.心理健康工作将全面提质增效,坚持“健康第一”理念。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:09:19'); +INSERT INTO `context_reference` VALUES (261, 165, '1.docx_24', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(一)统一货源投放周期**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:09:19'); +INSERT INTO `context_reference` VALUES (262, 166, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 0.9980, '2026-06-02 06:11:50'); +INSERT INTO `context_reference` VALUES (263, 166, '1.docx_34', NULL, '1.docx', 'other', 1, 1, NULL, '3.确定可供货源:下周可供货源量=期末库存量+本周确定到货\\*\\*量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:11:50'); +INSERT INTO `context_reference` VALUES (264, 166, '1.docx_33', NULL, '1.docx', 'other', 1, 1, NULL, '2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:11:50'); +INSERT INTO `context_reference` VALUES (265, 166, '1.docx_43', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(四)特殊客户供货**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:11:50'); +INSERT INTO `context_reference` VALUES (266, 164, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (267, 164, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (268, 164, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (269, 164, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '1.心理健康工作将全面提质增效,坚持“健康第一”理念。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (270, 164, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (271, 164, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (272, 164, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (273, 164, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:34:13'); +INSERT INTO `context_reference` VALUES (274, 164, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '1.市场状态的计算公式为:', 'text', NULL, NULL, NULL, NULL, '', 0.0010, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (275, 164, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (276, 164, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (277, 164, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (278, 164, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (279, 164, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (280, 164, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (281, 164, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (282, 164, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (283, 164, '1.docx_90', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (284, 164, '货源投放工作规范.docx_79', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:45:11'); +INSERT INTO `context_reference` VALUES (285, 164, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '1.市场状态的计算公式为:', 'text', NULL, NULL, NULL, NULL, '', 0.0010, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (286, 164, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (287, 164, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (288, 164, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (289, 164, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (290, 164, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (291, 164, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (292, 164, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (293, 164, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (294, 164, '1.docx_90', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (295, 164, '货源投放工作规范.docx_79', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 06:49:20'); +INSERT INTO `context_reference` VALUES (296, 164, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '1.市场状态的计算公式为:', 'text', NULL, NULL, NULL, NULL, '', 0.0010, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (297, 164, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (298, 164, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (299, 164, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (300, 164, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (301, 164, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (302, 164, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (303, 164, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (304, 164, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (305, 164, '1.docx_90', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (306, 164, '货源投放工作规范.docx_79', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:06:39'); +INSERT INTO `context_reference` VALUES (307, 166, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 公式:订货量/投放量\\*100%', 'text', NULL, NULL, NULL, NULL, '', 0.9560, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (308, 166, '', NULL, '1.docx', 'other', 1, 1, '1', '5.新品按照新品上市方案实施供应。 > 附件:1.\\*\\*市场状态评价指标体系', 'text', NULL, NULL, NULL, NULL, '', 0.9970, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (309, 166, '1.docx_39', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (310, 166, '1.docx_32', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(四)确定周可供货源量**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (311, 166, '1.docx_43', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(四)特殊客户供货**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (312, 166, '1.docx_42', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(三)投放限量设定**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (313, 166, '货源投放工作规范.docx_47', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(一)周货源投放策略的建立**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (314, 166, '1.docx_93', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **货源投放结果评估和优化策略**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (315, 166, '货源投放工作规范.docx_33', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (316, 166, '1.docx_31', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > ——应与区域物流分拣配送能力匹配。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (317, 166, '1.docx_16', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(一)货源属性分类**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (318, 166, '货源投放工作规范.docx_26', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (319, 166, '货源投放工作规范.docx_34', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '3.确定可供货源:下周可供货源量=期末库存量+本周确定到货\\*\\*量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:10:06'); +INSERT INTO `context_reference` VALUES (320, 167, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '5.新品按照新品上市方案实施供应。 > **(六)停供与减供**', 'text', NULL, NULL, NULL, NULL, '', 0.9920, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (321, 167, '', NULL, '1.docx', 'other', 1, 1, '1', '5.新品按照新品上市方案实施供应。 > 附件:1.\\*\\*市场状态评价指标体系', 'text', NULL, NULL, NULL, NULL, '', 0.9970, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (322, 167, '1.docx_43', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(四)特殊客户供货**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (323, 167, '货源投放工作规范.docx_47', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(一)周货源投放策略的建立**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (324, 167, '1.docx_93', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **货源投放结果评估和优化策略**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (325, 167, '1.docx_42', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(三)投放限量设定**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (326, 167, '货源投放工作规范.docx_33', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (327, 167, '1.docx_31', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > ——应与区域物流分拣配送能力匹配。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (328, 167, '货源投放工作规范.docx_34', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '3.确定可供货源:下周可供货源量=期末库存量+本周确定到货\\*\\*量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:26:28'); +INSERT INTO `context_reference` VALUES (329, 166, '', NULL, '1.docx', 'other', 1, 1, '1', '3.按价位段自选投放工作指引 > 说明:各品规库存与月销量之间的比值。', 'text', NULL, NULL, NULL, NULL, '', 0.9980, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (330, 166, '1.docx_34', NULL, '1.docx', 'other', 1, 1, NULL, '3.确定可供货源:下周可供货源量=期末库存量+本周确定到货\\*\\*量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (331, 166, '1.docx_43', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(四)特殊客户供货**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (332, 166, '1.docx_42', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(三)投放限量设定**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (333, 166, '1.docx_26', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (334, 166, '1.docx_33', NULL, '1.docx', 'other', 1, 1, NULL, '2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (335, 166, '1.docx_40', NULL, '1.docx', 'other', 1, 1, NULL, '5.新品按照新品上市方案实施供应。 > **(二)货源投放要求**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:47:16'); +INSERT INTO `context_reference` VALUES (336, 168, '', NULL, '1.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (337, 168, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > ——应符合公司商业库存管理要求。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (338, 168, '', NULL, '3.docx', 'other', 1, 1, '1', '3.法律诉讼。若处置过程中产生分歧纠纷无法调解的,由地市公司处理法律诉讼。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (339, 168, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9730, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (340, 168, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (341, 168, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (342, 168, '货源投放工作规范.docx_80', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (343, 168, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (344, 168, '1.docx_56', NULL, '1.docx', 'other', 1, 1, NULL, '3.按价位段自选投放工作指引 > **\\*\\*市场状态评价指标体系**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (345, 168, '货源投放工作规范.docx_90', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:54:40'); +INSERT INTO `context_reference` VALUES (346, 169, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '6.坚持区域协同、高效运作。 > **(三)确定周供应总量的主要依据**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (347, 169, '', NULL, '1.docx', 'other', 1, 1, '1', '3.状态评价办法与标准。 > ①状态评价', 'table', NULL, NULL, NULL, NULL, '', 0.9930, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (348, 169, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9820, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (349, 169, '', NULL, '3.docx', 'other', 1, 1, '1', '第五篇 投入管理 > **第三章 投入标准**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (350, 169, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 0.9990, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (351, 169, '', NULL, 'test.docx', 'other', 1, 1, '1', '第十六条 住宿登记纸质台账、电子记录须妥善留存,\\*\\*保存期限不少于两年\\*\\*,随时接受公安及主管部门检查。', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (352, 169, '货源投放工作规范.docx_80', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (353, 169, '1.docx_79', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (354, 169, '货源投放工作规范.docx_90', NULL, '货源投放工作规范.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 07:58:39'); +INSERT INTO `context_reference` VALUES (355, 170, '', NULL, '1.docx', 'other', 1, 1, '1', '2.地市公司营销中心采供部根据工业企业调运计划预计在途货源中本周可确定到货品种及数量。', 'text', NULL, NULL, NULL, NULL, '', 0.9960, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (356, 170, '', NULL, '1.txt', 'other', 9, 9, '9', '', 'text', NULL, NULL, NULL, NULL, '', 0.0040, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (357, 170, '', NULL, '货源投放工作规范.docx', 'other', 1, 1, '1', '2.指标值与权重确定。 > ①指标值的确定:', 'text', NULL, NULL, NULL, NULL, '', 0.9400, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (358, 170, '', NULL, '需求分析.docx', 'other', 1, 1, '1', '需求分析', 'text', NULL, NULL, NULL, NULL, '', 0.9820, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (359, 170, '', NULL, '3.docx', 'other', 1, 1, '1', '第五篇 投入管理 > **第三章 投入标准**', 'text', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (360, 170, '', NULL, 'test.docx', 'other', 1, 1, '1', '第五条 管理人员负责日常监督检查,定期核查登记台账、系统上传记录,对漏登、错登、代登等问题及时整改追责。', 'text', NULL, NULL, NULL, NULL, '', 0.9990, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (361, 170, '', NULL, 'longrule.docx', 'other', 1, 1, '1', '', 'text', NULL, NULL, NULL, NULL, '', 0.9990, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (362, 170, '', NULL, '2.docx', 'other', 1, 1, '1', '第六章 各类吸烟场所的建设标准 > **B1类:** 大型商场、超市', 'table', NULL, NULL, NULL, NULL, '', 1.0000, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (363, 170, '1.docx_62', NULL, '1.docx', 'other', 1, 1, NULL, '3.按价位段自选投放工作指引 > **3.终端动销率公式。**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (364, 170, '1.docx_94', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > 备注:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (365, 170, '1.docx_22', NULL, '1.docx', 'other', 1, 1, NULL, '6.坚持区域协同、高效运作。 > **3.市场状态指标:**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (366, 170, '1.docx_80', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为: > 市场状态=+二级指标均值\\*权重', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (367, 170, '1.docx_90', NULL, '1.docx', 'other', 1, 1, NULL, '3.状态评价办法与标准。 > **(二)品规属性聚类评价**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (368, 170, '1.docx_56', NULL, '1.docx', 'other', 1, 1, NULL, '3.按价位段自选投放工作指引 > **\\*\\*市场状态评价指标体系**', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); +INSERT INTO `context_reference` VALUES (369, 170, '1.docx_79', NULL, '1.docx', 'other', 1, 1, NULL, '1.市场状态的计算公式为:', NULL, 0, 'null', 'null', '', '', NULL, '2026-06-02 08:13:17'); + +-- ---------------------------- +-- Table structure for data_dict +-- ---------------------------- +DROP TABLE IF EXISTS `data_dict`; +CREATE TABLE `data_dict` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `dict_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '字典类型', + `dict_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '字典编码', + `dict_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '字典名称', + `dict_value` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '字典值', + `parent_id` bigint NULL DEFAULT NULL COMMENT '父级ID', + `sort_order` int NULL DEFAULT 0 COMMENT '排序号', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_dict_type_code`(`dict_type` ASC, `dict_code` ASC) USING BTREE, + INDEX `idx_dict_type`(`dict_type` ASC) USING BTREE, + INDEX `idx_parent_id`(`parent_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '数据字典表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of data_dict +-- ---------------------------- + +-- ---------------------------- +-- Table structure for department +-- ---------------------------- +DROP TABLE IF EXISTS `department`; +CREATE TABLE `department` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '部门 ID', + `dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '部门名称', + `dept_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '部门编码', + `parent_id` bigint NULL DEFAULT NULL COMMENT '父部门 ID', + `dept_level` int NULL DEFAULT NULL COMMENT '部门层级', + `manager_id` bigint NULL DEFAULT NULL COMMENT '部门管理员 ID', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用', + `sort_order` int NULL DEFAULT 0 COMMENT '排序号', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `dept_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'DEPARTMENT' COMMENT '部门类型:HEADQUARTERS-总公司, BRANCH-分公司, DEPARTMENT-部门', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_dept_code`(`dept_code` ASC) USING BTREE, + INDEX `idx_parent_id`(`parent_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `idx_dept_type`(`dept_type` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '部门表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of department +-- ---------------------------- +INSERT INTO `department` VALUES (1, '总公司', 'HEADQUARTERS_MAIN', 0, 1, NULL, 1, 0, '集团总公司', '2026-04-27 09:22:57', '2026-04-27 09:36:53', 'HEADQUARTERS'); +INSERT INTO `department` VALUES (2, '南京分公司', 'NANJING_BRANCH', 1, 2, NULL, 1, 1, '南京地区分公司', '2026-04-27 09:22:57', '2026-04-27 09:36:53', 'BRANCH'); +INSERT INTO `department` VALUES (3, '技术部', 'TECH_DEPT', 2, 3, NULL, 1, 1, NULL, '2026-04-16 09:05:15', '2026-04-27 09:36:53', 'DEPARTMENT'); +INSERT INTO `department` VALUES (4, '采购部门', 'PURCHASE_DEPT', 2, 3, NULL, 1, 2, NULL, '2026-04-16 19:30:00', '2026-04-27 09:36:53', 'DEPARTMENT'); +INSERT INTO `department` VALUES (5, '管理部门', 'MANAGE_DEPT', 2, 3, NULL, 1, 3, NULL, '2026-04-16 19:30:00', '2026-04-27 09:36:53', 'DEPARTMENT'); +INSERT INTO `department` VALUES (6, '人事部门', 'HR_DEPT', 2, 3, NULL, 1, 4, NULL, '2026-04-16 19:30:00', '2026-04-27 09:36:53', 'DEPARTMENT'); +INSERT INTO `department` VALUES (7, '公共部门', 'PUBLIC_DEPT', 2, 3, NULL, 1, 5, '公共资源和信息管理部门', '2026-04-16 19:30:00', '2026-04-27 09:36:53', 'DEPARTMENT'); + +-- ---------------------------- +-- Table structure for dept_admin +-- ---------------------------- +DROP TABLE IF EXISTS `dept_admin`; +CREATE TABLE `dept_admin` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` bigint NOT NULL COMMENT '用户ID(必须是部门管理员)', + `dept_id` bigint NOT NULL COMMENT '管理的部门ID', + `is_primary` tinyint NULL DEFAULT 0 COMMENT '是否主管理员:1 是,0 否', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_dept`(`user_id` ASC, `dept_id` ASC) USING BTREE, + INDEX `idx_dept_id`(`dept_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + CONSTRAINT `fk_dept_admin_dept` FOREIGN KEY (`dept_id`) REFERENCES `department` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_dept_admin_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '部门管理员关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of dept_admin +-- ---------------------------- +INSERT INTO `dept_admin` VALUES (1, 2, 3, 1, 1, NULL, '2026-04-16 09:05:15', '2026-04-16 09:05:15'); +INSERT INTO `dept_admin` VALUES (2, 7, 4, 1, 1, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); +INSERT INTO `dept_admin` VALUES (3, 8, 6, 1, 1, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); +INSERT INTO `dept_admin` VALUES (4, 9, 7, 1, 1, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); + +-- ---------------------------- +-- Table structure for exam_assignment +-- ---------------------------- +DROP TABLE IF EXISTS `exam_assignment`; +CREATE TABLE `exam_assignment` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `paper_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '试卷ID', + `dept_id` bigint NULL DEFAULT NULL COMMENT '部门ID(为空表示全部部门)', + `dept_ids` json NULL COMMENT '部门ID列表(JSON数组,支持多部门选择)', + `user_ids` json NULL COMMENT '指定用户ID列表(JSON数组)', + `exclude_user_ids` json NULL COMMENT '排除用户ID列表(JSON数组)', + `publish_time` datetime NOT NULL COMMENT '发布时间', + `deadline` datetime NULL DEFAULT NULL COMMENT '截止时间', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'active' COMMENT '状态:active-有效,revoked-已撤回', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_paper_id`(`paper_id` ASC) USING BTREE, + INDEX `idx_dept_id`(`dept_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + CONSTRAINT `fk_exam_assignment_dept` FOREIGN KEY (`dept_id`) REFERENCES `department` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT, + CONSTRAINT `fk_exam_assignment_paper` FOREIGN KEY (`paper_id`) REFERENCES `exam_paper` (`paper_id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 55 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '试卷分配表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of exam_assignment +-- ---------------------------- +INSERT INTO `exam_assignment` VALUES (45, '2bd9f7a1-5022-4133-9724-9dd5e25a601c', NULL, '[1]', '[]', '[]', '2026-06-01 13:49:30', NULL, 'active', '2026-06-01 13:49:30', '2026-06-01 13:49:30'); +INSERT INTO `exam_assignment` VALUES (46, '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '[1]', '[]', '[]', '2026-06-01 14:25:52', NULL, 'active', '2026-06-01 14:25:52', '2026-06-01 14:25:52'); +INSERT INTO `exam_assignment` VALUES (47, '72e283f2-683b-4f5b-b76e-39e3047b2ebe', NULL, '[1]', '[]', '[]', '2026-06-01 14:27:08', NULL, 'active', '2026-06-01 14:27:08', '2026-06-01 14:27:08'); +INSERT INTO `exam_assignment` VALUES (48, '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '[1]', '[]', '[]', '2026-06-02 00:09:56', NULL, 'active', '2026-06-02 00:09:56', '2026-06-02 00:09:56'); +INSERT INTO `exam_assignment` VALUES (49, 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', NULL, '[1]', '[]', '[]', '2026-06-02 03:16:17', NULL, 'active', '2026-06-02 03:16:17', '2026-06-02 03:16:17'); +INSERT INTO `exam_assignment` VALUES (50, 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '[1]', '[]', '[]', '2026-06-02 03:16:57', NULL, 'active', '2026-06-02 03:16:57', '2026-06-02 03:16:57'); +INSERT INTO `exam_assignment` VALUES (51, '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '[1]', '[]', '[]', '2026-06-02 04:27:33', NULL, 'active', '2026-06-02 04:27:33', '2026-06-02 04:27:33'); +INSERT INTO `exam_assignment` VALUES (52, '1df188af-647c-457e-9d1e-ea76a2b7fb2f', NULL, '[1]', '[]', '[]', '2026-06-02 05:35:45', NULL, 'active', '2026-06-02 05:35:45', '2026-06-02 05:35:45'); +INSERT INTO `exam_assignment` VALUES (53, 'c281572d-99f7-4e93-b684-590fbaea52e4', NULL, '[1]', '[]', '[]', '2026-06-02 05:38:27', NULL, 'active', '2026-06-02 05:38:27', '2026-06-02 05:38:27'); +INSERT INTO `exam_assignment` VALUES (54, '399ade38-3b02-4e83-8509-d6ea7624c437', NULL, '[1]', '[]', '[]', '2026-06-02 05:51:23', NULL, 'active', '2026-06-02 05:51:23', '2026-06-02 05:51:23'); + +-- ---------------------------- +-- Table structure for exam_paper +-- ---------------------------- +DROP TABLE IF EXISTS `exam_paper`; +CREATE TABLE `exam_paper` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `paper_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '试卷UUID', + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '试卷标题', + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '试卷描述', + `total_score` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '总分', + `duration` int NULL DEFAULT 0 COMMENT '考试时长(分钟),0表示不限时', + `question_count` int NULL DEFAULT 0 COMMENT '题目数量', + `question_ids` json NULL COMMENT '题目ID列表(JSON数组)', + `permission_scope` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '本部门' COMMENT '权限范围:本部门/本部门+本人/公开', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'draft' COMMENT '状态:draft-草稿,published-已发布,closed-已结束', + `created_by` bigint NOT NULL COMMENT '创建人ID', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `publish_time` datetime NULL DEFAULT NULL COMMENT '发布时间', + `deadline` datetime NULL DEFAULT NULL COMMENT '截止时间', + `dept_id` bigint NULL DEFAULT NULL COMMENT '部门ID', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_paper_id`(`paper_id` ASC) USING BTREE, + INDEX `idx_created_by`(`created_by` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `idx_publish_time`(`publish_time` ASC) USING BTREE, + CONSTRAINT `fk_exam_paper_user` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 47 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '试卷基础信息表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of exam_paper +-- ---------------------------- +INSERT INTO `exam_paper` VALUES (35, '2bd9f7a1-5022-4133-9724-9dd5e25a601c', '自动生成的试卷', NULL, 19.00, 120, 4, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"f30a7401-508b-4591-8fb8-46437699e577\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-01 13:49:23', '2026-06-01 13:49:30', '2026-06-01 13:49:30', NULL, 1); +INSERT INTO `exam_paper` VALUES (36, '31304832-7a1f-49e0-9f31-ed723d6d64f0', '自动生成的试卷', NULL, 24.00, 120, 5, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"f30a7401-508b-4591-8fb8-46437699e577\", \"bfbe43d3-fc30-44a8-9b05-7b7520dc20d0\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-01 14:25:48', '2026-06-01 14:25:52', '2026-06-01 14:25:52', NULL, 1); +INSERT INTO `exam_paper` VALUES (37, '72e283f2-683b-4f5b-b76e-39e3047b2ebe', '自动生成的试卷', NULL, 15.00, 120, 3, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-01 14:26:57', '2026-06-01 14:27:08', '2026-06-01 14:27:08', NULL, 1); +INSERT INTO `exam_paper` VALUES (38, '698eb5fa-13fa-4ed0-9831-6ee6934f053e', '自动生成的试卷', NULL, 24.00, 120, 5, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"f30a7401-508b-4591-8fb8-46437699e577\", \"bfbe43d3-fc30-44a8-9b05-7b7520dc20d0\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 00:09:50', '2026-06-02 00:09:56', '2026-06-02 00:09:56', NULL, 1); +INSERT INTO `exam_paper` VALUES (39, 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', '自动生成的试卷', NULL, 15.00, 120, 3, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 03:16:10', '2026-06-02 03:16:17', '2026-06-02 03:16:17', NULL, 1); +INSERT INTO `exam_paper` VALUES (40, 'c5656307-6255-4298-8c4e-82e53d91e379', '自动生成的试卷', NULL, 15.00, 120, 3, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 03:16:52', '2026-06-02 03:16:57', '2026-06-02 03:16:57', NULL, 1); +INSERT INTO `exam_paper` VALUES (41, '036bcb39-7d41-45f8-9fac-0d7899ceb16f', '自动生成的试卷', NULL, 24.00, 120, 5, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"f30a7401-508b-4591-8fb8-46437699e577\", \"bfbe43d3-fc30-44a8-9b05-7b7520dc20d0\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 04:27:24', '2026-06-02 04:27:33', '2026-06-02 04:27:33', NULL, 1); +INSERT INTO `exam_paper` VALUES (42, '1df188af-647c-457e-9d1e-ea76a2b7fb2f', '自动生成的试卷', NULL, 15.00, 120, 3, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 05:35:35', '2026-06-02 05:35:45', '2026-06-02 05:35:45', NULL, 1); +INSERT INTO `exam_paper` VALUES (43, 'c281572d-99f7-4e93-b684-590fbaea52e4', '自动生成的试卷', NULL, 15.00, 120, 3, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 05:38:18', '2026-06-02 05:38:27', '2026-06-02 05:38:27', NULL, 1); +INSERT INTO `exam_paper` VALUES (44, '399ade38-3b02-4e83-8509-d6ea7624c437', '自动生成的试卷', NULL, 19.00, 120, 4, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"f30a7401-508b-4591-8fb8-46437699e577\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'published', 1, '2026-06-02 05:51:06', '2026-06-02 05:51:23', '2026-06-02 05:51:23', NULL, 1); +INSERT INTO `exam_paper` VALUES (45, '377dcf90-4d6c-4530-af91-ba71adc69ea0', '自动生成的试卷', NULL, 30.00, 120, 6, '[\"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8\", \"a774495c-7bb1-448b-926f-a32bf1480836\", \"0831d91a-2e3a-4c9a-9b7a-74160ce4b5b5\", \"5e069067-a10d-4230-9564-e7503d72d738\", \"4d544b15-71c3-4335-ab0b-37b152206389\", \"e6bb2566-90a8-4651-b553-083a1d695b3c\"]', '本部门', 'draft', 1, '2026-06-02 06:28:48', '2026-06-02 06:28:48', NULL, NULL, 1); +INSERT INTO `exam_paper` VALUES (46, '3d1f2744-df2c-413d-8101-80ec70f68e9f', '自动生成的试卷', NULL, 40.00, 120, 8, '[\"dbc7c4dc-785c-4600-a3bd-893c608035dc\", \"fd2b20d1-584f-4a10-8b45-1bb5eff8fe19\", \"5a493eec-933c-4a1c-8c5c-aa7c32fe6736\", \"47384b42-decb-4610-b95e-c157f5f2f9e6\", \"209d3717-8288-47aa-908f-c88caec74c09\", \"ca0df912-a41c-4851-8391-cf4cc1696759\", \"5b99608a-bbdc-4435-9c86-f11e4953bd64\", \"01045a70-2b45-454c-aab5-bcc52b2eccec\"]', '本部门', 'draft', 1, '2026-06-02 06:42:55', '2026-06-02 06:42:55', NULL, NULL, 1); + +-- ---------------------------- +-- Table structure for exam_record +-- ---------------------------- +DROP TABLE IF EXISTS `exam_record`; +CREATE TABLE `exam_record` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `record_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '记录UUID', + `paper_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '试卷ID', + `answer_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'practice' COMMENT '答题类型:formal_exam-正式考试,mock_exam-模拟考,practice-日常练习', + `user_id` bigint NOT NULL COMMENT '用户ID', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'not_started' COMMENT '状态:not_started-未开始,in_progress-作答中,submitted-已提交,graded-已批阅', + `total_score` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '总得分', + `submit_time` datetime NULL DEFAULT NULL COMMENT '提交时间', + `graded_time` datetime NULL DEFAULT NULL COMMENT '批阅时间', + `graded_by` bigint NULL DEFAULT NULL COMMENT '批阅人ID', + `time_spent` int NULL DEFAULT 0 COMMENT '答题耗时(秒)', + `start_time` datetime NULL DEFAULT NULL COMMENT '开始时间', + `last_active_time` datetime NULL DEFAULT NULL COMMENT '最后活跃时间', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_record_id`(`record_id` ASC) USING BTREE, + UNIQUE INDEX `uk_user_paper_type`(`user_id` ASC, `paper_id` ASC, `answer_type` ASC) USING BTREE, + INDEX `idx_paper_id`(`paper_id` ASC) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `idx_exam_record_user_status`(`user_id` ASC, `status` ASC) USING BTREE, + INDEX `idx_exam_record_paper_status`(`paper_id` ASC, `status` ASC) USING BTREE, + INDEX `fk_exam_record_graded_by`(`graded_by` ASC) USING BTREE, + CONSTRAINT `fk_exam_record_graded_by` FOREIGN KEY (`graded_by`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT, + CONSTRAINT `fk_exam_record_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 196 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '考试记录表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of exam_record +-- ---------------------------- +INSERT INTO `exam_record` VALUES (165, '60eb4587-d9df-480b-900d-9e7d6eb8f39a', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', 'formal_exam', 1, 'graded', 0.00, '2026-06-01 14:26:37', '2026-06-01 14:26:37', NULL, 5, '2026-06-01 14:26:31', '2026-06-01 14:26:31', '2026-06-01 13:49:30', '2026-06-01 14:26:37'); +INSERT INTO `exam_record` VALUES (166, 'ace46d6b-62fb-4002-9e16-a3e45c8e0f3e', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 13:49:30', '2026-06-01 13:49:30'); +INSERT INTO `exam_record` VALUES (167, 'cad1aa48-9908-4657-80f1-a2358e921f8e', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 13:49:30', '2026-06-01 13:49:30'); +INSERT INTO `exam_record` VALUES (169, 'c935a703-5ba6-45d0-8e2c-68b0f3ed90be', '31304832-7a1f-49e0-9f31-ed723d6d64f0', 'formal_exam', 1, 'graded', 9.00, '2026-06-01 14:26:25', '2026-06-01 14:26:25', NULL, 4, '2026-06-01 14:26:20', '2026-06-01 14:26:20', '2026-06-01 14:25:52', '2026-06-01 14:26:25'); +INSERT INTO `exam_record` VALUES (170, 'd62513ce-0b62-4e1a-b88e-c94659d05214', '31304832-7a1f-49e0-9f31-ed723d6d64f0', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 14:25:52', '2026-06-01 14:25:52'); +INSERT INTO `exam_record` VALUES (171, '60012fe0-3703-422a-ad35-ac71f6b530b3', '31304832-7a1f-49e0-9f31-ed723d6d64f0', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 14:25:52', '2026-06-01 14:25:52'); +INSERT INTO `exam_record` VALUES (172, '1ba562e2-2f9a-41f5-aa26-89937edbe271', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', 'formal_exam', 1, 'graded', 0.00, '2026-06-01 14:27:23', '2026-06-01 14:27:23', NULL, 9, '2026-06-01 14:27:13', '2026-06-01 14:27:13', '2026-06-01 14:27:08', '2026-06-01 14:27:23'); +INSERT INTO `exam_record` VALUES (173, '22ab0dce-689e-48d9-8c0c-4c19c26aab45', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 14:27:08', '2026-06-01 14:27:08'); +INSERT INTO `exam_record` VALUES (174, '6f023a97-57a2-4329-8041-64e15bd56763', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-01 14:27:08', '2026-06-01 14:27:08'); +INSERT INTO `exam_record` VALUES (175, '1563b009-51f0-4ca9-9155-360c2f193242', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 00:10:02', '2026-06-02 00:10:02', NULL, 4, '2026-06-02 00:09:58', '2026-06-02 00:09:58', '2026-06-02 00:09:56', '2026-06-02 00:10:02'); +INSERT INTO `exam_record` VALUES (176, '69a7a4af-fb07-4dd3-b2e7-933d987484e4', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 00:09:56', '2026-06-02 00:09:56'); +INSERT INTO `exam_record` VALUES (177, 'cb12bd7a-108e-4b4a-8f2b-91483629554d', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 00:09:56', '2026-06-02 00:09:56'); +INSERT INTO `exam_record` VALUES (178, '27e5e21c-8acb-4aa6-9a52-801b7dfb1b84', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 03:16:24', '2026-06-02 03:16:24', NULL, 1, '2026-06-02 03:16:22', '2026-06-02 03:16:22', '2026-06-02 03:16:17', '2026-06-02 03:16:24'); +INSERT INTO `exam_record` VALUES (179, 'cc42f8a7-6f73-49be-ba8c-c0e7411d075e', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 03:16:17', '2026-06-02 03:16:17'); +INSERT INTO `exam_record` VALUES (180, 'e1e5219c-8212-4db7-b7f7-20d3846689e0', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 03:16:17', '2026-06-02 03:16:17'); +INSERT INTO `exam_record` VALUES (181, '4542d0b1-fa18-4177-a4a7-5bfc76ceac7f', 'c5656307-6255-4298-8c4e-82e53d91e379', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 03:17:04', '2026-06-02 03:17:04', NULL, 4, '2026-06-02 03:17:00', '2026-06-02 03:17:00', '2026-06-02 03:16:57', '2026-06-02 03:17:04'); +INSERT INTO `exam_record` VALUES (182, 'e05ab76c-6bf4-48a9-82bc-8e1772f6888c', 'c5656307-6255-4298-8c4e-82e53d91e379', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 03:16:57', '2026-06-02 03:16:57'); +INSERT INTO `exam_record` VALUES (183, 'b261ea2f-ecf2-49eb-aaf7-26b0d7e9697f', 'c5656307-6255-4298-8c4e-82e53d91e379', 'formal_exam', 4, 'graded', 0.00, '2026-06-02 04:29:51', '2026-06-02 04:29:51', NULL, 5, '2026-06-02 04:29:45', '2026-06-02 04:29:45', '2026-06-02 03:16:57', '2026-06-02 04:29:51'); +INSERT INTO `exam_record` VALUES (184, 'e5773b81-462a-418c-81a9-c80e6b7ee04b', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 04:27:52', '2026-06-02 04:27:52', NULL, 15, '2026-06-02 04:27:36', '2026-06-02 04:27:36', '2026-06-02 04:27:33', '2026-06-02 04:27:52'); +INSERT INTO `exam_record` VALUES (185, '9fa36286-b342-492c-a9f6-d7819d435b4d', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 04:27:33', '2026-06-02 04:27:33'); +INSERT INTO `exam_record` VALUES (186, '49a29c7a-96b8-43be-96c1-d239a5606dae', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', 'formal_exam', 4, 'graded', 0.00, '2026-06-02 04:29:32', '2026-06-02 04:29:32', NULL, 34, '2026-06-02 04:28:57', '2026-06-02 04:28:57', '2026-06-02 04:27:33', '2026-06-02 04:29:32'); +INSERT INTO `exam_record` VALUES (187, '985d18f0-383e-45c0-8232-bc38ceafd4a5', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 05:35:52', '2026-06-02 05:35:52', NULL, 4, '2026-06-02 05:35:48', '2026-06-02 05:35:48', '2026-06-02 05:35:45', '2026-06-02 05:35:52'); +INSERT INTO `exam_record` VALUES (188, 'f7c9e8a3-5b74-4288-b6d2-dfc7cfc75cd6', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:35:45', '2026-06-02 05:35:45'); +INSERT INTO `exam_record` VALUES (189, '1e1bc16c-f932-4cc1-b39d-c8403d45b3dc', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:35:45', '2026-06-02 05:35:45'); +INSERT INTO `exam_record` VALUES (190, '3b38596c-e66a-47b7-9dbf-ed6ea02ef9ba', 'c281572d-99f7-4e93-b684-590fbaea52e4', 'formal_exam', 1, 'graded', 0.00, '2026-06-02 05:38:42', '2026-06-02 05:38:42', NULL, 12, '2026-06-02 05:38:30', '2026-06-02 05:38:30', '2026-06-02 05:38:27', '2026-06-02 05:38:42'); +INSERT INTO `exam_record` VALUES (191, 'e311359e-f59b-4499-b6f9-831c88daf159', 'c281572d-99f7-4e93-b684-590fbaea52e4', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:38:27', '2026-06-02 05:38:27'); +INSERT INTO `exam_record` VALUES (192, '2dc1ecf5-33b2-47de-8d06-73d61b9becaf', 'c281572d-99f7-4e93-b684-590fbaea52e4', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:38:27', '2026-06-02 05:38:27'); +INSERT INTO `exam_record` VALUES (193, 'dbeaf205-2133-4fab-80d1-a2059f27c3a2', '399ade38-3b02-4e83-8509-d6ea7624c437', 'formal_exam', 1, 'graded', 5.00, '2026-06-02 05:51:33', '2026-06-02 05:51:33', NULL, 6, '2026-06-02 05:51:26', '2026-06-02 05:51:26', '2026-06-02 05:51:23', '2026-06-02 05:51:33'); +INSERT INTO `exam_record` VALUES (194, 'abe0cb88-cfe3-4078-bf15-c91c91f50a2f', '399ade38-3b02-4e83-8509-d6ea7624c437', 'formal_exam', 3, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:51:23', '2026-06-02 05:51:23'); +INSERT INTO `exam_record` VALUES (195, '22debf42-feb0-46da-902e-4b5133b7be42', '399ade38-3b02-4e83-8509-d6ea7624c437', 'formal_exam', 4, 'not_started', 0.00, NULL, NULL, NULL, 0, NULL, NULL, '2026-06-02 05:51:23', '2026-06-02 05:51:23'); + +-- ---------------------------- +-- Table structure for faq +-- ---------------------------- +DROP TABLE IF EXISTS `faq`; +CREATE TABLE `faq` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `question` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题', + `answer` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '答案', + `avg_rating` decimal(3, 2) NULL DEFAULT 0.00 COMMENT '平均评分', + `frequency` int NULL DEFAULT 0 COMMENT '查询频率', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'pending' COMMENT '状态:pending/approved/rejected', + `source_documents` json NULL COMMENT '来源文档', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + FULLTEXT INDEX `idx_question`(`question`) +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of faq +-- ---------------------------- + +-- ---------------------------- +-- Table structure for faq_suggestion +-- ---------------------------- +DROP TABLE IF EXISTS `faq_suggestion`; +CREATE TABLE `faq_suggestion` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户查询', + `answer` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '建议答案', + `avg_rating` decimal(3, 2) NULL DEFAULT 0.00 COMMENT '平均评分', + `frequency` int NULL DEFAULT 0 COMMENT '出现频率', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'pending' COMMENT '状态:pending/approved/rejected', + `faq_id` bigint NULL DEFAULT NULL COMMENT '关联的FAQ ID(审批通过后)', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `fk_faq_suggestion_faq`(`faq_id` ASC) USING BTREE, + CONSTRAINT `fk_faq_suggestion_faq` FOREIGN KEY (`faq_id`) REFERENCES `faq` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ建议表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of faq_suggestion +-- ---------------------------- + +-- ---------------------------- +-- Table structure for feedback +-- ---------------------------- +DROP TABLE IF EXISTS `feedback`; +CREATE TABLE `feedback` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `feedback_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '反馈唯一标识', + `session_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '会话ID', + `user_id` bigint NULL DEFAULT NULL COMMENT '用户ID', + `query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户问题', + `answer` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '系统回答', + `rating` tinyint NOT NULL COMMENT '评分:1=赞,-1=踩', + `reason` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '反馈原因', + `sources` json NULL COMMENT '来源文档列表', + `faq_suggested` tinyint NULL DEFAULT 0 COMMENT '是否建议为FAQ', + `suggestion_id` bigint NULL DEFAULT NULL COMMENT '关联的FAQ建议ID', + `created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_feedback_id`(`feedback_id` ASC) USING BTREE, + INDEX `idx_session_id`(`session_id` ASC) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_rating`(`rating` ASC) USING BTREE, + INDEX `idx_created_at`(`created_at` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户反馈表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of feedback +-- ---------------------------- +INSERT INTO `feedback` VALUES (1, '7', 'sess_test_001', NULL, '出差补助标准是什么?', '根据公司规定,国内出差补助标准为每天200元。', 1, '回答准确详细', '[\"报销制度.pdf\"]', 0, NULL, '2026-05-06 08:50:59'); +INSERT INTO `feedback` VALUES (2, '8', 'sess_test_001', NULL, '出差补助标准是什么?', '根据公司规定,国内出差补助标准为每天200元。', 1, '回答准确详细', '[\"报销制度.pdf\"]', 0, NULL, '2026-05-06 08:58:38'); + +-- ---------------------------- +-- Table structure for file +-- ---------------------------- +DROP TABLE IF EXISTS `file`; +CREATE TABLE `file` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名', + `file_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件存储路径', + `file_size` bigint NULL DEFAULT NULL COMMENT '文件大小(字节)', + `file_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件类型(MIME类型)', + `extension` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件扩展名', + `file_md5` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件MD5值', + `upload_user_id` bigint NULL DEFAULT NULL COMMENT '上传用户ID', + `dept_id` bigint NULL DEFAULT NULL COMMENT '部门ID', + `dept_ids` json NULL COMMENT '拥有该文件的部门ID列表(JSON数组,支持多部门)', + `is_public` tinyint NULL DEFAULT 1 COMMENT '是否公开:0-私密,1-公开', + `file_category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'personal' COMMENT '文件分类:rule-制度文件,personal-个人文件', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件描述', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `audit_status` tinyint NULL DEFAULT 0 COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝', + `vector_db_address` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '向量库地址', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `process_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'INDEXED' COMMENT '处理状态:PENDING-未入库, INDEXED-已入库, SLICING-正在切片, VECTORIZING-正在向量化, EXPIRED-已过期', + `exam_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'UNGENERATED' COMMENT '题目生成状态:UNGENERATED-未出题, GENERATING-出题中, GENERATED-已有题目, FAILED-出题失败', + `process_step_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'UPLOADED' COMMENT '流程状态: UPLOADED-文件已上传, VECTORIZING-正在向量化, VECTORIZED-向量化完成, VECTORIZE_FAILED-向量化失败, EXAM_GENERATING-正在生成题目, EXAM_GENERATED-题目生成完成, EXAM_GENERATE_FAILED-题目生成失败, COMPLETED-全部流程完成', + `process_step_message` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '流程状态信息', + `process_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '处理信息/错误消息', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_upload_user_id`(`upload_user_id` ASC) USING BTREE, + INDEX `idx_dept_id`(`dept_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `idx_create_time`(`create_time` ASC) USING BTREE, + INDEX `idx_is_public`(`is_public` ASC) USING BTREE, + INDEX `idx_file_category`(`file_category` ASC) USING BTREE, + INDEX `idx_audit_status`(`audit_status` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 69 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文件管理表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of file +-- ---------------------------- +INSERT INTO `file` VALUES (2, 'longrule.docx', 'resources/user/admin/public/longrule/v14/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-05 08:01:36', '2026-05-06 00:23:30', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (3, '15_朱灿文_实验四.docx', 'resources/user/admin/public/15_朱灿文_实验四/v5/15_朱灿文_实验四.docx', 461080, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-05 08:01:55', '2026-05-06 00:23:30', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (4, 'longrule.docx', 'resources/user/admin/public/longrule/v15/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'dept_hr/longrule.docx', '2026-05-05 08:02:00', '2026-05-06 00:30:32', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (5, 'AI绘图技能的应用.pdf', 'resources/user/admin/public/AI绘图技能的应用/v2/AI绘图技能的应用.pdf', 246639, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'public_kb/AI绘图技能的应用.pdf', '2026-05-05 11:58:09', '2026-05-27 08:22:32', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '警告:未生成任何题目。可能原因:文档内容为空、文档内容无法提取题目、或文档格式不支持。source_chunks_used=0'); +INSERT INTO `file` VALUES (6, '汤齐星.pdf', 'resources/user/admin/public/汤齐星/v10/汤齐星.pdf', 300008, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-06 00:09:59', '2026-05-06 00:23:33', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (7, '形势与政策.docx', 'resources/user/admin/public/形势与政策/v2/形势与政策.docx', 42513, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'personal_kb/形势与政策.docx', '2026-05-06 00:16:07', '2026-05-25 07:51:50', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, '向量化成功,耗时 112073 毫秒,生成 null 个切片'); +INSERT INTO `file` VALUES (8, '第8章练习题.pdf', 'resources/user/user/public/第8章练习题/v1/第8章练习题.pdf', 473518, 'application/pdf', '.pdf', NULL, 3, 1, NULL, 1, 'personal', NULL, 1, 1, NULL, '2026-05-06 00:18:48', '2026-05-06 00:49:56', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (9, '设备接入协议-MQTT.docx', 'resources/user/user/public/设备接入协议-MQTT/v1/设备接入协议-MQTT.docx', 702530, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 3, 1, NULL, 1, 'personal', NULL, 1, 1, NULL, '2026-05-06 00:22:22', '2026-05-06 00:49:57', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (10, '参考样例.pdf', 'resources/user/admin/public/参考样例/v1/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-06 00:38:55', '2026-05-07 06:29:38', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (11, '参考样例.pdf', 'resources/rule/总公司/参考样例/v2/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 0, 'rule', NULL, 0, 1, NULL, '2026-05-06 00:49:51', '2026-05-07 06:29:36', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (12, '电商后台1.docx', 'resources/user/user/public/电商后台1/v1/电商后台1.docx', 2891770, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 3, 1, NULL, 1, 'personal', NULL, 0, 1, NULL, '2026-05-07 02:41:44', '2026-05-07 02:43:19', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (13, '1安装node和vuecli.docx', 'resources/user/user/private/1安装node和vuecli/v1/1安装node和vuecli.docx', 777257, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 3, 1, NULL, 0, 'personal', NULL, 1, 1, NULL, '2026-05-07 02:43:45', '2026-05-07 10:43:44', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (14, '电商后台2.docx', 'resources/user/user/public/电商后台2/v1/电商后台2.docx', 2644467, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 3, 1, NULL, 1, 'personal', NULL, 1, 1, NULL, '2026-05-07 02:45:12', '2026-05-07 02:45:56', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (15, '电商后台3.docx', 'resources/user/user/public/电商后台3/v1/电商后台3.docx', 3753517, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 3, 1, NULL, 1, 'personal', NULL, 1, 1, NULL, '2026-05-07 02:46:57', '2026-05-07 02:47:20', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (16, 'test.pdf', 'resources/rule/总公司/南京分公司/人事部门/test/v1/test.pdf', 99261, 'application/pdf', '.pdf', NULL, 1, 6, NULL, 1, 'rule', NULL, 1, 1, 'dept_hr/test.pdf', '2026-05-07 06:25:03', '2026-05-07 06:26:01', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (17, '参考样例.pdf', 'resources/user/user_1/public/参考样例/v9/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'personal', NULL, 0, 1, NULL, '2026-05-07 06:30:15', '2026-05-07 06:50:16', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (18, '参考样例.pdf', 'resources/user/user_1/public/参考样例/v10/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'personal', NULL, 0, 1, NULL, '2026-05-07 06:56:34', '2026-05-13 02:05:18', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (19, '参考样例.pdf', 'resources/user/user_1/public/参考样例/v11/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-07 08:50:14', '2026-05-13 02:05:31', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (20, '参考样例.pdf', 'resources/user/user_1/public/参考样例/v12/参考样例.pdf', 3588003, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-07 09:08:07', '2026-05-13 02:05:30', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (21, '1.docx', 'resources/rule/总公司/南京分公司/公共部门/1/v1/1.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-07 11:00:23', '2026-05-29 10:27:26', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共15道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":7,\"multiple_choice\":6,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含具体规则、分类、步骤,适合单选、多选和填空;无判断性表述,主观题不适用。\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (22, '2.docx', 'resources/rule/总公司/南京分公司/公共部门/2/v1/2.docx', 775988, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-07 11:00:37', '2026-05-07 19:00:36', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (23, '3.docx', 'resources/rule/总公司/南京分公司/公共部门/3/v1/3.docx', 1454500, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-07 11:00:48', '2026-05-20 08:19:03', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共10道题'); +INSERT INTO `file` VALUES (24, '三峡公报_1-15页.pdf', 'resources/rule/总公司/南京分公司/公共部门/三峡公报_1-15页/v2/三峡公报_1-15页.pdf', 2773520, 'application/pdf', '.pdf', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, 'personal_kb/三峡公报_1-15页.pdf', '2026-05-07 11:01:03', '2026-05-21 00:21:37', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共0道题'); +INSERT INTO `file` VALUES (25, '货源投放.docx', 'resources/rule/总公司/南京分公司/公共部门/货源投放/v1/货源投放.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, 'public_kb/货源投放.docx', '2026-05-07 11:01:17', '2026-05-27 02:54:48', 'INDEXED', 'FAILED', 'UPLOADED', NULL, '生成失败: 所有题目保存失败,共5道题'); +INSERT INTO `file` VALUES (26, '住宿.docx', 'resources/rule/总公司/南京分公司/人事部门/住宿/v1/住宿.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 6, NULL, 1, 'rule', NULL, 1, 1, 'dept_hr/住宿.docx', '2026-05-09 02:02:53', '2026-05-09 02:07:10', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (27, '1774100313622.png', 'resources/rule/总公司/1774100313622/v1/1774100313622.png', 3945797, 'image/png', '.png', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-13 01:47:09', '2026-05-13 09:47:09', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (28, 'code.png', 'resources/rule/总公司/code/v1/code.png', 3004, 'image/png', '.png', NULL, 1, 1, NULL, 0, 'personal', NULL, 1, 1, NULL, '2026-05-13 01:47:32', '2026-05-13 09:47:31', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (29, '关于印发《南京工业职业技术大学2026年机构设置与调整方案》的通知.pdf', 'resources/rule/总公司/关于印发《南京工业职业技术大学2026年机构设置与调整方案》的通知/v1/关于印发《南京工业职业技术大学2026年机构设置与调整方案》的通知.pdf', 1016315, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 0, 'personal', NULL, 0, 1, NULL, '2026-05-13 02:04:42', '2026-05-13 02:05:10', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (30, '若依系统安装指南 (1).docx', 'resources/user/user_1/public/若依系统安装指南 (1)/v1/若依系统安装指南 (1).docx', 73872, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 4, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-13 02:06:25', '2026-05-13 02:06:43', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (31, '项目4作业-智能2531-15-张俊豪.pdf', 'resources/rule/总公司/项目4作业-智能2531-15-张俊豪/v1/项目4作业-智能2531-15-张俊豪.pdf', 1989774, 'application/pdf', '.pdf', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-13 06:07:55', '2026-05-13 14:07:55', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (32, '参赛声明签署件.pdf', 'resources/user/user_1/public/参赛声明签署件/v1/参赛声明签署件.pdf', 423200, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-13 06:09:38', '2026-05-13 06:10:08', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (33, '参赛声明签署件.pdf', 'resources/user/user_1/public/参赛声明签署件/v2/参赛声明签署件.pdf', 423200, 'application/pdf', '.pdf', NULL, 4, 1, NULL, 1, 'rule', NULL, 1, 1, 'personal_kb/参赛声明签署件.pdf', '2026-05-13 06:47:37', '2026-05-20 05:00:50', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (34, 'test.docx', 'resources/rule/总公司/南京分公司/公共部门/test/v1/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 7, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-17 07:06:24', '2026-05-17 15:06:24', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (35, 'test.docx', 'resources/rule/总公司/南京分公司/人事部门/test/v2/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 6, NULL, 1, 'rule', NULL, 0, 1, 'dept/test.docx', '2026-05-17 07:08:29', '2026-05-29 10:35:48', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '警告:未生成任何题目。可能原因:文档内容为空、文档内容无法提取题目、或文档格式不支持。source_chunks_used=0'); +INSERT INTO `file` VALUES (36, 'longrule.docx', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 6, NULL, 1, 'rule', NULL, 1, 1, 'public_kb/longrule.docx', '2026-05-20 09:53:50', '2026-05-27 02:16:09', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '向量化成功,耗时 23677 毫秒,生成 null 个切片'); +INSERT INTO `file` VALUES (37, 'start.txt', 'resources/rule/总公司/南京分公司/人事部门/start/v1/start.txt', 2047, 'text/plain', '.txt', NULL, 1, 6, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-20 10:16:49', '2026-05-20 18:16:48', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, NULL); +INSERT INTO `file` VALUES (38, '需求分析.docx', 'resources/rule/总公司/需求分析/v1/需求分析.docx', 15330, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, 'public_kb/需求分析.docx', '2026-05-27 07:58:03', '2026-05-30 14:30:07', 'INDEXED', 'UNGENERATED', 'UPLOADED', NULL, '向量化成功,耗时 21682 毫秒,生成 null 个切片'); +INSERT INTO `file` VALUES (41, 'test.docx', 'resources/rule/总公司/test/v3/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-29 10:30:39', '2026-05-29 10:35:12', 'FAILED', 'UNGENERATED', 'UPLOADED', NULL, '向量化失败: 确保collection存在失败: 创建collection失败: 400 BAD REQUEST: \"{\"error\":\"\\u521b\\u5efa\\u5411\\u91cf\\u5e93\\u5931\\u8d25\"}\" (耗时 163 毫秒)'); +INSERT INTO `file` VALUES (42, 'test.docx', 'resources/rule/总公司/test/v4/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-29 10:35:23', '2026-05-29 10:35:52', 'FAILED', 'UNGENERATED', 'UPLOADED', NULL, '向量化失败: 确保collection存在失败: 创建collection失败: 400 BAD REQUEST: \"{\"error\":\"\\u521b\\u5efa\\u5411\\u91cf\\u5e93\\u5931\\u8d25\"}\" (耗时 174 毫秒)'); +INSERT INTO `file` VALUES (43, 'longrule.docx', 'resources/rule/总公司/南京分公司/longrule/v2/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 1, 1, 'dept_2_kb/longrule.docx', '2026-05-29 10:36:14', '2026-05-29 10:39:08', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共4道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":1,\"multiple_choice\":0,\"single_choice\":2,\"subjective\":0,\"true_false\":1},\"reason\":\"内容政策性强、要点明确,适合单选、判断、填空;无复杂推理,主观题不适用。\",\"suitable_types\":[\"single_choice\",\"true_false\",\"fill_blank\"],\"total_knowledge_points\":4}'); +INSERT INTO `file` VALUES (44, 'test.docx', 'resources/rule/总公司/test/v5/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-29 10:48:32', '2026-05-29 10:48:32', 'FAILED', 'UNGENERATED', 'UPLOADED', NULL, '向量化失败: 确保collection存在失败: 创建collection失败: 400 BAD REQUEST: \"{\"error\":\"\\u521b\\u5efa\\u5411\\u91cf\\u5e93\\u5931\\u8d25\"}\" (耗时 172 毫秒)'); +INSERT INTO `file` VALUES (45, 'test.docx', 'resources/rule/总公司/南京分公司/技术部/test/v1/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 3, NULL, 1, 'rule', NULL, 1, 1, 'dept_3_kb/test.docx', '2026-05-29 10:49:55', '2026-05-29 10:56:06', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共20道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":5,\"multiple_choice\":0,\"single_choice\":8,\"subjective\":0,\"true_false\":7},\"reason\":\"内容以操作规范、原则性条款为主,适合考查记忆与辨析,主观题不适配;填空适配关键术语,单选和判断覆盖核心要点。\",\"suitable_types\":[\"single_choice\",\"true_false\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (46, 'longrule.docx', 'resources/rule/总公司/longrule/v3/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-29 11:04:08', '2026-05-29 11:04:08', 'FAILED', 'UNGENERATED', 'UPLOADED', NULL, '向量化失败: 确保collection存在失败: 创建collection失败: 400 BAD REQUEST: \"{\"error\":\"\\u521b\\u5efa\\u5411\\u91cf\\u5e93\\u5931\\u8d25\"}\" (耗时 314 毫秒)'); +INSERT INTO `file` VALUES (47, 'start.txt', 'resources/rule/总公司/start/v1/start.txt', 2047, 'text/plain', '.txt', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-29 11:05:08', '2026-05-29 11:05:08', 'FAILED', 'UNGENERATED', 'UPLOADED', NULL, '向量化失败: 确保collection存在失败: 创建collection失败: 400 BAD REQUEST: \"{\"error\":\"\\u521b\\u5efa\\u5411\\u91cf\\u5e93\\u5931\\u8d25\"}\" (耗时 143 毫秒)'); +INSERT INTO `file` VALUES (48, '1.docx', 'resources/rule/总公司/1/v1/1.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'test1/1.docx', '2026-05-29 11:16:30', '2026-05-29 11:21:18', 'INDEXED', 'GENERATED', 'UPLOADED', NULL, '生成成功,共11道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":3,\"multiple_choice\":4,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含明确规则、分类、流程,适合单选、多选、填空;无判断性陈述,主观题不适配\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (49, 'longrule.docx', 'resources/rule/总公司/南京分公司/采购部门/longrule/v1/longrule.docx', 14675, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 4, NULL, 1, 'rule', NULL, 1, 1, 'dept_4_kb/longrule.docx', '2026-05-29 12:01:46', '2026-05-29 12:02:22', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共4道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":1,\"multiple_choice\":0,\"single_choice\":2,\"subjective\":0,\"true_false\":1},\"reason\":\"内容政策性强、要点明确,适合单选、判断、填空;无复杂推理,主观题不适宜。\",\"suitable_types\":[\"single_choice\",\"true_false\",\"fill_blank\"],\"total_knowledge_points\":4}'); +INSERT INTO `file` VALUES (50, '作业管理系统测试计划.doc', 'resources/user/user_1/public/作业管理系统测试计划/v1/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 4, 1, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-05-29 13:07:01', '2026-05-29 14:23:07', 'FAILED', 'UNGENERATED', 'VECTORIZE_FAILED', '向量化失败: 文件不存在于服务器', '文件不存在于服务器'); +INSERT INTO `file` VALUES (51, '作业管理系统测试计划.doc', 'resources/user/user_1/public/作业管理系统测试计划/v3/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 4, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-05-29 13:07:58', '2026-06-02 04:24:41', 'FAILED', 'UNGENERATED', 'VECTORIZE_FAILED', '向量化失败: 文件不存在于服务器', '文件不存在于服务器'); +INSERT INTO `file` VALUES (52, '作业管理系统测试计划.doc', 'resources/rule/总公司/作业管理系统测试计划/v1/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, 'test1/作业管理系统测试计划.doc', '2026-05-29 14:04:35', '2026-05-29 14:23:06', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (53, '作业管理系统测试计划.doc', 'resources/rule/总公司/作业管理系统测试计划/v2/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, 'test1/作业管理系统测试计划.doc', '2026-05-29 14:23:15', '2026-05-30 13:17:31', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (54, '作业管理系统测试计划.doc', 'resources/rule/总公司/南京分公司/采购部门/作业管理系统测试计划/v1/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 4, NULL, 1, 'rule', NULL, 0, 1, 'dept_4_kb/作业管理系统测试计划.doc', '2026-05-30 13:17:43', '2026-05-30 13:43:22', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (55, '作业管理系统测试计划.doc', 'resources/rule/总公司/作业管理系统测试计划/v3/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'test1/作业管理系统测试计划.doc', '2026-05-30 13:43:51', '2026-05-30 13:43:53', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (56, '作业管理系统测试计划.doc', 'resources/rule/总公司/作业管理系统测试计划/v4/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'test1/作业管理系统测试计划.doc', '2026-05-30 13:48:43', '2026-05-30 13:54:20', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (57, '作业管理系统测试计划.doc', 'resources/rule/总公司/作业管理系统测试计划/v5/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 1, 1, NULL, 1, 'rule', NULL, 1, 1, 'test1/作业管理系统测试计划.doc', '2026-05-30 13:56:30', '2026-05-30 13:56:32', 'INDEXED', 'FAILED', 'EXAM_GENERATE_FAILED', '题目生成失败: 生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"', '生成失败: 生成题目失败: POST请求失败: 500 INTERNAL SERVER ERROR: \"{\"error_code\":\"EXAM_ERROR\",\"message\":\"AI \\u5206\\u6790\\u540e\\u672a\\u751f\\u6210\\u6709\\u6548\\u9898\\u578b\\u914d\\u7f6e\",\"status\":\"failed\",\"status_code\":5020,\"success\":false}\"'); +INSERT INTO `file` VALUES (58, '需求分析.docx', 'resources/rule/总公司/需求分析/v2/需求分析.docx', 15330, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, 'test1/需求分析.docx', '2026-05-30 14:06:26', '2026-05-30 14:30:07', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共2道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":0,\"multiple_choice\":0,\"single_choice\":0,\"subjective\":1,\"true_false\":1},\"reason\":\"内容空泛无实质知识点,仅可设判断题和简答题各1道\",\"suitable_types\":[\"true_false\",\"subjective\"],\"total_knowledge_points\":0}'); +INSERT INTO `file` VALUES (59, '需求分析.docx', 'resources/rule/总公司/需求分析/v3/需求分析.docx', 15330, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 1, NULL, 1, 'rule', NULL, 0, 1, 'test1/需求分析.docx', '2026-05-30 14:30:32', '2026-05-30 14:44:49', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共2道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":0,\"multiple_choice\":0,\"single_choice\":0,\"subjective\":1,\"true_false\":1},\"reason\":\"内容空泛无实质知识点,仅可设判断题和简答以考察基础概念理解\",\"suitable_types\":[\"true_false\",\"subjective\"],\"total_knowledge_points\":0}'); +INSERT INTO `file` VALUES (60, 'test.docx', 'resources/rule/总公司/南京分公司/人事部门/test/v3/test.docx', 16182, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 6, NULL, 1, 'rule', NULL, 1, 1, 'dept_6_kb/test.docx', '2026-05-30 14:39:28', '2026-05-30 14:41:49', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共19道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":5,\"multiple_choice\":0,\"single_choice\":8,\"subjective\":0,\"true_false\":6},\"reason\":\"内容多为刚性操作规范,侧重记忆与判断,适合单选、判断、填空;无复杂分析或开放场景,主观题不适用\",\"suitable_types\":[\"single_choice\",\"true_false\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (61, '需求分析.docx', 'resources/rule/总公司/南京分公司/采购部门/需求分析/v1/需求分析.docx', 15330, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 4, NULL, 1, 'rule', NULL, 1, 1, 'dept_4_kb/需求分析.docx', '2026-05-30 14:44:58', '2026-05-30 14:45:24', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共2道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":0,\"multiple_choice\":0,\"single_choice\":0,\"subjective\":1,\"true_false\":1},\"reason\":\"内容空泛无实质知识点,仅可设判断题和简答以考察基础概念理解\",\"suitable_types\":[\"true_false\",\"subjective\"],\"total_knowledge_points\":0}'); +INSERT INTO `file` VALUES (62, '作业管理系统测试计划.doc', 'resources/user/user_1/public/作业管理系统测试计划/v2/作业管理系统测试计划.doc', 77824, 'application/msword', '.doc', NULL, 4, 1, NULL, 1, 'rule', NULL, 1, 1, NULL, '2026-06-02 04:24:02', '2026-06-02 04:24:41', 'FAILED', 'UNGENERATED', 'VECTORIZE_FAILED', '向量化失败: 文件不存在于服务器', '文件不存在于服务器'); +INSERT INTO `file` VALUES (63, '1.docx', 'resources/rule/总公司/南京分公司/1/v1/1.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 0, 1, 'dept_2_kb/1.docx', '2026-06-02 06:32:32', '2026-06-02 07:47:17', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共13道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":5,\"multiple_choice\":6,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含政策要点、分类标准、流程步骤,适合客观题;知识点明确但实操性弱,主观题不适宜。\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (64, '货源投放工作规范.docx', 'resources/rule/总公司/南京分公司/货源投放工作规范/v1/货源投放工作规范.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 0, 1, 'dept_2_kb/货源投放工作规范.docx', '2026-06-02 06:37:22', '2026-06-02 07:41:48', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共11道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":3,\"multiple_choice\":4,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含明确规则、分类、流程,适合单选、多选、填空;无判断性陈述,主观题不适配\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (65, '货源投放工作规范.docx', 'resources/rule/总公司/南京分公司/货源投放工作规范/v2/货源投放工作规范.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 0, 1, 'dept_2_kb/货源投放工作规范.docx', '2026-06-02 07:42:07', '2026-06-02 07:50:14', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共11道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":3,\"multiple_choice\":4,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含明确规则、分类、流程,适合单选、多选、填空;无判断性陈述,主观题不适配\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (66, '货源投放工作规范.docx', 'resources/rule/总公司/南京分公司/货源投放工作规范/v3/货源投放工作规范.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 0, 1, NULL, '2026-06-02 07:44:16', '2026-06-02 07:47:16', 'FAILED', 'UNGENERATED', 'VECTORIZE_FAILED', '向量化失败: 文件不存在于服务器', '文件不存在于服务器'); +INSERT INTO `file` VALUES (67, '货源投放工作规范.docx', 'resources/rule/总公司/南京分公司/货源投放工作规范/v4/货源投放工作规范.docx', 483352, 'application/vnd.openxmlformats-officedocument.word', '.docx', NULL, 1, 2, NULL, 1, 'rule', NULL, 1, 1, 'dept_2_kb/货源投放工作规范.docx', '2026-06-02 07:47:54', '2026-06-02 07:55:10', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共11道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":3,\"multiple_choice\":4,\"single_choice\":8,\"subjective\":0,\"true_false\":0},\"reason\":\"内容含明确规则、分类、流程,适合单选、多选、填空;无判断性陈述,主观题不适配\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"fill_blank\"],\"total_knowledge_points\":21}'); +INSERT INTO `file` VALUES (68, '1.txt', 'resources/rule/总公司/南京分公司/1/v2/1.txt', 32544, 'text/plain', '.txt', NULL, 1, 2, NULL, 1, 'rule', NULL, 1, 1, 'dept_2_kb/1.txt', '2026-06-02 08:09:15', '2026-06-02 08:10:53', 'INDEXED', 'GENERATED', 'EXAM_GENERATED', '题目生成完成', '生成成功,共4道题\n\n【AI分析结果】\n{\"question_types\":{\"fill_blank\":0,\"multiple_choice\":1,\"single_choice\":3,\"subjective\":1,\"true_false\":0},\"reason\":\"内容含策略分析与问题判断,适合单选、多选和简答;无明确定义/数值,填空和判断不适用\",\"suitable_types\":[\"single_choice\",\"multiple_choice\",\"subjective\"],\"total_knowledge_points\":5}'); + +-- ---------------------------- +-- Table structure for operation_log +-- ---------------------------- +DROP TABLE IF EXISTS `operation_log`; +CREATE TABLE `operation_log` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志 ID', + `user_id` bigint NULL DEFAULT NULL COMMENT '操作用户 ID', + `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户名', + `operator_type` tinyint NULL DEFAULT NULL COMMENT '操作人类型:1 系统管理员,2 部门管理员,3 普通用户', + `operation_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作类型', + `operation_desc` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作描述', + `target_id` bigint NULL DEFAULT NULL COMMENT '目标 ID', + `target_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '目标类型', + `result` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'SUCCESS' COMMENT '操作结果:SUCCESS, FAIL', + `detail` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '操作详情(JSON格式)', + `ip_address` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'IP 地址', + `user_agent` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户代理', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_operator_type`(`operator_type` ASC) USING BTREE, + INDEX `idx_operation_type`(`operation_type` ASC) USING BTREE, + INDEX `idx_create_time`(`create_time` ASC) USING BTREE, + CONSTRAINT `fk_operation_log_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '操作日志表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of operation_log +-- ---------------------------- + +-- ---------------------------- +-- Table structure for permission +-- ---------------------------- +DROP TABLE IF EXISTS `permission`; +CREATE TABLE `permission` ( + `id` bigint NOT NULL COMMENT '权限ID (=前端permissionTree的key值)', + `uuid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '唯一标识符', + `permission_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '权限名称', + `permission_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '权限编码', + `parent_id` bigint NULL DEFAULT NULL COMMENT '父权限ID', + `type` tinyint NULL DEFAULT 1 COMMENT '1菜单 2按钮 3接口', + `path` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '路由路径', + `icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '图标', + `sort_order` int NULL DEFAULT 0 COMMENT '排序', + `status` tinyint NULL DEFAULT 1 COMMENT '1启用0禁用', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_uuid`(`uuid` ASC) USING BTREE, + UNIQUE INDEX `uk_code`(`permission_code` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '权限表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of permission +-- ---------------------------- +INSERT INTO `permission` VALUES (1, 'k01', '制度文件查看和学习', 'read', NULL, 1, '/read', 'FileTextOutlined', 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (2, 'k02', '制度文件管理', 'manage', NULL, 1, '/manage', 'FolderOutlined', 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (3, 'k03', '知识问答', 'qa', NULL, 1, '/qa', 'MessageOutlined', 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (4, 'k04', '考察和训练', 'exam', NULL, 1, '/exam', 'FileSearchOutlined', 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (5, 'k05', '纲要学习', 'mind', NULL, 1, '/mind', 'UnorderedListOutlined', 5, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (6, 'k06', '综合看板', 'dashboard', NULL, 1, '/dashboard', 'DashboardOutlined', 6, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (7, 'k07', '权限管理', 'permission', NULL, 1, '/permission', 'TeamOutlined', 7, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (8, 'k08', '查看文件列表', 'read:view', 1, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (9, 'k09', '阅读文档', 'read:read', 1, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (10, 'k10', '上传文件', 'read:upload', 1, 2, NULL, NULL, 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (11, 'k11', '下载文件', 'read:download', 1, 2, NULL, NULL, 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (12, 'k12', '收藏功能', 'read:favorite', 1, 2, NULL, NULL, 5, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (13, 'k13', '管理文件', 'manage:file', 2, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (14, 'k14', '知识库同步', 'manage:sync', 2, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (15, 'k15', '题库维护', 'manage:question', 2, 2, NULL, NULL, 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (16, 'k16', '智能对话', 'qa:chat', 3, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (17, 'k17', 'FAQ管理', 'qa:faq', 3, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (18, 'k18', 'AI出题', 'exam:ai', 4, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (19, 'k19', '题库管理', 'exam:bank', 4, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (20, 'k20', '智能组卷', 'exam:paper', 4, 2, NULL, NULL, 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (21, 'k21', '互动训练', 'exam:train', 4, 2, NULL, NULL, 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (22, 'k22', '纲要生成', 'mind:generate', 5, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (23, 'k23', '智能预警', 'dashboard:alert', 6, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (24, 'k24', '决策支持', 'dashboard:decision', 6, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (25, 'k25', '角色管理', 'role', 7, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (26, 'k26', '用户管理', 'user', 7, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (27, 'k27', '互动学习', 'mind:learn', 5, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (33, 'k33', '日志管理', 'log:manage', 6, 1, '/logs', 'FileTextOutlined', 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (34, 'k34', '日志查询', 'log:query', 33, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (35, 'k35', '系统配置', 'config:manage', 6, 1, '/config', 'SettingOutlined', 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (36, 'k36', '配置查询', 'config:query', 35, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (37, 'k37', '配置修改', 'config:update', 35, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (38, 'k38', '用户管理(详细)', 'user:manage', 7, 1, NULL, NULL, 5, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (39, 'k39', '用户查询', 'user:query', 38, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (40, 'k40', '用户创建', 'user:create', 38, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (41, 'k41', '用户修改', 'user:update', 38, 2, NULL, NULL, 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (42, 'k42', '用户删除', 'user:delete', 38, 2, NULL, NULL, 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (43, 'k43', '角色管理(详细)', 'role:manage', 7, 1, NULL, NULL, 6, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (44, 'k44', '角色查询', 'role:query', 43, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (45, 'k45', '角色创建', 'role:create', 43, 2, NULL, NULL, 2, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (46, 'k46', '角色修改', 'role:update', 43, 2, NULL, NULL, 3, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (47, 'k47', '角色删除', 'role:delete', 43, 2, NULL, NULL, 4, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (48, 'k48', '权限管理(详细)', 'permission:manage', 7, 1, NULL, NULL, 7, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (49, 'k49', '权限查询', 'permission:query', 48, 2, NULL, NULL, 1, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (53, 'k53', '超级管理员', 'super_admin', NULL, 1, NULL, NULL, 0, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (54, 'k54', '管理员', 'admin', NULL, 1, NULL, NULL, 0, 1, '2026-04-17 14:17:11', '2026-04-17 14:17:11'); +INSERT INTO `permission` VALUES (55, 'k55', '公司管理', 'company:manage', 7, 1, '/companies', 'BankOutlined', 3, 1, '2026-04-18 11:01:52', '2026-04-18 11:01:52'); +INSERT INTO `permission` VALUES (60, 'k60', '部门管理', 'dept:manage', 7, 1, '/departments', 'ApartmentOutlined', 4, 1, '2026-04-18 11:01:52', '2026-04-18 11:01:52'); +INSERT INTO `permission` VALUES (65, 'k65', '组织管理', 'organization:manage', 7, 1, '/organization', 'ApartmentOutlined', 3, 1, '2026-04-23 00:00:00', '2026-04-23 00:00:00'); +INSERT INTO `permission` VALUES (66, 'k66', '文件审批', 'read:approve', 1, 2, NULL, NULL, 6, 1, '2026-05-02 00:00:00', '2026-05-02 00:00:00'); +INSERT INTO `permission` VALUES (67, 'k67', '试卷考核', 'exam:exam', 4, 2, NULL, NULL, 5, 1, '2026-05-10 21:22:13', '2026-05-10 21:22:13'); +INSERT INTO `permission` VALUES (68, 'k68', '题目管理与审批', 'read:questionBank', 1, 2, NULL, NULL, 7, 1, '2026-05-31 00:00:00', '2026-05-31 00:00:00'); + +-- ---------------------------- +-- Table structure for question +-- ---------------------------- +DROP TABLE IF EXISTS `question`; +CREATE TABLE `question` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `question_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '题目UUID', + `question_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '题目类型:single_choice | multiple_choice | true_false | fill_blank | subjective', + `difficulty` int NULL DEFAULT 3 COMMENT '难度等级', + `tags` json NULL COMMENT '知识点标签', + `score` decimal(5, 2) NOT NULL COMMENT '分值', + `version` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '1.0' COMMENT '版本号', + `document_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '原始文档ID', + `document_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档名称', + `knowledge_base_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '知识库路径,关联knowledge_base_path表的name字段', + `source_context` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT 'AI出题参考的原文切片/上下文', + `chunk_ids` json NULL COMMENT '切片ID列表', + `source_chunks_used` int NULL DEFAULT 0 COMMENT '使用的源切片数量', + `exam_count` int NULL DEFAULT 0 COMMENT '出题次数', + `correct_count` int NULL DEFAULT 0 COMMENT '回答正确次数', + `last_exam_time` datetime NULL DEFAULT NULL COMMENT '最后作答时间', + `page_numbers` json NULL COMMENT '页码', + `content` json NOT NULL COMMENT '题目内容', + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'pending' COMMENT '审核状态:pending | approved | rejected', + `reviewer_comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '审核评论', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_question_id`(`question_id` ASC) USING BTREE, + INDEX `idx_question_type`(`question_type` ASC) USING BTREE, + INDEX `idx_difficulty`(`difficulty` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `idx_created_at`(`created_at` ASC) USING BTREE, + INDEX `idx_knowledge_base_path`(`knowledge_base_path` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 602 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '题目表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of question +-- ---------------------------- +INSERT INTO `question` VALUES (308, '9d05c475-b4fe-4679-a450-ee88a94b3b34', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"扩大终端数量规模\"}, {\"key\": \"B\", \"content\": \"提升终端信息化系统覆盖率\"}, {\"key\": \"C\", \"content\": \"着眼于终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"统一各类终端建设外观标准\"}]}, \"stem\": \"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘要着眼于终端功能发挥和服务水平提升’,这是务求实效原则的出发点和根本目标,直接体现其核心要求。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:45'); +INSERT INTO `question` VALUES (309, 'fa2fc6d7-a30b-4205-8849-1868f0ce52fc', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"终端建设数量最大化\"}, {\"key\": \"B\", \"content\": \"降低单位终端运营成本\"}, {\"key\": \"C\", \"content\": \"终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"统一各区域终端硬件配置规格\"}]}, \"stem\": \"根据文档内容,终端投入标准设定的根本依据应聚焦于哪一核心目标?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准’,这是设定标准的根本依据,对应chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:50'); +INSERT INTO `question` VALUES (310, 'b49f2f89-7216-438a-ad50-c7fadcf4015f', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"强调终端形象统一,强化品牌视觉识别功能\"}, {\"key\": \"B\", \"content\": \"聚焦功能发挥与服务水平提升,避免无效投入,保障六大功能可持续运行\"}, {\"key\": \"C\", \"content\": \"按销量规模划分终端等级,突出销售执行功能\"}, {\"key\": \"D\", \"content\": \"要求配备扫码设备,重点支撑数据采集功能\"}]}, \"stem\": \"根据文档中关于零售终端建设的原则性要求,以下哪一项最直接体现了‘务求实效原则’对零售终端六大功能维度的指导作用?\", \"answer\": \"B\", \"explanation\": \"文档chunk_id:3.docx_125明确指出‘务求实效原则’要‘着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,该原则并非指向某一单项功能,而是统筹保障零售终端六大功能维度整体、可持续、有效落地的核心指导方针;选项B准确概括了该原则对六大功能维度的统领性要求。其他选项均片面聚焦单一功能,未体现‘务求实效’对六大功能整体效能的导向。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:52'); +INSERT INTO `question` VALUES (311, 'ee652f7e-da5e-4129-8766-c113277332f4', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"科学设定终端投入标准\"}, {\"key\": \"B\", \"content\": \"严格执行终端投入标准\"}, {\"key\": \"C\", \"content\": \"优先采购高配置但非必需的智能设备\"}, {\"key\": \"D\", \"content\": \"坚决杜绝不必要的支出和浪费\"}]}, \"stem\": \"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)\", \"answer\": [\"A\", \"B\", \"D\"], \"explanation\": \"文档原文强调‘科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,A、B、D均直接对应;C项违背‘务求实效’,属于典型的形式主义浪费,不符合该原则。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:52'); +INSERT INTO `question` VALUES (312, 'bd300e09-bcdd-4d7e-aa40-6db43f2600b7', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"坚持务求实效原则\"}, {\"key\": \"B\", \"content\": \"以终端建成数量为首要考核指标\"}, {\"key\": \"C\", \"content\": \"科学设定并严格执行标准\"}, {\"key\": \"D\", \"content\": \"坚决杜绝不必要的支出和浪费\"}]}, \"stem\": \"依据文档,设定和执行终端投入标准时必须坚持的原则及要求包括哪些?(可多选)\", \"answer\": [\"A\", \"C\", \"D\"], \"explanation\": \"文档chunk_id:3.docx_125中明确提出‘务求实效原则’,并强调‘科学设定并严格执行’‘坚决杜绝不必要的支出和浪费’,B项在文档中无依据,且与‘务求实效’原则相悖。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:53'); +INSERT INTO `question` VALUES (313, '76913ad8-5325-4f30-a03a-6e19e434b59e', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"务求实效原则允许在终端建设中适度预留冗余预算,以应对未来可能的功能升级需求。\", \"answer\": \"false\", \"explanation\": \"文档明确要求‘坚决杜绝不必要的支出和浪费’,预留非当前功能所需、无明确实施计划的冗余预算,违背‘务求实效’对投入精准性与必要性的刚性约束。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:53'); +INSERT INTO `question` VALUES (314, 'fdc82b86-6bcc-45d3-8df5-8e7f34baaa65', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档指出,终端投入标准的设定只需参考历史投入数据,无需考虑实际功能发挥与服务水平。\", \"answer\": \"false\", \"explanation\": \"文档明确强调‘要着眼于终端功能发挥和服务水平提升’作为设定标准的出发点,否定仅依赖历史数据的片面做法,见chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:54'); +INSERT INTO `question` VALUES (315, '7ae3daef-0345-4852-bd37-336e74498cc4', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 3}, \"stem\": \"务求实效原则强调以___为出发点,通过科学设定和严格执行终端投入标准,实现___与___的双重提升。\", \"answer\": [[\"终端功能发挥和服务水平提升\"], [\"终端功能发挥\"], [\"服务水平提升\"]], \"explanation\": \"原文‘要着眼于终端功能发挥和服务水平提升’构成完整目的表述,后两空为其拆分要素,共同构成该原则的核心落脚点。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:55'); +INSERT INTO `question` VALUES (316, '8ea10928-cdd0-4ea0-be4b-8429361286f9', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"终端投入标准的设定必须坚持___原则,其核心是着眼于___和服务水平提升。\", \"answer\": [[\"务求实效\"], [\"终端功能发挥\"]], \"explanation\": \"原文‘(四)务求实效原则’及后续表述‘要着眼于终端功能发挥和服务水平提升’直接构成填空依据,出自chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:55'); +INSERT INTO `question` VALUES (317, 'ea6ba220-43ff-44d8-8b24-cd350eeb8b53', 'subjective', 3, NULL, 6.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"answer_length_hint\": \"150–200字\"}, \"stem\": \"请结合文档内容,简述‘务求实效原则’在终端建设管理中的具体体现,并说明其与形式主义倾向的根本区别。\", \"answer\": \"务求实效原则具体体现为:以终端功能发挥和服务水平提升为唯一导向,科学设定并严格执行投入标准,杜绝一切不必要的支出和浪费。其核心是结果导向和成本效益意识——每项投入必须可验证、可量化、可服务于实际服务能力提升。与形式主义的根本区别在于:形式主义重‘面子’轻‘里子’,追求表面达标、重复建设或超需配置;而务求实效坚持‘需要什么建什么、能用就好、够用即止’,拒绝脱离实际需求的盲目投入,确保资源真正转化为终端服务效能。\", \"explanation\": \"答案严格依据文档中‘着眼于……提升’‘科学设定并严格执行’‘坚决杜绝……浪费’三重表述展开,从目标、手段、底线三个维度阐释,并通过对比凸显原则的实践刚性。\"}', 'approved', NULL, '2026-05-20 07:28:45', '2026-05-27 09:22:56'); +INSERT INTO `question` VALUES (318, '8bf50c1a-ede7-4489-ae6e-41f2d8c4521b', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"扩大终端建设规模和数量\"}, {\"key\": \"B\", \"content\": \"提升终端信息化系统覆盖率\"}, {\"key\": \"C\", \"content\": \"着眼于终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"统一全国终端装修风格和标识\"}]}, \"stem\": \"根据文档内容,务求实效原则的核心要求聚焦于以下哪一目标?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘务求实效原则’要‘着眼于终端功能发挥和服务水平提升’,这是该原则最直接、最核心的目标表述。其他选项在文档中无依据。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:22:58'); +INSERT INTO `question` VALUES (319, 'a21231c6-505d-4ee5-9b64-4868a4f53724', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"终端建设的硬件配置等级\"}, {\"key\": \"B\", \"content\": \"终端所在地理位置的经济水平\"}, {\"key\": \"C\", \"content\": \"终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"零售户自主申报的投入意愿\"}]}, \"stem\": \"根据文档内容,终端投入标准设定的根本依据应聚焦于以下哪一项?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准’,这是设定终端投入标准的核心依据,对应chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:22:59'); +INSERT INTO `question` VALUES (320, '9a81cc06-a613-4002-ba6d-2960f4998553', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"强调终端形象统一,强化品牌视觉识别功能\"}, {\"key\": \"B\", \"content\": \"聚焦功能发挥与服务水平提升,避免无效投入,保障六大功能可持续运行\"}, {\"key\": \"C\", \"content\": \"按销量规模划分终端等级,突出销售执行功能\"}, {\"key\": \"D\", \"content\": \"要求配备扫码设备,重点支撑数据采集功能\"}]}, \"stem\": \"根据文档中关于零售终端建设的原则性要求,以下哪一项最直接体现了‘务求实效原则’对零售终端六大功能维度的指导作用?\", \"answer\": \"B\", \"explanation\": \"文档chunk_id:3.docx_125明确指出‘务求实效原则’要‘着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,该原则并非指向某一单项功能,而是统筹保障零售终端六大功能维度整体、可持续、有效落地的核心指导思想;选项B准确提炼了这一原则与六大功能维度的关系,其他选项均片面对应单一功能,且无直接文档依据。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:22:59'); +INSERT INTO `question` VALUES (321, '0c54a8cb-0c98-4a1a-8629-c45b95ed75f3', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"科学设定终端投入标准\"}, {\"key\": \"B\", \"content\": \"严格执行终端投入标准\"}, {\"key\": \"C\", \"content\": \"优先采购进口设备以保障质量\"}, {\"key\": \"D\", \"content\": \"坚决杜绝不必要的支出和浪费\"}]}, \"stem\": \"依据文档中关于务求实效原则的表述,下列哪些属于其具体实施要求?(可多选)\", \"answer\": [\"A\", \"B\", \"D\"], \"explanation\": \"文档原文为‘科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,A、B、D三项均直接对应原文;C项‘优先采购进口设备’在文档中完全未提及,属干扰项。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:05'); +INSERT INTO `question` VALUES (322, '6f33cac1-2e4a-44cf-b173-883861df15c9', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"坚持务求实效原则\"}, {\"key\": \"B\", \"content\": \"强调终端外观形象统一性\"}, {\"key\": \"C\", \"content\": \"坚决杜绝不必要的支出和浪费\"}, {\"key\": \"D\", \"content\": \"以终端历史销量为唯一量化依据\"}]}, \"stem\": \"依据文档,设定和执行终端投入标准时,必须坚持的原则及需防范的问题包括哪些?\", \"answer\": [\"A\", \"C\"], \"explanation\": \"文档chunk_id:3.docx_125明确提出‘务求实效原则’及‘坚决杜绝不必要的支出和浪费’,二者共同构成终端投入标准设定与执行的刚性依据;B、D项在文档中无依据。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:13'); +INSERT INTO `question` VALUES (323, '8f32499f-e1cc-4c57-a10b-2e9ae8139582', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"务求实效原则允许在终端建设中适度增加非功能性装饰投入,以增强品牌形象。\", \"answer\": false, \"explanation\": \"文档强调‘坚决杜绝不必要的支出和浪费’,而‘非功能性装饰投入’若不服务于终端功能发挥或服务水平提升,即属‘不必要’范畴,违背该原则。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:06'); +INSERT INTO `question` VALUES (324, 'c08b0485-d54c-4c90-a00e-bbf6d968782f', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档指出,终端投入标准的设定只需考虑建设成本控制,无需关联其实际服务效能。\", \"answer\": false, \"explanation\": \"错误。文档强调‘要着眼于终端功能发挥和服务水平提升’设定标准,说明效能是核心依据,而非仅成本控制;该说法违背‘务求实效原则’,见chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:08'); +INSERT INTO `question` VALUES (325, 'dedf27a6-f8ab-4db6-85df-6253d3b89062', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 3}, \"stem\": \"务求实效原则要求以___为导向,通过科学设定和严格执行终端投入标准,杜绝___和___。\", \"answer\": [[\"终端功能发挥和服务水平提升\"], [\"不必要的支出\"], [\"浪费\"]], \"explanation\": \"原文完整表述为‘要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,三处空格依次对应核心导向与两项禁止性要求。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (326, '7684f577-300a-476b-b4a7-507fcc579f81', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"终端投入标准的设定必须坚持___原则,其根本出发点是提升终端功能发挥和___。\", \"answer\": [[\"务求实效\"], [\"服务水平\"]], \"explanation\": \"原文chunk_id:3.docx_125明确表述为‘(四)务求实效原则’及‘要着眼于终端功能发挥和服务水平提升’,两个空分别对应原则名称和目标维度。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (327, '5071b392-8e37-4da2-9266-ec56d170a1b0', 'subjective', 3, NULL, 6.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', 'Section: Snippet:**(四)务求实效原则**\n**(四)务求实效原则**\n要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。 Page:0', '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"min_words\": 50}, \"stem\": \"请结合文档内容,简述‘务求实效原则’的核心要求及其在终端建设管理中的实践意义。(不少于50字)\", \"answer\": \"核心要求是着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费。其实践意义在于避免资源错配和形式主义,确保每项投入切实转化为服务能力提升,推动终端建设由数量扩张转向质量实效。\"}', 'approved', NULL, '2026-05-20 07:35:51', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (328, '78e4b121-a495-4032-9449-9c6df07b2c26', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"扩大终端数量规模\"}, {\"key\": \"B\", \"content\": \"提升终端信息化系统覆盖率\"}, {\"key\": \"C\", \"content\": \"着眼于终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"统一各类终端建设外观标准\"}]}, \"stem\": \"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘要着眼于终端功能发挥和服务水平提升’,这是务求实效原则的出发点和根本目标,直接体现其核心要求。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (329, '6343d584-da73-4fbd-bc7f-53c8c7d523e1', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"终端建设的硬件配置等级\"}, {\"key\": \"B\", \"content\": \"终端年度销售规模与盈利水平\"}, {\"key\": \"C\", \"content\": \"终端功能发挥和服务水平提升\"}, {\"key\": \"D\", \"content\": \"上级单位下达的预算分配额度\"}]}, \"stem\": \"根据文档内容,设定终端投入标准的根本出发点和核心依据是什么?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘要着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准’,这是设定标准的根本依据,对应chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (330, 'fc1ebceb-8423-44d9-9b13-49198cfade79', 'single_choice', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"强调终端形象统一,强化品牌视觉识别功能\"}, {\"key\": \"B\", \"content\": \"聚焦功能发挥与服务水平提升,科学设定终端投入标准\"}, {\"key\": \"C\", \"content\": \"依据划分标准明确一般现代终端准入门槛\"}, {\"key\": \"D\", \"content\": \"落实主要建成标准,确保硬件设施达标\"}]}, \"stem\": \"根据文档中关于零售终端建设的原则性要求,以下哪一项最直接体现了‘务求实效原则’对零售终端六大功能维度的指导作用?\", \"answer\": \"B\", \"explanation\": \"文档chunk_id:3.docx_125明确指出‘务求实效原则’的核心是‘着眼于终端功能发挥和服务水平提升,科学设定并严格执行终端投入标准’,这直接对应零售终端六大功能维度中‘功能发挥’与‘服务支撑’两大维度的实效导向要求;其他选项虽相关,但未体现该原则对功能维度的统领性指导。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (331, 'd479d548-2633-4091-85da-be75c0bd1504', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"科学设定终端投入标准\"}, {\"key\": \"B\", \"content\": \"严格执行终端投入标准\"}, {\"key\": \"C\", \"content\": \"优先采购高配置但非必需的智能设备\"}, {\"key\": \"D\", \"content\": \"坚决杜绝不必要的支出和浪费\"}]}, \"stem\": \"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)\", \"answer\": [\"A\", \"B\", \"D\"], \"explanation\": \"文档原文强调‘科学设定并严格执行终端投入标准,坚决杜绝不必要的支出和浪费’,A、B、D项均直接对应原文表述;C项违背‘务求实效’,属于典型浪费行为,故排除。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (332, '2c2ff5dc-f959-4446-84aa-3cc4970061fc', 'multiple_choice', 3, NULL, 4.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"坚持务求实效原则\"}, {\"key\": \"B\", \"content\": \"严格执行准入标准\"}, {\"key\": \"C\", \"content\": \"杜绝浪费和不必要支出\"}, {\"key\": \"D\", \"content\": \"依据划分标准确定终端类型\"}]}, \"stem\": \"依据文档,以下哪些原则或要求直接支撑了终端投入标准的科学设定?(可多选)\", \"answer\": [\"A\", \"C\"], \"explanation\": \"文档chunk_id:3.docx_125中明确提出‘务求实效原则’及‘坚决杜绝不必要的支出和浪费’,二者共同构成终端投入标准设定的直接依据;而准入标准、划分标准虽属终端管理框架,但未在文档中被表述为‘投入标准设定依据’。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (333, '650a1934-5d42-4b34-a5f7-24481d46b781', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"务求实效原则允许在终端建设中为追求形式美观而增加非功能性投入。\", \"answer\": \"False\", \"explanation\": \"文档明确要求‘坚决杜绝不必要的支出和浪费’,所有投入必须服务于‘终端功能发挥和服务水平提升’,形式主义投入不符合该原则。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (334, 'a84378e2-b0cf-4620-b628-16d053eb7449', 'true_false', 3, NULL, 1.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档指出,终端投入标准的设定应以预算额度为首要依据,再兼顾功能与服务效果。\", \"answer\": false, \"explanation\": \"文档强调‘着眼于终端功能发挥和服务水平提升’,并将‘务求实效’作为原则,未将预算额度列为首要依据;相反,要求‘坚决杜绝不必要的支出和浪费’,说明预算需服从实效目标,而非相反。依据来自chunk_id:3.docx_125。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (335, '558c6bc4-c02b-4822-807c-4995e9c47191', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 3}, \"stem\": \"务求实效原则强调以___为导向,通过科学设定与严格执行终端投入标准,实现___和___的双重提升。\", \"answer\": [[\"终端功能发挥和服务水平提升\"], [\"终端功能发挥\"], [\"服务水平提升\"]], \"explanation\": \"原文‘要着眼于终端功能发挥和服务水平提升’是原则的总导向,后半句‘科学设定并严格执行……’旨在支撑该导向落地,填空需准确还原原文逻辑结构。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (336, '6193d343-0094-4de6-8f37-54aeabb4460c', 'fill_blank', 3, NULL, 2.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"终端投入标准的设定必须坚持___原则,其核心是着眼于终端___和服务水平提升。\", \"answer\": [[\"务求实效\"], [\"功能发挥\"]], \"explanation\": \"原文出自chunk_id:3.docx_125:‘(四)务求实效原则’及‘要着眼于终端功能发挥和服务水平提升……’,两个空分别对应该原则名称及其核心指向。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (337, '0033b05b-d468-40b5-a183-d7a7ea7141e7', 'subjective', 3, NULL, 6.00, '1.0', '23', 'resources/rule/总公司/南京分公司/公共部门/3/v3/3.docx', 'public_kb', NULL, '[\"3.docx_125\"]', 15, 0, 0, NULL, NULL, '{\"data\": {\"max_points\": 5}, \"stem\": \"请结合文档内容,简述‘务求实效原则’在终端建设管理中的具体体现,并说明其与‘杜绝浪费’之间的内在逻辑关系。\", \"answer\": \"务求实效原则的具体体现包括:①以终端功能发挥和服务水平提升为根本出发点;②科学设定终端投入标准,确保投入精准匹配实际需求;③严格执行标准,防止随意性与超标支出。其与‘杜绝浪费’的内在逻辑在于:只有坚持结果导向(即功能与服务实效),才能识别并剔除无效投入;‘杜绝浪费’是落实实效导向的必然要求和刚性约束,二者构成目标与保障的统一体。\", \"explanation\": \"答案紧扣文档原文‘着眼于终端功能发挥和服务水平提升’‘科学设定并严格执行’‘坚决杜绝……浪费’三要素,阐明目标设定、过程管控与结果约束的闭环逻辑。\"}', 'approved', NULL, '2026-05-20 08:19:02', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (338, '2d80232c-fe87-4c0d-b6b2-c98efc76720d', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"提升艺术展演活动覆盖率\"}, {\"key\": \"B\", \"content\": \"强化对学生每天校内体育活动时间落实的跟踪督导\"}, {\"key\": \"C\", \"content\": \"推动中小学春秋假制度全面落地\"}, {\"key\": \"D\", \"content\": \"配齐配强心理健康教师队伍\"}]}, \"stem\": \"根据文档内容,“晒课表”这一举措主要服务于哪一具体目标?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位’,该表述直接对应选项B;其余选项分别关联艺术展演、春秋假、心理健康师资等不同措施,与‘晒课表’无直接因果关系。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (339, 'c301d358-a1d0-4341-ac99-1ccf69f3fccd', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"减轻教师教学负担,优化学校排课管理\"}, {\"key\": \"B\", \"content\": \"增加假期天数以提升学生家庭旅游消费\"}, {\"key\": \"C\", \"content\": \"支持开展艺术展演、劳动实践、户外拓展等有益活动,培育学生阳光心态、抗挫能力与合作精神\"}, {\"key\": \"D\", \"content\": \"替代寒暑假,缩短学年总教学时长\"}]}, \"stem\": \"根据文档内容,推行中小学春秋假制度的主要目的是什么?\", \"answer\": \"C\", \"explanation\": \"文档第3条明确指出:‘广泛开展艺术展演、劳动实践、户外拓展、读书行动等有益活动,支持有条件地区推行中小学春秋假,着力培育学生阳光心态、抗挫能力与合作精神。’因此C项为唯一符合原文依据的选项。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (340, 'fa9d0ab1-c34f-49a2-bbf2-29606799d9fb', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"将心理健康教师纳入年度绩效考核末位淘汰制\"}, {\"key\": \"B\", \"content\": \"建好用好心理辅导室,并发挥全国心理健康咨询委员会与片区专家组的专业支撑作用\"}, {\"key\": \"C\", \"content\": \"要求所有心理健康教师每学期完成不少于100学时的在线培训\"}, {\"key\": \"D\", \"content\": \"由省级教育部门统一组织心理健康教师资格认证考试\"}]}, \"stem\": \"根据文档要求,为落实“配齐配强心理健康教师”,除加强师资配备外,还应同步推进哪项关键举措?\", \"answer\": \"B\", \"explanation\": \"文档chunk_id:longrule.docx_4明确指出:‘同步配齐配强心理健康教师,建好用好心理辅导室,发挥全国心理健康咨询委员会与片区专家组专业支撑作用……’,B项完整对应原文表述,其他选项在文档中无依据。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (341, '8db4a873-eb5e-4a03-8e62-ee6e9b752bad', 'multiple_choice', 3, NULL, 4.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"是“学生体质强健行动”的重要组成部分\"}, {\"key\": \"B\", \"content\": \"旨在确保学生每天校内体育活动时间落实到位\"}, {\"key\": \"C\", \"content\": \"需与心理健康教师配备工作同步推进\"}, {\"key\": \"D\", \"content\": \"属于校园安全治理能力提升的专项任务\"}]}, \"stem\": \"关于“晒课表”强化体育督导的实施背景与要求,下列哪些说法符合文档原意?\", \"answer\": [\"A\", \"B\"], \"explanation\": \"文档第2条明确‘深入实施学生体质强健行动,通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位’,故A、B正确;C虽在同篇文档出现,但属独立条目(第4条),未与‘晒课表’构成逻辑绑定;D中‘校园安全治理’出自chunk_id:longrule.docx_0,聚焦安全工作联合部署,与体育督导无文本关联。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (342, '1a959505-b82c-4f03-81ea-f2df45816fcd', 'multiple_choice', 3, NULL, 4.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"该制度已在所有中小学全面强制实施\"}, {\"key\": \"B\", \"content\": \"推行前提是‘支持有条件地区’,体现因地制宜原则\"}, {\"key\": \"C\", \"content\": \"其实施与学生体质强健行动、心理健康工作等共同构成学生全面发展支持体系\"}, {\"key\": \"D\", \"content\": \"春秋假被明确列为国家法定节假日之一\"}]}, \"stem\": \"根据文档内容,关于‘推行中小学春秋假制度’,以下哪些说法是正确的?(可多选)\", \"answer\": [\"B\", \"C\"], \"explanation\": \"B项正确——原文‘支持有条件地区推行中小学春秋假’表明非全覆盖、非强制,强调条件性;C项正确——春秋假出现在第3条,紧接第2条‘体质强健行动’、关联第4条‘心理健康工作’及第5条‘家校社协同’,属整体育人体系一环;A项错误,原文用‘支持有条件地区’而非‘全面实施’;D项错误,文档未提及其法定地位。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (343, '896449eb-6ebf-4b10-b969-05d6edf99831', 'true_false', 3, NULL, 1.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"“晒课表”是为加强艺术展演活动管理而采取的督导方式。\", \"answer\": false, \"explanation\": \"文档中‘晒课表’仅出现在第2条,明确限定其功能是‘强化跟踪督导’以保障‘每天校内体育活动时间落实到位’;艺术展演在第3条单独提出,未与‘晒课表’建立任何联系。因此该说法错误。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (344, '5ba3b276-910a-4493-b297-f1097d15c62d', 'true_false', 3, NULL, 1.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档明确指出,推行中小学春秋假制度是为了直接提升学生学业成绩。\", \"answer\": false, \"explanation\": \"文档第3条将春秋假与‘艺术展演、劳动实践、户外拓展、读书行动’等并列,目标是‘培育学生阳光心态、抗挫能力与合作精神’,聚焦学生身心健康与综合素养,未提及学业成绩,故该说法错误。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (345, '5ff19c6d-76bf-4c1a-8aab-c70b7acfb9c2', 'fill_blank', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"教育部通过___方式强化对学生每天校内体育活动时间落实情况的跟踪督导,这是深入实施___行动的关键举措。\", \"answer\": [[\"晒课表\"], [\"学生体质强健\"]], \"explanation\": \"原文‘深入实施学生体质强健行动,通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位’直接提供两个空的答案,语序与逻辑完全匹配。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:22'); +INSERT INTO `question` VALUES (346, 'f163589e-f403-45aa-9893-aff8fe5a4d42', 'fill_blank', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"文档提出,支持有条件地区推行中小学春秋假,旨在配合开展___、___、户外拓展、读书行动等有益活动,着力培育学生阳光心态、抗挫能力与合作精神。\", \"answer\": [[\"艺术展演\"], [\"劳动实践\"]], \"explanation\": \"原文第3条原句为:‘广泛开展艺术展演、劳动实践、户外拓展、读书行动等有益活动……’,两个空对应前两项活动,顺序不可颠倒。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:23'); +INSERT INTO `question` VALUES (347, '77e1e5b7-ae3f-4e4c-9bf9-f9676f8e598d', 'subjective', 3, NULL, 6.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\", \"longrule.docx_1\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"min_words\": 80, \"max_points\": 5}, \"stem\": \"请结合文档内容,简述“晒课表”在体育督导中的实践意义,并说明其如何体现‘健康第一’理念。\", \"answer\": \"‘晒课表’将学校体育课程安排公开化、可视化,便于教育行政部门、家长及社会监督体育课时是否足额开设、活动时间是否真实落实,从而形成刚性约束和过程性督导机制。它直指‘每天校内体育活动时间落实到位’这一量化目标,是学生体质强健行动落地的关键抓手。该举措以可核查的课表为载体,将抽象的健康承诺转化为具体教学行为,切实保障学生体育权益,正是‘健康第一’理念从口号走向制度化、常态化实践的典型体现。\", \"explanation\": \"答案紧扣文档中‘晒课表’与‘学生体质强健行动’‘每天校内体育活动时间’的三重逻辑关系,并自然衔接第1条提出的‘坚持‘健康第一’理念’,强调其制度转化价值,符合字数与要点要求。\"}', 'approved', NULL, '2026-05-20 09:58:03', '2026-05-27 09:23:23'); +INSERT INTO `question` VALUES (378, '1cb16538-840c-41f6-849b-cd676a0d183c', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"学生心理健康提质增效行动\"}, {\"key\": \"B\", \"content\": \"学生体质强健行动\"}, {\"key\": \"C\", \"content\": \"家校社协同护航体系建设\"}, {\"key\": \"D\", \"content\": \"校园安全治理能力提升行动\"}]}, \"stem\": \"根据文档内容,“晒课表”这一举措主要服务于哪一教育行动?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:‘深入实施学生体质强健行动,通过“晒课表”强化跟踪督导……’,说明‘晒课表’是服务于学生体质强健行动的具体督导手段。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:17'); +INSERT INTO `question` VALUES (379, '41403155-16bf-4ab7-9b65-08f8b90be80b', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"减轻教师教学负担,优化学校排课机制\"}, {\"key\": \"B\", \"content\": \"增加假期天数以提升学生家庭旅游消费\"}, {\"key\": \"C\", \"content\": \"着力培育学生阳光心态、抗挫能力与合作精神\"}, {\"key\": \"D\", \"content\": \"替代寒暑假,实现学年教学时间重新分配\"}]}, \"stem\": \"根据文档内容,推行中小学春秋假制度的主要目的是什么?\", \"answer\": \"C\", \"explanation\": \"文档第3条明确指出:‘支持有条件地区推行中小学春秋假,着力培育学生阳光心态、抗挫能力与合作精神’,这是其直接目的。其他选项在文档中无依据。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:17'); +INSERT INTO `question` VALUES (380, 'e0418ad3-1693-42b3-a83d-88bec4f77fe1', 'single_choice', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"将学生身心健康纳入学校年度绩效工资分配方案\"}, {\"key\": \"B\", \"content\": \"建好用好心理辅导室,并发挥全国心理健康咨询委员会与片区专家组的专业支撑作用\"}, {\"key\": \"C\", \"content\": \"全面取消中小学书面家庭作业,减轻学业压力\"}, {\"key\": \"D\", \"content\": \"每学期组织一次全校性心理健康普查并向社会公开结果\"}]}, \"stem\": \"根据文档要求,为落实‘配齐配强心理健康教师’这一举措,除加强师资配备外,还应同步推进哪项关键支撑性工作?\", \"answer\": \"B\", \"explanation\": \"文档第4条明确指出:‘同步配齐配强心理健康教师,建好用好心理辅导室,发挥全国心理健康咨询委员会与片区专家组专业支撑作用……’,B项完整对应原文表述,是配齐配强师资的配套支撑措施;其余选项在文档中无依据。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:17'); +INSERT INTO `question` VALUES (381, 'd2954a04-9b39-455f-a852-77300c08eb9b', 'multiple_choice', 3, NULL, 4.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"其目的是确保学生每天校内体育活动时间落实到位\"}, {\"key\": \"B\", \"content\": \"属于心理健康工作提质增效的组成部分\"}, {\"key\": \"C\", \"content\": \"是跟踪督导的一种具体方式\"}, {\"key\": \"D\", \"content\": \"与艺术展演、劳动实践等活动同属‘广泛开展’类举措\"}]}, \"stem\": \"关于“晒课表”强化体育督导,以下哪些说法符合文档原意?\", \"answer\": [\"A\", \"C\"], \"explanation\": \"文档中‘晒课表’仅出现在‘学生体质强健行动’语境下,明确关联‘确保学生每天校内体育活动时间落实到位’(A正确)和‘强化跟踪督导’(C正确);B错误,心理健康工作另起段落且未提晒课表;D错误,艺术展演等属第3条独立举措,与晒课表无逻辑并列关系。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:18'); +INSERT INTO `question` VALUES (382, '58ae2cdf-966c-47e2-b3de-6baf7a26544f', 'multiple_choice', 3, NULL, 4.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"深入实施学生体质强健行动,确保每天校内体育活动时间落实到位\"}, {\"key\": \"B\", \"content\": \"广泛开展艺术展演、劳动实践、户外拓展、读书行动等有益活动\"}, {\"key\": \"C\", \"content\": \"同步配齐配强心理健康教师,建好用好心理辅导室\"}, {\"key\": \"D\", \"content\": \"将学生身心健康纳入学校考核与教育督导重点\"}]}, \"stem\": \"根据文档内容,下列哪些举措与‘推行中小学春秋假制度’具有相同育人导向?(可多选)\", \"answer\": [\"B\"], \"explanation\": \"文档第3条将‘推行中小学春秋假’与‘广泛开展艺术展演、劳动实践、户外拓展、读书行动等有益活动’并列提出,同属‘培育学生阳光心态、抗挫能力与合作精神’的路径,育人导向一致;其余选项虽属身心健康范畴,但未与春秋假在同一逻辑层级或句式中关联,不属于相同举措导向。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:19'); +INSERT INTO `question` VALUES (383, 'ca0e1e06-281d-461f-91d6-2c9ebf57a248', 'true_false', 3, NULL, 1.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档指出,“晒课表”旨在推动中小学春秋假制度的实施。\", \"answer\": false, \"explanation\": \"文档中‘晒课表’出现在第2条,对应‘学生体质强健行动’;而‘推行中小学春秋假’出现在第3条,属于‘广泛开展艺术展演、劳动实践……’等有益活动范畴,二者无因果或目的关系。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:21'); +INSERT INTO `question` VALUES (384, '56daf062-5c93-453b-bb35-d03ea63df86c', 'true_false', 3, NULL, 1.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"文档明确要求全国所有中小学必须统一实行春秋假制度。\", \"answer\": false, \"explanation\": \"文档第3条表述为‘支持有条件地区推行中小学春秋假’,强调‘支持’和‘有条件’,属于鼓励性、非强制性政策导向,并未要求‘全国统一实行’。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:25'); +INSERT INTO `question` VALUES (385, 'ced243c8-7513-4917-8308-24d1aa0929dd', 'fill_blank', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"教育部通过“___”方式强化体育跟踪督导,以保障学生每天___落实到位。\", \"answer\": [[\"晒课表\"], [\"校内体育活动时间\"]], \"explanation\": \"原文直接表述为:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位。’\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:26'); +INSERT INTO `question` VALUES (386, '6acc2616-be5e-499d-8ade-0a44cb18c1f9', 'fill_blank', 3, NULL, 2.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"文档提出,要支持___地区推行中小学春秋假,其育人目标聚焦于培育学生的___、抗挫能力与合作精神。\", \"answer\": [[\"有条件\"], [\"阳光心态\"]], \"explanation\": \"原文第3条原文为:‘支持有条件地区推行中小学春秋假,着力培育学生阳光心态、抗挫能力与合作精神’,两处填空均可直接对应原文关键词。\"}', 'approved', '', '2026-05-27 02:12:25', '2026-05-27 13:08:26'); +INSERT INTO `question` VALUES (387, '45fd96bd-c757-4cc1-af41-b39b9dbd1c30', 'subjective', 3, NULL, 6.00, '1.0', '36', 'resources/rule/总公司/南京分公司/人事部门/longrule/v1/longrule.docx', 'public_kb', NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"max_points\": 4}, \"stem\": \"请结合文档内容,简要说明“晒课表”在体育督导中的功能定位及其政策目标。\", \"answer\": \"‘晒课表’是落实学生体质强健行动的一项过程性督导机制,其功能定位在于通过公开透明的方式强化跟踪督导;政策目标是确保学生每天校内体育活动时间足额、保质落实到位。\", \"explanation\": \"依据文档第2条,‘晒课表’被明确界定为‘强化跟踪督导’的手段,服务于‘学生体质强健行动’,核心目标是保障‘每天校内体育活动时间落实到位’,体现过程监管与结果导向相结合的治理思路。\"}', 'approved', '题目审核通过', '2026-05-27 02:12:25', '2026-05-27 11:49:32'); +INSERT INTO `question` VALUES (388, 'b01f2ae1-f562-4185-8eff-d3dd1a16f015', 'single_choice', 3, NULL, 2.00, '1.0', '需求分析.docx', '需求分析.docx', 'public_kb', NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"编写可运行的程序代码\"}, {\"key\": \"B\", \"content\": \"确定系统‘做什么’,而非‘怎么做’\"}, {\"key\": \"C\", \"content\": \"进行系统性能压力测试\"}, {\"key\": \"D\", \"content\": \"完成数据库物理建模\"}]}, \"stem\": \"在软件工程中,需求分析阶段的核心目标是:\", \"answer\": \"B\", \"explanation\": \"需求分析的本质是理解并明确用户和系统的功能与非功能需求,聚焦于‘系统应该做什么’,不涉及实现细节(即‘怎么做’)。文档标题‘需求分析’直接指向该核心定义。\"}', 'approved', '', '2026-05-27 08:03:26', '2026-05-27 16:07:26'); +INSERT INTO `question` VALUES (389, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 3, NULL, 4.00, '1.0', '需求分析.docx', '需求分析.docx', 'public_kb', NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"与用户访谈并记录业务场景\"}, {\"key\": \"B\", \"content\": \"绘制用例图和需求规格说明书初稿\"}, {\"key\": \"C\", \"content\": \"设计数据库表结构并建立外键约束\"}, {\"key\": \"D\", \"content\": \"验证需求的一致性、完整性与可测试性\"}]}, \"stem\": \"以下哪些活动属于需求分析阶段的典型工作?(可多选)\", \"answer\": [\"A\", \"B\", \"D\"], \"explanation\": \"需求分析阶段包括需求获取(如访谈)、需求建模(如用例图)、需求规格化(如编写需求文档)及需求验证;而数据库表结构设计属于系统设计阶段,不在需求分析范畴。文档标题‘需求分析’作为唯一内容,确立了该阶段的边界与核心任务。\"}', 'approved', '', '2026-05-27 08:03:26', '2026-05-27 16:07:27'); +INSERT INTO `question` VALUES (390, '4efaa36e-0cdb-43cc-bfc7-ae4c8513ac79', 'true_false', 3, NULL, 1.00, '1.0', '需求分析.docx', '需求分析.docx', 'public_kb', NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"需求分析只需在项目初期开展一次,后续无需迭代或确认。\", \"answer\": false, \"explanation\": \"需求分析是一个持续、迭代的过程,需在开发过程中不断与用户沟通、反馈和修正。尽管文档仅出现标题‘需求分析’,但其作为独立知识模块被强调,隐含其作为基础且动态的关键环节,而非一次性活动。\"}', 'approved', '', '2026-05-27 08:03:26', '2026-05-27 16:07:28'); +INSERT INTO `question` VALUES (391, '67f63872-0ff6-4442-b978-8dd72e3eab71', 'fill_blank', 3, NULL, 2.00, '1.0', '需求分析.docx', '需求分析.docx', 'public_kb', NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"需求分析是软件开发生命周期中的关键阶段,其主要任务是准确识别、分析、___和___用户及系统的各项需求。\", \"answer\": [[\"规格化\"], [\"验证\"]], \"explanation\": \"标准需求分析流程包含识别、分析、规格化(形成SRS)和验证四大活动。文档虽仅列标题,但‘需求分析’作为专业术语,其公认内涵即涵盖这些核心动作。\"}', 'approved', '', '2026-05-27 08:03:26', '2026-05-27 16:07:30'); +INSERT INTO `question` VALUES (392, 'f07a219b-7ecd-400c-8437-2bb4a93ccf3f', 'subjective', 3, NULL, 6.00, '1.0', '需求分析.docx', '需求分析.docx', 'public_kb', NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": {}, \"stem\": \"请简述需求分析在整个软件开发过程中的作用,并说明若该阶段工作不到位可能引发的两类典型风险。\", \"answer\": \"需求分析是连接用户与开发团队的桥梁,其作用在于准确定义系统范围、功能行为与约束条件,为设计、实现和测试提供依据。若该阶段不到位,可能引发:① 需求误解导致开发成果与用户期望严重偏离(功能错位);② 需求遗漏或模糊引发频繁返工、进度延误与成本超支。\", \"explanation\": \"需求分析作为起始关键环节,决定后续所有工作的正确性基础。文档以‘需求分析’为唯一标题,凸显其不可替代的奠基性地位;其失效后果在工程实践中具有高度共识。\"}', 'approved', '', '2026-05-27 08:03:26', '2026-05-27 16:07:33'); +INSERT INTO `question` VALUES (473, 'c3c893ac-c95c-48fc-8e14-df7d9685799b', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档,零售价格指数的正确计算公式是?', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(零售指导价格 - 实际零售价格)/ 零售指导价格 × 100%\"}, {\"key\": \"B\", \"content\": \"实际零售价格 / 零售指导价格 × 100%\"}, {\"key\": \"C\", \"content\": \"零售指导价格 / 实际零售价格 × 100%\"}, {\"key\": \"D\", \"content\": \"实际零售价格 × 零售指导价格 × 100%\"}]}, \"stem\": \"根据文档,零售价格指数的正确计算公式是?\", \"answer\": \"B\", \"explanation\": \"文档明确给出公式:实际零售价格/零售指导价格*100%,对应选项B。其他选项均与原文不符。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (474, 'ea199f2e-26b4-4ff9-9686-24a89ddcd6fe', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、新品上市时间\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、均衡满足比例、完全满足品规数、社会库存\"}, {\"key\": \"D\", \"content\": \"社会存销比、新品销售时长、订单满足率、顺销品规数\"}]}, \"stem\": \"根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:紧俏品规应依据‘零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率’等指标自动测评确定。选项B中四项(市场流通价格、社会存销比、订货面、订足面)均在原文所列指标范围内;A中‘新品上市时间’属‘新品’定义范畴,非紧俏判定指标;C中‘均衡满足比例’‘完全满足品规数’属货源属性分类结果而非判定指标;D中‘新品销售时长’‘顺销品规数’亦非紧俏品规判定依据。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (475, '618b46c8-dda3-471a-b4ab-aefb61e9d75e', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档内容,‘单客户月度投放限量’的准确定义是:', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内对某一档级客户所有品规的总供货次数上限\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据文档内容,‘单客户月度投放限量’的准确定义是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘单客户月度投放限量:公司对单客户单月最高供货量。’选项C与原文完全一致;A对应的是‘单客户单次单品规投放限量’,D对应‘投放频次’,B在文档中无依据。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (476, '9808e072-434d-41e9-a187-d0d95a3aea16', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据《按价位段自选投放工作指引》,某品规的重购率计算公式为:', NULL, NULL, '[\"1.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"本周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"B\", \"content\": \"品规连续两周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"C\", \"content\": \"连续两周重复订货客户数 / 本周订货客户数 × 100%\"}, {\"key\": \"D\", \"content\": \"上周订货客户数 / 品规连续两周订货客户数 × 100%\"}]}, \"stem\": \"根据《按价位段自选投放工作指引》,某品规的重购率计算公式为:\", \"answer\": \"B\", \"explanation\": \"依据文档chunk_id:1.docx_75,重购率公式明确为‘品规连续两周订货客户数/上周订货客户数×100%’,强调‘连续两周订货’的客户作为分子,分母为‘上周订货客户数’,反映持续需求程度。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (477, '68dae967-9ecc-432e-8946-2f2c4f5b7be6', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档规定,一级指标合计权重的最低要求是多少?', NULL, NULL, '[\"1.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据文档规定,一级指标合计权重的最低要求是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该表述直接对应一级指标权重下限要求,是本知识点的核心依据。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (478, '4f45464e-7018-47ff-9b20-efc0b5184636', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档内容,市场状态被划分为哪五种类型?', NULL, NULL, '[\"1.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、畅、稳、滞、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、缓、急\"}, {\"key\": \"D\", \"content\": \"热、温、常、冷、寒\"}]}, \"stem\": \"根据文档内容,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接来源于chunk_id:1.docx_22。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (479, 'fd665936-189c-4403-9adc-774d71198b9a', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档内容,下列哪一项不属于品规聚类评价所依据的属性?', NULL, NULL, '[\"1.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"市场销量绝对值\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据文档内容,下列哪一项不属于品规聚类评价所依据的属性?\", \"answer\": \"C\", \"explanation\": \"文档明确指出品规聚类评价可基于‘价位段、价类、创新品类和所属工业企业、品牌等属性’进行,未将‘市场销量绝对值’列为聚类依据属性;销量仅用于后续加权平均计算,而非聚类本身的属性维度。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (480, '5a2bb37b-6501-456b-b91d-efdae9b84567', 'single_choice', 3, NULL, 5.00, '1.0', '21', '根据文档规定,每个投放周期各价位段参与自选投放的规格数量下限是多少?', NULL, NULL, '[\"1.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不少于1个\"}, {\"key\": \"B\", \"content\": \"不少于2个\"}, {\"key\": \"C\", \"content\": \"不少于3个\"}, {\"key\": \"D\", \"content\": \"不少于5个\"}]}, \"stem\": \"根据文档规定,每个投放周期各价位段参与自选投放的规格数量下限是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:\'每个投放周期各价位段参与自选投放的规格不得少于3个。\'这是对自选投放规格数量下限的直接规定,对应知识点【自选投放规格数量下限】。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (481, 'f7562a05-b26e-4f03-ba50-6806c78584eb', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [[\"实际零售价格/零售指导价格\"]], \"explanation\": \"根据文档内容,零售价格指数=实际零售价格/零售指导价格×100%,该公式直接体现价格偏离指导价的程度。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (482, '1adfa3aa-1ba7-4521-8ccb-edefd7451129', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___和___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___和___以及订单满足率等指标自动测评确定。\", \"answer\": [[\"订货面\"], [\"订足面\"]], \"explanation\": \"依据文档chunk_id:1.docx_16中‘(一)货源属性分类’第1条明确指出:‘紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。’\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (483, '566c464b-0321-4864-be62-5a98568f8aad', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '单客户月度投放限量是指公司对单客户在___内设定的最高供货量。', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"单客户月度投放限量是指公司对单客户在___内设定的最高供货量。\", \"answer\": [\"单月\"], \"explanation\": \"根据文档内容,‘单客户月度投放限量:公司对单客户单月最高供货量。’明确将时间范围限定为‘单月’,因此填空答案为‘单月’。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (484, 'a797da86-a18a-45c6-b9c9-d7acba632588', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '重购率的计算公式为:___ / 上周订货客户数 × 100%。', NULL, NULL, '[\"1.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"重购率的计算公式为:___ / 上周订货客户数 × 100%。\", \"answer\": [[\"品规连续两周订货客户数\"]], \"explanation\": \"根据文档内容,重购率公式明确为‘品规连续两周订货客户数/上周订货客户数×100%’,分子为连续两周订货客户数,分母为上周订货客户数。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (485, '09bf0dc7-bd04-472e-ac23-fd99216389b7', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '根据文档要求,一级指标合计权重的下限为___。', NULL, NULL, '[\"1.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档要求,一级指标合计权重的下限为___。\", \"answer\": [\"70%\"], \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,即其下限要求为70%。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (486, 'ddc27ddc-7e47-44cf-8ba5-439532dc6ffb', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '根据文档,市场状态分为五种类型,分别是俏、___、平、松、___。', NULL, NULL, '[\"1.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档,市场状态分为五种类型,分别是俏、___、平、松、___。\", \"answer\": [[\"紧\"], [\"软\"]], \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',因此两个空分别填入“紧”和“软”。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (487, '90bb51c9-a97c-4037-a225-6f236b73d411', 'fill_blank', 3, NULL, 5.00, '1.0', '21', '品规属性聚类评价中,可用于聚类的属性包括价位段、价类、创新品类、所属工业企业以及___。', NULL, NULL, '[\"1.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"品规属性聚类评价中,可用于聚类的属性包括价位段、价类、创新品类、所属工业企业以及___。\", \"answer\": [[\"品牌\"]], \"explanation\": \"根据文档内容,\'(二)品规属性聚类评价\'明确指出聚类可依据\'价位段、价类、创新品类和所属工业企业、品牌等属性\',因此填空处应为\'品牌\'。\"}', 'pending', NULL, '2026-05-29 10:27:26', '2026-05-29 10:27:26'); +INSERT INTO `question` VALUES (488, '3550afff-deab-4ed1-a6b5-779c55cefadb', 'single_choice', 3, NULL, 5.00, '1.0', '43', '根据文档内容,“晒课表”在学生体质强健行动中主要发挥什么作用?', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"用于公示教师教学能力考核结果\"}, {\"key\": \"B\", \"content\": \"作为课程资源共建共享的平台\"}, {\"key\": \"C\", \"content\": \"强化跟踪督导,确保每天校内体育活动时间落实到位\"}, {\"key\": \"D\", \"content\": \"替代传统体育教学评估方式\"}]}, \"stem\": \"根据文档内容,“晒课表”在学生体质强健行动中主要发挥什么作用?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位’,该表述直接对应选项C;其他选项在文档中无依据。\"}', 'pending', NULL, '2026-05-29 10:39:08', '2026-05-29 10:39:08'); +INSERT INTO `question` VALUES (489, '416e46c7-41d8-43bd-b84b-f7690e2cb78b', 'single_choice', 3, NULL, 5.00, '1.0', '43', '根据文档内容,2026年全国中小学、幼儿园安全工作由哪四个部门联合部署?', NULL, NULL, '[\"longrule.docx_0\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"教育部、卫生健康委、市场监管总局、公安部\"}, {\"key\": \"B\", \"content\": \"教育部、应急管理部、市场监管总局、公安部\"}, {\"key\": \"C\", \"content\": \"教育部、交通运输部、应急管理部、公安部\"}, {\"key\": \"D\", \"content\": \"教育部、民政部、市场监管总局、应急管理部\"}]}, \"stem\": \"根据文档内容,2026年全国中小学、幼儿园安全工作由哪四个部门联合部署?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'教育部、应急管理部、市场监管总局、公安部联合部署2026年全国中小学、幼儿园安全工作\',选项B与原文完全一致。\"}', 'pending', NULL, '2026-05-29 10:39:08', '2026-05-29 10:39:08'); +INSERT INTO `question` VALUES (490, 'e5748bcb-d80d-4176-96d8-eeeb2525b0b4', 'true_false', 3, NULL, 5.00, '1.0', '43', '通过‘晒课表’强化跟踪督导,是为确保学生每天校内体育活动时间落实到位的重要举措。', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"通过‘晒课表’强化跟踪督导,是为确保学生每天校内体育活动时间落实到位的重要举措。\", \"answer\": true, \"explanation\": \"文档明确指出:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位。’该表述与原文完全一致,故为正确。\"}', 'pending', NULL, '2026-05-29 10:39:08', '2026-05-29 10:39:08'); +INSERT INTO `question` VALUES (491, '4451ea45-b976-4f89-82e7-a6d4af22dd89', 'fill_blank', 3, NULL, 5.00, '1.0', '43', '为深入实施学生体质强健行动,文档提出通过“___”强化跟踪督导,确保学生每天校内体育活动时间落实到位。', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"为深入实施学生体质强健行动,文档提出通过“___”强化跟踪督导,确保学生每天校内体育活动时间落实到位。\", \"answer\": [\"晒课表\"], \"explanation\": \"依据文档第1页内容:\'2.深入实施学生体质强健行动,通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位。\'\"}', 'pending', NULL, '2026-05-29 10:39:08', '2026-05-29 10:39:08'); +INSERT INTO `question` VALUES (492, '654ed663-d770-4245-ae5c-a19b836dd6b0', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第九条,旅客办理入住时,前台必须逐项登记的关键信息中,以下哪一项属于法定身份核验类必填信息?', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"入住时间\"}, {\"key\": \"B\", \"content\": \"房号\"}, {\"key\": \"C\", \"content\": \"身份证号\"}, {\"key\": \"D\", \"content\": \"预定退房时间\"}]}, \"stem\": \"根据文档第九条,旅客办理入住时,前台必须逐项登记的关键信息中,以下哪一项属于法定身份核验类必填信息?\", \"answer\": \"C\", \"explanation\": \"根据文档第九条明确列出的必填信息项,\'身份证号\'是用于身份核验的法定关键信息,具有唯一性、法定性和不可替代性;其他选项虽为必填项,但不属于身份核验类(如入住时间、房号、预定退房时间为业务操作类信息)。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (493, '9fd6d28d-8fe2-4499-9024-655957f65a20', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第十八条,当班人员发现伪造证件时,应当首先采取的处置措施是?', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"立即没收伪造证件并自行调查\"}, {\"key\": \"B\", \"content\": \"第一时间保护现场并向公安机关报备\"}, {\"key\": \"C\", \"content\": \"暂扣证件并通知单位负责人后等待指示\"}, {\"key\": \"D\", \"content\": \"对持证人进行询问并记录口供\"}]}, \"stem\": \"根据文档第十八条,当班人员发现伪造证件时,应当首先采取的处置措施是?\", \"answer\": \"B\", \"explanation\": \"依据文档第十八条明确要求:\'发现伪造证件……当班人员须第一时间保护现场并向公安机关报备。\'该条款强调处置的时效性与法定程序,不得擅自处置或延迟上报。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (494, 'e883f9fd-d6f7-45ff-a428-22169fea8987', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第十九条,当员工在现场发现治安突发事件时,应当首先采取以下哪项行动?', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"自行处理并记录事件经过\"}, {\"key\": \"B\", \"content\": \"立即上报门店负责人\"}, {\"key\": \"C\", \"content\": \"等待其他同事到场后再决定是否上报\"}, {\"key\": \"D\", \"content\": \"先拍照取证,再视情况决定是否上报\"}]}, \"stem\": \"根据文档第十九条,当员工在现场发现治安突发事件时,应当首先采取以下哪项行动?\", \"answer\": \"B\", \"explanation\": \"依据文档第十九条明确要求:\'遇到纠纷、安全隐患、治安突发事件,立即上报门店负责人,按应急流程处置,不得隐瞒不报。\'因此,首要且必须的行动是立即上报门店负责人,而非自行处理、延迟决策或附加条件。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (495, 'eb957805-a471-498d-a028-1c2e9f8b434f', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第八条,当住宿经营者在进行人证信息严格比对时发现证件伪造情形,应当采取的正确处置措施是:', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"登记后允许入住,并事后向公安机关报备\"}, {\"key\": \"B\", \"content\": \"要求旅客提供其他辅助证明材料,视情况决定是否入住\"}, {\"key\": \"C\", \"content\": \"一律不予入住,并立即上报属地公安机关\"}, {\"key\": \"D\", \"content\": \"暂扣伪造证件,自行调查核实后再作处理\"}]}, \"stem\": \"根据文档第八条,当住宿经营者在进行人证信息严格比对时发现证件伪造情形,应当采取的正确处置措施是:\", \"answer\": \"C\", \"explanation\": \"依据文档第八条明确要求:\'发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。\'选项C完全符合原文规定;其他选项均违背\'一律不予入住\'和\'立即上报\'的强制性要求。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (496, '8131006a-e6e0-4f2c-be42-f8f9aaeeaeec', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档规定,旅馆在接待未成年人入住时,除核实监护人身份外,还必须完成哪项关键操作?', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"要求未成年人签署安全承诺书\"}, {\"key\": \"B\", \"content\": \"留存监护人联系方式并详细登记随行人员信息\"}, {\"key\": \"C\", \"content\": \"强制配备专职看护人员全程陪同\"}, {\"key\": \"D\", \"content\": \"向当地教育部门报备入住信息\"}]}, \"stem\": \"根据文档规定,旅馆在接待未成年人入住时,除核实监护人身份外,还必须完成哪项关键操作?\", \"answer\": \"B\", \"explanation\": \"依据文档第十一条明确指出:‘未成年人入住严格执行“五必须”规定,核实监护人身份、留存联系方式,详细登记随行人员信息。’选项B完整、准确对应原文要求;其余选项均无文档依据。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (497, 'd865903e-0cc6-4d07-a00f-5e0dd9593993', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第五条,管理人员在履行日常监督检查职责时,必须定期核查哪些具体内容?', NULL, NULL, '[\"test.docx_6\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"仅登记台账\"}, {\"key\": \"B\", \"content\": \"仅系统上传记录\"}, {\"key\": \"C\", \"content\": \"登记台账和系统上传记录\"}, {\"key\": \"D\", \"content\": \"登记台账、系统上传记录及人员考勤记录\"}]}, \"stem\": \"根据文档第五条,管理人员在履行日常监督检查职责时,必须定期核查哪些具体内容?\", \"answer\": \"C\", \"explanation\": \"文档第五条明确指出‘管理人员负责日常监督检查,定期核查登记台账、系统上传记录’,未提及考勤记录,故C项为唯一符合原文的选项。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (498, '24f99572-caac-4a2d-b98e-85d58d96293f', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档规定,住宿登记所坚持的‘实名、实数、实时、实情’原则,具体要求不包括以下哪一项?', NULL, NULL, '[\"test.docx_3\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"一人一证、人证相符\"}, {\"key\": \"B\", \"content\": \"逐人登记、及时上传\"}, {\"key\": \"C\", \"content\": \"登记信息可事后补录,无需即时操作\"}, {\"key\": \"D\", \"content\": \"确保登记身份真实、人数准确、时间及时、情况属实\"}]}, \"stem\": \"根据文档规定,住宿登记所坚持的‘实名、实数、实时、实情’原则,具体要求不包括以下哪一项?\", \"answer\": \"C\", \"explanation\": \"文档明确要求‘实时’原则,对应‘及时上传’;选项C中‘可事后补录,无需即时操作’违背‘实时’要求,与原文‘逐人登记、及时上传’直接冲突。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (499, '8f1fa5ce-5167-40b2-95cd-d9da9ec6dd7d', 'single_choice', 3, NULL, 5.00, '1.0', '45', '根据文档第十七条,当系统出现故障或网络中断时,正确的手工登记操作顺序是?', NULL, NULL, '[\"test.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"先等待系统自动恢复,再统一补录登记信息\"}, {\"key\": \"B\", \"content\": \"立即暂停登记工作,待故障完全排除后再开始\"}, {\"key\": \"C\", \"content\": \"先手工逐人登记,故障恢复后第一时间补录上传,并做好故障登记记录备案\"}, {\"key\": \"D\", \"content\": \"仅手工登记重点人员信息,其余人员信息暂不处理\"}]}, \"stem\": \"根据文档第十七条,当系统出现故障或网络中断时,正确的手工登记操作顺序是?\", \"answer\": \"C\", \"explanation\": \"依据文档第十七条明确要求:\'系统出现故障、网络中断时,先手工逐人登记,故障恢复后第一时间补录上传,并做好故障登记记录备案。\'选项C完整、准确复现该要求,其他选项均与原文不符。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (500, '7c26800f-5d47-47dd-81f3-c63b72bf99e1', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档第九条,旅客入住登记时,\'户籍地址\'属于前台必须逐项登记的必填信息项。', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档第九条,旅客入住登记时,\'户籍地址\'属于前台必须逐项登记的必填信息项。\", \"answer\": true, \"explanation\": \"文档第九条明确列出‘户籍地址’为前台必须逐项登记的关键信息之一,属于入住登记必填信息项。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (501, 'a61d8df4-e018-4243-8d1d-edafea27658e', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档第十八条,当班人员发现伪造证件时,应当第一时间保护现场并向公安机关报备。', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"true\", \"content\": \"正确\"}, {\"key\": \"false\", \"content\": \"错误\"}]}, \"stem\": \"根据文档第十八条,当班人员发现伪造证件时,应当第一时间保护现场并向公安机关报备。\", \"answer\": \"true\", \"explanation\": \"文档第十八条明确规定:\'发现伪造证件、可疑人员、违法犯罪线索、突发事件,当班人员须第一时间保护现场并向公安机关报备。\'该表述与原文完全一致,因此正确。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (502, '22af15a8-3fc7-4ba4-b1d8-688c411ffc22', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档第十九条,遇到治安突发事件时,员工必须立即上报门店负责人,不得隐瞒不报。', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档第十九条,遇到治安突发事件时,员工必须立即上报门店负责人,不得隐瞒不报。\", \"answer\": true, \"explanation\": \"文档明确指出:\'遇到纠纷、安全隐患、治安突发事件,立即上报门店负责人,按应急流程处置,不得隐瞒不报。\' 治安突发事件属于必须立即上报的情形之一,符合原文规定。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (503, '87871d3c-e0ba-42f0-b488-0215ed5bfee5', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档规定,旅馆工作人员在办理入住时发现人证信息不符,但旅客态度良好且能提供其他辅助证明,可酌情允许其入住。', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档规定,旅馆工作人员在办理入住时发现人证信息不符,但旅客态度良好且能提供其他辅助证明,可酌情允许其入住。\", \"answer\": false, \"explanation\": \"文档第八条明确规定:发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。\'一律\'表明无例外情形,不得酌情处理。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (504, '6bcb80bb-388c-48de-91e2-88aa5708bf38', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档规定,未成年人入住时,旅馆必须核实监护人身份、留存联系方式,并详细登记随行人员信息。', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档规定,未成年人入住时,旅馆必须核实监护人身份、留存联系方式,并详细登记随行人员信息。\", \"answer\": true, \"explanation\": \"文档第十一条明确指出:\'未成年人入住严格执行“五必须”规定,核实监护人身份、留存联系方式,详细登记随行人员信息。\'题干内容与原文完全一致,因此正确。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (505, '9aa91d50-087d-4bbe-832f-79acb396ea74', 'true_false', 3, NULL, 5.00, '1.0', '45', '管理人员的日常监督检查职责包括定期核查登记台账和系统上传记录,并对漏登、错登、代登等问题及时整改追责。', NULL, NULL, '[\"test.docx_6\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"管理人员的日常监督检查职责包括定期核查登记台账和系统上传记录,并对漏登、错登、代登等问题及时整改追责。\", \"answer\": true, \"explanation\": \"依据文档第五条明确指出:\'管理人员负责日常监督检查,定期核查登记台账、系统上传记录,对漏登、错登、代登等问题及时整改追责。\'\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (506, '559887c4-ae72-4379-aed1-fc55c5f81f20', 'true_false', 3, NULL, 5.00, '1.0', '45', '根据文档规定,住宿登记必须坚持‘实名、实数、实时、实情’原则,并做到一人一证、人证相符、逐人登记、及时上传。', NULL, NULL, '[\"test.docx_3\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档规定,住宿登记必须坚持‘实名、实数、实时、实情’原则,并做到一人一证、人证相符、逐人登记、及时上传。\", \"answer\": true, \"explanation\": \"文档第三条明确指出:‘住宿登记坚持实名、实数、实时、实情原则,做到一人一证、人证相符、逐人登记、及时上传。’题干内容与原文完全一致,因此正确。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (507, 'b352a208-b811-431b-979f-ac9538c92d18', 'fill_blank', 3, NULL, 5.00, '1.0', '45', '根据文档第九条,旅客办理入住时,前台必须逐项登记的必填信息包括:姓名、性别、身份证号、___、联系电话、入住时间、预定退房时间、房号。', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档第九条,旅客办理入住时,前台必须逐项登记的必填信息包括:姓名、性别、身份证号、___、联系电话、入住时间、预定退房时间、房号。\", \"answer\": [\"户籍地址\"], \"explanation\": \"依据文档第九条明确列出的必填信息项,\'户籍地址\'是必须登记的关键信息之一,与其他七项共同构成完整的入住登记必填内容。\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (508, '989ecc62-61df-4508-9526-dbb0de134c47', 'fill_blank', 3, NULL, 5.00, '1.0', '45', '根据文档第十八条,当班人员发现伪造证件时,须第一时间___并___。', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档第十八条,当班人员发现伪造证件时,须第一时间___并___。\", \"answer\": [[\"保护现场\"], [\"向公安机关报备\"]], \"explanation\": \"依据文档第十八条:\'发现伪造证件、可疑人员、违法犯罪线索、突发事件,当班人员须第一时间保护现场并向公安机关报备。\'\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (509, '9c1ea2dc-8536-4757-9cb7-14c34d4b57ca', 'fill_blank', 3, NULL, 5.00, '1.0', '45', '根据文档第十九条,遇到纠纷、安全隐患或治安突发事件时,必须___,并按应急流程处置,不得隐瞒不报。', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档第十九条,遇到纠纷、安全隐患或治安突发事件时,必须___,并按应急流程处置,不得隐瞒不报。\", \"answer\": [\"立即上报门店负责人\"], \"explanation\": \"依据文档第十九条原文:\'遇到纠纷、安全隐患、治安突发事件,立即上报门店负责人,按应急流程处置,不得隐瞒不报。\'\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (510, '04d30ec9-d2bb-4bb6-8d59-468ac130d14a', 'fill_blank', 3, NULL, 5.00, '1.0', '45', '根据文档第八条,发现人证不符、证件伪造或冒用证件时,应一律不予入住,并立即上报___。', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档第八条,发现人证不符、证件伪造或冒用证件时,应一律不予入住,并立即上报___。\", \"answer\": [\"属地公安机关\"], \"explanation\": \"依据文档内容‘第八条 严格比对人脸与证件信息,发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。’\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (511, 'dc7e88f6-bb15-4eef-8c12-e0b0b4cdea5f', 'fill_blank', 3, NULL, 5.00, '1.0', '45', '根据规定,未成年人入住时须严格执行“五必须”,其中包括核实___身份、留存其___,并详细登记随行人员信息。', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据规定,未成年人入住时须严格执行“五必须”,其中包括核实___身份、留存其___,并详细登记随行人员信息。\", \"answer\": [[\"监护人\"], [\"联系方式\"]], \"explanation\": \"依据文档第十一条:‘未成年人入住严格执行“五必须”规定,核实监护人身份、留存联系方式,详细登记随行人员信息。’\"}', 'pending', NULL, '2026-05-29 10:56:06', '2026-05-29 10:56:06'); +INSERT INTO `question` VALUES (512, 'd1c87589-0ef5-4711-919f-b9bbf58dc7d2', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档,零售价格指数的正确计算公式是?', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(零售指导价格 - 实际零售价格)/ 零售指导价格 × 100%\"}, {\"key\": \"B\", \"content\": \"实际零售价格 / 零售指导价格 × 100%\"}, {\"key\": \"C\", \"content\": \"零售指导价格 / 实际零售价格 × 100%\"}, {\"key\": \"D\", \"content\": \"实际零售价格 × 零售指导价格 × 100%\"}]}, \"stem\": \"根据文档,零售价格指数的正确计算公式是?\", \"answer\": \"B\", \"explanation\": \"文档明确给出公式:实际零售价格/零售指导价格*100%,对应选项B。其他选项均与原文不符。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (513, 'fdf477c4-9476-47c2-ba8c-06f9a759bf51', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、新品上市时间\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、均衡满足比例、完全满足品规数、社会库存\"}, {\"key\": \"D\", \"content\": \"社会存销比、新品销售时长、订单满足率、顺销品规数\"}]}, \"stem\": \"根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:紧俏品规应依据‘零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率’等指标自动测评确定。选项B中四项(市场流通价格、社会存销比、订货面、订足面)均在原文所列指标范围内;A中‘新品上市时间’属‘新品’定义范畴,非紧俏判定指标;C中‘均衡满足比例’‘完全满足品规数’属于货源属性分类结果而非判定指标;D中‘新品销售时长’‘顺销品规数’亦非紧俏品规判定依据。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (514, '10fc943d-84ac-47c4-bf56-fc73c7b436a9', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档内容,‘单客户月度投放限量’的准确定义是:', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内对某一档级客户所有品规的累计供货总量上限\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据文档内容,‘单客户月度投放限量’的准确定义是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘单客户月度投放限量:公司对单客户单月最高供货量。’该定义强调主体是‘单客户’、时间维度是‘单月’、性质是‘最高供货量’,不区分品规或档级,与A(对应‘单客户单次单品规投放限量’)、B(文档未提‘所有品规累计’)、D(对应‘投放频次’)均不符。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (515, '99850e41-6ffd-485c-aa8c-8e18adad1c68', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据《按价位段自选投放工作指引》,某品规的重购率计算公式中,分子应为以下哪一项?', NULL, NULL, '[\"1.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"上周订货客户数\"}, {\"key\": \"B\", \"content\": \"本周订货客户数\"}, {\"key\": \"C\", \"content\": \"连续两周订货客户数\"}, {\"key\": \"D\", \"content\": \"近四周平均订货客户数\"}]}, \"stem\": \"根据《按价位段自选投放工作指引》,某品规的重购率计算公式中,分子应为以下哪一项?\", \"answer\": \"C\", \"explanation\": \"依据文档chunk_id:1.docx_75,重购率公式明确为‘品规连续两周订货客户数/上周订货客户数×100%’,因此分子是‘连续两周订货客户数’,即同时在上周和本周均订货的客户数量。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (516, 'c1108de8-9596-4486-b55d-5afde9f7d82a', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档规定,一级指标合计权重的最低要求是多少?', NULL, NULL, '[\"1.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据文档规定,一级指标合计权重的最低要求是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该表述直接对应一级指标权重下限要求,是本知识点的核心依据。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (517, 'fe2157a3-7a02-456b-aab8-6de161141fd1', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档内容,市场状态被划分为哪五种类型?', NULL, NULL, '[\"1.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、畅、稳、滞、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、缓、急\"}, {\"key\": \"D\", \"content\": \"热、温、常、冷、寒\"}]}, \"stem\": \"根据文档内容,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接出自chunk_id:1.docx_22,是唯一符合原文的选项。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (518, '6900ffcd-0284-4745-b76b-4bf5d9d6b1c9', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档内容,以下哪一项不属于品规属性聚类评价中明确列出的聚类维度?', NULL, NULL, '[\"1.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"市场状态评分等级\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据文档内容,以下哪一项不属于品规属性聚类评价中明确列出的聚类维度?\", \"answer\": \"C\", \"explanation\": \"文档明确指出聚类维度包括‘价位段、价类、创新品类和所属工业企业、品牌等属性’,而‘市场状态评分等级’是聚类后的评价结果或计算依据,并非用于聚类本身的属性维度。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (519, 'a525c487-ca8c-4fae-b722-2ebf2360831b', 'single_choice', 3, NULL, 5.00, '1.0', '48', '根据文档规定,紧俏烟规格数量占所有参与自选投放规格总数的比例原则上不得超过多少?', NULL, NULL, '[\"1.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"10%\"}, {\"key\": \"B\", \"content\": \"15%\"}, {\"key\": \"C\", \"content\": \"20%\"}, {\"key\": \"D\", \"content\": \"25%\"}]}, \"stem\": \"根据文档规定,紧俏烟规格数量占所有参与自选投放规格总数的比例原则上不得超过多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:\'紧俏烟应通过系统依据订单满足率、订足率、订足面、市场价格、社会库存等指标自动测评确定,规格数量占比原则上不得超过20%。\'\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (520, '28d701dc-d478-4441-a2a8-dce9e13c7d98', 'fill_blank', 3, NULL, 5.00, '1.0', '48', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [[\"实际零售价格/零售指导价格\"]], \"explanation\": \"根据文档内容,零售价格指数=实际零售价格/零售指导价格×100%,该公式直接体现价格偏离指导价的程度。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (521, '3e1f1945-ca06-4409-84a0-a12e6650ecd6', 'fill_blank', 3, NULL, 5.00, '1.0', '48', '根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。\", \"answer\": [[\"订货面\"], [\"订足面\"]], \"explanation\": \"依据文档chunk_id:1.docx_16中‘(一)货源属性分类’第1条明确指出:‘紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。’\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (522, '138ab132-ba81-4498-ba31-754852584495', 'fill_blank', 3, NULL, 5.00, '1.0', '48', '单客户月度投放限量是指公司对单客户在___内设定的最高供货量。', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"单客户月度投放限量是指公司对单客户在___内设定的最高供货量。\", \"answer\": [\"单月\"], \"explanation\": \"根据文档内容,‘单客户月度投放限量:公司对单客户单月最高供货量。’明确将时间范围限定为‘单月’,因此填空答案为‘单月’。\"}', 'pending', NULL, '2026-05-29 11:21:18', '2026-05-29 11:21:18'); +INSERT INTO `question` VALUES (523, '5915276d-8a4f-4771-9427-0215d31246b3', 'single_choice', 3, NULL, 5.00, '1.0', '49', '根据文档内容,“晒课表”在学生体质强健行动中的核心作用是:', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"优化体育课程教学设计,提升教师专业能力\"}, {\"key\": \"B\", \"content\": \"强化跟踪督导,确保学生每天校内体育活动时间落实到位\"}, {\"key\": \"C\", \"content\": \"促进家校沟通,提高家长对学生体育锻炼的重视程度\"}, {\"key\": \"D\", \"content\": \"作为艺术展演和劳动实践的配套管理手段\"}]}, \"stem\": \"根据文档内容,“晒课表”在学生体质强健行动中的核心作用是:\", \"answer\": \"B\", \"explanation\": \"文档明确指出:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位’,该表述直接对应选项B;其他选项虽属文档提及的其他举措(如艺术展演、家校协同等),但均未与‘晒课表’形成直接因果或功能关联。\"}', 'pending', NULL, '2026-05-29 12:02:22', '2026-05-29 12:02:22'); +INSERT INTO `question` VALUES (524, '5ce30b1a-b358-43e6-9b62-315e3539d70f', 'single_choice', 3, NULL, 5.00, '1.0', '49', '根据文档内容,2026年全国中小学、幼儿园安全工作由哪四个部门联合部署?', NULL, NULL, '[\"longrule.docx_0\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"教育部、卫生健康委、市场监管总局、公安部\"}, {\"key\": \"B\", \"content\": \"教育部、应急管理部、市场监管总局、公安部\"}, {\"key\": \"C\", \"content\": \"教育部、交通运输部、应急管理部、公安部\"}, {\"key\": \"D\", \"content\": \"教育部、人力资源和社会保障部、市场监管总局、应急管理部\"}]}, \"stem\": \"根据文档内容,2026年全国中小学、幼儿园安全工作由哪四个部门联合部署?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:‘教育部、应急管理部、市场监管总局、公安部联合部署2026年全国中小学、幼儿园安全工作’,选项B与原文完全一致。\"}', 'pending', NULL, '2026-05-29 12:02:22', '2026-05-29 12:02:22'); +INSERT INTO `question` VALUES (525, '69e0eb0e-090e-4f7c-ac97-45cc44830869', 'true_false', 3, NULL, 5.00, '1.0', '49', '通过‘晒课表’强化跟踪督导,是为确保学生每天校内体育活动时间落实到位的重要举措。', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"通过‘晒课表’强化跟踪督导,是为确保学生每天校内体育活动时间落实到位的重要举措。\", \"answer\": true, \"explanation\": \"文档明确指出:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位。’该表述与原文完全一致,故为正确。\"}', 'pending', NULL, '2026-05-29 12:02:22', '2026-05-29 12:02:22'); +INSERT INTO `question` VALUES (526, '9a74563a-b618-4e1d-a888-7c36c9de5a05', 'fill_blank', 3, NULL, 5.00, '1.0', '49', '通过“___”强化跟踪督导,是深入实施学生体质强健行动的重要举措,旨在确保学生每天校内体育活动时间落实到位。', NULL, NULL, '[\"longrule.docx_2\"]', 3, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"通过“___”强化跟踪督导,是深入实施学生体质强健行动的重要举措,旨在确保学生每天校内体育活动时间落实到位。\", \"answer\": [\"晒课表\"], \"explanation\": \"依据文档第1页明确表述:‘通过“晒课表”强化跟踪督导,确保学生每天校内体育活动时间落实到位。’该举措聚焦体育督导的可视化、常态化机制建设。\"}', 'pending', NULL, '2026-05-29 12:02:22', '2026-05-29 12:02:22'); +INSERT INTO `question` VALUES (527, '8abbb80f-7705-4a71-a5eb-71659ccc60b0', 'true_false', 3, NULL, 5.00, '1.0', '58', '需求分析是软件开发过程中用于明确用户需求、系统功能及约束条件的关键阶段。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"需求分析是软件开发过程中用于明确用户需求、系统功能及约束条件的关键阶段。\", \"answer\": true, \"explanation\": \"文档标题及核心内容反复强调‘需求分析’作为独立主题,表明其在软件工程中属于基础且关键的前期阶段,用于界定系统应做什么,符合标准定义。\"}', 'pending', NULL, '2026-05-30 14:06:47', '2026-05-30 14:06:47'); +INSERT INTO `question` VALUES (528, 'dddf9885-9625-49f3-b6ab-80fb846b4474', 'subjective', 3, NULL, 5.00, '1.0', '58', '结合文档中‘需求分析’这一主题,简述需求分析在软件工程生命周期中的核心作用,并说明其主要目标为何不能被后续开发阶段(如设计或编码)所替代。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"结合文档中‘需求分析’这一主题,简述需求分析在软件工程生命周期中的核心作用,并说明其主要目标为何不能被后续开发阶段(如设计或编码)所替代。\", \"answer\": \"需求分析是软件工程生命周期的起始关键阶段,其核心作用在于准确理解、捕获、建模和验证用户及利益相关者的实际业务需要与约束条件,为系统构建提供明确、一致、可验证的输入基础。其主要目标包括:明确‘系统必须做什么’(功能需求)、‘系统应满足哪些质量属性’(非功能需求)以及识别边界与约束(如法规、技术、环境等)。该阶段不可被后续阶段替代,因为设计与编码解决的是‘如何做’,而需求分析解决的是‘做什么’——若此前提缺失或失真,将导致方向性错误,引发返工、成本超支甚至项目失败。\", \"explanation\": \"文档明确将‘需求分析’作为独立主题提出,强调其基础性与前置性;根据软件工程基本原理,需求分析是唯一聚焦于问题域理解而非解域实现的阶段,具有不可逆的奠基作用。\"}', 'pending', NULL, '2026-05-30 14:06:47', '2026-05-30 14:06:47'); +INSERT INTO `question` VALUES (529, '46b1d60a-649d-4b1f-b9d3-ccf13c071a00', 'true_false', 3, NULL, 5.00, '1.0', '59', '需求分析是软件开发过程中仅需在项目初期进行一次的活动。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"需求分析是软件开发过程中仅需在项目初期进行一次的活动。\", \"answer\": false, \"explanation\": \"需求分析是一个持续、迭代的过程,贯穿整个软件生命周期,需根据用户反馈和环境变化不断细化和验证,而非仅在初期进行一次。文档中虽未展开细节,但标题‘需求分析’作为独立主题反复出现,体现其作为核心基础环节的持续性与重要性。\"}', 'approved', '', '2026-05-30 14:30:53', '2026-05-31 12:01:35'); +INSERT INTO `question` VALUES (530, '68393185-0eed-459b-8982-9cf6b753882d', 'subjective', 3, NULL, 5.00, '1.0', '59', '请结合‘需求分析’这一核心主题,简述需求分析在软件工程生命周期中的关键作用,并说明其主要目标是什么。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"请结合‘需求分析’这一核心主题,简述需求分析在软件工程生命周期中的关键作用,并说明其主要目标是什么。\", \"answer\": \"需求分析是软件工程生命周期中承上启下的关键阶段,位于问题定义之后、系统设计之前。其关键作用在于准确理解并建模用户真实业务需求,消除歧义与隐含假设,为后续设计、开发和测试提供一致、完整、可验证的需求基础。主要目标包括:明确系统功能与非功能需求;识别利益相关方及其期望;建立需求规格说明(如SRS);确保需求的正确性、一致性、可追溯性和可验证性。\", \"explanation\": \"本题紧扣文档中反复强调的核心知识点‘需求分析’及‘主题:需求分析’,考察对需求分析定位、作用与目标的综合理解。虽文档内容简略,但‘需求分析’作为独立标题多次出现,表明其作为基础性、主题性概念的地位,因此需从软件工程常识出发,严格围绕该主题作答。\"}', 'pending', NULL, '2026-05-30 14:30:53', '2026-05-30 14:30:53'); +INSERT INTO `question` VALUES (531, 'e1e099d9-f94f-447f-9297-1f3bb218fe19', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第九条,旅客办理入住时,前台必须逐项登记的关键信息中,以下哪一项属于法定身份核验类必填信息?', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"入住时间\"}, {\"key\": \"B\", \"content\": \"房号\"}, {\"key\": \"C\", \"content\": \"身份证号\"}, {\"key\": \"D\", \"content\": \"预定退房时间\"}]}, \"stem\": \"根据文档第九条,旅客办理入住时,前台必须逐项登记的关键信息中,以下哪一项属于法定身份核验类必填信息?\", \"answer\": \"C\", \"explanation\": \"根据文档第九条,‘身份证号’是用于旅客身份核验的法定关键信息,属于强制登记的身份类必填项;其他选项虽为必填信息,但不承担法定身份核验功能。\"}', 'pending', NULL, '2026-05-30 14:41:49', '2026-05-30 14:41:49'); +INSERT INTO `question` VALUES (532, '1467c6c5-0ddd-4b92-87a5-050b8b72cbe9', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第十八条,当班人员发现伪造证件时,应当首先采取的处置措施是?', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"立即没收伪造证件并自行调查\"}, {\"key\": \"B\", \"content\": \"第一时间保护现场并向公安机关报备\"}, {\"key\": \"C\", \"content\": \"暂扣证件并通知单位负责人后等待指示\"}, {\"key\": \"D\", \"content\": \"对持证人进行询问并记录口供\"}]}, \"stem\": \"根据文档第十八条,当班人员发现伪造证件时,应当首先采取的处置措施是?\", \"answer\": \"B\", \"explanation\": \"依据文档第十八条明确要求:\'发现伪造证件……当班人员须第一时间保护现场并向公安机关报备。\'该条款强调处置的时效性与法定程序,不得擅自处置或延迟上报。\"}', 'pending', NULL, '2026-05-30 14:41:49', '2026-05-30 14:41:49'); +INSERT INTO `question` VALUES (533, '43a06fd8-6402-4180-a739-8dd5c40c70b9', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第十九条,员工在遇到纠纷、安全隐患或治安突发事件时,应采取的首要行动是?', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"自行处理后视情况决定是否上报\"}, {\"key\": \"B\", \"content\": \"立即上报门店负责人\"}, {\"key\": \"C\", \"content\": \"先拍照取证,再上报门店负责人\"}, {\"key\": \"D\", \"content\": \"仅在事态扩大时才上报门店负责人\"}]}, \"stem\": \"根据文档第十九条,员工在遇到纠纷、安全隐患或治安突发事件时,应采取的首要行动是?\", \"answer\": \"B\", \"explanation\": \"文档第十九条明确要求‘遇到纠纷、安全隐患、治安突发事件,立即上报门店负责人’,强调‘立即’性和强制性,不得延迟或自行判断是否上报。\"}', 'pending', NULL, '2026-05-30 14:41:49', '2026-05-30 14:41:49'); +INSERT INTO `question` VALUES (534, '84653fb4-e7bb-4678-809f-f5a66083eeb1', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第八条,当住宿经营者在办理入住时发现人证信息不一致,应采取的正确处置措施是?', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"登记后允许入住,后续补查证件真伪\"}, {\"key\": \"B\", \"content\": \"要求旅客提供辅助证明材料,视情况决定是否入住\"}, {\"key\": \"C\", \"content\": \"一律不予入住,并立即上报属地公安机关\"}, {\"key\": \"D\", \"content\": \"暂扣证件并自行开展调查,确认无误后再办理入住\"}]}, \"stem\": \"根据文档第八条,当住宿经营者在办理入住时发现人证信息不一致,应采取的正确处置措施是?\", \"answer\": \"C\", \"explanation\": \"依据文档第八条明确要求:\'发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关\',C选项完全符合原文规定;其他选项均违背‘严格比对’和‘立即上报’的强制性要求。\"}', 'pending', NULL, '2026-05-30 14:41:49', '2026-05-30 14:41:49'); +INSERT INTO `question` VALUES (535, '826341b5-7966-4a78-995c-e42d86090839', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档规定,旅馆在接待未成年人入住时,除核实监护人身份外,还必须完成哪项关键操作?', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"要求未成年人签署安全承诺书\"}, {\"key\": \"B\", \"content\": \"留存监护人联系方式并详细登记随行人员信息\"}, {\"key\": \"C\", \"content\": \"强制配备专职看护人员全程陪同\"}, {\"key\": \"D\", \"content\": \"向当地教育部门报备入住信息\"}]}, \"stem\": \"根据文档规定,旅馆在接待未成年人入住时,除核实监护人身份外,还必须完成哪项关键操作?\", \"answer\": \"B\", \"explanation\": \"依据文档第十一条明确指出:‘未成年人入住严格执行“五必须”规定,核实监护人身份、留存联系方式,详细登记随行人员信息。’选项B完整、准确对应原文要求;其余选项在文档中无依据。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-06-02 14:42:07'); +INSERT INTO `question` VALUES (536, '0831d91a-2e3a-4c9a-9b7a-74160ce4b5b5', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第五条,管理人员在日常监督检查中,针对登记台账和系统上传记录的核查工作,其核心目的之一是及时发现并处理哪类问题?', NULL, NULL, '[\"test.docx_6\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"数据备份不及时问题\"}, {\"key\": \"B\", \"content\": \"漏登、错登、代登等问题\"}, {\"key\": \"C\", \"content\": \"系统响应延迟问题\"}, {\"key\": \"D\", \"content\": \"操作人员资质缺失问题\"}]}, \"stem\": \"根据文档第五条,管理人员在日常监督检查中,针对登记台账和系统上传记录的核查工作,其核心目的之一是及时发现并处理哪类问题?\", \"answer\": \"B\", \"explanation\": \"文档第五条明确指出:‘管理人员负责日常监督检查,定期核查登记台账、系统上传记录,对漏登、错登、代登等问题及时整改追责’,因此B项为原文直接依据。其他选项在文档中无对应表述。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-06-02 14:42:12'); +INSERT INTO `question` VALUES (537, 'a774495c-7bb1-448b-926f-a32bf1480836', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档规定,住宿登记所坚持的‘实名、实数、实时、实情’原则,具体落实要求不包括以下哪一项?', NULL, NULL, '[\"test.docx_3\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"一人一证\"}, {\"key\": \"B\", \"content\": \"人证相符\"}, {\"key\": \"C\", \"content\": \"按房间批量登记后统一上传\"}, {\"key\": \"D\", \"content\": \"逐人登记、及时上传\"}]}, \"stem\": \"根据文档规定,住宿登记所坚持的‘实名、实数、实时、实情’原则,具体落实要求不包括以下哪一项?\", \"answer\": \"C\", \"explanation\": \"文档明确要求‘逐人登记、及时上传’,强调个体化、即时性操作;‘按房间批量登记后统一上传’违背‘实时’和‘逐人登记’要求,不符合实名实数实时实情原则。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-06-02 12:26:28'); +INSERT INTO `question` VALUES (538, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 3, NULL, 5.00, '1.0', '60', '根据文档第十七条,当系统出现故障或网络中断时,正确的应急登记操作顺序是?', NULL, NULL, '[\"test.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"先等待系统自动恢复,再统一补录登记信息\"}, {\"key\": \"B\", \"content\": \"立即暂停登记工作,待故障完全排除后再开始登记\"}, {\"key\": \"C\", \"content\": \"先手工逐人登记,故障恢复后第一时间补录上传,并做好故障登记记录备案\"}, {\"key\": \"D\", \"content\": \"仅手工登记关键人员信息,其余人员信息待系统恢复后择日补录\"}]}, \"stem\": \"根据文档第十七条,当系统出现故障或网络中断时,正确的应急登记操作顺序是?\", \"answer\": \"C\", \"explanation\": \"文档第十七条明确要求:‘系统出现故障、网络中断时,先手工逐人登记,故障恢复后第一时间补录上传,并做好故障登记记录备案。’选项C完全符合原文规定,其他选项均与文档要求不符。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-06-02 12:26:27'); +INSERT INTO `question` VALUES (539, 'a425faf3-e03f-4f08-9dd2-1e9adace94a3', 'true_false', 3, NULL, 5.00, '1.0', '60', '根据文档第九条,旅客入住登记时,‘户籍地址’属于必须逐项登记的关键信息之一。', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档第九条,旅客入住登记时,‘户籍地址’属于必须逐项登记的关键信息之一。\", \"answer\": true, \"explanation\": \"文档第九条明确列出‘户籍地址’为前台必须逐项登记的关键信息之一,因此该陈述正确。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:57:05'); +INSERT INTO `question` VALUES (540, '2399c83d-3403-4b91-abc6-d46ce17b6f73', 'true_false', 3, NULL, 5.00, '1.0', '60', '根据文档规定,当班人员发现伪造证件时,应当第一时间保护现场并向公安机关报备。', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档规定,当班人员发现伪造证件时,应当第一时间保护现场并向公安机关报备。\", \"answer\": true, \"explanation\": \"依据文档第十八条明确指出:\'发现伪造证件、可疑人员、违法犯罪线索、突发事件,当班人员须第一时间保护现场并向公安机关报备。\'\"}', 'rejected', '', '2026-05-30 14:41:49', '2026-06-02 12:26:21'); +INSERT INTO `question` VALUES (541, '54eb041e-25aa-4006-9e1a-8ce054394bc0', 'true_false', 3, NULL, 5.00, '1.0', '60', '根据文档第十九条,遇到治安突发事件时,员工应在初步处理后再上报门店负责人。', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档第十九条,遇到治安突发事件时,员工应在初步处理后再上报门店负责人。\", \"answer\": false, \"explanation\": \"文档明确要求‘立即上报门店负责人’,强调时效性,不得延迟或自行处理后再报;‘立即’意味着第一时间报告,而非先处置再上报。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-06-02 12:26:20'); +INSERT INTO `question` VALUES (542, '8c33ebe7-5e24-4c9a-a73c-0ae4701002dd', 'true_false', 3, NULL, 5.00, '1.0', '60', '根据规定,旅馆在办理入住时发现人证信息不符,但旅客态度良好且能提供其他辅助证明,可酌情允许其入住。', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据规定,旅馆在办理入住时发现人证信息不符,但旅客态度良好且能提供其他辅助证明,可酌情允许其入住。\", \"answer\": false, \"explanation\": \"文档明确要求‘发现人证不符、证件伪造、冒用证件的,一律不予入住’,不存在酌情允许的情形,必须严格执行。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:36'); +INSERT INTO `question` VALUES (543, 'a2763b2a-62c8-4577-b8dd-2403c6caefec', 'true_false', 3, NULL, 5.00, '1.0', '60', '根据规定,未成年人入住时必须核实监护人身份、留存联系方式,并详细登记随行人员信息。', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据规定,未成年人入住时必须核实监护人身份、留存联系方式,并详细登记随行人员信息。\", \"answer\": true, \"explanation\": \"文档明确指出‘第十一条 未成年人入住严格执行‘五必须’规定,核实监护人身份、留存联系方式,详细登记随行人员信息。’题干内容与原文完全一致,故为正确。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:35'); +INSERT INTO `question` VALUES (544, '82297be4-5a66-4d8e-a179-0a289d8713ef', 'true_false', 3, NULL, 5.00, '1.0', '60', '管理人员的监督检查职责包括定期核查登记台账和系统上传记录,并对漏登、错登、代登等问题及时整改追责。', NULL, NULL, '[\"test.docx_6\"]', 25, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"管理人员的监督检查职责包括定期核查登记台账和系统上传记录,并对漏登、错登、代登等问题及时整改追责。\", \"answer\": true, \"explanation\": \"依据文档第五条明确指出:\'管理人员负责日常监督检查,定期核查登记台账、系统上传记录,对漏登、错登、代登等问题及时整改追责。\'\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:35'); +INSERT INTO `question` VALUES (545, '06031ee0-afa4-4d2b-b79b-ded993eee654', 'fill_blank', 3, NULL, 5.00, '1.0', '60', '根据文档第九条,旅客办理入住时,前台必须逐项登记的必填信息包括:姓名、性别、身份证号、___、联系电话、入住时间、预定退房时间、房号。', NULL, NULL, '[\"test.docx_12\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档第九条,旅客办理入住时,前台必须逐项登记的必填信息包括:姓名、性别、身份证号、___、联系电话、入住时间、预定退房时间、房号。\", \"answer\": [\"户籍地址\"], \"explanation\": \"依据文档第九条明确列出的必填信息项,\'户籍地址\'是其中一项关键登记内容,不可遗漏。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:35'); +INSERT INTO `question` VALUES (546, 'bf7d6457-14a7-4fb1-8906-c9e27e25e199', 'fill_blank', 3, NULL, 5.00, '1.0', '60', '根据文档第十八条,当班人员发现伪造证件时,须第一时间___并___。', NULL, NULL, '[\"test.docx_24\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档第十八条,当班人员发现伪造证件时,须第一时间___并___。\", \"answer\": [[\"保护现场\"], [\"向公安机关报备\"]], \"explanation\": \"依据文档第十八条:\'发现伪造证件、可疑人员、违法犯罪线索、突发事件,当班人员须第一时间保护现场并向公安机关报备。\'\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:34'); +INSERT INTO `question` VALUES (547, '00718848-949d-4913-9313-d15ede4da8c2', 'fill_blank', 3, NULL, 5.00, '1.0', '60', '根据文档第十九条,遇到纠纷、安全隐患或治安突发事件时,必须___,并按应急流程处置,不得隐瞒不报。', NULL, NULL, '[\"test.docx_25\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档第十九条,遇到纠纷、安全隐患或治安突发事件时,必须___,并按应急流程处置,不得隐瞒不报。\", \"answer\": [[\"立即上报门店负责人\"]], \"explanation\": \"依据文档第十九条原文:\'遇到纠纷、安全隐患、治安突发事件,立即上报门店负责人,按应急流程处置,不得隐瞒不报。\'\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:33'); +INSERT INTO `question` VALUES (548, '4d544b15-71c3-4335-ab0b-37b152206389', 'fill_blank', 3, NULL, 5.00, '1.0', '60', '根据规定,发现人证不符、证件伪造或冒用证件的,应一律不予入住,并立即上报___。', NULL, NULL, '[\"test.docx_10\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据规定,发现人证不符、证件伪造或冒用证件的,应一律不予入住,并立即上报___。\", \"answer\": [\"属地公安机关\"], \"explanation\": \"依据文档第八条:\'发现人证不符、证件伪造、冒用证件的,一律不予入住,并立即上报属地公安机关。\'\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:30'); +INSERT INTO `question` VALUES (549, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 3, NULL, 5.00, '1.0', '60', '根据规定,未成年人入住时须严格执行“五必须”,其中包括核实___身份、留存其___,并详细登记___信息。', NULL, NULL, '[\"test.docx_14\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 3}, \"stem\": \"根据规定,未成年人入住时须严格执行“五必须”,其中包括核实___身份、留存其___,并详细登记___信息。\", \"answer\": [[\"监护人\"], [\"联系方式\"], [\"随行人员\"]], \"explanation\": \"依据文档第十一条:‘未成年人入住严格执行“五必须”规定,核实监护人身份、留存联系方式,详细登记随行人员信息。’三个空分别对应该句中的核心执行要素。\"}', 'approved', '', '2026-05-30 14:41:49', '2026-05-31 11:53:18'); +INSERT INTO `question` VALUES (550, 'bfbe43d3-fc30-44a8-9b05-7b7520dc20d0', 'true_false', 3, NULL, 5.00, '1.0', '61', '需求分析是软件开发过程中用于明确用户需求、定义系统功能边界并为后续设计与实现提供依据的关键阶段。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"需求分析是软件开发过程中用于明确用户需求、定义系统功能边界并为后续设计与实现提供依据的关键阶段。\", \"answer\": true, \"explanation\": \"文档标题及主题明确为‘需求分析’,且该描述符合软件工程中对需求分析的标准定义,即聚焦于理解、捕获和规范用户需求,是系统开发的起点和基础。\"}', 'rejected', '', '2026-05-30 14:45:24', '2026-05-31 11:23:39'); +INSERT INTO `question` VALUES (551, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 3, NULL, 5.00, '1.0', '61', '请结合‘需求分析’这一核心主题,简述需求分析在软件开发过程中的主要目标及其关键活动,并说明为何该阶段是整个系统开发生命周期中至关重要的环节。', NULL, NULL, '[\"需求分析.docx_0\"]', 1, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"请结合‘需求分析’这一核心主题,简述需求分析在软件开发过程中的主要目标及其关键活动,并说明为何该阶段是整个系统开发生命周期中至关重要的环节。\", \"answer\": \"需求分析的主要目标是准确理解并明确用户及利益相关者的真实需求,将其转化为清晰、一致、可验证的系统需求规格说明。关键活动包括:需求获取(如访谈、问卷、观察)、需求建模(如用例图、活动图)、需求规格说明(编写SRS文档)、需求验证与确认(如评审、原型演示)。该阶段至关重要,因为它是后续设计、开发、测试的基础;需求错误或遗漏若未在早期发现,将导致后期返工成本剧增、项目延期甚至失败。\", \"explanation\": \"本题紧扣文档中反复强调的核心知识点‘需求分析’及‘主题:需求分析’,考察对需求分析本质、作用与实践维度的综合理解,符合难度等级3/5的要求;题目从目标、活动、重要性三个递进层面设问,体现多角度考查,且所有要点均依据‘需求分析’这一基础主题展开,严格遵循文档内容。\"}', 'approved', '', '2026-05-30 14:45:24', '2026-05-31 11:23:25'); +INSERT INTO `question` VALUES (552, '2d182ec6-38c2-4842-88bc-e7af9587799f', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档,零售价格指数的正确计算公式是?', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(零售指导价格 - 实际零售价格)/ 零售指导价格 × 100%\"}, {\"key\": \"B\", \"content\": \"实际零售价格 / 零售指导价格 × 100%\"}, {\"key\": \"C\", \"content\": \"零售指导价格 / 实际零售价格 × 100%\"}, {\"key\": \"D\", \"content\": \"实际零售价格 × 零售指导价格 × 100%\"}]}, \"stem\": \"根据文档,零售价格指数的正确计算公式是?\", \"answer\": \"B\", \"explanation\": \"文档明确给出公式:实际零售价格/零售指导价格*100%,选项B完全匹配该定义。其他选项均不符合原文表述。\"}', 'pending', NULL, '2026-06-02 06:38:39', '2026-06-02 06:38:39'); +INSERT INTO `question` VALUES (553, '6ada7561-0ca5-4657-a447-d933a4594240', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、新品上市时间\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、均衡满足比例、完全满足品规数、社会库存\"}, {\"key\": \"D\", \"content\": \"社会存销比、新品销售时长、订单满足率、顺销品规数\"}]}, \"stem\": \"根据文档规定,下列哪一组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:紧俏品规应依据‘零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率’等指标自动测评确定。选项B中四项(市场流通价格、社会存销比、订货面、订足面)均在原文所列指标范围内;A中‘新品上市时间’属‘新品’定义范畴,非紧俏判定指标;C中‘均衡满足比例’‘完全满足品规数’属于货源属性分类结果而非判定指标;D中‘新品销售时长’‘顺销品规数’亦非紧俏品规判定依据。\"}', 'pending', NULL, '2026-06-02 06:38:39', '2026-06-02 06:38:39'); +INSERT INTO `question` VALUES (554, '81049fd9-0b2e-4b2f-a153-0663e362b715', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档内容,‘单客户月度投放限量’的准确定义是:', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内对某一档级客户所有品规的累计供货总量上限\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据文档内容,‘单客户月度投放限量’的准确定义是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘单客户月度投放限量:公司对单客户单月最高供货量。’该定义强调主体是‘单客户’、时间维度是‘单月’、性质是‘最高供货量’,不区分品规或档级,与A(对应‘单客户单次单品规投放限量’)、B(文档未提‘所有品规累计’)、D(对应‘投放频次’)均不符。\"}', 'pending', NULL, '2026-06-02 06:38:39', '2026-06-02 06:38:39'); +INSERT INTO `question` VALUES (555, '20ce77ea-eb74-45b2-b6ca-a39960d8d490', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据《按价位段自选投放工作指引》,某品规的重购率计算公式中,分子应为以下哪一项?', NULL, NULL, '[\"1.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"上周订货客户数\"}, {\"key\": \"B\", \"content\": \"本周订货客户数\"}, {\"key\": \"C\", \"content\": \"连续两周订货客户数\"}, {\"key\": \"D\", \"content\": \"近四周平均订货客户数\"}]}, \"stem\": \"根据《按价位段自选投放工作指引》,某品规的重购率计算公式中,分子应为以下哪一项?\", \"answer\": \"C\", \"explanation\": \"依据文档chunk_id:1.docx_75,重购率公式明确为‘品规连续两周订货客户数/上周订货客户数×100%’,因此分子是‘连续两周订货客户数’,即同时在上周和本周均订货的客户数量。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:04'); +INSERT INTO `question` VALUES (556, 'b50a082c-3a14-421a-9df3-85346272aaa7', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档规定,一级指标合计权重的最低要求是多少?', NULL, NULL, '[\"1.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据文档规定,一级指标合计权重的最低要求是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该表述直接对应一级指标权重下限要求,是本知识点的核心依据。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:04'); +INSERT INTO `question` VALUES (557, 'f8f86705-8bbf-4329-a40d-c912fb990442', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档内容,市场状态被划分为哪五种类型?', NULL, NULL, '[\"1.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、畅、稳、滞、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、缓、急\"}, {\"key\": \"D\", \"content\": \"热、温、常、冷、寒\"}]}, \"stem\": \"根据文档内容,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接来源于chunk_id:1.docx_22。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:03'); +INSERT INTO `question` VALUES (558, 'c40f3731-16b7-4639-b9b9-c6c220122422', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档内容,以下哪一项不属于品规属性聚类评价中明确列出的聚类维度?', NULL, NULL, '[\"1.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"市场状态评分等级\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据文档内容,以下哪一项不属于品规属性聚类评价中明确列出的聚类维度?\", \"answer\": \"C\", \"explanation\": \"文档明确指出聚类维度包括‘价位段、价类、创新品类和所属工业企业、品牌等属性’,而‘市场状态评分等级’是聚类后的评价结果或标准参考项,并非用于聚类本身的属性维度。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:03'); +INSERT INTO `question` VALUES (559, '1cbebcec-050a-45f7-bbc8-70fe8696b599', 'single_choice', 3, NULL, 5.00, '1.0', '63', '根据文档规定,每个投放周期各价位段参与自选投放的规格数量下限是多少?', NULL, NULL, '[\"1.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不少于1个\"}, {\"key\": \"B\", \"content\": \"不少于2个\"}, {\"key\": \"C\", \"content\": \"不少于3个\"}, {\"key\": \"D\", \"content\": \"不少于5个\"}]}, \"stem\": \"根据文档规定,每个投放周期各价位段参与自选投放的规格数量下限是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘每个投放周期各价位段参与自选投放的规格不得少于3个’,这是对自选投放规格数量的刚性下限要求,直接对应知识点【自选投放规格数量下限】。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:03'); +INSERT INTO `question` VALUES (560, '42a9dda7-ff4e-46a7-b31e-ccfd36541060', 'fill_blank', 3, NULL, 5.00, '1.0', '63', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"1.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [\"实际零售价格/零售指导价格\"], \"explanation\": \"根据文档内容,零售价格指数=实际零售价格/零售指导价格×100%,该公式直接体现价格偏离指导价的程度。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:02'); +INSERT INTO `question` VALUES (561, 'e729211f-88a1-4da0-98ce-61606cf4e630', 'fill_blank', 3, NULL, 5.00, '1.0', '63', '根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"1.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据文档,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。\", \"answer\": [[\"订货面\"], [\"订足面\"]], \"explanation\": \"依据文档chunk_id:1.docx_16中‘(一)货源属性分类’第1条明确指出:‘紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。’\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:02'); +INSERT INTO `question` VALUES (562, '0bd202aa-826c-484f-9eba-d6bd20e7af25', 'fill_blank', 3, NULL, 5.00, '1.0', '63', '单客户月度投放限量是指公司对___的最高供货量。', NULL, NULL, '[\"1.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"单客户月度投放限量是指公司对___的最高供货量。\", \"answer\": [[\"单客户单月\"]], \"explanation\": \"根据文档内容,‘单客户月度投放限量:公司对单客户单月最高供货量。’该定义明确限定主体为‘单客户’、时间维度为‘单月’,故填空应为‘单客户单月’。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:01'); +INSERT INTO `question` VALUES (563, 'd2e3e81e-04d5-44c7-b8de-2d72e9ae4573', 'fill_blank', 3, NULL, 5.00, '1.0', '63', '重购率的计算公式为:___ / 上周订货客户数 × 100%。', NULL, NULL, '[\"1.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"重购率的计算公式为:___ / 上周订货客户数 × 100%。\", \"answer\": [\"品规连续两周订货客户数\"], \"explanation\": \"根据文档内容,重购率公式明确为‘品规连续两周订货客户数/上周订货客户数×100%’,分子反映持续订购该品规的客户数量,分母为基准期(上周)的订货客户数。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:01'); +INSERT INTO `question` VALUES (564, '01045a70-2b45-454c-aab5-bcc52b2eccec', 'fill_blank', 3, NULL, 5.00, '1.0', '63', '根据文档要求,一级指标合计权重的下限为___。', NULL, NULL, '[\"1.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据文档要求,一级指标合计权重的下限为___。\", \"answer\": [\"70%\"], \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,即其下限要求为70%。\"}', 'approved', '', '2026-06-02 06:38:39', '2026-06-02 14:42:00'); +INSERT INTO `question` VALUES (565, '5ce219d3-0e4b-41b2-ba0d-b0e4524890ab', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》中关于零售价格指数的定义,若某品规实际零售价格为120元,零售指导价格为100元,则该品规的零售价格指数为多少?', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"100%\"}, {\"key\": \"B\", \"content\": \"110%\"}, {\"key\": \"C\", \"content\": \"120%\"}, {\"key\": \"D\", \"content\": \"130%\"}]}, \"stem\": \"根据《货源投放工作规范》中关于零售价格指数的定义,若某品规实际零售价格为120元,零售指导价格为100元,则该品规的零售价格指数为多少?\", \"answer\": \"C\", \"explanation\": \"根据文档公式:零售价格指数 = 实际零售价格 / 零售指导价格 × 100% = 120 / 100 × 100% = 120%,故正确答案为C。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (566, 'cdbd9a09-feac-49bf-8ef7-fcaff81dea01', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,以下哪组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、客户投诉率\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、市场流通价格、社会库存、客户毛利率\"}, {\"key\": \"D\", \"content\": \"社会存销比、订足率、终端动销率、工商库存\"}]}, \"stem\": \"根据《货源投放工作规范》,以下哪组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:紧俏品规应依据‘零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率’等指标自动测评确定。选项B中四项均在原文列举范围内;A中‘客户投诉率’、C中‘客户毛利率’、D中‘终端动销率’‘工商库存’均未在文档中作为紧俏品规判定指标出现。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (567, '65722b98-e5cb-4921-a6ef-4f08baf0eb01', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,\'单客户月度投放限量\'的准确定义是:', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内对某一档级客户所有品规的累计供货总量\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据《货源投放工作规范》,\'单客户月度投放限量\'的准确定义是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出:\'单客户月度投放限量:公司对单客户单月最高供货量。\'选项A描述的是\'单客户单次单品规投放限量\',选项D描述的是\'投放频次\',均不符合题干所问定义。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (568, '55a0af18-5bcc-44ab-b997-90321004344b', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》中关于重购率的定义,以下哪一项是其正确的计算公式?', NULL, NULL, '[\"货源投放工作规范.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(本周订货客户数 - 上周订货客户数)/ 上周订货客户数 × 100%\"}, {\"key\": \"B\", \"content\": \"品规连续两周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"C\", \"content\": \"品规本周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"D\", \"content\": \"品规连续三周订货客户数 / 上周订货客户数 × 100%\"}]}, \"stem\": \"根据《货源投放工作规范》中关于重购率的定义,以下哪一项是其正确的计算公式?\", \"answer\": \"B\", \"explanation\": \"依据文档‘货源投放工作规范.docx_75’明确指出:重购率公式为‘品规连续两周订货客户数/上周订货客户数×100%’。选项B完全匹配原文公式;A为增长率计算逻辑,C缺少‘连续两周’关键条件,D中‘连续三周’与原文不符。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (569, '47384b42-decb-4610-b95e-c157f5f2f9e6', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,一级指标合计权重的最低要求是:', NULL, NULL, '[\"货源投放工作规范.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据《货源投放工作规范》,一级指标合计权重的最低要求是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该数值为强制性下限要求,是权重分配的刚性约束。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (570, '5a493eec-933c-4a1c-8c5c-aa7c32fe6736', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,市场状态被划分为哪五种类型?', NULL, NULL, '[\"货源投放工作规范.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、畅、稳、滞、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、缓、急\"}, {\"key\": \"D\", \"content\": \"热、温、冷、淡、疲\"}]}, \"stem\": \"根据《货源投放工作规范》,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接出自货源投放工作规范.docx_22第1页,是全省统一采用的分类标准。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (571, 'fd2b20d1-584f-4a10-8b45-1bb5eff8fe19', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪一项不属于文档明确列出的聚类维度?', NULL, NULL, '[\"货源投放工作规范.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"市场状态评分等级\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪一项不属于文档明确列出的聚类维度?\", \"answer\": \"C\", \"explanation\": \"文档明确指出聚类维度包括‘价位段、价类、创新品类和所属工业企业、品牌等属性’,而‘市场状态评分等级’是聚类后的评价结果,属于输出指标而非聚类维度,故C项不符合要求。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (572, 'dbc7c4dc-785c-4600-a3bd-893c608035dc', 'single_choice', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?', NULL, NULL, '[\"货源投放工作规范.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不少于1个\"}, {\"key\": \"B\", \"content\": \"不少于2个\"}, {\"key\": \"C\", \"content\": \"不少于3个\"}, {\"key\": \"D\", \"content\": \"不少于5个\"}]}, \"stem\": \"根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘每个投放周期各价位段参与自选投放的规格不得少于3个’,这是对自选投放规格数量的刚性下限要求。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (573, '5b99608a-bbdc-4435-9c86-f11e4953bd64', 'fill_blank', 3, NULL, 5.00, '1.0', '64', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [\"实际零售价格/零售指导价格\"], \"explanation\": \"根据文档内容,零售价格指数 = 实际零售价格 / 零售指导价格 × 100%,该公式直接来源于‘货源投放工作规范.docx_60’中‘3.按价位段自选投放工作指引 > 1.零售价格指数’部分。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (574, 'ca0df912-a41c-4851-8391-cf4cc1696759', 'fill_blank', 3, NULL, 5.00, '1.0', '64', '根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___以及订单满足率等指标自动测评确定。\", \"answer\": [\"订足面\"], \"explanation\": \"文档明确指出:\'紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。\'题干中已列出前四类及最后一类指标,空缺处对应原文中的\'订足面\'。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (575, '209d3717-8288-47aa-908f-c88caec74c09', 'fill_blank', 3, NULL, 5.00, '1.0', '64', '单客户月度投放限量是指公司对单客户在___内设定的最高供货量。', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"单客户月度投放限量是指公司对单客户在___内设定的最高供货量。\", \"answer\": [\"单月\"], \"explanation\": \"根据文档内容,‘单客户月度投放限量:公司对单客户单月最高供货量’,明确限定时间为‘单月’。\"}', 'pending', NULL, '2026-06-02 06:42:17', '2026-06-02 06:42:17'); +INSERT INTO `question` VALUES (576, '3d579fe3-8e59-4ada-af16-697538ae7b8b', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》文档,零售价格指数的正确计算公式是:', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(零售指导价格 - 实际零售价格)/ 零售指导价格 × 100%\"}, {\"key\": \"B\", \"content\": \"实际零售价格 / 零售指导价格 × 100%\"}, {\"key\": \"C\", \"content\": \"零售指导价格 / 实际零售价格 × 100%\"}, {\"key\": \"D\", \"content\": \"(实际零售价格 + 零售指导价格)/ 零售指导价格 × 100%\"}]}, \"stem\": \"根据《货源投放工作规范》文档,零售价格指数的正确计算公式是:\", \"answer\": \"B\", \"explanation\": \"文档明确指出零售价格指数公式为‘实际零售价格/零售指导价格*100%’,选项B完全匹配原文公式。其他选项均与文档内容不符。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (577, '0250d6ee-d803-4848-b35e-68ffc1c71d37', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,以下哪组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、客户投诉率\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、市场流通价格、社会库存、客户毛利率\"}, {\"key\": \"D\", \"content\": \"社会存销比、订足率、终端动销率、工商库存\"}]}, \"stem\": \"根据《货源投放工作规范》,以下哪组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:紧俏品规应依据‘零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率’等指标自动测评确定。选项B中四项均在原文列举范围内;A中‘客户投诉率’、C中‘客户毛利率’、D中‘终端动销率’‘工商库存’均未在文档中作为紧俏品规判定指标出现。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (578, '40922dbb-399b-45d4-ae5e-bfe42909669e', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,\'单客户月度投放限量\'的准确定义是:', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内所有客户对该单品规的总供货上限\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据《货源投放工作规范》,\'单客户月度投放限量\'的准确定义是:\", \"answer\": \"C\", \"explanation\": \"依据文档原文:\'单客户月度投放限量:公司对单客户单月最高供货量。\' 选项A描述的是\'单客户单次单品规投放限量\',选项D描述的是\'投放频次\',均不符合题干所问定义。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (579, '9bc113c7-9c1b-4540-bb5f-a04f90cce31f', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》中关于重购率的定义,以下哪一项是其正确的计算公式?', NULL, NULL, '[\"货源投放工作规范.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(本周订货客户数 - 上周订货客户数)/ 上周订货客户数 × 100%\"}, {\"key\": \"B\", \"content\": \"品规连续两周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"C\", \"content\": \"品规本周订货客户数 / 上周订货客户数 × 100%\"}, {\"key\": \"D\", \"content\": \"品规连续三周订货客户数 / 上周订货客户数 × 100%\"}]}, \"stem\": \"根据《货源投放工作规范》中关于重购率的定义,以下哪一项是其正确的计算公式?\", \"answer\": \"B\", \"explanation\": \"依据文档‘货源投放工作规范.docx_75’第1页明确指出:重购率公式为‘品规连续两周订货客户数/上周订货客户数×100%’。该公式强调‘连续两周订货’这一关键条件,用于衡量客户对某品规的持续需求程度,而非单周变动或更长周期。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (580, '0a65fe86-36ae-40b7-83de-7ab38afdeee8', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,一级指标合计权重的最低要求是:', NULL, NULL, '[\"货源投放工作规范.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据《货源投放工作规范》,一级指标合计权重的最低要求是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该数值为强制性下限要求,是权重分配的刚性约束。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (581, 'bb0bd825-ee66-44b2-9d0b-f327fdf02149', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》文档,市场状态被划分为哪五种类型?', NULL, NULL, '[\"货源投放工作规范.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、滞、稳、缓、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、稳、异\"}, {\"key\": \"D\", \"content\": \"热、冷、匀、积、压\"}]}, \"stem\": \"根据《货源投放工作规范》文档,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接出自chunk_id:货源投放工作规范.docx_22,是唯一符合原文的选项。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (582, '791c65b1-d888-4fd8-bacb-a5afe924c3cd', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪项不属于文档明确列出的聚类依据属性?', NULL, NULL, '[\"货源投放工作规范.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"上市时间\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪项不属于文档明确列出的聚类依据属性?\", \"answer\": \"C\", \"explanation\": \"文档原文明确指出聚类依据属性包括‘价位段、价类、创新品类和所属工业企业、品牌等属性’,未提及‘上市时间’,因此C项不属于文档规定的聚类评价属性。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (583, 'ce45f9f3-683f-4711-9d4d-64e5e0adb810', 'single_choice', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?', NULL, NULL, '[\"货源投放工作规范.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不少于1个\"}, {\"key\": \"B\", \"content\": \"不少于2个\"}, {\"key\": \"C\", \"content\": \"不少于3个\"}, {\"key\": \"D\", \"content\": \"不少于5个\"}]}, \"stem\": \"根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘每个投放周期各价位段参与自选投放的规格不得少于3个’,这是对自选投放规格数量的刚性下限要求,直接对应知识点【自选投放规格数量下限】。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (584, 'a797ce85-b242-48b2-9232-ba399ac53137', 'fill_blank', 3, NULL, 5.00, '1.0', '65', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [[\"实际零售价格/零售指导价格\"]], \"explanation\": \"根据文档内容,零售价格指数=实际零售价格/零售指导价格×100%,该公式直接体现价格执行情况与指导价的偏离程度。\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (585, 'd839dbaf-4a6c-4b54-b7f6-fe588a4f862d', 'fill_blank', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 2}, \"stem\": \"根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___、___以及订单满足率等指标自动测评确定。\", \"answer\": [[\"订货面\"], [\"订足面\"]], \"explanation\": \"文档明确指出:\'紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。\'\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (586, '6ee3ce06-3784-40d3-bb22-950ebf7cd71d', 'fill_blank', 3, NULL, 5.00, '1.0', '65', '根据《货源投放工作规范》,单客户月度投放限量是指公司对___的最高供货量。', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据《货源投放工作规范》,单客户月度投放限量是指公司对___的最高供货量。\", \"answer\": [[\"单客户单月\"]], \"explanation\": \"依据文档内容:\'单客户月度投放限量:公司对单客户单月最高供货量。\'\"}', 'pending', NULL, '2026-06-02 07:50:14', '2026-06-02 07:50:14'); +INSERT INTO `question` VALUES (587, '14178db0-193d-4136-a189-5e4ec1874b0c', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》中关于零售价格指数的定义,若某品规实际零售价格为120元,零售指导价格为100元,则该品规的零售价格指数为多少?', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"100%\"}, {\"key\": \"B\", \"content\": \"110%\"}, {\"key\": \"C\", \"content\": \"120%\"}, {\"key\": \"D\", \"content\": \"130%\"}]}, \"stem\": \"根据《货源投放工作规范》中关于零售价格指数的定义,若某品规实际零售价格为120元,零售指导价格为100元,则该品规的零售价格指数为多少?\", \"answer\": \"C\", \"explanation\": \"根据文档公式:零售价格指数 = 实际零售价格 / 零售指导价格 × 100% = 120 / 100 × 100% = 120%,故正确答案为C。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (588, '47cc20b3-3753-4e0a-a1b0-898870700a01', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,下列哪组指标全部属于紧俏品规的自动测评判定依据?', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"零售价格、社会库存、订单满足率、新品上市时长\"}, {\"key\": \"B\", \"content\": \"市场流通价格、社会存销比、订货面、订足面\"}, {\"key\": \"C\", \"content\": \"零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面、订单满足率\"}, {\"key\": \"D\", \"content\": \"零售价格、社会库存、均衡满足比例、完全满足比例\"}]}, \"stem\": \"根据《货源投放工作规范》,下列哪组指标全部属于紧俏品规的自动测评判定依据?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:‘紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定’,共七项指标,C选项完整涵盖且无冗余或错误项;A含‘新品上市时长’(属‘新品’定义指标,非紧俏判定依据);B遗漏零售价格、订单满足率;D中‘均衡满足比例’‘完全满足比例’未在文档中作为紧俏判定指标出现。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (589, 'cde64681-ed12-4c61-84a1-81de428e35d5', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,\'单客户月度投放限量\'是指:', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"单品规在一个投放周期内对某一档级客户的最高供货量\"}, {\"key\": \"B\", \"content\": \"单月内对某一档级客户所有品规的累计供货总量上限\"}, {\"key\": \"C\", \"content\": \"公司对单客户单月最高供货量\"}, {\"key\": \"D\", \"content\": \"单月单品规对某一档级客户的投放次数上限\"}]}, \"stem\": \"根据《货源投放工作规范》,\'单客户月度投放限量\'是指:\", \"answer\": \"C\", \"explanation\": \"文档明确定义:\'单客户月度投放限量:公司对单客户单月最高供货量。\'选项C完全匹配原文表述;A对应的是\'单客户单次单品规投放限量\',D对应\'投放频次\',B为干扰项,文档中未出现该定义。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (590, '973cc436-c00a-479a-83a2-f59dba8d4581', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》中关于重购率的定义,以下哪一项是正确的重购率计算公式?', NULL, NULL, '[\"货源投放工作规范.docx_75\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"(本周订货客户数 / 上周订货客户数)×100%\"}, {\"key\": \"B\", \"content\": \"(连续两周订货客户数 / 上周订货客户数)×100%\"}, {\"key\": \"C\", \"content\": \"(连续两周订货客户数 / 本周订货客户数)×100%\"}, {\"key\": \"D\", \"content\": \"(上周订货客户数 / 连续两周订货客户数)×100%\"}]}, \"stem\": \"根据《货源投放工作规范》中关于重购率的定义,以下哪一项是正确的重购率计算公式?\", \"answer\": \"B\", \"explanation\": \"依据文档‘货源投放工作规范.docx_75’第1页‘3.按价位段自选投放工作指引 > 5.重购率’明确指出:重购率公式为‘品规连续两周订货客户数/上周订货客户数×100%’,故B选项完全匹配原文公式。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (591, 'a2edc974-ec67-4739-a31c-309e49e21dc4', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,一级指标合计权重的最低要求是:', NULL, NULL, '[\"货源投放工作规范.docx_86\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不低于50%\"}, {\"key\": \"B\", \"content\": \"不低于60%\"}, {\"key\": \"C\", \"content\": \"不低于70%\"}, {\"key\": \"D\", \"content\": \"不低于80%\"}]}, \"stem\": \"根据《货源投放工作规范》,一级指标合计权重的最低要求是:\", \"answer\": \"C\", \"explanation\": \"文档明确指出‘一级指标合计权重不低于70%’,该数值为强制性下限要求,是权重分配的刚性约束。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (592, '8452862e-fd3b-4048-bfba-b42a489bc84d', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》文档,市场状态被划分为哪五种类型?', NULL, NULL, '[\"货源投放工作规范.docx_22\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"旺、滞、稳、缓、衰\"}, {\"key\": \"B\", \"content\": \"俏、紧、平、松、软\"}, {\"key\": \"C\", \"content\": \"高、中、低、稳、异\"}, {\"key\": \"D\", \"content\": \"热、冷、匀、积、压\"}]}, \"stem\": \"根据《货源投放工作规范》文档,市场状态被划分为哪五种类型?\", \"answer\": \"B\", \"explanation\": \"文档明确指出:\'市场状态分为“俏、紧、平、松、软”五种类型\',该表述直接出自chunk_id:货源投放工作规范.docx_22,是唯一准确的官方分类。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (593, '1ba1c080-e75e-4796-b277-ec8f7d1d804d', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪项不属于文档明确列出的聚类维度?', NULL, NULL, '[\"货源投放工作规范.docx_90\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"价位段\"}, {\"key\": \"B\", \"content\": \"价类\"}, {\"key\": \"C\", \"content\": \"市场状态评分等级\"}, {\"key\": \"D\", \"content\": \"所属工业企业\"}]}, \"stem\": \"根据《货源投放工作规范》,在进行品规属性聚类评价时,以下哪项不属于文档明确列出的聚类维度?\", \"answer\": \"C\", \"explanation\": \"文档原文明确指出聚类维度包括‘价位段、价类、创新品类和所属工业企业、品牌等属性’,而‘市场状态评分等级’是聚类后的评价结果或计算依据,并非用于聚类本身的属性维度。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (594, 'b33d5f8d-392a-42a7-be57-824fe2479a90', 'single_choice', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?', NULL, NULL, '[\"货源投放工作规范.docx_100\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"不少于1个\"}, {\"key\": \"B\", \"content\": \"不少于2个\"}, {\"key\": \"C\", \"content\": \"不少于3个\"}, {\"key\": \"D\", \"content\": \"不少于5个\"}]}, \"stem\": \"根据《货源投放工作规范》,每个投放周期内,各价位段参与自选投放的规格数量下限是多少?\", \"answer\": \"C\", \"explanation\": \"文档明确指出:\'每个投放周期各价位段参与自选投放的规格不得少于3个。\'这是对自选投放规格数量下限的刚性要求,直接对应知识点【自选投放规格数量下限】。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (595, 'd6e2ad7a-1df7-4ce9-a27a-9163d930176c', 'fill_blank', 3, NULL, 5.00, '1.0', '67', '零售价格指数的计算公式为:___ × 100%。', NULL, NULL, '[\"货源投放工作规范.docx_60\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"零售价格指数的计算公式为:___ × 100%。\", \"answer\": [\"实际零售价格/零售指导价格\"], \"explanation\": \"根据文档内容,零售价格指数 = 实际零售价格 / 零售指导价格 × 100%,该公式直接来源于‘货源投放工作规范.docx_60’中‘3.按价位段自选投放工作指引 > 1.零售价格指数’部分。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (596, 'f42fb33c-1c73-42a3-b751-e3eea53ea181', 'fill_blank', 3, NULL, 5.00, '1.0', '67', '根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___以及订单满足率等指标自动测评确定。', NULL, NULL, '[\"货源投放工作规范.docx_16\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"根据《货源投放工作规范》,紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、___以及订单满足率等指标自动测评确定。\", \"answer\": [\"订足面\"], \"explanation\": \"文档明确指出:\'紧俏品规应依据零售价格、市场流通价格、社会库存、社会存销比、订货面、订足面以及订单满足率等指标自动测评确定。\'题干中已列出前四类及最后一类指标,空缺处对应原文中的\'订足面\'。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (597, 'a0b1275d-f33d-434e-8f18-feafbe8f4690', 'fill_blank', 3, NULL, 5.00, '1.0', '67', '单客户月度投放限量是指公司对单客户在___内设定的最高供货量。', NULL, NULL, '[\"货源投放工作规范.docx_20\"]', 25, 0, 0, NULL, NULL, '{\"data\": {\"blank_count\": 1}, \"stem\": \"单客户月度投放限量是指公司对单客户在___内设定的最高供货量。\", \"answer\": [\"单月\"], \"explanation\": \"根据文档内容,‘单客户月度投放限量:公司对单客户单月最高供货量’,明确其时间单位为‘单月’。\"}', 'pending', NULL, '2026-06-02 07:55:10', '2026-06-02 07:55:10'); +INSERT INTO `question` VALUES (598, '310a150f-2494-4cfe-942f-59deae6743c4', 'single_choice', 3, NULL, 5.00, '1.0', '68', '根据文档内容,关于供货面、订货面、订足面的定义标准,以下哪项表述完全正确?', NULL, NULL, '[\"1.txt_1\"]', 12, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"供货面=实际订货客户数/可供货客户数;订货面=投放客户数/可供货客户数;订足面=按实际投放限量订足的客户数/投放客户数\"}, {\"key\": \"B\", \"content\": \"供货面=投放客户数/可供货客户数;订货面=实际订货客户数/投放客户数;订足面=按实际投放限量订足的客户数/实际订货客户数\"}, {\"key\": \"C\", \"content\": \"供货面=投放客户数/可供货客户数;订货面=实际订货客户数/可供货客户数;订足面=订足客户数/投放客户数\"}, {\"key\": \"D\", \"content\": \"供货面=实际订货客户数/投放客户数;订货面=按实际投放限量订足的客户数/实际订货客户数;订足面=订足客户数/可供货客户数\"}]}, \"stem\": \"根据文档内容,关于供货面、订货面、订足面的定义标准,以下哪项表述完全正确?\", \"answer\": \"B\", \"explanation\": \"依据文档chunk_id:1.txt_1第2页‘(二)货源投放策略指标’明确:供货面=投放客户数/可供货客户数;订货面=实际订货客户数/投放客户数;订足面=按实际投放限量订足的客户数/实际订货客户数。其他选项均混淆了分子分母关系或指标定义。\"}', 'pending', NULL, '2026-06-02 08:10:53', '2026-06-02 08:10:53'); +INSERT INTO `question` VALUES (599, '9d25c6dd-8eb0-4f2c-870d-92912be104e2', 'single_choice', 3, NULL, 5.00, '1.0', '68', '根据《按价位段自选投放工作指引》,以下哪类卷烟规格原则上不应纳入按价位段自选投放范围?', NULL, NULL, '[\"1.txt_10\", \"1.txt_1\"]', 12, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"二类以上、市场状态为‘平’的均衡满足品规\"}, {\"key\": \"B\", \"content\": \"销售时间不足24个月的二类新品\"}, {\"key\": \"C\", \"content\": \"紧俏烟,且经系统测评其数量占比为18%\"}, {\"key\": \"D\", \"content\": \"正在退市清盘的在销二类卷烟\"}]}, \"stem\": \"根据《按价位段自选投放工作指引》,以下哪类卷烟规格原则上不应纳入按价位段自选投放范围?\", \"answer\": \"D\", \"explanation\": \"文档明确指出:\'除新品、紧俏烟、雪茄烟、国产混合型卷烟、进口烟以及一些特殊产品(如正在或即将退市清盘的产品、罚没烟等)外,原则上二类以上所有卷烟规格均应纳入按价位段自选投放\'。因此,正在退市清盘的产品属于明确排除范围。A、B、C均符合纳入条件(A属均衡满足品规且未超40%限额;B属新品,未超24个月;C属紧俏烟且占比≤20%,符合要求)。\"}', 'pending', NULL, '2026-06-02 08:10:53', '2026-06-02 08:10:53'); +INSERT INTO `question` VALUES (600, 'e6cb0f3b-a7ed-4c9a-910f-902bf923dabc', 'single_choice', 3, NULL, 5.00, '1.0', '68', '某地市公司某品规出现‘供货面为低、订货面为高、订足面为低、市场状态评价为平’的情形,根据文档内容,该状态下最可能存在的投放策略问题是?', NULL, NULL, '[\"1.txt_9\"]', 12, 0, 0, NULL, NULL, '{\"data\": {\"options\": [{\"key\": \"A\", \"content\": \"供货面设定过高,导致货源外流风险增大\"}, {\"key\": \"B\", \"content\": \"分客户档级投放限量设定偏高,造成供非所求\"}, {\"key\": \"C\", \"content\": \"投放频次设置偏低,无法满足客户持续订货需求\"}, {\"key\": \"D\", \"content\": \"供货面设定较低且所投放客户档级针对性不强,同时分客户档级投放限量设定偏高\"}]}, \"stem\": \"某地市公司某品规出现‘供货面为低、订货面为高、订足面为低、市场状态评价为平’的情形,根据文档内容,该状态下最可能存在的投放策略问题是?\", \"answer\": \"D\", \"explanation\": \"依据文档chunk_id:1.txt_9第2条:‘供货面为低、订货面为高、订足面为低、市场状态评价为平’对应的问题明确列出三项——‘供货面设定较低;所投放的客户档级针对性不强;分客户档级投放限量设定偏高’,选项D完整涵盖这三方面,其他选项均只覆盖部分或与原文不符(如A中‘供货面设定过高’与原文‘低’矛盾;C中‘投放频次偏低’未在该情形下被列为问题)。\"}', 'pending', NULL, '2026-06-02 08:10:53', '2026-06-02 08:10:53'); +INSERT INTO `question` VALUES (601, '5a38baf2-f2a9-4557-ba22-9a79ec7b1a01', 'subjective', 3, NULL, 5.00, '1.0', '68', '根据文档内容,请分别写出‘供货面’、‘订货面’和‘订足面’的明确定义(含计算公式及说明),并指出三者在数值判断上的通用标准(即‘高’‘低’的阈值)。', NULL, NULL, '[\"1.txt_10\", \"1.txt_1\"]', 12, 0, 0, NULL, NULL, '{\"data\": null, \"stem\": \"根据文档内容,请分别写出‘供货面’、‘订货面’和‘订足面’的明确定义(含计算公式及说明),并指出三者在数值判断上的通用标准(即‘高’‘低’的阈值)。\", \"answer\": \"供货面:投放客户数/可供货客户数;反映货源覆盖的客户广度。订货面:实际订货客户数/投放客户数;反映已获投放资格的客户中实际参与订购的比例。订足面:按实际投放限量订足的客户数/实际订货客户数;反映订货客户中达到单次/单月投放上限的比例。判断标准:供货面和订货面一般以60%以上为‘高’、20%以下为‘低’;订足面一般以90%以上为‘高’、20%以下为‘低’。\", \"explanation\": \"定义及公式依据文档chunk_id:1.txt_1第2页‘(二)零售客户订货指标’部分;判断标准依据chunk_id:1.txt_10第11页‘备注1’。\"}', 'pending', NULL, '2026-06-02 08:10:53', '2026-06-02 08:10:53'); + +-- ---------------------------- +-- Table structure for role +-- ---------------------------- +DROP TABLE IF EXISTS `role`; +CREATE TABLE `role` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色 ID', + `uuid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'uuid()' COMMENT '唯一标识符(用于角色判断和数据交互)', + `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称', + `role_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编码', + `role_type` tinyint NULL DEFAULT 3 COMMENT '角色类型:1 超级管理员角色,2 部门管理员角色,3 普通用户角色', + `description` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色描述', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用', + `sort_order` int NULL DEFAULT 0 COMMENT '排序号', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_uuid`(`uuid` ASC) USING BTREE, + UNIQUE INDEX `uk_role_code`(`role_code` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of role +-- ---------------------------- +INSERT INTO `role` VALUES (1, 'dad50ea8-392f-11f1-8d4f-7cb566b7fb8c', '系统管理员', 'admin', 1, '系统管理员,拥有所有权限', 1, 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `role` VALUES (2, 'dad51391-392f-11f1-8d4f-7cb566b7fb8c', '部门管理员', 'dept_admin', 2, '部门管理员,管理本部门事务', 1, 2, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `role` VALUES (3, 'dad5150d-392f-11f1-8d4f-7cb566b7fb8c', '普通用户', 'general_user', 3, '普通用户,基础使用权限', 1, 3, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); + +-- ---------------------------- +-- Table structure for role_permission +-- ---------------------------- +DROP TABLE IF EXISTS `role_permission`; +CREATE TABLE `role_permission` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `role_id` bigint NOT NULL COMMENT '角色 ID', + `permission_id` bigint NOT NULL COMMENT '权限 ID', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_role_permission`(`role_id` ASC, `permission_id` ASC) USING BTREE, + INDEX `idx_role_id`(`role_id` ASC) USING BTREE, + INDEX `idx_permission_id`(`permission_id` ASC) USING BTREE, + CONSTRAINT `fk_role_permission_permission` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_role_permission_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 705 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色权限关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of role_permission +-- ---------------------------- +INSERT INTO `role_permission` VALUES (509, 2, 1, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (510, 2, 8, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (511, 2, 9, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (512, 2, 10, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (513, 2, 12, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (514, 2, 66, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (515, 2, 2, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (516, 2, 13, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (517, 2, 14, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (518, 2, 15, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (519, 2, 3, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (520, 2, 16, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (521, 2, 17, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (522, 2, 4, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (523, 2, 18, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (524, 2, 19, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (525, 2, 20, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (526, 2, 21, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (527, 2, 67, '2026-05-17 10:21:30'); +INSERT INTO `role_permission` VALUES (552, 3, 8, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (553, 3, 9, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (554, 3, 10, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (555, 3, 16, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (556, 3, 21, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (557, 3, 67, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (558, 3, 1, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (559, 3, 3, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (560, 3, 4, '2026-05-30 01:28:26'); +INSERT INTO `role_permission` VALUES (686, 1, 8, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (687, 1, 9, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (688, 1, 10, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (689, 1, 13, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (690, 1, 16, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (691, 1, 25, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (692, 1, 26, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (693, 1, 48, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (694, 1, 65, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (695, 1, 66, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (696, 1, 67, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (697, 1, 68, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (698, 1, 20, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (699, 1, 1, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (700, 1, 3, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (701, 1, 4, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (702, 1, 7, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (703, 1, 55, '2026-05-31 04:17:01'); +INSERT INTO `role_permission` VALUES (704, 1, 60, '2026-05-31 04:17:01'); + +-- ---------------------------- +-- Table structure for system_config +-- ---------------------------- +DROP TABLE IF EXISTS `system_config`; +CREATE TABLE `system_config` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `config_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '配置键', + `config_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '配置值', + `config_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'STRING' COMMENT '配置类型:STRING, NUMBER, BOOLEAN, JSON', + `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '配置描述', + `is_system` tinyint NULL DEFAULT 0 COMMENT '是否系统配置:1 是(不可删除),0 否', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_config_key`(`config_key` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统配置表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of system_config +-- ---------------------------- +INSERT INTO `system_config` VALUES (1, 'system_name', '知识管理系统', 'STRING', '系统名称', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `system_config` VALUES (2, 'system_version', '1.0.0', 'STRING', '系统版本', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `system_config` VALUES (3, 'default_user_role', 'general_user', 'STRING', '默认用户角色编码', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `system_config` VALUES (4, 'session_timeout', '7200', 'NUMBER', '会话超时时间(秒)', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `system_config` VALUES (5, 'password_expire_days', '90', 'NUMBER', '密码过期天数', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); +INSERT INTO `system_config` VALUES (6, 'login_fail_limit', '5', 'NUMBER', '登录失败限制次数', 1, '2026-04-16 09:01:52', '2026-04-16 09:01:52'); + +-- ---------------------------- +-- Table structure for user +-- ---------------------------- +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户 ID', + `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名', + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码(bcrypt加密)', + `real_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '真实姓名', + `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号', + `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像', + `user_type` tinyint NOT NULL DEFAULT 3 COMMENT '用户类型:1 系统管理员,2 部门管理员,3 普通用户', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1 启用,0 禁用', + `dept_id` bigint NULL DEFAULT NULL COMMENT '所属部门 ID', + `last_login_time` datetime NULL DEFAULT NULL COMMENT '最后登录时间', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE, + UNIQUE INDEX `uk_email`(`email` ASC) USING BTREE, + UNIQUE INDEX `uk_phone`(`phone` ASC) USING BTREE, + INDEX `idx_user_type`(`user_type` ASC) USING BTREE, + INDEX `idx_dept_id`(`dept_id` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + CONSTRAINT `fk_user_dept` FOREIGN KEY (`dept_id`) REFERENCES `department` (`id`) ON DELETE SET NULL ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of user +-- ---------------------------- +INSERT INTO `user` VALUES (1, 'admin', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '系统管理员', 'admin@system.com', '13800000000', NULL, 1, 1, 1, NULL, '2026-04-16 09:05:15', '2026-05-10 19:08:59'); +INSERT INTO `user` VALUES (2, 'dept_admin', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '技术部门管理员', 'dept@tech.com', '13800000001', NULL, 2, 1, 3, NULL, '2026-04-16 09:05:15', '2026-05-17 16:52:37'); +INSERT INTO `user` VALUES (3, 'user', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '普通用户', 'user@tech.com', '13800000002', NULL, 3, 1, 1, NULL, '2026-04-16 09:05:15', '2026-04-16 09:12:17'); +INSERT INTO `user` VALUES (4, 'user_1', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '用户1', 'user_1@example.com', '13800000003', NULL, 3, 1, 1, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); +INSERT INTO `user` VALUES (5, 'user_2', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '用户2', 'user_2@example.com', '13800000004', NULL, 3, 1, 2, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); +INSERT INTO `user` VALUES (6, 'user_3', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '用户3', 'user_3@example.com', '13800000005', NULL, 3, 1, 3, NULL, '2026-04-16 19:30:00', '2026-04-16 19:30:00'); +INSERT INTO `user` VALUES (7, 'dept_admin_purchase', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '采购部门管理员', 'dept_admin_purchase@example.com', '13800000006', NULL, 2, 1, 4, NULL, '2026-04-16 19:30:00', '2026-05-17 16:52:32'); +INSERT INTO `user` VALUES (8, 'adminhr', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '人事部门管理员', 'dept_admin_hr@example.com', '13800000007', NULL, 2, 1, 6, NULL, '2026-04-16 19:30:00', '2026-05-17 16:52:12'); +INSERT INTO `user` VALUES (9, 'dept_admin_public', '$2a$10$o/mdlaGbhm7Fm0yrYFGICODFyqYUu/cd9Nj1xzc/qD9r3ukNZQr8W', '公共部门管理员', 'dept_admin_public@example.com', '13800000008', NULL, 2, 1, 7, NULL, '2026-04-16 19:30:00', '2026-05-17 16:52:23'); + +-- ---------------------------- +-- Table structure for user_answer +-- ---------------------------- +DROP TABLE IF EXISTS `user_answer`; +CREATE TABLE `user_answer` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '用户ID', + `question_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '题目UUID', + `question_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '题目类型:single_choice | multiple_choice | true_false | fill_blank | subjective', + `answer_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'practice' COMMENT '答题类型:formal_exam-正式考试,mock_exam-模拟考,practice-日常练习', + `paper_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '试卷ID(考试答题时关联)', + `session_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '会话ID(日常练习时关联对话上下文)', + `user_answer` json NOT NULL COMMENT '用户答案(JSON格式,支持各种题型)', + `score` decimal(5, 2) NULL DEFAULT 0.00 COMMENT '得分', + `max_score` decimal(5, 2) NULL DEFAULT NULL COMMENT '题目满分', + `is_correct` tinyint NULL DEFAULT NULL COMMENT '是否正确:0-错误,1-正确,2-部分正确', + `grading_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'auto' COMMENT '批改类型:auto-自动批改,manual-人工批改,llm-AI批改', + `feedback` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '批改反馈/评语', + `graded_by` bigint NULL DEFAULT NULL COMMENT '批阅人ID', + `graded_at` datetime NULL DEFAULT NULL COMMENT '批阅时间', + `time_spent` int NULL DEFAULT 0 COMMENT '单题答题耗时(秒)', + `paper_time_spent` int NULL DEFAULT 0 COMMENT '试卷总耗时(秒,仅考试场景使用)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '答题时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_question_type`(`user_id` ASC, `question_id` ASC, `paper_id` ASC, `answer_type` ASC) USING BTREE COMMENT '用户-题目-试卷-答题类型唯一索引', + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_question_id`(`question_id` ASC) USING BTREE, + INDEX `idx_paper_id`(`paper_id` ASC) USING BTREE, + INDEX `idx_answer_type`(`answer_type` ASC) USING BTREE, + INDEX `idx_created_at`(`created_at` ASC) USING BTREE, + CONSTRAINT `fk_user_answer_question` FOREIGN KEY (`question_id`) REFERENCES `question` (`question_id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_user_answer_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 360 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户答题记录表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of user_answer +-- ---------------------------- +INSERT INTO `user_answer` VALUES (314, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '\"\"', 5.00, 5.00, 1, 'ai', '正确!', NULL, NULL, 0, 0, '2026-06-01 14:26:25', '2026-06-01 14:26:25'); +INSERT INTO `user_answer` VALUES (315, 1, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '[]', 4.00, 4.00, 1, 'ai', '正确!', NULL, NULL, 0, 0, '2026-06-01 14:26:25', '2026-06-01 14:26:25'); +INSERT INTO `user_answer` VALUES (316, 1, 'bfbe43d3-fc30-44a8-9b05-7b7520dc20d0', 'true_false', 'formal_exam', '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '\"false\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-01 14:26:25', '2026-06-01 14:26:25'); +INSERT INTO `user_answer` VALUES (317, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:26:25', '2026-06-01 14:26:25'); +INSERT INTO `user_answer` VALUES (318, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '31304832-7a1f-49e0-9f31-ed723d6d64f0', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:26:25', '2026-06-01 14:26:25'); +INSERT INTO `user_answer` VALUES (319, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', NULL, '\"A\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-01 14:26:37', '2026-06-01 14:26:37'); +INSERT INTO `user_answer` VALUES (320, 1, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', NULL, '[\"B\", \"A\"]', 0.00, 4.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-01 14:26:37', '2026-06-01 14:26:37'); +INSERT INTO `user_answer` VALUES (321, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:26:37', '2026-06-01 14:26:37'); +INSERT INTO `user_answer` VALUES (322, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '2bd9f7a1-5022-4133-9724-9dd5e25a601c', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:26:37', '2026-06-01 14:26:37'); +INSERT INTO `user_answer` VALUES (323, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', NULL, '\"A\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-01 14:27:23', '2026-06-01 14:27:23'); +INSERT INTO `user_answer` VALUES (324, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', NULL, '[\"1\", \"1\", \"1\"]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:27:23', '2026-06-01 14:27:23'); +INSERT INTO `user_answer` VALUES (325, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '72e283f2-683b-4f5b-b76e-39e3047b2ebe', NULL, '\"123123\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-01 14:27:23', '2026-06-01 14:27:23'); +INSERT INTO `user_answer` VALUES (326, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '\"A\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 00:10:02', '2026-06-02 00:10:02'); +INSERT INTO `user_answer` VALUES (327, 1, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '[\"A\", \"B\"]', 0.00, 4.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 00:10:02', '2026-06-02 00:10:02'); +INSERT INTO `user_answer` VALUES (328, 1, 'bfbe43d3-fc30-44a8-9b05-7b7520dc20d0', 'true_false', 'formal_exam', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '\"false\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 00:10:02', '2026-06-02 00:10:02'); +INSERT INTO `user_answer` VALUES (329, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 00:10:02', '2026-06-02 00:10:02'); +INSERT INTO `user_answer` VALUES (330, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '698eb5fa-13fa-4ed0-9831-6ee6934f053e', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 00:10:02', '2026-06-02 00:10:02'); +INSERT INTO `user_answer` VALUES (331, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', NULL, '\"\"', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 03:16:24', '2026-06-02 03:16:24'); +INSERT INTO `user_answer` VALUES (332, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', NULL, '[]', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 03:16:24', '2026-06-02 03:16:24'); +INSERT INTO `user_answer` VALUES (333, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', 'ce575b92-2a3c-43ba-a1a5-4f3d2efb9f2c', NULL, '\"\"', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 03:16:24', '2026-06-02 03:16:24'); +INSERT INTO `user_answer` VALUES (334, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '\"A\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 03:17:04', '2026-06-02 03:17:04'); +INSERT INTO `user_answer` VALUES (335, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 03:17:04', '2026-06-02 03:17:04'); +INSERT INTO `user_answer` VALUES (336, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 03:17:04', '2026-06-02 03:17:04'); +INSERT INTO `user_answer` VALUES (337, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"B\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:27:52', '2026-06-02 04:27:52'); +INSERT INTO `user_answer` VALUES (338, 1, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '[\"B\", \"C\", \"D\"]', 0.00, 4.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:27:52', '2026-06-02 04:27:52'); +INSERT INTO `user_answer` VALUES (339, 1, 'bfbe43d3-fc30-44a8-9b05-7b7520dc20d0', 'true_false', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"true\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:27:52', '2026-06-02 04:27:52'); +INSERT INTO `user_answer` VALUES (340, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '[\"1\", \"1\", \"1\"]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 04:27:52', '2026-06-02 04:27:52'); +INSERT INTO `user_answer` VALUES (341, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 04:27:52', '2026-06-02 04:27:52'); +INSERT INTO `user_answer` VALUES (342, 4, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"A\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:29:32', '2026-06-02 04:29:32'); +INSERT INTO `user_answer` VALUES (343, 4, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '[\"A\", \"B\", \"C\"]', 0.00, 4.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:29:32', '2026-06-02 04:29:32'); +INSERT INTO `user_answer` VALUES (344, 4, 'bfbe43d3-fc30-44a8-9b05-7b7520dc20d0', 'true_false', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"true\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 04:29:32', '2026-06-02 04:29:32'); +INSERT INTO `user_answer` VALUES (345, 4, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '[\"1\", \"1\", \"1\"]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 04:29:32', '2026-06-02 04:29:32'); +INSERT INTO `user_answer` VALUES (346, 4, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '036bcb39-7d41-45f8-9fac-0d7899ceb16f', NULL, '\"123\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 04:29:32', '2026-06-02 04:29:32'); +INSERT INTO `user_answer` VALUES (347, 4, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '\"\"', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 04:29:51', '2026-06-02 04:29:51'); +INSERT INTO `user_answer` VALUES (348, 4, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '[]', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 04:29:51', '2026-06-02 04:29:51'); +INSERT INTO `user_answer` VALUES (349, 4, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', 'c5656307-6255-4298-8c4e-82e53d91e379', NULL, '\"\"', 0.00, 5.00, 0, 'ai', '未作答', NULL, NULL, 0, 0, '2026-06-02 04:29:51', '2026-06-02 04:29:51'); +INSERT INTO `user_answer` VALUES (350, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', NULL, '\"C\"', 0.00, 5.00, 1, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 05:35:52', '2026-06-02 13:36:39'); +INSERT INTO `user_answer` VALUES (351, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:35:52', '2026-06-02 05:35:52'); +INSERT INTO `user_answer` VALUES (352, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '1df188af-647c-457e-9d1e-ea76a2b7fb2f', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:35:52', '2026-06-02 05:35:52'); +INSERT INTO `user_answer` VALUES (353, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', 'c281572d-99f7-4e93-b684-590fbaea52e4', NULL, '\"C\"', 0.00, 5.00, 0, 'ai', '正确答案: ', NULL, NULL, 0, 0, '2026-06-02 05:38:42', '2026-06-02 05:38:42'); +INSERT INTO `user_answer` VALUES (354, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', 'c281572d-99f7-4e93-b684-590fbaea52e4', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:38:42', '2026-06-02 05:38:42'); +INSERT INTO `user_answer` VALUES (355, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', 'c281572d-99f7-4e93-b684-590fbaea52e4', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:38:42', '2026-06-02 05:38:42'); +INSERT INTO `user_answer` VALUES (356, 1, '5af6fdce-dbf2-4a57-9ef4-bef176be6fb8', 'single_choice', 'formal_exam', '399ade38-3b02-4e83-8509-d6ea7624c437', NULL, '\"C\"', 5.00, 5.00, 1, 'ai', '正确!', NULL, NULL, 0, 0, '2026-06-02 05:51:33', '2026-06-02 05:51:33'); +INSERT INTO `user_answer` VALUES (357, 1, 'f30a7401-508b-4591-8fb8-46437699e577', 'multiple_choice', 'formal_exam', '399ade38-3b02-4e83-8509-d6ea7624c437', NULL, '[]', 0.00, 4.00, 0, 'ai', '正确答案: [\'A\', \'B\', \'D\']', NULL, NULL, 0, 0, '2026-06-02 05:51:33', '2026-06-02 05:51:33'); +INSERT INTO `user_answer` VALUES (358, 1, '5e069067-a10d-4230-9564-e7503d72d738', 'fill_blank', 'formal_exam', '399ade38-3b02-4e83-8509-d6ea7624c437', NULL, '[]', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:51:33', '2026-06-02 05:51:33'); +INSERT INTO `user_answer` VALUES (359, 1, 'e6bb2566-90a8-4651-b553-083a1d695b3c', 'subjective', 'formal_exam', '399ade38-3b02-4e83-8509-d6ea7624c437', NULL, '\"\"', 0.00, 5.00, 0, 'ai', NULL, NULL, NULL, 0, 0, '2026-06-02 05:51:33', '2026-06-02 05:51:33'); + +-- ---------------------------- +-- Table structure for user_role +-- ---------------------------- +DROP TABLE IF EXISTS `user_role`; +CREATE TABLE `user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` bigint NOT NULL COMMENT '用户 ID', + `role_id` bigint NOT NULL COMMENT '角色 ID', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_role`(`user_id` ASC, `role_id` ASC) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_role_id`(`role_id` ASC) USING BTREE, + CONSTRAINT `fk_user_role_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_user_role_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of user_role +-- ---------------------------- +INSERT INTO `user_role` VALUES (1, 1, 1, '2026-04-16 09:05:15'); +INSERT INTO `user_role` VALUES (2, 2, 2, '2026-04-16 09:05:15'); +INSERT INTO `user_role` VALUES (3, 3, 3, '2026-04-16 09:05:15'); +INSERT INTO `user_role` VALUES (4, 4, 3, '2026-04-16 19:30:00'); +INSERT INTO `user_role` VALUES (5, 5, 3, '2026-04-16 19:30:00'); +INSERT INTO `user_role` VALUES (6, 6, 3, '2026-04-16 19:30:00'); +INSERT INTO `user_role` VALUES (7, 7, 2, '2026-04-16 19:30:00'); +INSERT INTO `user_role` VALUES (8, 8, 2, '2026-04-16 19:30:00'); +INSERT INTO `user_role` VALUES (9, 9, 2, '2026-04-16 19:30:00'); + +-- ---------------------------- +-- Table structure for wrong_question_collection +-- ---------------------------- +DROP TABLE IF EXISTS `wrong_question_collection`; +CREATE TABLE `wrong_question_collection` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '用户ID', + `answer_id` bigint NULL DEFAULT NULL COMMENT '答题记录ID', + `question_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '题目ID', + `note` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '备注', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_answer`(`user_id` ASC, `answer_id` ASC) USING BTREE, + INDEX `idx_user_id`(`user_id` ASC) USING BTREE, + INDEX `idx_answer_id`(`answer_id` ASC) USING BTREE, + INDEX `idx_question_id`(`question_id` ASC) USING BTREE, + INDEX `idx_user_question`(`user_id` ASC, `question_id` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '错题收藏表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of wrong_question_collection +-- ---------------------------- +INSERT INTO `wrong_question_collection` VALUES (1, 1, NULL, '2d4cbe7f-0c72-49ed-815c-bcd38a025f4e', NULL, '2026-05-11 08:03:46', '2026-05-11 08:03:46'); + +-- ---------------------------- +-- View structure for v_role_permission +-- ---------------------------- +DROP VIEW IF EXISTS `v_role_permission`; +CREATE ALGORITHM = UNDEFINED SQL SECURITY DEFINER VIEW `v_role_permission` AS select `r`.`id` AS `role_id`,`r`.`uuid` AS `role_uuid`,`r`.`role_name` AS `role_name`,`r`.`role_code` AS `role_code`,`r`.`role_type` AS `role_type`,`r`.`description` AS `description`,`r`.`status` AS `status`,`p`.`id` AS `permission_id`,`p`.`uuid` AS `permission_uuid`,`p`.`permission_name` AS `permission_name`,`p`.`permission_code` AS `permission_code`,`p`.`type` AS `permission_type`,`p`.`parent_id` AS `parent_id`,`p`.`path` AS `path`,`p`.`icon` AS `icon`,`p`.`sort_order` AS `sort_order` from ((`role` `r` join `role_permission` `rp` on((`r`.`id` = `rp`.`role_id`))) join `permission` `p` on(((`rp`.`permission_id` = `p`.`id`) and (`p`.`status` = 1)))) where (`r`.`status` = 1); + +-- ---------------------------- +-- View structure for v_user_info +-- ---------------------------- +DROP VIEW IF EXISTS `v_user_info`; +CREATE ALGORITHM = UNDEFINED SQL SECURITY DEFINER VIEW `v_user_info` AS select `u`.`id` AS `id`,`u`.`username` AS `username`,`u`.`real_name` AS `real_name`,`u`.`email` AS `email`,`u`.`phone` AS `phone`,`u`.`avatar` AS `avatar`,`u`.`user_type` AS `user_type`,(case `u`.`user_type` when 1 then '系统管理员' when 2 then '部门管理员' else '普通用户' end) AS `user_type_name`,`u`.`status` AS `status`,(case `u`.`status` when 1 then '启用' else '禁用' end) AS `status_name`,`u`.`dept_id` AS `dept_id`,`d`.`dept_name` AS `dept_name`,`d`.`dept_code` AS `dept_code`,`u`.`last_login_time` AS `last_login_time`,`u`.`create_time` AS `create_time`,`u`.`update_time` AS `update_time`,group_concat(distinct `r`.`role_name` separator ',') AS `role_names`,group_concat(distinct `r`.`role_code` separator ',') AS `role_codes`,(select count(distinct `da`.`dept_id`) from `dept_admin` `da` where ((`da`.`user_id` = `u`.`id`) and (`da`.`status` = 1))) AS `managed_dept_count` from (((`user` `u` left join `department` `d` on((`u`.`dept_id` = `d`.`id`))) left join `user_role` `ur` on((`u`.`id` = `ur`.`user_id`))) left join `role` `r` on((`ur`.`role_id` = `r`.`id`))) group by `u`.`id`; + +-- ---------------------------- +-- View structure for v_user_permission +-- ---------------------------- +DROP VIEW IF EXISTS `v_user_permission`; +CREATE ALGORITHM = UNDEFINED SQL SECURITY DEFINER VIEW `v_user_permission` AS select `u`.`id` AS `user_id`,`u`.`username` AS `username`,`u`.`user_type` AS `user_type`,`r`.`id` AS `role_id`,`r`.`uuid` AS `role_uuid`,`r`.`role_name` AS `role_name`,`r`.`role_code` AS `role_code`,`p`.`id` AS `permission_id`,`p`.`uuid` AS `permission_uuid`,`p`.`permission_name` AS `permission_name`,`p`.`permission_code` AS `permission_code`,`p`.`type` AS `permission_type`,`p`.`path` AS `path` from ((((`user` `u` join `user_role` `ur` on((`u`.`id` = `ur`.`user_id`))) join `role` `r` on(((`ur`.`role_id` = `r`.`id`) and (`r`.`status` = 1)))) join `role_permission` `rp` on((`r`.`id` = `rp`.`role_id`))) join `permission` `p` on(((`rp`.`permission_id` = `p`.`id`) and (`p`.`status` = 1)))) where (`u`.`status` = 1); + +-- ---------------------------- +-- Triggers structure for table question +-- ---------------------------- +DROP TRIGGER IF EXISTS `trg_question_question_id`; +delimiter ;; +CREATE TRIGGER `trg_question_question_id` BEFORE INSERT ON `question` FOR EACH ROW BEGIN + IF NEW.question_id IS NULL OR NEW.question_id = '' THEN + SET NEW.question_id = UUID(); + END IF; +END +;; +delimiter ; + +-- ---------------------------- +-- Triggers structure for table role +-- ---------------------------- +DROP TRIGGER IF EXISTS `trg_role_uuid`; +delimiter ;; +CREATE TRIGGER `trg_role_uuid` BEFORE INSERT ON `role` FOR EACH ROW BEGIN + IF NEW.uuid IS NULL OR NEW.uuid = '' THEN + SET NEW.uuid = UUID(); + END IF; +END +;; +delimiter ; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/docs/前端图片URL使用指南.md b/docs/前端图片URL使用指南.md new file mode 100644 index 0000000..8c78b21 --- /dev/null +++ b/docs/前端图片URL使用指南.md @@ -0,0 +1,172 @@ +# 前端图片 URL 使用指南 + +## 概述 +后端已优化图片访问方式,现在支持通过 Nginx 静态资源直接访问图片,提高了图片加载效率。 + +## 图片数据结构 + +### 聊天消息中的图片 +```json +{ + "images": [ + { + "id": "abc123", + "url": "/files/img/abc123.jpg", + "type": "image", + "source": "文档名.pdf", + "page": 1, + "description": "图片描述" + } + ] +} +``` + +## URL 格式说明 + +### 两种 URL 格式 +后端会返回以下两种格式的 URL: +1. **新格式(推荐):`/files/img/xxx.jpg` - 通过 Nginx 静态资源访问 +2. **旧格式(兼容)**:`/api/image/xxx/data` - 通过后端 API 代理访问 + +## 前端适配说明 + +### 无需修改的情况 +如果您的前端目前使用如下方式处理图片,则无需修改代码: +```javascript +// 直接使用后端返回的 URL +{image.description} +``` + +### 推荐的处理方式 +```javascript +// 通用图片加载器 +function loadImage(imageData) { + const imageUrl = imageData.url; + + // 直接使用后端返回的 URL + const img = document.createElement('img'); + img.src = imageUrl; + img.alt = imageData.description || '图片'; + img.onerror = function() { + // 图片加载失败时的处理 + console.error('图片加载失败:', imageUrl); + }; + + return img; +} +``` + +### 检查 URL 类型 +```javascript +function isNginxUrl(url) { + return url && url.startsWith('/files/'); +} + +function isApiUrl(url) { + return url && url.includes('/api/image/'); +} +``` + +## SSE 流处理示例 + +### JavaScript 示例 +```javascript +const eventSource = new EventSource('/api/chat/stream'); + +eventSource.onmessage = function(event) { + const data = JSON.parse(event.data); + + if (data.type === 'finish') { + // 处理图片 + if (data.images && data.images.length > 0) { + renderImages(data.images); + } + } +}; + +function renderImages(images) { + const container = document.getElementById('image-container'); + + images.forEach(image => { + const img = document.createElement('img'); + img.src = image.url; + img.alt = image.description || '图片'; + img.style.maxWidth = '100%'; + container.appendChild(img); + + // 显示图片描述 + if (image.description) { + const desc = document.createElement('p'); + desc.textContent = image.description; + container.appendChild(desc); + } + }); +} +``` + +### React 示例 +```jsx +function ChatImages({ images }) { + return ( +
+ {images && images.map((image, index) => ( +
+ {image.description} + {image.description && ( +
+ {image.description} +
+ )} +
+ ))} +
+ ); +} +``` + +### Vue 示例 +```vue + + + +``` + +## 历史记录查看 +在查看历史会话时,后端已自动将图片 URL 转换为 Nginx 格式,无需额外处理。 + +## 注意事项 + +1. **向后兼容**:后端保留了对旧 URL 格式的支持,以确保旧数据仍然可用 +2. **错误处理**:建议添加图片加载失败的错误处理逻辑 +3. **相对路径**:`/files/` 开头的 URL 是相对路径,会自动根据当前域名拼接完整地址 + +## 兼容性 +- 无需对现有前端代码进行大量修改 +- 两种格式都支持 diff --git a/docs/前端图片渲染修复.md b/docs/前端图片渲染修复.md new file mode 100644 index 0000000..d6b28fa --- /dev/null +++ b/docs/前端图片渲染修复.md @@ -0,0 +1,251 @@ +# 前端图片渲染修复指南 + +## 问题分析 + +根据日志分析,**后端已完美修复**,图片数据已正确返回,但前端没有正确渲染: + +### 后端返回的数据格式 +```javascript +{ + "success": true, + "data": [ + { + "id": 175, + "role": "assistant", + "content": "...", + "images": [ + { + "doc_name": "1.docx", + "page": 1, + "image_id": "49d2910b148d.jpg", + "url": "/files/img/49d2910b148d.jpg" + } + ] + } + ] +} +``` + +### 问题现象 +从HTML片段可以看到,页面中只有引用来源和文本内容,**没有任何 `` 标签渲染出来**。 + +--- + +## 修复步骤 + +### 1. 确认前端代码位置 + +首先找到渲染聊天消息的前端组件,通常在以下位置: +- `src/components/ChatMessage.vue` +- `src/views/ChatView.vue` +- 或其他类似位置 + +### 2. 修改消息渲染代码 + +在渲染消息的地方,添加对 `images` 字段的处理: + +#### Vue 组件示例: +```vue + + + + + +``` + +### 3. 处理历史会话消息 + +确认在获取历史会话的接口调用后,正确地将数据传递给消息渲染组件: + +```javascript +// 示例:在 API 调用处 +async function loadSessionHistory(sessionId) { + try { + const response = await fetch(`/api/ai/session/${sessionId}`); + const result = await response.json(); + + if (result.success) { + // 确保 messages 数据正确传递给组件 + messages.value = result.data; + } + } catch (error) { + console.error('加载历史会话失败:', error); + } +} +``` + +### 4. 添加图片点击预览功能(可选优化) + +```vue + +
+ 预览 + +
+ + + + + +``` + +--- + +## 验证步骤 + +1. **检查浏览器控制台**:打开浏览器开发者工具(F12),查看 Console 标签,确认没有 JavaScript 错误 +2. **检查网络请求**:在 Network 标签中,确认图片请求(如 `/files/img/49d2910b148d.jpg`)状态码为 200 +3. **确认数据结构**:在 Network 标签中,检查 `/api/ai/session/{sessionId}` 接口的响应,确认 `images` 字段存在且格式正确 + +--- + +## 常见问题排查 + +### 问题1:images 字段为 null 或 undefined +**原因**:可能旧版本的消息没有 images 字段 +**解决**:添加空值检查 +```vue +
+``` + +### 问题2:图片加载 404 +**原因**:Nginx 路径配置问题 +**解决**:确认 Nginx 配置正确映射了 `/files/img/` 路径 + +### 问题3:图片跨域 +**原因**:图片域名与前端域名不一致 +**解决**:使用后端代理接口 `/api/image/{imageId}/data` 而不是 `/files/img/` 路径 + +--- + +## 后端返回的完整数据说明 + +后端返回的 ChatMessage 中 `images` 字段的完整结构: + +```json +{ + "images": [ + { + "image_id": "49d2910b148d.jpg", + "url": "/files/img/49d2910b148d.jpg", + "doc_name": "1.docx", + "page": 1, + "description": "图片描述(可选)", + "type": "image" + } + ] +} +``` + +主要字段说明: +- `url`: 图片的 Nginx 静态资源路径,直接使用即可 +- `image_id`: 图片的唯一标识 +- `doc_name`: 来源文档名称 +- `page`: 来源页码 +- `description`: 图片描述(可能为 null) + +--- + +## 修改总结 + +| 任务 | 优先级 | +|------|--------| +| 在消息渲染组件中添加 `images` 字段处理 | 🔴 高 | +| 确保正确解析和渲染图片列表 | 🔴 高 | +| 添加图片加载错误处理 | 🟡 中 | +| 添加图片预览功能 | 🟢 低 | +| 优化图片样式和交互 | 🟢 低 | diff --git a/docs/图片URL优化更新文档.md b/docs/图片URL优化更新文档.md new file mode 100644 index 0000000..7afa222 --- /dev/null +++ b/docs/图片URL优化更新文档.md @@ -0,0 +1,70 @@ +# 图片 URL 优化更新文档 + +## 概述 +本次更新优化了图片访问方式,从原来通过后端 API 代理访问改为直接通过 Nginx 静态资源访问,提高了图片加载效率。 + +## 修改内容 + +### 1. 配置文件 +- 使用配置项 `file.upload.path` 确定本地保存路径 +- 使用配置项 `file.upload.url-prefix` 确定 Nginx 访问前缀(默认为 `/files/`) + +### 2. ImageService 接口 +- 新增 `getNginxUrlByImageId(String imageId)` 方法,用于根据图片 ID 获取 Nginx 格式的 URL + +### 3. ImageServiceImpl 实现 +- 使用配置的 `file.upload.path` + `/img` 作为图片存储目录 +- 保存图片时返回相对路径(如 `img/xxx.jpg`)而非绝对路径 +- 修改本地读取图片逻辑,支持相对路径 +- 新增 `getNginxUrlByImageId` 方法 +- 新增 `extractFilenameFromPath` 辅助方法 + +### 4. AiChatServiceImpl 修改 +- 在保存图片后立即更新 imageMetadata 中的 url 为 Nginx 格式 +- 在 SSE 的 finish 事件中,对无 metadata 的图片也尝试生成 Nginx 格式 URL + +### 5. ChatServiceImpl 修改 +- 查看历史记录时,将图片 URL 从 `/api/image/xxx/data` 转换为 Nginx 格式(`/files/img/xxx.jpg`) + +## 使用示例 + +### Nginx 配置示例 +```nginx +server { + listen 80; + + location /files/ { + alias /path/to/upload/; + expires 7d; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### 配置文件 (application-prod.yml) +```yaml +file: + upload: + path: /opt/deploy/uploads/ + url-prefix: /files/ +``` + +## 部署说明 +1. 确保 Nginx 配置正确,`/files/` 路径指向实际的上传目录 +2. 确保应用配置文件中的 `file.upload.path` 和 `file.upload.url-prefix` 设置正确 +3. 重新构建并部署应用 + +## 兼容性 +- 保留了向后兼容,如果 Nginx 格式 URL 无法获取,会回退为原 `/api/image/xxx/data` 格式 +- 旧数据仍然可以正常访问 + +## 图片 URL 格式对比 +| 旧格式 | 新格式 | +|--------|--------| +| /api/image/xxx/data | /files/img/xxx.jpg | diff --git a/docs/完整curl测试手册.md b/docs/完整curl测试手册.md new file mode 100644 index 0000000..e4fbce2 --- /dev/null +++ b/docs/完整curl测试手册.md @@ -0,0 +1,1252 @@ +# 知识库管理系统 - 完整CURL测试手册 + +## 文档信息 + +| 项目 | 内容 | +| ----------- | ----------------------- | +| **文档名称** | 完整CURL测试文档 | +| **版本号** | V2.15.0 | +| **生成日期** | 2026-05-29 | +| **基础URL** | | +| **RAG服务地址** | | + +*** + +## 一、使用说明 + +### 1.1 认证Token获取 + +首先需要通过登录接口获取JWT Token: + +```bash +curl -X POST "http://localhost:8080/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"123456"}' +``` + +### 1.2 Token说明 + +本文档中所有curl命令均已预置Token,可直接复制使用。 + +### 1.3 通用请求头 + +所有需要认证的接口都需要添加以下请求头: + +```bash +-H "Authorization: Bearer " \ +-H "Content-Type: application/json" +``` + +*** + +## 二、认证接口 + +### 2.1 登录 + +```bash +curl -X POST "http://localhost:8080/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"123456"}' +``` + +### 2.2 获取当前用户信息 + +```bash +curl -X GET "http://localhost:8080/api/auth/me" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 三、用户管理接口 + +### 3.1 获取用户列表 + +```bash +curl -X GET "http://localhost:8080/api/user/list" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 3.2 获取用户详情 + +```bash +curl -X GET "http://localhost:8080/api/user/1" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 3.3 创建用户 + +```bash +curl -X POST "http://localhost:8080/api/user" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "username": "testuser", + "password": "123456", + "email": "test@example.com", + "deptId": 1, + "userType": 3 + }' +``` + +### 3.4 更新用户 + +```bash +curl -X PUT "http://localhost:8080/api/user/1" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{"email": "newemail@example.com"}' +``` + +### 3.5 删除用户 + +```bash +curl -X DELETE "http://localhost:8080/api/user/1" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 3.6 获取部门列表 + +```bash +curl -X GET "http://localhost:8080/api/department/list" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 四、向量库管理接口 + +### 4.1 获取向量库列表 + +```bash +curl -X GET "http://localhost:8080/api/collection" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.2 创建向量库 + +```bash +curl -X POST "http://localhost:8080/api/collection?name=dept_hr&displayName=人力资源知识库&departmentId=1&description=人力资源部专用知识库" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.3 修改向量库 + +```bash +curl -X PUT "http://localhost:8080/api/collection/dept_hr?displayName=人力资源知识库V2&description=更新后的描述" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.4 删除向量库 + +```bash +curl -X DELETE "http://localhost:8080/api/collection/dept_hr" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.5 文件向量化(单文件) + +```bash +curl -X POST "http://localhost:8080/api/collection/public_kb/vectorize?fileId=36" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.6 批量文件向量化 + +```bash +curl -X POST "http://localhost:8080/api/collection/public_kb/vectorize/batch?fileIds=1&fileIds=2&fileIds=3" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.7 获取文档列表 + +```bash +curl -X GET "http://localhost:8080/api/collection/public_kb/documents" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.8 删除文档 + +```bash +# 推荐:使用 fileId 精确删除(避免同名文件删除错误) +curl -X DELETE "http://localhost:8080/api/collection/public_kb/documents/test_document.pdf?fileId=56" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" + +# 兼容旧版本:仅使用文件名(存在同名文件风险) +curl -X DELETE "http://localhost:8080/api/collection/public_kb/documents/test_document.pdf" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.9 废止文档 + +```bash +curl -X POST "http://localhost:8080/api/collection/public_kb/documents/test_document.pdf/deprecate?reason=new_version" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.10 恢复已废止文档 + +```bash +curl -X POST "http://localhost:8080/api/collection/public_kb/documents/test_document_v2.pdf/restore" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.11 获取文档版本历史 + +```bash +curl -X GET "http://localhost:8080/api/collection/public_kb/documents/test_document.pdf/versions?limit=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.12 获取向量库切片列表 + +```bash +curl -X GET "http://localhost:8080/api/collection/public_kb/chunks?limit=100&offset=0" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.13 获取文档切片列表 + +```bash +curl -X GET "http://localhost:8080/api/collection/public_kb/documents/test_document.pdf/chunks?page=1&pageSize=20" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.14 新增切片 + +```bash +curl -X POST "http://localhost:8080/api/collection/public_kb/chunks?content=新的切片内容§ion=第一章" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.15 修改切片 + +```bash +curl -X PUT "http://localhost:8080/api/collection/public_kb/chunks/chunk_001?content=更新后的内容§ion=第一章" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 4.16 删除切片 + +```bash +curl -X DELETE "http://localhost:8080/api/collection/public_kb/chunks/chunk_001" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 四、知识库访问控制接口 + +### 4.1 分页查询知识库列表 + +```bash +curl -X GET "http://localhost:8080/api/collection-access/page?pageNum=1&pageSize=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -H "Content-Type: application/json" +``` + +### 4.2 获取知识库详情 + +```bash +curl -X GET "http://localhost:8080/api/collection-access/1" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -H "Content-Type: application/json" +``` + +### 4.3 获取用户可访问的知识库列表 + +```bash +curl -X GET "http://localhost:8080/api/collection-access/accessible" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -H "Content-Type: application/json" +``` + +*** + +## 五、AI流式问答接口 + +### 5.1 流式问答 + +```bash +curl -X POST "http://localhost:8080/api/ai/chat/stream" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "2003—2022 年,三峡电站累计发电量是多少?最好带图片说明", + "session_id": "" + }' \ + --no-buffer +``` + +### 5.2 中断AI流式问答 + +```bash +curl -X POST "http://localhost:8080/api/ai/chat/stop?sessionId=sess_1777600000000_abc123" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.3 获取用户会话列表 + +```bash +curl -X GET "http://localhost:8080/api/ai/sessions?page=1&pageSize=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.4 获取会话详情 + +```bash +curl -X GET "http://localhost:8080/api/ai/session/sess_123456789" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.5 获取消息文件来源列表 + +```bash +curl -X GET "http://localhost:8080/api/ai/message/1/sources" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.6 获取消息引用信息 + +```bash +curl -X GET "http://localhost:8080/api/ai/message/1/references" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.7 获取会话引用信息 + +```bash +curl -X GET "http://localhost:8080/api/ai/session/sess_123456789/references" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.8 删除单个会话 + +```bash +curl -X DELETE "http://localhost:8080/api/ai/session/sess_123456789" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 5.9 批量删除会话 + +```bash +curl -X DELETE "http://localhost:8080/api/ai/sessions" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '["sess_001", "sess_002", "sess_003"]' +``` + +*** + +## 六、反馈管理接口 + +### 6.1 提交反馈 + +```bash +curl -X POST "http://localhost:8080/api/feedback" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "sessionId": "sess_test_001", + "query": "出差补助标准是什么?", + "answer": "根据公司规定,国内出差补助标准为每天200元。", + "rating": 1, + "reason": "回答准确详细", + "sources": ["出差报销制度.pdf"] + }' +``` + +### 6.2 获取反馈列表 + +```bash +curl -X GET "http://localhost:8080/api/feedback/list?page=1&page_size=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 6.3 获取反馈统计 + +```bash +curl -X GET "http://localhost:8080/api/feedback/stats" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 6.4 获取差评案例 + +```bash +curl -X GET "http://localhost:8080/api/feedback/bad-cases" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 七、FAQ管理接口 + +### 7.1 获取FAQ列表 + +```bash +curl -X GET "http://localhost:8080/api/faq?page=1&page_size=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 7.2 获取FAQ建议列表 + +```bash +curl -X GET "http://localhost:8080/api/faq/suggestions?page=1&page_size=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 7.3 批准FAQ建议 + +```bash +curl -X POST "http://localhost:8080/api/faq/suggestions/1/approve" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{"answer": "修改后的标准答案内容"}' +``` + +### 7.4 拒绝FAQ建议 + +```bash +curl -X POST "http://localhost:8080/api/faq/suggestions/1/reject" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 八、图片管理接口 + +### 8.0 图片完整处理流程测试 + +#### 8.0.1 整体流程说明 + +本系统实现了完整的AI图片处理流程: + +``` +用户提问 → RAG服务返回 → 图片提取 → 下载保存 → 数据库存储 → API访问 → 前端展示 +``` + +#### 8.0.2 步骤1:AI流式问答(包含图片) + +```bash +curl -X POST "http://localhost:8080/api/ai/chat/stream" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "message": "2003—2022 年,三峡电站累计发电量是多少?最好带图片说明", + "session_id": "" + }' \ + --no-buffer +``` + +**预期返回的 finish 事件包含 images 数组:** + +```json +{ + "type": "finish", + "images": [ + { + "id": "ab77281e7913.jpg", + "score": 7.8, + "type": "chart", + "source": "三峡公报_1-15页.pdf", + "page": 12, + "description": "这是一张柱状图...", + "full_description": "这是一张柱状图,展示了...", + "url": "/images/ab77281e7913.jpg" + } + ] +} +``` + +#### 8.0.3 步骤2:获取图片数据(二进制) + +```bash +curl -X GET "http://localhost:8080/api/image/ab77281e7913.jpg/data" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -o downloaded_image.jpg +``` + +**验证下载的文件:** + +```bash +# 查看文件信息 +ls -lh downloaded_image.jpg + +# 查看文件类型 +file downloaded_image.jpg + +# 查看文件大小 +wc -c downloaded_image.jpg +``` + +**预期输出:** + +``` +-rw-r--r-- 1 root root 79827 May 19 15:37 downloaded_image.jpg +downloaded_image.jpg: JPEG image data, ... +79827 downloaded_image.jpg +``` + +#### 8.0.4 步骤3:获取图片详情(JSON) + +```bash +curl -X GET "http://localhost:8080/api/image/1" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +**预期返回:** + +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 1, + "imageId": "ab77281e7913.jpg", + "filename": "ab77281e7913.jpg", + "path": "/img/ab77281e7913.jpg", + "sizeBytes": 79827, + "format": "JPEG", + "source": "三峡公报_1-15页.pdf", + "page": 12, + "type": "chart", + "description": "这是一张柱状图,展示了2003年至2022年发电量的变化趋势...", + "fullDescription": "这是一张柱状图,展示了2003年至2022年发电量(单位:亿千瓦时)的变化趋势...", + "score": 7.8 + } +} +``` + +#### 8.0.5 步骤4:查看数据库中的图片记录 + +```bash +mysql -h localhost -u root -p knowledge_management_system -e "SELECT id, image_id, path, format, size_bytes, source, page, type, score FROM context_image LIMIT 5;" +``` + +**预期输出:** + +``` ++----+-------------------------+--------------------------+--------+-------------+-------------------------------+------+-------+-------+ +| id | image_id | path | format | size_bytes | source | page | type | score | ++----+-------------------------+--------------------------+--------+-------------+-------------------------------+------+-------+-------+ +| 1 | ab77281e7913.jpg | /img/ab77281e7913.jpg | JPEG | 79827 | 三峡公报_1-15页.pdf | 12 | chart | 7.80 | ++----+-------------------------+--------------------------+--------+-------------+-------------------------------+------+-------+-------+ +``` + +### 8.1 获取图片列表 + +```bash +curl -X GET "http://localhost:8080/api/image/list?limit=10&offset=0" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 8.2 获取单张图片 + +```bash +curl -X GET "http://localhost:8080/api/image/img_001" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -o downloaded_image.jpg +``` + +### 8.3 获取图片统计 + +```bash +curl -X GET "http://localhost:8080/api/image/stats" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 九、报告管理接口 + +### 9.1 获取周报 + +```bash +curl -X GET "http://localhost:8080/api/report/weekly" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 9.2 获取月报 + +```bash +curl -X GET "http://localhost:8080/api/report/monthly" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 十、知识检索接口 + +### 10.1 混合检索 + +```bash +curl -X POST "http://localhost:8080/api/search" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "query": "出差补助标准", + "topK": 5, + "collections": ["public_kb", "dept"] + }' +``` + +*** + +## 十一、知识库路由接口 + +### 11.1 测试知识库路由 + +```bash +curl -X POST "http://localhost:8080/api/kb-route" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "query": "财务部的报销流程是什么" + }' +``` + +*** + +## 十二、模拟考接口 🔵 模拟考 + +### 12.1 生成试卷(按权限范围) + +```bash +curl -X POST "http://localhost:8080/api/exam/paper/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg1ODc5OSwiZXhwIjoxNzc5ODY1OTk5fQ.2KJe1eZZ1t3AZ_ri1saILw2urf7DtxAKW_xHqCTN_kx4hIaJDq9bDHVzXNpdPbgjjf_jbBVJKBIsMCcvvKupgw" \ + -d '{ + "single_choice_count": 5, + "multiple_choice_count": 3, + "true_false_count": 2, + "fill_blank_count": 2, + "subjective_count": 1, + "difficulty": 3, + "include_personal": false + }' +``` + +### 12.2 生成试卷(指定文件) + +```bash +curl -X POST "http://localhost:8080/api/exam/paper/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "single_choice_count": 5, + "multiple_choice_count": 3, + "true_false_count": 2, + "fill_blank_count": 2, + "subjective_count": 1, + "difficulty": 3, + "file_ids": [1, 2, 3] + }' +``` + +### 12.3 生成题目 + +**重要说明**:此接口为异步接口,请求后立即返回任务提交状态,实际生成在后台执行。需通过 `GET /api/file/{fileId}` 接口轮询查询生成进度和结果。 + +**测试用例1:智能生成模式(推荐)** + +不提供 `question_types` 参数,AI 会根据文档内容自动判断题型和数量: + +```bash +curl -X POST "http://localhost:8080/api/exam/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDA0NDU1MSwiZXhwIjoxNzgwMDUxNzUxfQ.YVF7kNwTDKI1SPFLe5KHGVPDCCrTNUBu6vChGrqRid8uZJvOXwnboU67c0KZRCV6ZEBwGnSrstIxkTi94iHJeA" \ + -d '{ + "file_id": 43, + "difficulty": 3, + "request_id": "gen_req_smart_001" + }' +``` + +**测试用例2:指定题型模式 - 通过 file\_id 指定文件** + +```bash +curl -X POST "http://localhost:8080/api/exam/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "file_id": 21, + "question_types": { + "single_choice": 3, + "multiple_choice": 2, + "true_false": 2, + "fill_blank": 2, + "subjective": 1 + }, + "difficulty": 3, + "request_id": "gen_req_001" + }' +``` + +**预期响应:** + +```json +{ + "code": 200, + "message": "收到请求", + "data": { + "requestId": "gen_req_001", + "fileId": 21, + "fileMatchStatus": "success", + "matchedCollection": "test1", + "matchedFilePath": "test1/1.docx", + "documentName": "1.docx", + "collectionFilled": true, + "collectionFillSource": "直接使用", + "asyncTaskStarted": true, + "nextStep": "请通过文件状态接口查询生成进度", + "status": "success" + } +} +``` + +**测试用例3:通过 file\_path 指定文件** + +```bash +curl -X POST "http://localhost:8080/api/exam/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "file_path": "test1/1.docx", + "collection": "test1", + "question_types": { + "single_choice": 3, + "multiple_choice": 2, + "true_false": 2, + "fill_blank": 2, + "subjective": 1 + }, + "difficulty": 3, + "request_id": "gen_req_002" + }' +``` + +**测试用例4:自定义题型分数** + +```bash +curl -X POST "http://localhost:8080/api/exam/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "file_id": 21, + "question_types": { + "single_choice": 5, + "multiple_choice": 3 + }, + "question_scores": { + "single_choice": 4.00, + "multiple_choice": 8.00 + }, + "difficulty": 3, + "request_id": "gen_req_003" + }' +``` + +**测试用例5:查询生成进度** + +提交请求后,通过文件状态查询接口轮询生成进度: + +```bash +curl -X GET "http://localhost:8080/api/file/21" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +**预期响应(生成中):** + +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 21, + "fileName": "1.docx", + "filePath": "test1/1.docx", + "processStatus": "INDEXED", + "examStatus": "GENERATING", + "processMessage": "正在生成题目,请稍后...", + "createTime": "2026-05-28T10:00:00", + "updateTime": "2026-05-28T10:00:01" + } +} +``` + +**预期响应(生成成功):** + +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 21, + "fileName": "1.docx", + "filePath": "test1/1.docx", + "processStatus": "INDEXED", + "examStatus": "GENERATED", + "processMessage": "生成成功,共5道题\n\n【AI分析结果】\n{\"summary\":\"文档摘要...\",\"questionDistribution\":{\"single_choice\":3,\"multiple_choice\":2}}", + "createTime": "2026-05-28T10:00:00", + "updateTime": "2026-05-28T10:00:03" + } +} +``` + +**AI生成题目状态说明:** + +| 状态值 | 说明 | +| ------------- | ---- | +| `UNGENERATED` | 未生成 | +| `GENERATING` | 正在生成 | +| `GENERATED` | 已生成 | +| `FAILED` | 生成失败 | + +**轮询策略建议:** + +- 初始轮询间隔:2-3秒 +- 超时时间:建议设置为 5-10 分钟 +- 停止条件:`examStatus` 变为 `GENERATED` 或 `FAILED` + +**默认分值说明:** + +| 题型 | 默认分值 | +| ---------------------- | ---- | +| 单选题 (single\_choice) | 2分 | +| 多选题 (multiple\_choice) | 4分 | +| 判断题 (true\_false) | 1分 | +| 填空题 (fill\_blank) | 2分 | +| 简答题 (subjective) | 6分 | + +### 12.4 批改答案(日常练习) + +**测试用例1:单选题** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_single_001", + "answers": [ + { + "question_id": "q_single_001", + "question_type": "single_choice", + "question_content": { + "stem": "请假制度中,事假的最长申请天数为?", + "data": { + "options": [ + {"key": "A", "content": "3天"}, + {"key": "B", "content": "7天"}, + {"key": "C", "content": "15天"}, + {"key": "D", "content": "30天"} + ] + }, + "answer": "B" + }, + "student_answer": "B", + "max_score": 2.0 + } + ] + }' +``` + +**测试用例2:多选题** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_multi_001", + "answers": [ + { + "question_id": "q_multi_001", + "question_type": "multiple_choice", + "question_content": { + "stem": "以下哪些属于公司福利?", + "data": { + "options": [ + {"key": "A", "content": "五险一金"}, + {"key": "B", "content": "年终奖金"}, + {"key": "C", "content": "带薪年假"}, + {"key": "D", "content": "加班工资"} + ] + }, + "answer": "ABC" + }, + "student_answer": "AB", + "max_score": 4.0 + } + ] + }' +``` + +**测试用例3:判断题** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_tf_001", + "answers": [ + { + "question_id": "q_tf_001", + "question_type": "true_false", + "question_content": { + "stem": "公式\"样本零售客户期末库存/月销量\"可以用来衡量客户对货源的依赖程度。", + "data": {"options": [{"key": "T", "content": "正确"}, {"key": "F", "content": "错误"}]}, + "answer": "F" + }, + "student_answer": "F", + "max_score": 1.0 + } + ] + }' +``` + +**测试用例4:填空题** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_fill_001", + "answers": [ + { + "question_id": "q_fill_001", + "question_type": "fill_blank", + "question_content": { + "stem": "三峡电站2020年发电量达到____亿千瓦时,为历年最高。", + "data": {"blank_count": 1}, + "answer": ["1118.02"] + }, + "student_answer": ["1118"], + "max_score": 2.0 + } + ] + }' +``` + +**测试用例5:主观题** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_subjective_001", + "answers": [ + { + "question_id": "q_subjective_001", + "question_type": "subjective", + "question_content": { + "stem": "请简述库存周转率的定义及其计算公式。", + "data": {}, + "answer": "库存周转率是衡量库存管理效率的指标。公式:库存周转率 = 销售成本 / 平均库存" + }, + "student_answer": "库存周转率是衡量库存管理效率的指标,反映了库存的周转速度。计算公式为:库存周转率 = 销售成本 / 平均库存", + "max_score": 6.0 + } + ] + }' +``` + +**测试用例6:混合题型(完整试卷)** + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "practice", + "request_id": "grade_req_mixed_001", + "answers": [ + { + "question_id": "q_001", + "question_type": "single_choice", + "question_content": { + "stem": "以下哪个不是公司的核心价值观?", + "data": { + "options": [ + {"key": "A", "content": "诚信"}, + {"key": "B", "content": "创新"}, + {"key": "C", "content": "利润"}, + {"key": "D", "content": "责任"} + ] + }, + "answer": "C" + }, + "student_answer": "C", + "max_score": 2.0 + }, + { + "question_id": "q_002", + "question_type": "multiple_choice", + "question_content": { + "stem": "以下哪些属于人力资源部的职责?", + "data": { + "options": [ + {"key": "A", "content": "招聘"}, + {"key": "B", "content": "培训"}, + {"key": "C", "content": "财务"}, + {"key": "D", "content": "绩效"} + ] + }, + "answer": "ABD" + }, + "student_answer": "AB", + "max_score": 4.0 + }, + { + "question_id": "q_003", + "question_type": "true_false", + "question_content": { + "stem": "员工请假需要提前一天申请。", + "data": {"options": [{"key": "T", "content": "正确"}, {"key": "F", "content": "错误"}]}, + "answer": "T" + }, + "student_answer": "T", + "max_score": 1.0 + }, + { + "question_id": "q_004", + "question_type": "fill_blank", + "question_content": { + "stem": "公司成立于____年,总部位于____。", + "data": {"blank_count": 2}, + "answer": ["2010", "北京"] + }, + "student_answer": ["2010", "上海"], + "max_score": 4.0 + }, + { + "question_id": "q_005", + "question_type": "subjective", + "question_content": { + "stem": "请简述绩效考核的目的和意义。", + "data": {}, + "answer": "绩效考核的目的是评估员工工作表现,激励员工提升绩效,为薪酬调整和晋升提供依据。" + }, + "student_answer": "绩效考核可以评估员工的工作表现,帮助发现问题并改进,同时作为薪酬和晋升的参考依据。", + "max_score": 6.0 + } + ] + }' +``` + +### 12.5 获取考试记录 + +```bash +curl -X GET "http://localhost:8080/api/exam/my/papers" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 12.6 开始考试(获取题目) + +```bash +curl -X POST "http://localhost:8080/api/exam/paper/paper_12345678-1234-1234-1234-123456789abc-start" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 12.7 获取考试结果 + +```bash +curl -X GET "http://localhost:8080/api/exam/record/record_12345678-1234-1234-1234-123456789abc/result" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 12.8 查询用户答题记录(按试卷) + +```bash +curl -X POST "http://localhost:8080/api/exam/answers/query" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "paper_id": "paper_12345678-1234-1234-1234-123456789abc" + }' +``` + +### 12.9 查询用户答题记录(按会话) + +```bash +curl -X POST "http://localhost:8080/api/exam/answers/query" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "session_id": "sess_practice_001" + }' +``` + +*** + +## 十三、题目管理接口 + +### 13.1 获取题目列表(按权限过滤) + +```bash +curl -X GET "http://localhost:8080/api/question/list?questionType=&difficulty=&status=&documentName=&limit=10" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 13.2 创建题目 + +```bash +curl -X POST "http://localhost:8080/api/question" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "question_type": "single_choice", + "difficulty": 3, + "content": { + "stem": "测试题目", + "data": { + "options": [ + {"key": "A", "content": "选项A"}, + {"key": "B", "content": "选项B"}, + {"key": "C", "content": "选项C"}, + {"key": "D", "content": "选项D"} + ] + }, + "answer": "B", + "explanation": "测试解析" + }, + "score": 5.0, + "document_name": "public_kb/test.docx" + }' +``` + +### 13.3 题目统计 + +```bash +curl -X POST "http://localhost:8080/api/question/stats" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "status": "approved", + "question_types": ["single_choice", "multiple_choice"] + }' +``` + +*** + +## 十四、正式考试接口 🟢 正式考 + +### 14.1 开始考试(获取题目) + +```bash +curl -X POST "http://localhost:8080/api/formal-exam/paper/paper_12345678-1234-1234-1234-123456789abc/start" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +### 14.2 提交考试(通过 recordId 方式,推荐) + +```bash +curl -X POST "http://localhost:8080/api/exam/record/record_12345678-1234-1234-1234-123456789abc/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "answers": [ + {"questionId": "q_001", "questionType": "true_false", "answer": true}, + {"questionId": "q_002", "questionType": "subjective", "answer": "该指标用于衡量库存与销售的匹配程度。"}, + {"questionId": "q_003", "questionType": "fill_blank", "answer": ["散点图","销量"]}, + {"questionId": "q_004", "questionType": "single_choice", "answer": "B"}, + {"questionId": "q_005", "questionType": "multiple_choice", "answer": ["A","C","D"]} + ] + }' +``` + +### 14.3 提交考试(通过 /api/exam/grade 方式,支持完整题目信息) + +```bash +curl -X POST "http://localhost:8080/api/exam/grade" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "type": "formal_exam", + "record_id": "record_12345678-1234-1234-1234-123456789abc", + "answers": [ + { + "question_id": "q_001", + "question_type": "true_false", + "question_content": { + "stem": "公式\"样本零售客户期末库存/月销量\"可以用来衡量客户对货源的依赖程度。", + "data": {"options": [{"key": "T", "content": "正确"}, {"key": "F", "content": "错误"}]}, + "answer": "F" + }, + "student_answer": true, + "max_score": 3.0 + } + ] + }' +``` + +### 14.4 获取考试结果 + +```bash +curl -X GET "http://localhost:8080/api/formal-exam/record/record_12345678-1234-1234-1234-123456789abc/result" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" +``` + +*** + +## 十五、正式考管理接口 🟢 正式考管理 + +### 15.1 预览试卷 + +```bash +curl -X POST "http://localhost:8080/api/admin/exam/paper/preview" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "single_choice_count": 1, + "multiple_choice_count": 1, + "true_false_count": 1, + "fill_blank_count": 2, + "subjective_count": 1, + "difficulty": 3 + }' +``` + +### 15.2 保存试卷 + +```bash +curl -X POST "http://localhost:8080/api/admin/exam/paper/save" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "title": "月度考核试卷", + "questionIds": [ + "q_001", + "q_002", + "q_003", + "q_004", + "q_005", + "q_006" + ] + }' +``` + +### 15.3 发布试卷 + +```bash +curl -X POST "http://localhost:8080/api/admin/exam/paper/paper_12345678-1234-1234-1234-123456789abc/publish" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3OTg0NDExNCwiZXhwIjoxNzc5ODUxMzE0fQ.IyCqysE3WRNHTwHoFK_dF_loMFRCVaa1LXlloaFcFwkaMUzansW72b5SkI6gxymZAnJKpDfL0I-y6WdLLbAGkg" \ + -d '{ + "exam_name": "月度考核", + "start_time": "2026-06-01 09:00:00", + "end_time": "2026-06-01 11:00:00", + "duration_minutes": 120, + "department_ids": ["dept_001", "dept_002"], + "allow_retake": false + }' +``` + diff --git a/docs/完整接口文档.md b/docs/完整接口文档.md new file mode 100644 index 0000000..9f354bc --- /dev/null +++ b/docs/完整接口文档.md @@ -0,0 +1,2128 @@ +# 知识库管理系统 - 完整接口文档 + +## 文档信息 + +| 项目 | 内容 | +| ---------- | ------------------ | +| **文档名称** | 知识库管理系统完整接口文档 | +| **版本号** | V2.15.0 | +| **发布日期** | 2026-05-29 | +| **文档状态** | 正式发布 | +| **接口基础路径** | `/api` | +| **认证方式** | Bearer Token (JWT) | + +--- + +## 一、认证说明 + +### 1.1 认证流程 + +所有接口(除登录接口外)均需要在请求头中携带有效的JWT Token: + +``` +Authorization: Bearer +``` + +**Token获取方式**: +- 通过 `/api/auth/login` 接口登录获取 +- Token有效期为24小时 + +### 1.2 通用请求头 + +| 请求头 | 类型 | 必填 | 说明 | +| ------------- | ------ | -- | -------------------------------------- | +| Content-Type | string | 是 | application/json 或 multipart/form-data | +| Authorization | string | 是 | Bearer Token认证信息 | + +### 1.3 通用响应格式 + +```json +{ + "code": 200, + "message": "成功", + "data": {}, + "timestamp": "2026-05-17 10:00:00" +} +``` + +**响应状态码说明**: + +| 状态码 | 说明 | +| --- | ----------- | +| 200 | 成功 | +| 400 | 请求参数错误 | +| 401 | 未授权/Token无效 | +| 403 | 权限不足 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 二、认证接口 + +### 2.1 登录 + +**接口路径**: `POST /api/auth/login` + +**请求体**: +```json +{ + "username": "admin", + "password": "123456" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "登录成功", + "data": { + "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc3ODQzNDg1MCwiZXhwIjoxNzc4NDQyMDUwfQ.q38XpqBPFMrYTuupyTN-trR4p_e80yyNLw2reuybPov6A-EBvmFtXjh_l61hCrLax6Zx6KrdAVFHC9gtNQVLjg", + "user": { + "id": 1, + "username": "admin", + "role": 1 + } + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +## 三、向量库管理接口 + +### 模块说明 + +向量库(也称知识库)是本系统的核心概念,所有向量库的管理操作都通过本模块完成。 + +**核心设计**: +- 创建/更新/删除向量库时,先调用AI端接口,再同步到 `knowledge_base_path` 表 +- `knowledge_base_path` 表存储向量库的元信息(部门、公开/私密、属于公司或个人等) + +**基础路径**:`/api/collection` + +--- + +### 3.1 获取向量库列表 + +**接口路径**: `GET /api/collection` + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": [ + { + "name": "public_kb", + "displayName": "公开知识库", + "documentCount": 150, + "department": "公共部门", + "description": "全员可访问", + "ownerType": "department", + "visibility": "public", + "uploader": "1", + "fileSize": 5242880, + "createTime": "2026-01-01T10:00:00", + "updateTime": "2026-01-01T10:00:00" + } + ], + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 3.2 创建向量库 + +**接口路径**: `POST /api/collection` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | ---------------------------- | +| name | String | 是 | 向量库名称(唯一) | +| displayName | String | 否 | 显示名称,默认使用name | +| department | String | 否 | 所属部门,有值时ownerType为department | +| description | String | 否 | 描述信息 | + +**请求示例**: +```http +POST /api/collection?name=dept_hr&displayName=人力资源知识库&department=人事部门&description=人力资源部专用知识库 +Authorization: Bearer +``` + +--- + +### 3.3 修改向量库 + +**接口路径**: `PUT /api/collection/{name}` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | ---- | +| displayName | String | 否 | 显示名称 | +| department | String | 否 | 所属部门 | +| description | String | 否 | 描述信息 | + +--- + +### 3.4 删除向量库 + +**接口路径**: `DELETE /api/collection/{name}` + +--- + +### 3.5 文件向量化(单文件) + +**接口路径**: `POST /api/collection/{name}/vectorize` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | -- | ---- | +| fileId | Long | 是 | 文件ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "success": true, + "message": "文件已成功解析并存储到向量库", + "fileId": 1, + "fileName": "考勤制度.pdf", + "collection": "public_kb", + "fileSize": 1024000, + "chunkCount": 15, + "path": "public_kb/考勤制度.pdf" + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 3.6 批量文件向量化 + +**接口路径**: `POST /api/collection/{name}/vectorize/batch` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------- | ---------- | -- | ------ | +| fileIds | List | 是 | 文件ID列表 | + +--- + +### 3.7 获取文档列表 + +**接口路径**: `GET /api/collection/{name}/documents` + +--- + +### 3.8 删除文档 + +**接口路径**: `DELETE /api/collection/{name}/documents/{filename}` + +**请求参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | String | 是 | 向量库名称 | +| filename | String | 是 | 文档文件名 | +| fileId | Long | 否 | 文件ID(优先使用,避免同名文件删除错误) | + +**说明**: 删除文档时优先根据 `fileId` 查找记录,若未提供 `fileId` 则回退到 `collection+filename` 查找,避免同名文件删除错误。 + +--- + +### 3.9 废止文档 + +**接口路径**: `POST /api/collection/{name}/documents/{filename}/deprecate` + +--- + +### 3.10 恢复已废止文档 + +**接口路径**: `POST /api/collection/{name}/documents/{filename}/restore` + +--- + +### 3.11 获取文档版本历史 + +**接口路径**: `GET /api/collection/{name}/documents/{filename}/versions` + +--- + +### 3.12 获取文档切片列表 + +**接口路径**: `GET /api/collection/{name}/documents/{filename}/chunks` + +**请求参数**: +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | ---- | ---- | ------ | ---- | +| page | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 20 | 每页数量 | + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "chunks": [ + { + "id": "chunk_001", + "document": "文档内容摘要...", + "metadata": { + "chunk_id": "chunk_001", + "chunk_type": "text", + "collection": "public_kb", + "page": 1, + "source": "文档名.pdf", + "status": "active" + }, + "status": "active", + "version": "v1" + } + ], + "collection": "public_kb", + "document_id": "public_kb/文档名.pdf", + "total": 25 + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 3.13 新增切片 + +**接口路径**: `POST /api/collection/{name}/chunks` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | ---- | ---- | +| content | String | 是 | 切片内容 | +| section | String | 否 | 章节信息 | + +--- + +### 3.14 修改切片 + +**接口路径**: `PUT /api/collection/{name}/chunks/{chunkId}` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | ---- | ---- | +| content | String | 否 | 新内容 | +| section | String | 否 | 章节信息 | + +--- + +### 3.15 删除切片 + +**接口路径**: `DELETE /api/collection/{name}/chunks/{chunkId}` + +--- + +## 四、文件管理接口 + +**基础路径**:`/api/file` + +### 4.1 查询文件详情 + +**接口路径**: `GET /api/file/{id}` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | ---- | ---- | +| id | Long | 是 | 文件ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 1, + "fileName": "test.pdf", + "filePath": "/uploads/test.pdf", + "fileSize": 1024000, + "fileType": "application/pdf", + "extension": "pdf", + "description": "测试文件", + "isPublic": true, + "status": 1, + "auditStatus": 1, + "processStatus": "INDEXED", + "examStatus": "COMPLETED", + "createTime": "2026-05-17T10:00:00" + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 4.2 文件上传 + +**接口路径**: `POST /api/file/upload` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------- | ------ | -- | -------- | +| file | MultipartFile | 是 | 文件内容 | +| deptId | Long | 否 | 部门ID(管理员上传制度文件时指定) | +| description | String | 否 | 文件描述 | +| isPublic | Boolean | 否 | 是否公开,默认true | + +**响应示例**: +```json +{ + "code": 200, + "message": "上传成功", + "data": { + "id": 1, + "fileName": "test.pdf", + "filePath": "/uploads/test.pdf", + "fileSize": 1024000, + "fileType": "application/pdf", + "status": 1 + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 4.3 文件列表分页查询 + +**接口路径**: `GET /api/file/page` + +**请求参数**: +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | ------- | -- | --- | --------- | +| pageNum | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 10 | 每页数量 | +| fileName | String | 否 | - | 文件名模糊查询 | +| status | Integer | 否 | - | 文件状态 | +| auditStatus | Integer | 否 | - | 审核状态 | + +--- + +### 4.4 获取文件信息 + +**接口路径**: `GET /api/file/info/{id}` + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 1, + "fileName": "test.pdf", + "filePath": "/uploads/test.pdf", + "fileSize": 1024000, + "fileType": "application/pdf", + "extension": "pdf", + "description": "测试文件", + "isPublic": true, + "status": 1, + "auditStatus": 1, + "processStatus": "INDEXED", + "examStatus": "COMPLETED", + "createTime": "2026-05-17T10:00:00" + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 4.5 文件下载 + +**接口路径**: `GET /api/file/download/{id}` + +**返回类型**: 文件流 + +--- + +### 4.6 文件预览 + +**接口路径**: `GET /api/file/preview/{id}` + +**返回类型**: 文件流(支持浏览器预览的格式) + +--- + +### 4.7 获取文件文本内容 + +**接口路径**: `GET /api/file/text/{id}` + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "content": "文档文本内容...", + "pageCount": 10 + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +### 4.8 获取文件内容(二进制) + +**接口路径**: `GET /api/file/content/{id}` + +**功能说明**: 获取文件的二进制内容,供后端服务使用 + +**返回类型**: `byte[]` + +--- + +### 4.9 文件RAG流式问答 + +**接口路径**: `POST /api/file/rag/stream` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | ---- | ---- | +| message | String | 是 | 用户提问内容 | + +**请求示例**: +```http +POST /api/file/rag/stream?message=请总结这份文档的内容 +Authorization: Bearer +``` + +**返回类型**: Server-Sent Events (SSE) + +--- + +### 4.10 文件审核通过 + +**接口路径**: `POST /api/file/audit/approve/{id}` + +--- + +### 4.11 文件审核拒绝 + +**接口路径**: `POST /api/file/audit/reject/{id}` + +**请求体**: +```json +{ + "rejectReason": "内容不符合要求" +} +``` + +--- + +### 4.12 获取待审核文件列表 + +**接口路径**: `GET /api/file/audit/pending` + +**请求参数**: +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | ------- | -- | --- | ---- | +| pageNum | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 10 | 每页数量 | + +--- + +### 4.13 更新文件信息 + +**接口路径**: `PUT /api/file/{id}` + +**请求体**: +```json +{ + "isPublic": true, + "description": "更新后的描述" +} +``` + +--- + +### 4.14 删除文件 + +**接口路径**: `DELETE /api/file/{id}` + +--- + +## 五、知识库访问控制接口 + +**基础路径**:`/api/collection-access` + +**说明**:此模块负责查询用户可访问的知识库列表,实现基于部门权限的访问控制。 + +### 5.1 分页查询知识库列表 + +**接口路径**: `GET /api/collection-access/page` + +**请求参数**: +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ---------- | ------- | -- | --- | ------------------------- | +| pageNum | Integer | 否 | 1 | 页码,从1开始 | +| pageSize | Integer | 否 | 10 | 每页记录数 | +| name | String | 否 | - | 知识库名称(模糊查询) | +| department | String | 否 | - | 部门 | +| ownerType | String | 否 | - | 所有者类型:department/personal | +| visibility | String | 否 | - | 可见性:public/private | + +--- + +### 5.2 获取知识库详情 + +**接口路径**: `GET /api/collection-access/{id}` + +--- + +### 5.3 获取用户可访问的知识库列表 + +**接口路径**: `GET /api/collection-access/accessible` + +**功能说明**:根据当前登录用户的部门信息,返回其可访问的所有知识库列表,包括: +- 用户所属部门的公开知识库 +- 公共部门的知识库(所有用户都可访问) +- 用户个人的知识库 + +**响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "collections": [ + { + "name": "public_kb", + "displayName": "公开知识库", + "department": "公共部门", + "description": "全员可访问", + "createTime": "2026-01-01T10:00:00" + }, + { + "name": "dept", + "displayName": "部门知识库", + "department": "人事部门", + "description": "人事部门专用", + "createTime": "2026-05-16T13:44:10" + } + ], + "total": 2 + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +--- + +## 六、AI流式问答接口 + +### 6.1 基本信息 + +| 属性 | 说明 | +| ---------------- | ---------------------------------- | +| **接口名称** | AI流式问答 | +| **描述** | 向AI服务发起流式问答请求,实时返回处理过程,完成后自动保存到数据库 | +| **请求方法** | POST | +| **URL路径** | /api/ai/chat/stream | +| **Content-Type** | application/json | +| **返回格式** | Server-Sent Events (SSE) | + +### 6.2 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | ------------------- | +| message | String | 是 | 用户提问内容 | +| session_id | String | 否 | 会话ID,用于多轮对话,不传则自动生成 | +| id | String | 否 | 自定义请求ID,用于跟踪请求 | + +**请求示例**: +```json +{ + "message": "请介绍一下这个知识库管理系统", + "session_id": "sess_1777600000000_abc123", + "id": "custom_req_001" +} +``` + +### 6.3 流式事件类型 + +| 事件类型 | 说明 | +| ------------- | --------- | +| start | 对话开始事件 | +| connected | RAG服务连接成功 | +| thinking | AI思考中 | +| searching | 知识库检索中 | +| sources | 检索到的来源信息 | +| chunk | 流式内容片段 | +| result/finish | 最终结果 | +| error | 错误信息 | + +--- + +## 七、AI数据接口 + +**基础路径**:`/api/ai/data` + +### 7.1 获取会话消息 + +**接口路径**: `GET /api/ai/data/session/{sessionId}/messages` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| ------ | ---- | ---- | ---- | +| sessionId | String | 是 | 会话ID | + +--- + +### 7.2 获取反馈列表 + +**接口路径**: `GET /api/ai/data/feedbacks` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 20 | 每页大小 | + +--- + +### 7.3 获取差评案例 + +**接口路径**: `GET /api/ai/data/feedbacks/bad-cases` + +--- + +## 八、反馈管理接口 + +**基础路径**:`/api/feedback` + +### 8.1 提交反馈 + +**接口路径**: `POST /api/feedback` + +**请求体**: +```json +{ + "session_id": "会话ID(可选)", + "query": "用户问题", + "answer": "AI回答", + "rating": 1, + "reason": "反馈原因(可选)", + "sources": ["来源文档列表(可选)"] +} +``` + +--- + +### 8.2 获取反馈列表 + +**接口路径**: `GET /api/feedback/list` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 20 | 每页大小 | + +--- + +### 8.3 获取反馈统计 + +**接口路径**: `GET /api/feedback/stats` + +--- + +### 8.4 获取差评案例 + +**接口路径**: `GET /api/feedback/bad-cases` + +--- + +## 九、FAQ管理接口 + +**基础路径**:`/api/faq` + +### 9.1 创建FAQ + +**接口路径**: `POST /api/faq` + +**请求体**: +```json +{ + "question": "常见问题内容", + "answer": "问题答案", + "source_documents": ["来源文档列表(可选)"] +} +``` + +--- + +### 9.2 获取FAQ列表 + +**接口路径**: `GET /api/faq` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 20 | 每页大小 | + +--- + +### 9.3 获取FAQ详情 + +**接口路径**: `GET /api/faq/{id}` + +--- + +### 9.4 更新FAQ + +**接口路径**: `PUT /api/faq/{id}` + +**请求体**: +```json +{ + "question": "更新后的问题(可选)", + "answer": "更新后的答案(可选)", + "status": "approved/pending/rejected(可选)" +} +``` + +--- + +### 9.5 删除FAQ + +**接口路径**: `DELETE /api/faq/{id}` + +--- + +### 9.6 批量删除FAQ + +**接口路径**: `DELETE /api/faq/batch?ids=1,2,3` + +--- + +### 9.7 条件查询FAQ + +**接口路径**: `GET /api/faq/list` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| status | String | 否 | - | 状态筛选 | +| limit | Integer | 否 | 100 | 返回数量限制 | + +--- + +### 9.8 获取FAQ建议列表 + +**接口路径**: `GET /api/faq/suggestions` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 20 | 每页大小 | + +--- + +### 9.9 批准FAQ建议 + +**接口路径**: `POST /api/faq/suggestions/{id}/approve` + +**请求体**: +```json +{ + "answer": "修改后的标准答案(可选)" +} +``` + +--- + +### 9.10 拒绝FAQ建议 + +**接口路径**: `POST /api/faq/suggestions/{id}/reject` + +--- + +## 十、图片管理接口 + +**基础路径**:`/api/image` + +### 10.0 图片完整处理流程 + +#### 10.0.1 整体流程说明 + +本系统实现了完整的AI图片处理流程,包括: + +``` +用户提问 → RAG服务返回 → 图片提取 → 下载保存 → 数据库存储 → API访问 → 前端展示 +``` + +#### 10.0.2 详细处理步骤 + +**步骤1:用户提问** +``` +POST /api/ai/chat/stream +{ + "message": "2003—2022 年,三峡电站累计发电量是多少?最好带图片说明" +} +``` + +**步骤2:RAG服务返回完整数据** +```json +{ + "type": "finish", + "answer": "根据【参考资料】...", + "images": [ + { + "id": "ab77281e7913.jpg", + "score": 7.8, + "type": "chart", + "source": "三峡公报_1-15页.pdf", + "page": 12, + "description": "这是一张柱状图...", + "full_description": "这是一张柱状图,展示了...", + "url": "/images/ab77281e7913.jpg" + } + ] +} +``` + +**步骤3:系统自动处理** +- ✅ 提取图片元数据(id, score, type, source, page, description, full_description) +- ✅ 从AI服务下载图片到本地目录 +- ✅ 保存图片元数据到 `context_image` 表 +- ✅ 保存图片到本地文件系统(`/tmp/img/` 或 `./img/`) + +**步骤4:前端获取图片数据** +```bash +GET /api/image/ab77281e7913.jpg/data +``` +返回:图片二进制流 + +#### 10.0.3 前端展示示例 + +**JavaScript (原生)** +```javascript +// 从 finish 事件获取图片数组 +const images = finishEvent.images; + +images.forEach(image => { + // 创建图片元素 + const img = document.createElement('img'); + img.src = image.url; // url = "/api/image/ab77281e7913.jpg/data" + img.alt = image.description; + img.style.maxWidth = '100%'; + + // 创建描述容器 + const descDiv = document.createElement('div'); + descDiv.innerHTML = ` +

来源:${image.source}

+

页码:第 ${image.page} 页

+

类型:${image.type}

+

描述:${image.description}

+ `; + + // 添加到页面 + document.getElementById('image-gallery').appendChild(img); + document.getElementById('image-gallery').appendChild(descDiv); +}); +``` + +**React 示例** +```jsx +{images && images.length > 0 && ( +
+ {images.map((image, index) => ( +
+ {image.description} +
+

来源:{image.source}

+

页码:第 {image.page} 页

+

类型:{image.type}

+

描述:{image.description}

+ {image.full_description && ( +
+ 查看完整描述 +

{image.full_description}

+
+ )} +
+
+ ))} +
+)} +``` + +**Vue 示例** +```vue + + + +``` + +### 10.1 创建图片记录 + +**接口路径**: `POST /api/image` + +**请求体**: +```json +{ + "image_id": "图片唯一标识", + "filename": "原始文件名(可选)", + "path": "存储路径", + "size_bytes": 1024000, + "format": "图片格式(可选)", + "source": "来源文档(可选)", + "page": 1, + "collection": "所属向量库(可选)" +} +``` + +--- + +### 10.2 获取图片列表 + +**接口路径**: `GET /api/image/list` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| limit | Integer | 否 | 50 | 返回数量 | +| offset | Integer | 否 | 0 | 偏移量 | + +--- + +### 10.3 获取图片详情 + +**接口路径**: `GET /api/image/{id}` + +--- + +### 10.4 获取图片数据 + +**接口路径**: `GET /api/image/{id}/data` + +**返回类型**: 图片二进制流 + +--- + +### 10.5 更新图片记录 + +**接口路径**: `PUT /api/image/{id}` + +**请求体**: +```json +{ + "filename": "更新后的文件名(可选)", + "path": "更新后的路径(可选)", + "format": "更新后的格式(可选)" +} +``` + +--- + +### 10.6 删除图片 + +**接口路径**: `DELETE /api/image/{id}` + +--- + +### 10.7 批量删除图片 + +**接口路径**: `DELETE /api/image/batch?ids=1,2,3` + +--- + +### 10.8 获取图片统计 + +**接口路径**: `GET /api/image/stats` + +--- + +## 十一、报告管理接口 + +**基础路径**:`/api/report` + +### 11.1 获取周报 + +**接口路径**: `GET /api/report/weekly` + +--- + +### 11.2 获取月报 + +**接口路径**: `GET /api/report/monthly` + +--- + +## 十二、知识检索接口 + +### 12.1 混合检索 + +**接口路径**: `POST /api/search` + +**请求体**: +```json +{ + "query": "检索关键词", + "topK": 5, + "collections": ["public_kb", "dept"] +} +``` + +--- + +## 十三、知识库路由接口 + +### 13.1 测试知识库路由 + +**接口路径**: `POST /api/kb-route` + +**请求体**: +```json +{ + "query": "财务部的报销流程是什么" +} +``` + +--- + +## 十四、模拟考接口 🔵 模拟考 + +**基础路径**:`/api/exam` + +**功能说明**:模拟考接口,通过 AI 生成模拟试卷、题目,支持批改答案。前端可传 `type` 参数指定本次试卷类型(日常练习/模拟考/正式考均可)。 + +**type 参数可选值**: + +| 值 | 说明 | +|----|------| +| `practice` | 日常练习 | +| `mock_exam` | 模拟考 | +| `formal_exam` | 正式考试 | + +### 14.1 生成试卷 + +**接口路径**: `POST /api/exam/paper/generate` + +**功能说明**: 根据权限范围或指定文件从题库中动态生成试卷 + +**请求体**: +```json +{ + "single_choice_count": 5, + "multiple_choice_count": 3, + "true_false_count": 2, + "fill_blank_count": 2, + "subjective_count": 1, + "difficulty": 3, + "include_personal": false, + "file_ids": [1, 2, 3] +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| single_choice_count | Integer | 否 | - | 单选题数量 | +| multiple_choice_count | Integer | 否 | - | 多选题数量 | +| true_false_count | Integer | 否 | - | 判断题数量 | +| fill_blank_count | Integer | 否 | - | 填空题数量 | +| subjective_count | Integer | 否 | - | 简答题数量 | +| difficulty | Integer | 否 | - | 难度等级(1-5) | +| include_personal | Boolean | 否 | false | 是否包含个人专属题目 | +| file_ids | List | 否 | - | 指定文件ID列表,仅从这些文件生成题目 | + +**响应示例**: +```json +{ + "code": 200, + "message": "试卷生成成功", + "data": { + "paper_id": "paper_xxx", + "paper_title": "考试试卷 - 2026-05-17T15:41:56", + "total_score": 100.00, + "question_count": 13, + "generated_at": "2026-05-17T15:41:56", + "permission_scope": "本部门", + "questions": [ + { + "question_id": "q_xxx", + "question_type": "single_choice", + "question_type_name": "单选题", + "difficulty": 3, + "score": 5.00, + "content": {...} + } + ] + }, + "timestamp": "2026-05-17 15:41:56" +} +``` + +--- + +### 14.2 开始考试(获取题目) + +**接口路径**: `POST /api/exam/paper/{paperId}/start` + +**功能说明**: 通过试卷ID开始模拟考试,系统自动查找或创建考试记录,返回试卷题目内容 + +**路径参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| paperId | String | 是 | 试卷ID | + +**响应示例**: 同14.1 生成试卷的响应格式 + +--- + +### 14.3 获取考试结果 + +**接口路径**: `GET /api/exam/record/{recordId}/result` + +**功能说明**: 获取模拟考试的完整结果,包括题目、答案、分数等信息 + +**路径参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| recordId | String | 是 | 考试记录ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "record_id": "59759c40-ee4e-4ff5-ab03-4819a0499beb", + "paper_id": "0ef70443-67d3-48cd-9309-f5088c092fe1", + "answer_type": "practice", + "paper_title": "考试试卷 - 2026-05-17T15:41:56", + "user_id": 1, + "status": "submitted", + "total_score": 85.0, + "answers": [ + { + "question_id": "q_001", + "score": 5.0, + "is_correct": true, + "feedback": "回答正确" + } + ] + }, + "timestamp": "2026-05-17 16:05:00" +} +``` + +--- + +### 14.4 生成题目 + +**接口路径**: `POST /api/exam/generate` + +**功能说明**: 根据指定文件生成考试题目,支持两种生成模式: +1. **智能生成模式**(推荐):不提供 `question_types` 参数,系统会调用 AI 智能出题接口 `/exam/generate-smart`,AI 会根据文档内容自动判断题型和数量 +2. **指定题型模式**:提供 `question_types` 参数,系统会调用传统出题接口 `/exam/generate`,按照指定的题型和数量生成题目 + +**重要说明**:此接口为异步接口,请求后立即返回任务提交状态,实际生成在后台执行。需通过 `GET /api/file/{fileId}` 接口轮询查询生成进度和结果。 + +支持两种方式指定文件: +1. **通过 file_id 指定**(推荐):系统自动从 `collection_file` 表查询向量库信息 +2. **通过 file_path 指定**:需要同时指定 collection 参数 + +**请求体(智能生成模式)**: +```json +{ + "file_id": 1, + "difficulty": 3, + "request_id": "gen_req_smart_001" +} +``` + +**请求体(指定题型模式)**: +```json +{ + "file_id": 1, + "question_types": { + "single_choice": 3, + "multiple_choice": 2, + "true_false": 2, + "fill_blank": 2, + "subjective": 1 + }, + "question_scores": { + "single_choice": 5.00, + "multiple_choice": 8.00 + }, + "difficulty": 3, + "request_id": "gen_req_001" +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file_id | Long | 二选一 | 文件ID,系统自动查询向量库 | +| file_path | String | 二选一 | 文件路径 | +| collection | String | 当使用 file_path 时必填 | 向量库名称 | +| question_types | Object | 否 | 题型及数量配置(不提供则使用智能生成模式) | +| question_scores | Object | 否 | 自定义题型分数配置(仅在指定题型模式下有效) | +| difficulty | Integer | 否 | 难度等级(1-5),默认3 | +| request_id | String | 否 | 请求ID,用于幂等性 | + +**响应示例(通过 file_id)**: +```json +{ + "code": 200, + "message": "收到请求", + "data": { + "requestId": "gen_req_001", + "fileId": 1, + "fileMatchStatus": "success", + "matchedCollection": "public_kb", + "matchedFilePath": "薪酬制度.docx", + "documentName": "薪酬制度.docx", + "collectionFilled": true, + "collectionFillSource": "collection_file 表查询", + "asyncTaskStarted": true, + "nextStep": "请通过文件状态接口查询生成进度", + "status": "success" + }, + "timestamp": "2026-05-28 10:00:00" +} +``` + +**响应示例(通过 file_path)**: +```json +{ + "code": 200, + "message": "收到请求", + "data": { + "requestId": "gen_req_001", + "fileMatchStatus": "skipped", + "reason": "使用 file_path 直接指定文件", + "collectionFilled": false, + "asyncTaskStarted": true, + "nextStep": "请通过文件状态接口查询生成进度", + "status": "success" + }, + "timestamp": "2026-05-28 10:00:00" +} +``` + +**响应字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| requestId | String | 请求ID,用于追踪 | +| fileId | Long | 文件ID | +| fileMatchStatus | String | 文件匹配状态:success/skipped/failed | +| matchedCollection | String | 匹配到的向量库名称 | +| matchedFilePath | String | 匹配到的文件路径 | +| documentName | String | 文档名称 | +| collectionFilled | Boolean | 向量库是否已填充 | +| collectionFillSource | String | 向量库填充来源 | +| asyncTaskStarted | Boolean | 异步任务是否已启动 | +| nextStep | String | 下一步操作提示 | +| status | String | 状态:success/error | + +**生成状态查询**: +请求成功后,需通过 `GET /api/file/{fileId}` 接口轮询查询生成进度: + +**AI生成题目状态**: +| 状态值 | 说明 | 前端处理建议 | +|--------|------|--------------| +| `UNGENERATED` | 未生成 | 显示"未生成"状态 | +| `GENERATING` | 正在生成 | 显示加载动画,轮询状态 | +| `GENERATED` | 已生成 | 显示成功状态,可查看题目 | +| `FAILED` | 生成失败 | 显示错误信息,支持重试 | + +**文件状态查询响应示例**: +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": 1, + "fileName": "薪酬制度.docx", + "filePath": "public_kb/薪酬制度.docx", + "processStatus": "INDEXED", + "examStatus": "GENERATED", + "processMessage": "生成成功,共5道题\n\n【AI分析结果】\n{\"summary\":\"文档摘要...\",\"questionDistribution\":{\"single_choice\":3,\"multiple_choice\":2}}", + "createTime": "2026-05-28T10:00:00", + "updateTime": "2026-05-28T10:00:03" + } +} +``` + +**processMessage 字段说明**: +- 生成成功时:包含题目数量,智能模式还会包含 AI 分析结果(JSON格式) +- 生成失败时:包含错误原因 +- AI 分析结果通过 `【AI分析结果】` 标记标识 + +**默认分值配置**: + +| 题型 | question_type | 默认分值 | +|------|---------------|---------| +| 单选题 | single_choice | 2分 | +| 多选题 | multiple_choice | 4分 | +| 判断题 | true_false | 1分 | +| 填空题 | fill_blank | 2分 | +| 简答题 | subjective | 6分 | + +**分数优先级**: +1. 题目级配置(AI返回的 score 字段) +2. 题型级配置(question_scores 参数) +3. 默认值(上表) + +**轮询策略建议**: +- 初始轮询间隔:2-3秒 +- 超时时间:建议设置为 5-10 分钟 +- 停止条件:`examStatus` 变为 `GENERATED` 或 `FAILED` + +--- + +### 14.5 提交考试并批改(正式考试专用) + +**接口路径**: `POST /api/exam/record/{recordId}/grade` + +**功能说明**: 通过考试记录ID提交正式考试答案并批改,自动将类型设置为 `formal_exam`。 + +**路径参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| recordId | String | 是 | 考试记录ID | + +**请求体**: +```json +{ + "answers": [ + {"questionId": "q_001", "questionType": "single_choice", "answer": "B"}, + {"questionId": "q_002", "questionType": "multiple_choice", "answer": ["A", "C", "D"]}, + {"questionId": "q_003", "questionType": "true_false", "answer": true}, + {"questionId": "q_004", "questionType": "fill_blank", "answer": ["答案1", "答案2", "答案3"]}, + {"questionId": "q_005", "questionType": "subjective", "answer": "主观题答案内容"} + ] +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "提交成功", + "data": { + "recordId": "record_xxx", + "paperId": "paper_xxx", + "paperTitle": "月度考核试卷", + "status": "submitted", + "totalScore": 85.0, + "totalMaxScore": 100.0, + "scoreRate": 85.0, + "submitTime": "2026-05-17T14:30:00", + "answers": [ + { + "questionId": "q_001", + "score": 5.0, + "maxScore": 5.0, + "isCorrect": true, + "feedback": "回答正确" + } + ] + }, + "timestamp": "2026-05-17T14:30:00" +} +``` + +--- + +### 14.6 批改答案(日常练习/模拟考/正式考试通用) + +**接口路径**: `POST /api/exam/grade` + +**功能说明**: 对学生提交的答案进行批改,支持两种模式: +1. **日常练习/模拟考模式**(无 record_id):直接批改答案,可重复提交更新记录 +2. **正式考试模式**(有 record_id):通过考试记录ID提交正式考试答案并批改 + +`type` 字段由前端传入,后端原样使用(不限值)。 + +**请求体**: +```json +{ + "request_id": "grade_req_001", + "type": "practice", + "record_id": "59759c40-ee4e-4ff5-ab03-4819a0499beb", + "answers": [ + { + "question_id": "q_001", + "question_type": "single_choice", + "question_content": { + "stem": "题目内容", + "data": {"options": [...]}, + "answer": "B" + }, + "student_answer": "A", + "max_score": 5.0 + } + ] +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "批阅成功", + "data": { + "success": true, + "total_score": 5.0, + "total_max_score": 5.0, + "score_rate": 100.0, + "results": [ + { + "question_id": "q_001", + "question_type": "single_choice", + "score": 5.0, + "max_score": 5.0, + "is_correct": true, + "correct": true, + "student_answer": "B", + "correct_answer": "B", + "feedback": "回答正确" + } + ] + }, + "timestamp": "2026-05-17 10:00:00" +} +``` + +**响应字段说明**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| code | Integer | 状态码,200表示成功 | +| message | String | 响应消息 | +| data.success | Boolean | 批改是否成功 | +| data.total_score | Double | 总得分 | +| data.total_max_score | Double | 总满分 | +| data.score_rate | Double | 得分率(0-100) | +| data.results | Array | 每题批改结果 | +| data.results[].question_id | String | 题目ID | +| data.results[].question_type | String | 题型 | +| data.results[].score | Double | 本题得分 | +| data.results[].max_score | Double | 本题满分 | +| data.results[].is_correct | Boolean | 是否正确(推荐使用) | +| data.results[].correct | Boolean | 是否正确(兼容字段) | +| data.results[].student_answer | String | 学生答案 | +| data.results[].correct_answer | String | 正确答案 | +| data.results[].feedback | String | 反馈信息 | + +**字段兼容性说明**: + +| 字段名 | 说明 | 兼容性 | +|--------|------|--------| +| `is_correct` | 布尔值,表示是否正确 | ✅ 主字段,推荐使用 | +| `correct` | 布尔值,表示是否正确 | ✅ 兼容字段,与is_correct值相同 | +| `feedback` | 反馈信息 | ✅ 后端自动生成,确保不为空 | +| `student_answer` | 学生答案 | ✅ 返回前端提交的答案 | +| `correct_answer` | 正确答案 | ✅ 从题目信息中获取 | + +**各题型反馈生成规则**: + +- **单选题、多选题、判断题**:优先使用AI返回的`feedback`字段,内容简单直接 +- **填空题**:优先使用AI返回的`feedback`,否则从`details.blank_scores`和`details.correct_answers`自动生成 +- **主观题**:优先使用`details.overall_feedback`,否则使用`feedback`,最后从`details.scoring_breakdown`生成 + +--- + +### 14.7 查询用户答题记录 + +**接口路径**: `POST /api/exam/answers/query` + +**功能说明**: 根据试卷ID或会话ID查询用户答题记录,支持按试卷/练习维度分组展示 + +**请求体**: +```json +{ + "paper_id": "paper_xxx" +} +``` + +或 + +```json +{ + "session_id": "sess_xxx" +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| paper_id | String | 二选一 | 试卷ID | +| session_id | String | 二选一 | 会话ID(日常练习) | + +--- + +### 14.8 获取考试记录列表(统一接口) + +**接口路径**: `GET /api/exam/records?type={type}` + +**功能说明**: 根据类型获取用户的考试/练习记录,统一查询接口 + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| type | String | 否 | practice | 记录类型:practice(日常练习), mock_exam(模拟考), formal_exam(正式考) | + +**响应示例**(日常练习): +```json +{ + "code": 200, + "message": "成功", + "data": { + "success": true, + "totalCount": 10, + "groups": [ + { + "groupType": "practice", + "groupId": "sess_xxx", + "groupName": "2026-05-17", + "createdAt": "2026-05-17T10:30:00" + } + ] + } +} +``` + +**响应示例**(模拟考/正式考): +```json +{ + "code": 200, + "message": "成功", + "data": [ + { + "recordId": "rec_xxx", + "paperId": "paper_xxx", + "paperTitle": "月度考核试卷", + "answerType": "formal_exam", + "status": "completed", + "totalScore": 85.0, + "submitTime": "2026-05-17T11:30:00" + } + ] +} +``` + +--- + +## 十五、正式考试接口 🟢 正式考 + +**基础路径**:`/api/formal-exam` + +**功能说明**:正式考试接口,仅供被分配了正式试卷的用户使用。提交答案时 `type` 自动设置为 `formal_exam`,同一用户对同一题目只能答题一次(唯一索引防重复)。 + +### 15.1 开始考试(获取题目) + +**接口路径**: `POST /formal-exam/paper/{paperId}/start` + +**功能说明**: 直接通过试卷ID开始考试,系统自动查找或创建考试记录,返回试卷题目内容 + +**响应示例**: +```json +{ + "code": 200, + "message": "开始考试成功", + "data": { + "recordId": "record_xxx", + "paperId": "paper_xxx", + "paperTitle": "月度考核试卷", + "status": "in_progress", + "startTime": "2026-05-17T14:00:00", + "questions": [ + { + "questionId": "q_001", + "questionType": "single_choice", + "questionTypeName": "单选题", + "score": 5.0, + "content": { + "stem": "题目内容...", + "data": {"options": [...]} + } + } + ] + }, + "timestamp": "2026-05-17 14:00:00" +} +``` + +--- + +**重要说明**:正式考试提交接口已统一到 `/api/exam/grade(推荐使用)或 `/api/exam/record/{recordId}/grade`。 + +### 15.2 获取考试结果 + +**接口路径**: `GET /formal-exam/record/{recordId}/result` + +**功能说明**: 获取考试结果详情,包含每题得分和反馈 + +--- + +## 十六、正式考管理接口 🟢 正式考管理 + +**基础路径**:`/admin/exam` + +**功能说明**:正式考管理接口,供管理员预览、保存、发布正式考试试卷。管理员生成试卷后发布给指定部门/人员,员工在正式考试接口中答题。`type` 自动设置为 `formal_exam`。 + +### 16.1 预览试卷 + +**接口路径**: `POST /admin/exam/paper/preview` + +**请求体**: +```json +{ + "single_choice_count": 5, + "multiple_choice_count": 3, + "true_false_count": 2, + "fill_blank_count": 2, + "subjective_count": 1, + "difficulty": 3 +} +``` + +--- + +### 16.2 保存试卷 + +**接口路径**: `POST /admin/exam/paper/save` + +**请求体**: +```json +{ + "title": "月度考核试卷", + "questionIds": ["q_001", "q_002", "q_003"] +} +``` + +--- + +### 16.3 发布试卷 + +**接口路径**: `POST /admin/exam/paper/{paperId}/publish` + +**请求体**: +```json +{ + "deptId": 1, + "userIds": [10, 20, 30], + "excludeUserIds": [5], + "publishTime": "2026-05-17T00:00:00", + "deadline": "2026-05-20T23:59:59" +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| deptId | Long | 否 | 部门ID,发布给该部门的所有用户 | +| userIds | List\ | 否 | 指定用户ID列表 | +| excludeUserIds | List\ | 否 | 排除用户ID列表 | +| publishTime | DateTime | 否 | 发布时间,默认立即发布 | +| deadline | DateTime | 否 | 截止时间 | + +**说明**: +- 最终考试人员 = (部门用户 + 指定用户) - 排除用户 +- 三种组合方式: + 1. 只设置 `deptId`:发布给整个部门 + 2. 只设置 `userIds`:只发布给指定用户 + 3. 同时设置:覆盖更广泛范围,可排除特定人员 + +**响应示例**: +```json +{ + "code": 200, + "message": "发布成功", + "data": null, + "timestamp": "2026-05-17 00:00:00" +} +``` + +--- + +### 16.4 撤销试卷 + +**接口路径**: `POST /admin/exam/paper/{paperId}/revoke` + +--- + +### 16.5 删除试卷 + +**接口路径**: `DELETE /admin/exam/paper/{paperId}` + +--- + +### 16.6 获取试卷详情(管理) + +**接口路径**: `GET /admin/exam/paper/{paperId}` + +--- + +### 16.7 获取试卷列表(管理) + +**接口路径**: `GET /admin/exam/paper/list` + +--- + +### 16.8 获取考试记录列表 + +**接口路径**: `GET /admin/exam/records/{paperId}` + +--- + +### 16.9 获取考试记录详情 + +**接口路径**: `GET /admin/exam/record/{recordId}` + +--- + +## 十七、题目管理接口 + +**基础路径**:`/api/question` + +### 17.1 创建题目 + +**接口路径**: `POST /api/question` + +**请求体**: +```json +{ + "question_type": "single_choice", + "difficulty": 3, + "content": {"stem": "题目内容", "data": {...}, "answer": "答案"}, + "score": 5.0, + "document_name": "来源文档" +} +``` + +--- + +### 17.2 分页查询题目 + +**接口路径**: `GET /api/question/page` + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| pageNum | Integer | 否 | 1 | 页码 | +| pageSize | Integer | 否 | 10 | 每页大小 | +| questionType | String | 否 | - | 题型筛选 | +| difficulty | Integer | 否 | - | 难度筛选 | +| status | String | 否 | - | 状态筛选 | + +--- + +### 17.3 获取题目详情 + +**接口路径**: `GET /api/question/{id}` + +--- + +### 17.4 更新题目 + +**接口路径**: `PUT /api/question/{id}` + +**请求体**: +```json +{ + "question_type": "更新后的题型(可选)", + "difficulty": 4, + "content": {"stem": "更新后的题目内容"}, + "status": "approved" +} +``` + +--- + +### 17.5 删除题目 + +**接口路径**: `DELETE /api/question/{id}` + +--- + +### 17.6 批量删除题目 + +**接口路径**: `DELETE /api/question/batch?ids=1,2,3` + +--- + +### 17.7 条件查询题目(按权限过滤) + +**接口路径**: `GET /api/question/list` + +**功能说明**: 根据用户权限过滤,返回用户可见的题目(本人库、本部门库、公共库的题目) + +**请求参数**: +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| questionType | String | 否 | - | 题型筛选 | +| difficulty | Integer | 否 | - | 难度筛选 | +| status | String | 否 | - | 状态筛选 | +| documentName | String | 否 | - | 文档名称筛选 | +| limit | Integer | 否 | 100 | 返回数量限制 | + +--- + +### 17.8 获取待审核题目 + +**接口路径**: `GET /api/question/pending` + +--- + +### 17.9 审核题目 + +**接口路径**: `PUT /api/question/review/{questionId}` + +**请求参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| status | String | 是 | 审核状态:approved/rejected | +| reviewerComment | String | 否 | 审核评论 | + +--- + +### 17.10 获取题目统计 + +**接口路径**: `POST /api/question/stats` + +**请求体**: +```json +{ + "group_by": "question_type" +} +``` + +--- + +## 十八、错题管理接口 + +**基础路径**:`/api/wrong-questions` + +### 18.1 获取错题列表 + +**接口路径**: `POST /api/wrong-questions/list` + +**请求体**: +```json +{ + "page": 1, + "pageSize": 10, + "collection": true +} +``` + +--- + +### 18.2 收藏/取消收藏错题 + +**接口路径**: `POST /api/wrong-questions/collection/toggle` + +**请求体**: +```json +{ + "question_id": "q_001" +} +``` + +--- + +### 18.3 重新做题 + +**接口路径**: `POST /api/wrong-questions/redo` + +**请求体**: +```json +{ + "question_ids": ["q_001", "q_002"] +} +``` + +--- + +### 18.4 导出错题 + +**接口路径**: `POST /api/wrong-questions/export` + +**请求体**: +```json +{ + "question_ids": ["q_001", "q_002"] +} +``` + +--- + +## 十九、数据同步接口 + +**基础路径**:`/api/data-sync` + +### 19.1 验证用户数据同步 + +**接口路径**: `GET /api/data-sync/validate/user/{userId}` + +--- + +### 19.2 验证当前用户数据同步 + +**接口路径**: `GET /api/data-sync/validate/me` + +--- + +### 19.3 验证所有用户数据同步 + +**接口路径**: `GET /api/data-sync/validate/all` + +--- + +### 19.4 验证最近用户数据同步 + +**接口路径**: `GET /api/data-sync/validate/recent` + +--- + +## 二十、接口权限说明 + +### 20.1 权限层级 + +| 用户类型 | 类型值 | 权限说明 | +| ----- | --- | ------------------- | +| 超级管理员 | 1 | 完全访问权限,可管理所有向量库和文档 | +| 部门管理员 | 2 | 可管理本部门的所有向量库和文档 | +| 普通用户 | 3 | 可管理个人上传的文档和本部门的公开文档 | + +### 20.2 权限矩阵 + +| 接口 | 超级管理员 | 部门管理员 | 普通用户 | +|------|-----------|-----------|----------| +| 获取向量库列表 | ✅ | ✅ | ✅ | +| 创建向量库 | ✅ | ✅ | ❌ | +| 修改向量库 | ✅ | ✅ | ❌ | +| 删除向量库 | ✅ | ✅ | ❌ | +| 文件向量化 | ✅ | ✅ | ✅ | +| 获取文档列表 | ✅ | ✅ | ✅ | +| 删除文档 | ✅ | ✅ | ❌ | +| 生成题目 | ✅ | ✅ | ✅ | +| 批改答案 | ✅ | ✅ | ✅ | +| 提交反馈 | ✅ | ✅ | ✅ | +| 获取反馈列表 | ✅ | ✅ | ✅ | +| 题目CRUD | ✅ | ✅ | ❌ | +| FAQ CRUD | ✅ | ✅ | ❌ | +| 图片CRUD | ✅ | ✅ | ❌ | +| 试卷管理 | ✅ | ✅ | ❌ | +| 用户考试 | ✅ | ✅ | ✅ | + +--- + +## 二十一、错误码详解 + +### 21.1 认证相关错误 + +| 错误码 | 错误信息 | 解决方案 | +| --- | -------- | -------------- | +| 401 | 用户未登录 | 请先登录获取Token | +| 401 | Token无效 | 请重新登录获取有效Token | +| 401 | Token已过期 | 请重新登录 | +| 403 | 权限不足 | 联系管理员提升权限 | + +### 21.2 业务逻辑错误 + +| 错误码 | 错误信息 | 解决方案 | +| --- | -------------------- | -------------- | +| 400 | 参数错误 | 检查请求参数格式和必填项 | +| 404 | 向量库不存在 | 检查向量库名称是否正确 | +| 404 | 文档不存在 | 检查文档路径是否正确 | +| 500 | 创建向量库失败 | 检查数据库连接或重试 | + +--- + +## 二十二、使用限制 + +### 22.1 通用限制 + +| 限制项 | 限制值 | 说明 | +| -------- | ---- | ----------- | +| 文件上传大小 | 50MB | 单个文件最大50MB | +| 批量上传文件数 | 20 | 一次最多上传20个文件 | +| 分页最大页码 | 1000 | 超过后性能下降 | +| 每页最大记录数 | 100 | 超过后建议优化查询 | +| Token有效期 | 24小时 | 登录Token有效期 | + +### 22.2 频率限制 + +| 接口类型 | 限制 | 时间窗口 | +| ------- | ---- | ---- | +| 文件上传 | 10次 | 60秒 | +| 其他增删改接口 | 30次 | 60秒 | +| 查询接口 | 100次 | 60秒 | + +--- + +## 二十三、变更记录 + +| 版本 | 日期 | 修改内容 | +|------|------|----------| +| V2.15.0 | 2026-05-29 | 1. 新增用户管理接口(用户CRUD);2. 新增部门管理接口;3. 新增AI会话管理接口(获取会话列表、会话详情、删除会话、批量删除会话);4. 新增向量库切片管理接口(新增切片、修改切片、删除切片、获取向量库切片列表);5. 优化文档结构,添加通用请求头说明;6. 完善向量库管理接口,新增获取文档切片列表等接口。 | +| V2.14.0 | 2026-05-28 | 1. 生成题目接口新增智能生成模式,不提供 `question_types` 参数时自动调用 AI 智能出题接口 `/exam/generate-smart`,AI 根据文档内容自动判断题型和数量;2. `question_types` 参数改为可选,仅在指定题型模式下需要提供;3. `process_message` 字段会追加 AI 分析结果(aiAnalysis),包含文档摘要和题型分布等信息;4. 更新文件详情查询接口响应示例,添加 `processMessage` 字段说明;5. 更新 `process_message` 字段类型从 varchar 改为 text,支持存储更长内容。 | +| V2.13.0 | 2026-05-19 | 1. 生成题目接口新增 `file_id` 参数支持,系统自动从 `collection_file` 表查询向量库信息;2. 新增 `question_scores` 参数支持自定义题型分数;3. 更新默认分值配置(单选2分、多选4分、判断1分、填空2分、简答6分);4. 添加完整的反馈信息机制,包含文件匹配状态、向量库填充进度等。 | +| V2.12.0 | 2026-05-19 | 1. 新增图片完整处理流程说明(10.0节);2. 新增前端图片展示示例(JavaScript/React/Vue);3. 完善图片元数据字段说明(新增 score、type、description、full_description);4. 新增图片自动下载和本地保存功能说明。 | +| V2.11.0 | 2026-05-17 | 1. 控制器重命名:KnowledgeBasePathController → CollectionAccessController,路径从 /api/kb-path 改为 /api/collection-access;2. 部门字段重命名:HR_DEPT → 人事部门,PUBLIC_DEPT → 公共部门;3. 图片接口路径更新:/api/images → /api/image。 | +| V2.10.0 | 2026-05-14 | 新增文件来源展示优化功能:1. AI流式问答 finish 事件新增 `sources`(文件来源列表)和 `images`(图片列表)字段;2. 新增 `GET /api/ai/message/{messageId}/sources` 接口获取文件来源列表(去重);3. `GET /api/ai/message/{messageId}/references` 新增 `docName` 参数支持按文档过滤;4. 消息实体新增 `sources` 字段存储文件来源摘要;5. 图片接口新增兼容路径 `/api/images/list`;6. 修复异步题目生成问题:添加 `@EnableAsync` 配置和自定义线程池。 | +| V2.9.3 | 2026-05-11 | 统一考试接口架构:新增 `/api/exam/record/{recordId}/grade` 接口;`/api/exam/grade` 新增 `record_id` 参数支持正式考试提交;删除冗余接口 `/formal-exam/record/{recordId}/submit` 和 `/admin/exam/record/{recordId}/submit`。 | +| V2.9.2 | 2026-05-11 | 删除重复字段 `status_detail`,只保留 `status` 字段。`exam_record` 表新增 `answer_type` 字段,所有考试记录增加类型标识。 | +| V2.9.1 | 2026-05-11 | 参数重命名:answer_type → type,值改为英文(practice/mock_exam/formal_exam)。删除 recordId 方式开始考试接口,统一使用 paperId。删除旧 UserExamController。数据库 answer_type 字段默认值改为 practice。 | +| V2.8.0 | 2026-05-10 | 简化用户考试流程:删除获取试卷详情和保存答案接口,开始考试接口同时返回题目,提交试卷接口改为接收答案参数一次性提交 | +| V2.7.0 | 2026-05-10 | 完善生成试卷权限控制:根据用户类型(超级管理员/部门管理员/普通用户)进行题目权限过滤,删除重复接口 GET /api/exam/list | +| V2.6.0 | 2026-05-10 | 修改 GET /api/question/list 添加用户权限过滤(本人库、本部门库、公共库);生成试卷添加公共部门向量库支持 | +| V2.5.0 | 2026-05-10 | 补充文件管理接口:新增查询文件详情、获取文件内容接口;更新RAG流式问答接口参数格式 | +| V2.4.0 | 2026-05-10 | 添加文件管理接口、用户考试接口、管理员考试接口、错题管理接口、AI数据接口、数据同步接口 | +| V2.3.0 | 2026-05-09 | 添加生成试卷接口、用户答题记录查询接口,完善考试管理模块 | +| V2.2.0 | 2026-05-07 | 更新向量库列表和文档列表接口说明,改为从本地数据库读取 | +| V2.1.0 | 2026-05-06 | 添加题目、FAQ、图片完整CRUD接口文档 | +| V2.0.0 | 2026-05-05 | 整合考试管理、知识库管理、AI交互、AI流式等接口文档 | +| V1.0.2 | 2026-05-02 | 重构向量库管理接口 | +| V1.0.1 | 2026-05-02 | 整合向量库/知识库接口 | +| V1.0.0 | 2026-04-30 | 初始版本 | + +--- + +**文档结束** diff --git a/docs/批阅接口测试.md b/docs/批阅接口测试.md new file mode 100644 index 0000000..fa6a141 --- /dev/null +++ b/docs/批阅接口测试.md @@ -0,0 +1,473 @@ +# 批阅接口测试文档 + +## 一、接口说明 + +| 接口 | 地址 | 功能 | +| -------- | ------------------------------------------------------------- | --------------------- | +| 后端通用接口 | POST | 批阅答案(日常练习/模拟考/正式考试通用) | +| 后端正式考试接口 | POST | 正式考试专用批阅接口 | +| AI端接口 | POST | AI端批阅答案 | + +**answerType 参数说明**:后端接口支持 `answerType` 字段区分答题类型,前端传入什么值后端就使用什么值。 + +| answerType 值 | 说明 | 重复答题 | +| ------------- | ---- | ----------- | +| `practice` | 日常练习 | 允许(更新记录) | +| `mock_exam` | 模拟考 | 允许(更新记录) | +| `formal_exam` | 正式考试 | 不允许(唯一索引保护) | + +*** + +## 二、接口路径规范 + +**注意**:所有接口路径都在 `/api` 前缀下,实际完整路径为: + +- `http://localhost:8080/api/exam/grade` +- `http://localhost:8080/api/exam/record/{recordId}/grade` + +*** + +## 三、获取 Token + +在测试前,需要先获取有效的访问令牌: + +```bash +curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"123456"}' +``` + +**新 Token**(有效期2小时): + +``` +eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w +``` + +*** + +## 四、后端接口测试 + +### 4.1 单选题测试(日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"test_001","answers":[{"questionId":"9d05c475-b4fe-4679-a450-ee88a94b3b34","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C"},"studentAnswer":"C","maxScore":2.0}]}' +``` + +### 4.2 多选题测试(日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"test_002","answers":[{"questionId":"ee652f7e-da5e-4129-8766-c113277332f4","questionType":"multiple_choice","questionContent":{"stem":"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)","data":{"options":[{"key":"A","content":"科学设定终端投入标准"},{"key":"B","content":"严格执行终端投入标准"},{"key":"C","content":"优先采购高配置但非必需的智能设备"},{"key":"D","content":"坚决杜绝不必要的支出和浪费"}]},"answer":["A","B","D"]},"studentAnswer":["A","B"],"maxScore":4.0}]}' +``` + +### 4.3 判断题测试(日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"test_003","answers":[{"questionId":"76913ad8-5325-4f30-a03a-6e19e434b59e","questionType":"true_false","questionContent":{"stem":"务求实效原则允许在终端建设中适度预留冗余预算,以应对未来可能的功能升级需求。","data":{},"answer":"false"},"studentAnswer":"false","maxScore":1.0}]}' +``` + +### 4.4 填空题测试(日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"test_004","answers":[{"questionId":"7ae3daef-0345-4852-bd37-336e74498cc4","questionType":"fill_blank","questionContent":{"stem":"务求实效原则强调以___为出发点,通过科学设定和严格执行终端投入标准,实现___与___的双重提升。","data":{"blank_count":3},"answer":["终端功能发挥和服务水平提升","终端功能发挥","服务水平提升"]},"studentAnswer":["终端功能发挥和服务水平提升","终端功能发挥","服务水平"],"maxScore":2.0}]}' +``` + +### 4.5 主观题测试(日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"test_005","answers":[{"questionId":"ea6ba220-43ff-44d8-8b24-cd350eeb8b53","questionType":"subjective","questionContent":{"stem":"请结合文档内容,简述\"务求实效原则\"在终端建设管理中的具体体现,并说明其与形式主义倾向的根本区别。","data":{"answer_length_hint":"150–200字"},"answer":"务求实效原则具体体现为:以终端功能发挥和服务水平提升为唯一导向,科学设定并严格执行投入标准,杜绝一切不必要的支出和浪费。"},"studentAnswer":"务求实效原则强调终端功能发挥和服务水平提升,杜绝浪费。形式主义只注重表面,不注重实际效果。","maxScore":6.0}]}' +``` + +### 4.6 批量测试(所有题型,包含错题,日常练习) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDMwNDMwOCwiZXhwIjoxNzgwMzExNTA4fQ.5NLIrXfK-7MVrrmU7TZ7L_z2TXxc4OzaYIOmoFKBLOAkX9pjCBtxz915QQ1SGLekHTj5DBcgF-NhIZDSw-CPtQ" -d '{"answerType":"practice","requestId":"batch_test_001","answers":[{"questionId":"9d05c475-b4fe-4679-a450-ee88a94b3b34","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C"},"studentAnswer":"","maxScore":2.0},{"questionId":"ee652f7e-da5e-4129-8766-c113277332f4","questionType":"multiple_choice","questionContent":{"stem":"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)","data":{"options":[{"key":"A","content":"科学设定终端投入标准"},{"key":"B","content":"严格执行终端投入标准"},{"key":"C","content":"优先采购高配置但非必需的智能设备"},{"key":"D","content":"坚决杜绝不必要的支出和浪费"}]},"answer":["A","B","D"]},"studentAnswer":[],"maxScore":4.0},{"questionId":"76913ad8-5325-4f30-a03a-6e19e434b59e","questionType":"true_false","questionContent":{"stem":"务求实效原则允许在终端建设中适度预留冗余预算,以应对未来可能的功能升级需求。","data":{},"answer":"false"},"studentAnswer":"false","maxScore":1.0}]}' +``` + +**错题说明**: + +- 单选题:学生选A(正确答案是C) +- 多选题:学生选A、B、C(正确答案是A、B、D,C选项违背务求实效原则) +- 判断题:学生选true(正确答案是false) + +### 4.7 模拟考测试(answerType = mock\_exam) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"mock_exam","requestId":"mock_test_001","answers":[{"questionId":"9d05c475-b4fe-4679-a450-ee88a94b3b34","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C"},"studentAnswer":"C","maxScore":2.0},{"questionId":"ee652f7e-da5e-4129-8766-c113277332f4","questionType":"multiple_choice","questionContent":{"stem":"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)","data":{"options":[{"key":"A","content":"科学设定终端投入标准"},{"key":"B","content":"严格执行终端投入标准"},{"key":"C","content":"优先采购高配置但非必需的智能设备"},{"key":"D","content":"坚决杜绝不必要的支出和浪费"}]},"answer":["A","B","D"]},"studentAnswer":["A","B","D"],"maxScore":4.0}]}' +``` + +### 4.8 正式考测试(通过 recordId 接口,推荐方式) + +> **注意**:正式考试推荐使用 `/api/exam/record/{recordId}/grade` 接口提交。这种方式会关联已存在的考试记录。 + +```bash +curl -X POST http://localhost:8080/api/exam/record/59759c40-ee4e-4ff5-ab03-4819a0499beb/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answers":[{"questionId":"9d05c475-b4fe-4679-a450-ee88a94b3b34","questionType":"single_choice","answer":"C"},{"questionId":"ee652f7e-da5e-4129-8766-c113277332f4","questionType":"multiple_choice","answer":["A","B","D"]}]}' +``` + +### 4.9 正式考测试(通过通用接口 + recordId 参数) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"formal_exam","recordId":"59759c40-ee4e-4ff5-ab03-4819a0499beb","requestId":"formal_test_001","paperId":"0ef70443-67d3-48cd-9309-f5088c092fe1","answers":[{"questionId":"9d05c475-b4fe-4679-a450-ee88a94b3b34","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C"},"studentAnswer":"C","maxScore":2.0}]}' +``` + +### 4.10 空白试卷测试 - 日常练习(answers 为空数组) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"practice","requestId":"blank_test_001","answers":[]}' +``` + +### 4.11 空白试卷测试 - 模拟考(answers 为空数组) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"mock_exam","requestId":"blank_test_002","answers":[]}' +``` + +### 4.12 空白试卷测试 - 正式考试(通过 recordId 接口) + +```bash +curl -X POST http://localhost:8080/api/exam/record/4e34c752-1e0e-4207-8fdb-b0027b30521e/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answers":[]}' +``` + +### 4.13 空白试卷测试 - 正式考试(通过通用接口 + recordId) + +```bash +curl -X POST http://localhost:8080/api/exam/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDAzMTgyNSwiZXhwIjoxNzgwMDM5MDI1fQ.an_bqtorziJ5Yb-otGPIIg400fCM1-VLLfX7mgQYHspOgSsTczcSY70evxsMUnfGmOr_euXgXwf2VkMQoXpG5w" -d '{"answerType":"formal_exam","recordId":"4e34c752-1e0e-4207-8fdb-b0027b30521e","requestId":"blank_test_003","answers":[]}' +``` + +### 4.14 正式考试 - 只提交一道题(通过 recordId 接口) + +```bash +curl -X POST http://localhost:8080/api/exam/record/2fa88db4-bc90-4096-9970-3286639c5b5b/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDMxOTgzMiwiZXhwIjoxNzgwMzI3MDMyfQ.azBEWs7-LiicNDA_O0qdm-TPmBtKLBUeCplRPnkGl7NFUhn-JMP5-G9gboP7eHxsaIvowoY6B2TVlB4z8bDIuQ" -d '{"answers":[{"questionId":"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8","questionType":"single_choice","answer":"C"}]}' +``` + +### 4.15 正式考试 - 一道题没选(通过 recordId 接口) + +```bash +curl -X POST http://localhost:8080/api/exam/record/389806c8-3b42-4b5a-adb8-23d166bb1053/grade -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImlhdCI6MTc4MDMxOTgzMiwiZXhwIjoxNzgwMzI3MDMyfQ.azBEWs7-LiicNDA_O0qdm-TPmBtKLBUeCplRPnkGl7NFUhn-JMP5-G9gboP7eHxsaIvowoY6B2TVlB4z8bDIuQ" -d '{"answers":[{"questionId":"5af6fdce-dbf2-4a57-9ef4-bef176be6fb8","questionType":"single_choice","answer":""},{"questionId":"5e069067-a10d-4230-9564-e7503d72d738","questionType":"multiple_choice","answer":["A","B"]},{"questionId":"e6bb2566-90a8-4651-b553-083a1d695b3c","questionType":"true_false","answer":"true"}]}' +``` + +**answerType 测试总结**: + +| answerType 值 | 场景 | 重复提交同一题目 | 数据库行为 | +| ------------- | ---- | -------- | ------------ | +| `practice` | 日常练习 | 允许 | UPDATE 已有记录 | +| `mock_exam` | 模拟考 | 允许 | UPDATE 已有记录 | +| `formal_exam` | 正式考试 | 不允许 | 抛出异常(唯一索引保护) | + +*** + +## 五、AI端接口测试 + +AI端接口**支持** **`explanation`** **字段**,可以直接使用包含完整题目信息的JSON。 + +### 5.1 单选题测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q1","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C","explanation":"文档明确指出要着眼于终端功能发挥和服务水平提升"},"studentAnswer":"C","maxScore":2}]}' +``` + +### 5.2 多选题测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q2","questionType":"multiple_choice","questionContent":{"stem":"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)","data":{"options":[{"key":"A","content":"科学设定终端投入标准"},{"key":"B","content":"严格执行终端投入标准"},{"key":"C","content":"优先采购高配置但非必需的智能设备"},{"key":"D","content":"坚决杜绝不必要的支出和浪费"}]},"answer":["A","B","D"],"explanation":"A、B、D均符合务求实效原则"},"studentAnswer":["A","B","D"],"maxScore":4}]}' +``` + +### 5.3 空白试卷测试(answers 为空数组) + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[]}' +``` + +**预期响应**: + +- **正常情况**(Python AI 已做空值防御): + ```json + { + "data": { + "requestId": null, + "results": [], + "scoreRate": 0.0, + "success": true, + "totalMaxScore": 0, + "totalScore": 0 + }, + "message": "批阅完成", + "status": "success", + "statusCode": 2012, + "success": true + } + ``` +- **异常情况**(Python AI 未做空值防御): + ```json + { + "error_code": "GRADE_ERROR", + "message": "'NoneType' object has no attribute 'get'", + "status": "failed", + "status_code": 5021, + "success": false + } + ``` + +### 5.4 填空题空答案测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q3","questionType":"fill_blank","questionContent":{"stem":"务求实效原则强调以___为出发点,通过科学设定和严格执行终端投入标准,实现___与___的双重提升。","data":{"blank_count":3},"answer":["终端功能发挥和服务水平提升","终端功能发挥","服务水平提升"]},"studentAnswer":null,"maxScore":3}]}' +``` + +### 5.5 主观题空答案测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q4","questionType":"subjective","questionContent":{"stem":"请简述\"务求实效原则\"的核心要求。","data":{"scoring_points":[{"point":"功能发挥","weight":0.5},{"point":"服务水平","weight":0.3},{"point":"杜绝浪费","weight":0.2}]},"answer":"以终端功能发挥和服务水平提升为唯一导向"},"studentAnswer":"","maxScore":5}]}' +``` + +### 5.6 判断题空答案测试(studentAnswer 为空字符串) + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q5","questionType":"true_false","questionContent":{"stem":"务求实效原则允许在终端建设中适度预留冗余预算,以应对未来可能的功能升级需求。","answer":"false"},"studentAnswer":"","maxScore":2}]}' +``` + +### 5.7 判断题空答案测试(studentAnswer 为 null) + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q6","questionType":"true_false","questionContent":{"stem":"务求实效原则强调以终端功能发挥和服务水平提升为唯一导向。","answer":"true"},"studentAnswer":null,"maxScore":2}]}' +``` + +### 5.8 单选题空答案测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q7","questionType":"single_choice","questionContent":{"stem":"根据文档内容,务求实效原则的核心要求聚焦于哪一根本目标?","data":{"options":[{"key":"A","content":"扩大终端数量规模"},{"key":"B","content":"提升终端信息化系统覆盖率"},{"key":"C","content":"着眼于终端功能发挥和服务水平提升"},{"key":"D","content":"统一各类终端建设外观标准"}]},"answer":"C"},"studentAnswer":null,"maxScore":2}]}' +``` + +### 5.9 多选题空答案测试 + +```bash +curl -X POST http://localhost:5001/exam/grade -H "Content-Type: application/json" -d '{"answers":[{"questionId":"q8","questionType":"multiple_choice","questionContent":{"stem":"依据务求实效原则,以下哪些做法符合其核心要求?(可多选)","data":{"options":[{"key":"A","content":"科学设定终端投入标准"},{"key":"B","content":"严格执行终端投入标准"},{"key":"C","content":"优先采购高配置但非必需的智能设备"},{"key":"D","content":"坚决杜绝不必要的支出和浪费"}]},"answer":["A","B","D"]},"studentAnswer":[],"maxScore":4}]}' +``` + +*** + +## 六、接口对比总结 + +| 特性 | 后端通用接口 (/api/exam/grade) | 后端正式考试接口 (/api/exam/record/{recordId}/grade) | AI端接口 (/exam/grade) | +| --------------- | -------------------------------------- | -------------------------------------------------------- | ---------------------------------- | +| 地址 | | | | +| 认证 | 需要 Bearer Token | 需要 Bearer Token | 不需要认证 | +| requestId | 必需 | 不需要 | 不需要 | +| recordId | 可选(正式考试时使用) | 路径参数(必需) | 不支持 | +| answerType | 前端传入,后端原样使用 | 固定为 "formal\_exam" | 不支持 | +| questionId | 使用数据库UUID | 使用数据库UUID | 可自定义ID | +| maxScore | 小数 (2.0) | 不需要(从数据库获取) | 整数 (2) | +| explanation字段 | **不支持** | **不支持** | 支持 | +| questionContent | 日常练习/模拟考时必需 | 不需要 | 支持 | + +**recordId 参数说明**: + +- 当在通用接口中传入 `recordId` 时,系统会尝试查找并更新对应的考试记录 +- 这种方式主要用于正式考试场景,配合 `answerType: "formal_exam"` 使用 +- 对于日常练习或模拟考,可以不传入 `recordId` +- 推荐使用专门的 `/api/exam/record/{recordId}/grade` 接口进行正式考试 + +*** + +## 七、预期响应格式 + +### 7.1 后端通用接口响应示例(日常练习/模拟考) + +```json +{ + "code": 200, + "message": "批阅成功", + "data": { + "requestId": "test_001", + "results": [ + { + "questionId": "9d05c475-b4fe-4679-a450-ee88a94b3b34", + "correct": true, + "feedback": "回答正确!", + "score": 2.0, + "maxScore": 2.0 + } + ], + "totalScore": 2.0, + "totalMaxScore": 2.0, + "scoreRate": 100.0 + } +} +``` + +### 7.2 后端正式考试接口响应示例 + +```json +{ + "code": 200, + "message": "提交成功", + "data": { + "recordId": "59759c40-ee4e-4ff5-ab03-4819a0499beb", + "paperId": "0ef70443-67d3-48cd-9309-f5088c092fe1", + "userId": 1, + "answerType": "formal_exam", + "status": "graded", + "totalScore": 12.0, + "paperTitle": "测试试卷", + "answers": [ + { + "questionId": "9d05c475-b4fe-4679-a450-ee88a94b3b34", + "score": 2.0, + "isCorrect": true, + "feedback": "回答正确!" + } + ], + "startTime": "2024-05-20T10:00:00", + "submitTime": "2024-05-20T10:30:00", + "gradedTime": "2024-05-20T10:30:05" + } +} +``` + +### 7.3 AI端接口响应示例 + +```json +{ + "data": { + "requestId": null, + "results": [ + { + "correct": true, + "feedback": "正确!", + "maxScore": 2, + "questionId": "9d05c475-b4fe-4679-a450-ee88a94b3b34", + "score": 2 + } + ], + "scoreRate": 100.0, + "success": true, + "totalMaxScore": 2, + "totalScore": 2 + }, + "message": "批阅完成", + "status": "success", + "statusCode": 2021, + "success": true +} +``` + +### 7.4 空白试卷响应示例(日常练习/模拟考) + +```json +{ + "code": 200, + "message": "批阅成功", + "data": { + "requestId": "blank_test_001", + "results": [], + "totalScore": 0.0, + "totalMaxScore": 0.0, + "scoreRate": 0.0 + } +} +``` + +### 7.5 空白试卷响应示例(正式考试) + +```json +{ + "code": 200, + "message": "提交成功", + "data": { + "recordId": "59759c40-ee4e-4ff5-ab03-4819a0499beb", + "paperId": "0ef70443-67d3-48cd-9309-f5088c092fe1", + "userId": 1, + "answerType": "formal_exam", + "status": "graded", + "totalScore": 0.0, + "paperTitle": "测试试卷", + "answers": [], + "startTime": "2024-05-20T10:00:00", + "submitTime": "2024-05-20T10:30:00", + "gradedTime": "2024-05-20T10:30:05" + } +} +``` + +*** + +## 八、测试数据来源说明 + +本测试文档中的题目数据均来源于数据库表 `question`,具体来源如下: + +| questionId | 题目类型 | 文档来源 | +| -------------------------------------- | ---------------- | ------------------- | +| `9d05c475-b4fe-4679-a450-ee88a94b3b34` | single\_choice | public\_kb - 务求实效原则 | +| `ee652f7e-da5e-4129-8766-c113277332f4` | multiple\_choice | public\_kb - 务求实效原则 | +| `76913ad8-5325-4f30-a03a-6e19e434b59e` | true\_false | public\_kb - 务求实效原则 | +| `7ae3daef-0345-4852-bd37-336e74498cc4` | fill\_blank | public\_kb - 务求实效原则 | +| `ea6ba220-43ff-44d8-8b24-cd350eeb8b53` | subjective | public\_kb - 务求实效原则 | + +数据库文件路径:[knowledge\_management\_system.sql](file:///d:/Users/19211/Desktop/demo/docs/sql/knowledge_management_system.sql) + +### 8.1 数据库题目查询 SQL + +如需从数据库获取更多测试题目,可使用以下 SQL 查询: + +```sql +-- 查询所有已审核通过的题目 +SELECT question_id, question_type, difficulty, score, content +FROM question +WHERE status = 'approved' +ORDER BY question_type, id; + +-- 查询指定类型的题目 +SELECT question_id, question_type, content +FROM question +WHERE status = 'approved' + AND question_type = 'single_choice' +LIMIT 10; + +-- 查询指定难度的题目 +SELECT question_id, question_type, difficulty, content +FROM question +WHERE status = 'approved' + AND difficulty = 3; +``` + +*** + +## 九、重要提示 + +**后端接口 QuestionContent 字段定义**: + +```java +private String stem; +private Map data; +private Object answer; +``` + +**注意**:后端接口**不支持** **`explanation`** **字段**,如果传入会导致JSON解析错误。 + +**字段命名规范**: + +- 所有字段使用驼峰命名(camelCase),而不是下划线命名(snake\_case) +- 例如:`answerType`、`recordId`、`questionId`、`studentAnswer`、`maxScore` + +**使用建议**: + +1. **所有curl命令已改为单行格式**,可以直接复制使用 +2. **Token有效期2小时**,过期后需要重新获取 +3. **测试时请确保后端服务和AI服务都已启动** +4. **日常练习和模拟考可以重复提交**,会自动更新记录 +5. **正式考试只能提交一次**,重复提交会报错 + +**空白试卷测试说明**: + +- 空白试卷(`answers` 为空数组)测试用于验证系统在无答案情况下的处理能力 +- 正常情况下,空白试卷应返回总分 0.0,不触发错误 +- 若 Python AI 批改服务未做空值防御,可能会出现 `'NoneType' object has no attribute 'get'` 错误(500 状态码) +- 相关修复方案可参考:`后端问题诊断报告_考试提交AI批改服务崩溃.md` + +**AI端空白试卷测试要点**: + +- AI端接口(`http://localhost:5001/exam/grade`)**支持完整的题目信息**,包含 `explanation` 字段 +- AI端采用**小写驼峰命名**(如 `questionId`、`questionType`、`questionContent`、`studentAnswer`、`maxScore`) +- 空白试卷测试重点验证 Python AI 服务的空值防御能力 +- 填空题空答案(`studentAnswer: null`)和主观题空答案(`studentAnswer: ""`)也是常见的边界测试场景 + +*** + +**文档结束** diff --git a/jar_contents.txt b/jar_contents.txt new file mode 100644 index 0000000..84ce677 Binary files /dev/null and b/jar_contents.txt differ diff --git a/logs/file-management-system.log b/logs/file-management-system.log new file mode 100644 index 0000000..f84bfe1 --- /dev/null +++ b/logs/file-management-system.log @@ -0,0 +1,939 @@ +2026-04-22 08:01:04 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 168168 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:01:04 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:01:04 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:01:04 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:01:05 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:01:05 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:01:06 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 23 ms. Found 0 Redis repository interfaces. +2026-04-22 08:01:07 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:01:07 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:01:07 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:01:07 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:01:07 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 2149 ms +2026-04-22 08:01:09 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:01:09 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:01:09 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 4.852 seconds (process running for 5.618) +2026-04-22 08:01:41 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:01:41 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:01:41 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:01:41 [http-nio-8080-exec-1] WARN t.t.d.handler.GlobalExceptionHandler - 权限拒绝: 用户未登录 +2026-04-22 08:01:41 [http-nio-8080-exec-1] WARN o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Resolved [top.tqx.demo_1.exception.PermissionDeniedException: 用户未登录] +2026-04-22 08:02:27 [http-nio-8080-exec-9] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/auth/login | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:02:28 [http-nio-8080-exec-9] WARN t.tqx.demo_1.aspect.RateLimitAspect - Redis 限流失败,降级使用本地缓存: Unable to connect to Redis +2026-04-22 08:02:28 [http-nio-8080-exec-9] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:02:28 [http-nio-8080-exec-9] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@325ca0d8 +2026-04-22 08:02:28 [http-nio-8080-exec-9] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:02:28 [http-nio-8080-exec-9] INFO top.tqx.demo_1.service.AuthService - 用户登录成功: admin +2026-04-22 08:02:28 [http-nio-8080-exec-9] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 1009ms +2026-04-22 08:02:53 [http-nio-8080-exec-5] ERROR t.t.d.handler.GlobalExceptionHandler - 系统异常: +org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present. + at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValueInternal(RequestParamMethodArgumentResolver.java:216) + at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValue(RequestParamMethodArgumentResolver.java:196) + at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:126) + at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122) + at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:224) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:178) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:02:53 [http-nio-8080-exec-5] WARN o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present.] +2026-04-22 08:03:03 [http-nio-8080-exec-6] ERROR t.t.d.handler.GlobalExceptionHandler - 系统异常: +org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present. + at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValueInternal(RequestParamMethodArgumentResolver.java:216) + at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValue(RequestParamMethodArgumentResolver.java:196) + at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:126) + at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122) + at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:224) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:178) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:03:03 [http-nio-8080-exec-6] WARN o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present.] +2026-04-22 08:04:09 [http-nio-8080-exec-7] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/upload | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:04:09 [http-nio-8080-exec-7] WARN t.tqx.demo_1.aspect.RateLimitAspect - Redis 限流失败,降级使用本地缓存: Unable to connect to Redis +2026-04-22 08:04:09 [http-nio-8080-exec-7] INFO t.t.d.service.impl.FileServiceImpl - 文件上传成功: fileName=API 接口测试报告.docx, userId=1, deptId=null, isPublic=1, category=personal, auditStatus=1, path=./uploads/resources/rule/默认部门/API 接口测试报告/v1/API 接口测试报告.docx +2026-04-22 08:04:09 [http-nio-8080-exec-7] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 101ms +2026-04-22 08:07:24 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:07:24 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:08:48 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 167580 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:08:48 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:08:48 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:08:48 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:08:49 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:08:49 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:08:49 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 15 ms. Found 0 Redis repository interfaces. +2026-04-22 08:08:50 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:08:50 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:08:50 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:08:50 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:08:50 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1398 ms +2026-04-22 08:08:51 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:08:51 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:08:51 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.326 seconds (process running for 3.823) +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/auth/login | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:08:54 [http-nio-8080-exec-1] WARN t.tqx.demo_1.aspect.RateLimitAspect - Redis 限流失败,降级使用本地缓存: Unable to connect to Redis +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@d47421c +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO top.tqx.demo_1.service.AuthService - 用户登录成功: user_1 +2026-04-22 08:08:54 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 604ms +2026-04-22 08:10:23 [http-nio-8080-exec-7] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/upload | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:10:23 [http-nio-8080-exec-7] WARN t.tqx.demo_1.aspect.RateLimitAspect - Redis 限流失败,降级使用本地缓存: Unable to connect to Redis +2026-04-22 08:10:23 [http-nio-8080-exec-7] INFO t.t.d.service.impl.FileServiceImpl - 文件上传成功: fileName=a.pdf, userId=4, deptId=1, isPublic=0, category=rule, auditStatus=1, path=./uploads/resources/user/user_4/private/a/v1/a.pdf +2026-04-22 08:10:23 [http-nio-8080-exec-7] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 65ms +2026-04-22 08:11:01 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:11:01 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:11:38 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 159152 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:11:38 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:11:38 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:11:38 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:11:39 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:11:39 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:11:39 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 17 ms. Found 0 Redis repository interfaces. +2026-04-22 08:11:40 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:11:40 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:11:40 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:11:40 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:11:40 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1502 ms +2026-04-22 08:11:42 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:11:42 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:11:42 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 4.163 seconds (process running for 4.699) +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 2 ms +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@a9a5c87 +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:12:38 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:12:39 [http-nio-8080-exec-1] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 文件状态不正确,无法审核 + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:761) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:12:39 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 45ms +2026-04-22 08:14:19 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:14:19 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:14:25 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 166188 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:14:25 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:14:25 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:14:25 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:14:25 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:14:25 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:14:25 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 17 ms. Found 0 Redis repository interfaces. +2026-04-22 08:14:26 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:14:26 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:14:26 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:14:26 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:14:26 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1387 ms +2026-04-22 08:14:27 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:14:28 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:14:28 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.249 seconds (process running for 3.787) +2026-04-22 08:14:33 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:14:33 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:14:33 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:14:34 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:14:34 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@75e2067c +2026-04-22 08:14:34 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:14:34 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:14:34 [http-nio-8080-exec-1] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 文件已审核通过,无需重复审核 + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:765) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:14:34 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 29ms +2026-04-22 08:17:34 [http-nio-8080-exec-3] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:17:34 [http-nio-8080-exec-3] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 文件已审核通过,无需重复审核 + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:765) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:17:34 [http-nio-8080-exec-3] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 42ms +2026-04-22 08:17:35 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:17:35 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:18:49 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 161016 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:18:49 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:18:49 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:18:49 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:18:50 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:18:50 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:18:50 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 17 ms. Found 0 Redis repository interfaces. +2026-04-22 08:18:50 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:18:50 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:18:50 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:18:51 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:18:51 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1343 ms +2026-04-22 08:18:52 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:18:52 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:18:52 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.154 seconds (process running for 3.593) +2026-04-22 08:18:54 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:18:54 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:18:54 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 0 ms +2026-04-22 08:18:55 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:18:55 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@566fcf71 +2026-04-22 08:18:55 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:18:55 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:18:55 [http-nio-8080-exec-1] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 文件已审核通过,无需重复审核 + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:765) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:18:55 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 45ms +2026-04-22 08:19:49 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:19:49 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:19:54 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 151444 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:19:54 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:19:54 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:19:54 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:19:55 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:19:55 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:19:55 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 18 ms. Found 0 Redis repository interfaces. +2026-04-22 08:19:55 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:19:55 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:19:55 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:19:55 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:19:55 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1373 ms +2026-04-22 08:19:57 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:19:57 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:19:57 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.511 seconds (process running for 3.986) +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@11f0f7be +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:20:11 [http-nio-8080-exec-1] WARN t.t.d.service.impl.FileServiceImpl - 文件已审核通过,执行更新操作: fileId=27, auditorId=1 +2026-04-22 08:20:11 [http-nio-8080-exec-1] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 原文件不存在 + at top.tqx.demo_1.service.impl.FileServiceImpl.moveFileToPublic(FileServiceImpl.java:839) + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:769) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:20:11 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 28ms +2026-04-22 08:21:16 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:21:16 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:21:22 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 159688 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:21:22 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:21:22 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:21:22 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:21:22 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:21:22 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:21:22 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 16 ms. Found 0 Redis repository interfaces. +2026-04-22 08:21:23 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:21:23 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:21:23 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:21:23 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:21:23 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1374 ms +2026-04-22 08:21:24 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:21:24 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:21:24 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.172 seconds (process running for 3.61) +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@4130d0f +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:21:43 [http-nio-8080-exec-1] WARN t.t.d.service.impl.FileServiceImpl - 文件已审核通过,执行更新操作: fileId=27, auditorId=1 +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO t.t.d.service.impl.FileServiceImpl - 开始移动文件: fileId=27, oldPath=./uploads/resources/user/user_4/private/a/v1/a.pdf, fullPath=.\uploads\.\uploads\resources\user\user_4\private\a\v1\a.pdf +2026-04-22 08:21:43 [http-nio-8080-exec-1] ERROR t.t.d.service.impl.FileServiceImpl - 原文件不存在: fileId=27, filePath=a.pdf, fullPath=./uploads/resources/user/user_4/private/a/v1/a.pdf +2026-04-22 08:21:43 [http-nio-8080-exec-1] ERROR t.t.demo_1.controller.FileController - 审核通过失败 +java.lang.RuntimeException: 原文件不存在: .\uploads\.\uploads\resources\user\user_4\private\a\v1\a.pdf + at top.tqx.demo_1.service.impl.FileServiceImpl.moveFileToPublic(FileServiceImpl.java:830) + at top.tqx.demo_1.service.impl.FileServiceImpl.approveFile(FileServiceImpl.java:769) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) + at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) + at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.service.impl.FileServiceImpl$$SpringCGLIB$$0.approveFile() + at top.tqx.demo_1.controller.FileController.approveFile(FileController.java:260) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) + at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) + at top.tqx.demo_1.aspect.LogAspect.around(LogAspect.java:43) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637) + at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:627) + at org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:71) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) + at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) + at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:720) + at top.tqx.demo_1.controller.FileController$$SpringCGLIB$$0.approveFile() + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:206) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:175) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:150) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1736) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.base/java.lang.Thread.run(Thread.java:840) +2026-04-22 08:21:43 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 27ms +2026-04-22 08:22:58 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:22:58 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:23:03 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 167832 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:23:03 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:23:03 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:23:03 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:23:04 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:23:04 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:23:04 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 17 ms. Found 0 Redis repository interfaces. +2026-04-22 08:23:04 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:23:04 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:23:04 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:23:05 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:23:05 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1341 ms +2026-04-22 08:23:06 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:23:06 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:23:06 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 3.162 seconds (process running for 3.63) +2026-04-22 08:23:07 [http-nio-8080-exec-1] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-04-22 08:23:07 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet' +2026-04-22 08:23:07 [http-nio-8080-exec-1] INFO o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting... +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@e4e0fd3 +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed. +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 请求 | URL: http://127.0.0.1:8080/api/file/audit/approve/27 | 方法: POST | IP: 127.0.0.1 +2026-04-22 08:23:08 [http-nio-8080-exec-1] WARN t.t.d.service.impl.FileServiceImpl - 文件已审核通过,执行更新操作: fileId=27, auditorId=1 +2026-04-22 08:23:08 [http-nio-8080-exec-1] WARN t.t.d.service.impl.FileServiceImpl - 检测到路径包含uploads前缀,已清理: original=./uploads/resources/user/user_4/private/a/v1/a.pdf, cleaned=resources/user/user_4/private/a/v1/a.pdf +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO t.t.d.service.impl.FileServiceImpl - 开始移动文件: fileId=27, oldPath=resources/user/user_4/private/a/v1/a.pdf, fullPath=.\uploads\resources\user\user_4\private\a\v1\a.pdf +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO t.t.d.service.impl.FileServiceImpl - 文件移动成功: .\uploads\resources\user\user_4\private\a\v1\a.pdf -> .\uploads\resources\user\user_4\public\a\v2\a.pdf +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO t.t.d.service.impl.FileServiceImpl - 审核通过,文件已移动到公开目录: ./uploads/resources/user/user_4/private/a/v1/a.pdf -> .\uploads\resources\user\user_4\public\a\v2\a.pdf +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO t.t.d.service.impl.FileServiceImpl - 文件审核通过: fileId=27, auditorId=1 +2026-04-22 08:23:08 [http-nio-8080-exec-1] INFO top.tqx.demo_1.aspect.LogAspect - 响应 | 耗时: 83ms +2026-04-22 08:23:21 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... +2026-04-22 08:23:21 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed. +2026-04-22 08:36:31 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Starting Demo1Application using Java 17.0.18 with PID 159800 (C:\Users\35018\Desktop\demo\target\classes started by 35018 in C:\Users\35018\Desktop\demo) +2026-04-22 08:36:31 [restartedMain] INFO top.tqx.demo_1.Demo1Application - The following 1 profile is active: "dev" +2026-04-22 08:36:31 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2026-04-22 08:36:31 [restartedMain] INFO o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2026-04-22 08:36:32 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +2026-04-22 08:36:32 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2026-04-22 08:36:32 [restartedMain] INFO o.s.d.r.c.RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 18 ms. Found 0 Redis repository interfaces. +2026-04-22 08:36:33 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat initialized with port 8080 (http) +2026-04-22 08:36:33 [restartedMain] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] +2026-04-22 08:36:33 [restartedMain] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-04-22 08:36:33 [restartedMain] INFO o.a.c.c.C.[.[localhost].[/api] - Initializing Spring embedded WebApplicationContext +2026-04-22 08:36:33 [restartedMain] INFO o.s.b.w.s.c.ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1919 ms +2026-04-22 08:36:35 [restartedMain] INFO o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729 +2026-04-22 08:36:35 [restartedMain] INFO o.s.b.w.e.tomcat.TomcatWebServer - Tomcat started on port 8080 (http) with context path '/api' +2026-04-22 08:36:35 [restartedMain] INFO top.tqx.demo_1.Demo1Application - Started Demo1Application in 4.314 seconds (process running for 5.075) diff --git a/logs/file-management-system.log.2026-04-02.0.gz b/logs/file-management-system.log.2026-04-02.0.gz new file mode 100644 index 0000000..454a81b Binary files /dev/null and b/logs/file-management-system.log.2026-04-02.0.gz differ diff --git a/logs/file-management-system.log.2026-04-07.0.gz b/logs/file-management-system.log.2026-04-07.0.gz new file mode 100644 index 0000000..d4f0702 Binary files /dev/null and b/logs/file-management-system.log.2026-04-07.0.gz differ diff --git a/logs/file-management-system.log.2026-04-08.0.gz b/logs/file-management-system.log.2026-04-08.0.gz new file mode 100644 index 0000000..258ba96 Binary files /dev/null and b/logs/file-management-system.log.2026-04-08.0.gz differ diff --git a/logs/file-management-system.log.2026-04-10.0.gz b/logs/file-management-system.log.2026-04-10.0.gz new file mode 100644 index 0000000..e05ac55 Binary files /dev/null and b/logs/file-management-system.log.2026-04-10.0.gz differ diff --git a/logs/file-management-system.log.2026-04-11.0.gz b/logs/file-management-system.log.2026-04-11.0.gz new file mode 100644 index 0000000..7671a44 Binary files /dev/null and b/logs/file-management-system.log.2026-04-11.0.gz differ diff --git a/logs/file-management-system.log.2026-04-12.0.gz b/logs/file-management-system.log.2026-04-12.0.gz new file mode 100644 index 0000000..664f0be Binary files /dev/null and b/logs/file-management-system.log.2026-04-12.0.gz differ diff --git a/logs/file-management-system.log.2026-04-13.0.gz b/logs/file-management-system.log.2026-04-13.0.gz new file mode 100644 index 0000000..309909b Binary files /dev/null and b/logs/file-management-system.log.2026-04-13.0.gz differ diff --git a/logs/file-management-system.log.2026-04-14.0.gz b/logs/file-management-system.log.2026-04-14.0.gz new file mode 100644 index 0000000..406fcc2 Binary files /dev/null and b/logs/file-management-system.log.2026-04-14.0.gz differ diff --git a/logs/file-management-system.log.2026-04-15.0.gz b/logs/file-management-system.log.2026-04-15.0.gz new file mode 100644 index 0000000..289435e Binary files /dev/null and b/logs/file-management-system.log.2026-04-15.0.gz differ diff --git a/logs/file-management-system.log.2026-04-16.0.gz b/logs/file-management-system.log.2026-04-16.0.gz new file mode 100644 index 0000000..0c42e9f Binary files /dev/null and b/logs/file-management-system.log.2026-04-16.0.gz differ diff --git a/logs/file-management-system.log.2026-04-17.0.gz b/logs/file-management-system.log.2026-04-17.0.gz new file mode 100644 index 0000000..c35671a Binary files /dev/null and b/logs/file-management-system.log.2026-04-17.0.gz differ diff --git a/logs/file-management-system.log.2026-04-18.0.gz b/logs/file-management-system.log.2026-04-18.0.gz new file mode 100644 index 0000000..63964e1 Binary files /dev/null and b/logs/file-management-system.log.2026-04-18.0.gz differ diff --git a/logs/file-management-system.log.2026-04-19.0.gz b/logs/file-management-system.log.2026-04-19.0.gz new file mode 100644 index 0000000..ebc863a Binary files /dev/null and b/logs/file-management-system.log.2026-04-19.0.gz differ diff --git a/logs/file-management-system.log.2026-04-21.0.gz b/logs/file-management-system.log.2026-04-21.0.gz new file mode 100644 index 0000000..87369a0 Binary files /dev/null and b/logs/file-management-system.log.2026-04-21.0.gz differ diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..eeefe8c --- /dev/null +++ b/pom.xml @@ -0,0 +1,143 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + top.tqx + demo_1 + 0.0.1-SNAPSHOT + demo_1 + demo_1 + + + 17 + 1.18.32 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.5 + + + + com.mysql + mysql-connector-j + runtime + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + + + + org.springframework.boot + spring-boot-starter-validation + + + + io.swagger.core.v3 + swagger-annotations + 2.2.20 + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + com.alibaba + fastjson + 2.0.43 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/Demo1Application.java b/src/main/java/top/tqx/demo_1/Demo1Application.java new file mode 100644 index 0000000..6289217 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/Demo1Application.java @@ -0,0 +1,17 @@ +package top.tqx.demo_1; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@MapperScan("top.tqx.demo_1.mapper") +@EnableAsync +public class Demo1Application { + + public static void main(String[] args) { + SpringApplication.run(Demo1Application.class, args); + } + +} diff --git a/src/main/java/top/tqx/demo_1/annotation/RateLimit.java b/src/main/java/top/tqx/demo_1/annotation/RateLimit.java new file mode 100644 index 0000000..54d194e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/annotation/RateLimit.java @@ -0,0 +1,12 @@ +package top.tqx.demo_1.annotation; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + int maxRequests() default 100; + long timeWindow() default 60; + String key() default "ip"; +} diff --git a/src/main/java/top/tqx/demo_1/annotation/RequiresPermission.java b/src/main/java/top/tqx/demo_1/annotation/RequiresPermission.java new file mode 100644 index 0000000..40787f2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/annotation/RequiresPermission.java @@ -0,0 +1,13 @@ +package top.tqx.demo_1.annotation; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequiresPermission { + + String value(); + + String description() default ""; +} diff --git a/src/main/java/top/tqx/demo_1/aspect/LogAspect.java b/src/main/java/top/tqx/demo_1/aspect/LogAspect.java new file mode 100644 index 0000000..d34259c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/aspect/LogAspect.java @@ -0,0 +1,55 @@ +package top.tqx.demo_1.aspect; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import top.tqx.demo_1.util.IpUtil; + +@Aspect +@Component +@Slf4j +public class LogAspect { + + @Pointcut("execution(* top.tqx.demo_1.controller..*(..))") + public void controllerPointcut() { + } + + @Around("controllerPointcut()") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return joinPoint.proceed(); + } + + HttpServletRequest request = attributes.getRequest(); + + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + log.info("请求 | URL: {} | 方法: {} | IP: {}", + request.getRequestURL(), + request.getMethod(), + IpUtil.getIpAddress(request)); + + try { + Object result = joinPoint.proceed(); + long endTime = System.currentTimeMillis(); + + log.info("响应 | 耗时: {}ms", endTime - startTime); + + return result; + } catch (Exception e) { + long endTime = System.currentTimeMillis(); + log.error("异常 | 消息: {} | 耗时: {}ms", e.getMessage(), endTime - startTime); + throw e; + } + } +} diff --git a/src/main/java/top/tqx/demo_1/aspect/RateLimitAspect.java b/src/main/java/top/tqx/demo_1/aspect/RateLimitAspect.java new file mode 100644 index 0000000..4f27645 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/aspect/RateLimitAspect.java @@ -0,0 +1,100 @@ +package top.tqx.demo_1.aspect; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import top.tqx.demo_1.annotation.RateLimit; +import top.tqx.demo_1.util.IpUtil; +import top.tqx.demo_1.util.RedisUtil; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +@Aspect +@Component +@Slf4j +public class RateLimitAspect { + + @Autowired(required = false) + private RedisUtil redisUtil; + + private final ConcurrentHashMap localCache = new ConcurrentHashMap<>(); + + @Around("@annotation(rateLimit)") + public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return joinPoint.proceed(); + } + + HttpServletRequest request = attributes.getRequest(); + + if (redisUtil != null) { + try { + return handleWithRedis(joinPoint, rateLimit, request); + } catch (Exception e) { + log.warn("Redis 限流失败,降级使用本地缓存: {}", e.getMessage()); + } + } + + return handleWithLocalCache(joinPoint, rateLimit, request); + } + + private Object handleWithRedis(ProceedingJoinPoint joinPoint, RateLimit rateLimit, HttpServletRequest request) throws Throwable { + String key = generateKey(request, rateLimit); + Long count = redisUtil.increment(key); + if (count == 1) { + redisUtil.expire(key, rateLimit.timeWindow(), TimeUnit.SECONDS); + } + + if (count > rateLimit.maxRequests()) { + log.warn("IP: {} 触发限流, 接口: {}", IpUtil.getIpAddress(request), request.getRequestURI()); + throw new RuntimeException("请求过于频繁,请稍后再试"); + } + + return joinPoint.proceed(); + } + + private Object handleWithLocalCache(ProceedingJoinPoint joinPoint, RateLimit rateLimit, HttpServletRequest request) throws Throwable { + String key = generateKey(request, rateLimit); + long now = System.currentTimeMillis(); + + RateLimitInfo info = localCache.computeIfAbsent(key, k -> new RateLimitInfo()); + + if (now - info.windowStart > rateLimit.timeWindow() * 1000) { + info.windowStart = now; + info.count = 0; + } + + info.count++; + + if (info.count > rateLimit.maxRequests()) { + log.warn("IP: {} 触发限流(本地), 接口: {}", IpUtil.getIpAddress(request), request.getRequestURI()); + throw new RuntimeException("请求过于频繁,请稍后再试"); + } + + return joinPoint.proceed(); + } + + private String generateKey(HttpServletRequest request, RateLimit rateLimit) { + String prefix = "rate_limit:"; + if ("ip".equals(rateLimit.key())) { + return prefix + IpUtil.getIpAddress(request) + ":" + request.getRequestURI(); + } else if ("user".equals(rateLimit.key())) { + String userId = request.getHeader("X-User-Id"); + return prefix + (userId != null ? userId : "anonymous") + ":" + request.getRequestURI(); + } + return prefix + "global:" + request.getRequestURI(); + } + + static class RateLimitInfo { + long windowStart; + long count; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/common/PageResult.java b/src/main/java/top/tqx/demo_1/common/PageResult.java new file mode 100644 index 0000000..53b096b --- /dev/null +++ b/src/main/java/top/tqx/demo_1/common/PageResult.java @@ -0,0 +1,37 @@ +package top.tqx.demo_1.common; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class PageResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private List records; + + private Long total; + + private Long size; + + private Long current; + + private Long pages; + + public PageResult() { + } + + public PageResult(List records, Long total, Long size, Long current) { + this.records = records; + this.total = total; + this.size = size; + this.current = current; + this.pages = (total + size - 1) / size; + } + + public static PageResult of(List records, Long total, Long size, Long current) { + return new PageResult<>(records, total, size, current); + } +} diff --git a/src/main/java/top/tqx/demo_1/common/Result.java b/src/main/java/top/tqx/demo_1/common/Result.java new file mode 100644 index 0000000..6304371 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/common/Result.java @@ -0,0 +1,116 @@ +package top.tqx.demo_1.common; + +import top.tqx.demo_1.enums.ResultCode; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class Result implements Serializable { + + private Integer code; + private String message; + private T data; + private String timestamp; + + public Result() { + this.timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + // Getter methods + public Integer getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public T getData() { + return data; + } + + public String getTimestamp() { + return timestamp; + } + + // Setter methods + public void setCode(Integer code) { + this.code = code; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setData(T data) { + this.data = data; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public static Result success() { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMessage(ResultCode.SUCCESS.getMessage()); + return result; + } + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMessage(ResultCode.SUCCESS.getMessage()); + result.setData(data); + return result; + } + + public static Result success(String message, T data) { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMessage(message); + result.setData(data); + return result; + } + + public static Result success(String message) { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMessage(message); + return result; + } + + public static Result error(String message) { + Result result = new Result<>(); + result.setCode(ResultCode.ERROR.getCode()); + result.setMessage(message); + return result; + } + + public static Result error(Integer code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + return result; + } + + public static Result error(ResultCode resultCode) { + Result result = new Result<>(); + result.setCode(resultCode.getCode()); + result.setMessage(resultCode.getMessage()); + return result; + } + + public static Result error(Integer code, String message, T data) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + result.setData(data); + return result; + } + + public boolean isSuccess() { + return ResultCode.SUCCESS.getCode().equals(this.code); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/config/AiApiConfig.java b/src/main/java/top/tqx/demo_1/config/AiApiConfig.java new file mode 100644 index 0000000..ea00a5f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/AiApiConfig.java @@ -0,0 +1,140 @@ +package top.tqx.demo_1.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "ai.api") +public class AiApiConfig { + + private String host = "rag-service"; + + private Integer port = 5001; + + private ChatConfig chat = new ChatConfig(); + + private WorkflowConfig workflow = new WorkflowConfig(); + + private DocumentConfig document = new DocumentConfig(); + + private CollectionConfig collection = new CollectionConfig(); + + private SyncConfig sync = new SyncConfig(); + + private ExamConfig exam = new ExamConfig(); + + @Data + public static class ChatConfig { + private String chat = "/chat"; + private String rag = "/rag"; + private String ragStream = "/rag"; + private String search = "/search"; + } + + @Data + public static class WorkflowConfig { + private String run = "/v1/workflows/run"; + private String stop = "/v1/workflows/stop"; + } + + @Data + public static class DocumentConfig { + private String upload = "/documents/upload"; + private String batchUpload = "/documents/batch-upload"; + private String list = "/documents/list"; + private String delete = "/documents"; + private String chunks = "/documents/{path}/chunks"; + private String deprecate = "/collections/{name}/documents/{filename}/deprecate"; + private String restore = "/collections/{name}/documents/{filename}/restore"; + private String versions = "/collections/{name}/documents/{filename}/versions"; + } + + @Data + public static class CollectionConfig { + private String list = "/collections"; + private String create = "/collections"; + private String update = "/collections/{name}"; + private String delete = "/collections/{name}"; + private String chunks = "/collections/{name}/chunks"; + } + + @Data + public static class SyncConfig { + private String trigger = "/sync"; + private String status = "/sync/status"; + } + + @Data + public static class FeedbackConfig { + private String submit = "/feedback"; + private String list = "/feedback/list"; + private String stats = "/feedback/stats"; + private String badCases = "/feedback/bad-cases"; + } + + @Data + public static class FAQConfig { + private String faq = "/faq"; + private String suggestions = "/faq/suggestions"; + private String approve = "/faq/suggestions/{id}/approve"; + private String reject = "/faq/suggestions/{id}/reject"; + } + + @Data + public static class ImageConfig { + private String list = "/images/list"; + private String image = "/images/{image_id}"; + private String info = "/images/{image_id}/info"; + private String stats = "/images/stats"; + } + + @Data + public static class ReportConfig { + private String weekly = "/reports/weekly"; + private String monthly = "/reports/monthly"; + } + + @Data + public static class KBRouteConfig { + private String route = "/kb/route"; + } + + @Data + public static class SearchConfig { + private String search = "/search"; + } + + @Data + public static class ExamConfig { + private String generate = "/exam/generate"; + private String generateSmart = "/exam/generate-smart"; + private String grade = "/exam/grade"; + } + + private FeedbackConfig feedback = new FeedbackConfig(); + private FAQConfig faq = new FAQConfig(); + private ImageConfig images = new ImageConfig(); + private ReportConfig reports = new ReportConfig(); + private KBRouteConfig kbRoute = new KBRouteConfig(); + private SearchConfig search = new SearchConfig(); + private ChunkConfig chunk = new ChunkConfig(); + + @Data + public static class ChunkConfig { + private String create = "/chunks"; + private String update = "/chunks/{id}"; + private String delete = "/chunks/{id}"; + private String documentChunks = "/documents/{path}/chunks"; + } + + public String getFullUrl(String path) { + String baseUrl = "http://" + host + ":" + port; + if (path == null || path.isEmpty()) { + return baseUrl; + } + String requestPath = path.startsWith("/") ? path : "/" + path; + return baseUrl + requestPath; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/config/AsyncConfig.java b/src/main/java/top/tqx/demo_1/config/AsyncConfig.java new file mode 100644 index 0000000..d6f81ee --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/AsyncConfig.java @@ -0,0 +1,39 @@ +package top.tqx.demo_1.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "examTaskExecutor") + public Executor examTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix("ExamAsync-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } + + @Bean(name = "vectorizeTaskExecutor") + public Executor vectorizeTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("VectorizeAsync-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(120); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/config/CorsConfig.java b/src/main/java/top/tqx/demo_1/config/CorsConfig.java new file mode 100644 index 0000000..3830e3f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/CorsConfig.java @@ -0,0 +1,43 @@ +package top.tqx.demo_1.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + + @Value("${cors.allowed-origins:*}") + private String allowedOrigins; + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + if ("*".equals(allowedOrigins)) { + config.addAllowedOriginPattern("*"); + config.setAllowCredentials(true); + } else { + String[] origins = allowedOrigins.split(","); + for (String origin : origins) { + String trimmedOrigin = origin.trim(); + if (!trimmedOrigin.isEmpty()) { + config.addAllowedOrigin(trimmedOrigin); + } + } + config.setAllowCredentials(true); + } + + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/src/main/java/top/tqx/demo_1/config/JacksonConfig.java b/src/main/java/top/tqx/demo_1/config/JacksonConfig.java new file mode 100644 index 0000000..7ee8735 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/JacksonConfig.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Configuration +public class JacksonConfig { + + @Bean + public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + + JavaTimeModule javaTimeModule = new JavaTimeModule(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); + + objectMapper.registerModule(javaTimeModule); + converter.setObjectMapper(objectMapper); + + return converter; + } +} diff --git a/src/main/java/top/tqx/demo_1/config/JwtProperties.java b/src/main/java/top/tqx/demo_1/config/JwtProperties.java new file mode 100644 index 0000000..b835658 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/JwtProperties.java @@ -0,0 +1,72 @@ +package top.tqx.demo_1.config; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secret; + + private Long expiration; + + private String header; + + private String prefix; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @PostConstruct + public void validateSecret() { + if (secret == null || secret.isEmpty()) { + throw new IllegalStateException("JWT Secret不能为空"); + } + + if ("prod".equals(activeProfile) || "production".equals(activeProfile)) { + if (secret.contains("change-in-production") || secret.length() < 64) { + log.error("生产环境JWT Secret不符合安全要求"); + throw new IllegalStateException("生产环境必须配置强密码(JWT Secret至少64字符且不包含默认值)"); + } + } + + log.info("JWT配置已加载, expiration={}, header={}, prefix={}", expiration, header, prefix); + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public Long getExpiration() { + return expiration; + } + + public void setExpiration(Long expiration) { + this.expiration = expiration; + } + + public String getHeader() { + return header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getPrefix() { + return prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } +} diff --git a/src/main/java/top/tqx/demo_1/config/MybatisPlusConfig.java b/src/main/java/top/tqx/demo_1/config/MybatisPlusConfig.java new file mode 100644 index 0000000..bf309e7 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/MybatisPlusConfig.java @@ -0,0 +1,18 @@ +package top.tqx.demo_1.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/src/main/java/top/tqx/demo_1/config/RedisConfig.java b/src/main/java/top/tqx/demo_1/config/RedisConfig.java new file mode 100644 index 0000000..e335f8e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/RedisConfig.java @@ -0,0 +1,29 @@ +package top.tqx.demo_1.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); + + template.setKeySerializer(stringRedisSerializer); + template.setHashKeySerializer(stringRedisSerializer); + template.setValueSerializer(jsonRedisSerializer); + template.setHashValueSerializer(jsonRedisSerializer); + template.afterPropertiesSet(); + + return template; + } +} diff --git a/src/main/java/top/tqx/demo_1/config/RestTemplateConfig.java b/src/main/java/top/tqx/demo_1/config/RestTemplateConfig.java new file mode 100644 index 0000000..af3e2e1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/RestTemplateConfig.java @@ -0,0 +1,17 @@ +package top.tqx.demo_1.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + ClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + return new RestTemplate(factory); + } +} diff --git a/src/main/java/top/tqx/demo_1/config/SwaggerConfig.java b/src/main/java/top/tqx/demo_1/config/SwaggerConfig.java new file mode 100644 index 0000000..12284e9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/SwaggerConfig.java @@ -0,0 +1,44 @@ +package top.tqx.demo_1.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("知识管理系统 API 文档") + .version("2.11.0") + .description("基于 Spring Boot 3 + MyBatis-Plus 的企业级知识管理系统,提供文件管理、考试管理、错题管理、知识库检索、AI流式问答等功能。新增特性:题目批改功能优化,支持字段兼容性处理(is_correct/correct)、自动生成feedback反馈、完善五种题型(单选/多选/判断/填空/主观)的批改逻辑") + .contact(new Contact() + .name("开发团队") + .email("dev@example.com")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0.html"))) + .servers(List.of( + new Server().url("http://localhost:8080/api").description("本地开发服务器") + )) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT Token,格式:Bearer {token}") + )); + } +} diff --git a/src/main/java/top/tqx/demo_1/config/WebConfig.java b/src/main/java/top/tqx/demo_1/config/WebConfig.java new file mode 100644 index 0000000..4abe90e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/config/WebConfig.java @@ -0,0 +1,29 @@ +package top.tqx.demo_1.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import top.tqx.demo_1.interceptor.PermissionInterceptor; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Autowired + private PermissionInterceptor permissionInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(permissionInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/auth/login", + "/auth/logout", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ); + } +} diff --git a/src/main/java/top/tqx/demo_1/constant/CommonConstant.java b/src/main/java/top/tqx/demo_1/constant/CommonConstant.java new file mode 100644 index 0000000..1376e2a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/constant/CommonConstant.java @@ -0,0 +1,35 @@ +package top.tqx.demo_1.constant; + +public class CommonConstant { + + public static final String TOKEN_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; + + public static final Integer STATUS_ENABLE = 1; + public static final Integer STATUS_DISABLE = 0; + + public static final String DEFAULT_PASSWORD = "123456"; + + public static final String UPLOAD_PATH = "./uploads/"; + + public static final String DEPT_TYPE_HEADQUARTERS = "HEADQUARTERS"; + public static final String DEPT_TYPE_BRANCH = "BRANCH"; + public static final String DEPT_TYPE_DEPARTMENT = "DEPARTMENT"; + + public static class RoleType { + public static final Integer SUPER_ADMIN = 1; + public static final Integer DEPT_ADMIN = 2; + public static final Integer NORMAL_USER = 3; + } + + public static class PermissionType { + public static final Integer MENU = 1; + public static final Integer BUTTON = 2; + public static final Integer API = 3; + } + + public static class FilePermissionType { + public static final Integer READ_ONLY = 1; + public static final Integer READ_WRITE = 2; + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/AIChatController.java b/src/main/java/top/tqx/demo_1/controller/AIChatController.java new file mode 100644 index 0000000..3ae3054 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/AIChatController.java @@ -0,0 +1,533 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import top.tqx.demo_1.dto.RagRequest; +import top.tqx.demo_1.entity.ChatMessage; +import top.tqx.demo_1.entity.ChatReference; +import top.tqx.demo_1.entity.ChatSession; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.service.AiChatService; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.service.ChatService; +import top.tqx.demo_1.service.SseEmitterManager; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.service.KnowledgeBasePathService; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Tag(name = "AI对话", description = "AI智能对话接口") +@RestController +@RequestMapping("/ai") +@Slf4j +public class AIChatController { + + @Autowired + private AiChatService aiChatService; + + @Autowired + private AuthService authService; + + @Autowired + private ChatService chatService; + + @Autowired + private SseEmitterManager sseEmitterManager; + + @Autowired + private KnowledgeBasePathService knowledgeBasePathService; + + @Autowired + private DepartmentService departmentService; + + @Operation(summary = "AI流式问答", description = "向AI服务发起流式问答请求,实时返回处理过程,完成后自动保存到数据库") + @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStream( + @RequestBody Map requestMap, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"用户未登录\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + User user = authService.validateToken(httpRequest.getHeader("Authorization").substring(7)); + if (user == null) { + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"用户信息获取失败\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + RagRequest request = new RagRequest(); + request.setMessage((String) requestMap.get("message")); + request.setId((String) requestMap.get("id")); + + // 获取用户可访问的知识库 + String deptName = null; + try { + if (user.getDeptId() != null) { + top.tqx.demo_1.entity.Department department = departmentService.getDepartmentById(user.getDeptId()); + if (department != null) { + deptName = department.getDeptName(); + } + } + } catch (Exception e) { + log.warn("获取部门信息失败: {}", e.getMessage()); + } + + List collections = knowledgeBasePathService.getUserAccessibleKnowledgeBaseNames(userId, user.getDeptId(), deptName); + if (collections.isEmpty()) { + log.warn("用户无可用知识库"); + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"没有可用的知识库,请联系管理员添加知识库\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + request.setCollections(collections); + + // 生成或使用session_id + String sessionId = (String) requestMap.get("session_id"); + if (sessionId == null || sessionId.isEmpty()) { + sessionId = "sess_" + System.currentTimeMillis(); + } + request.setSessionId(sessionId); + + request.setUserId(userId); + request.setDeptId(user.getDeptId()); + request.setUserRole(user.getUserType()); + + // 从数据库获取历史记录 + List history = new ArrayList<>(); + try { + history = chatService.getSessionMessagesAsHistory(sessionId); + } catch (Exception e) { + log.warn("获取历史记录失败: {}", e.getMessage()); + } + request.setHistory(history); + + log.info("开始AI流式问答: userId={}, deptId={}, role={}, message={}, collections={}, sessionId={}, id={}", + userId, user.getDeptId(), user.getUserType(), request.getMessage(), + request.getCollections(), request.getSessionId(), request.getId()); + return aiChatService.streamFromRagService(request, userId, user.getDeptId()); + + } catch (Exception e) { + log.error("AI流式问答失败", e); + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"" + e.getMessage() + "\"}")); + emitter.complete(); + } catch (Exception ex) { + emitter.completeWithError(ex); + } + return emitter; + } + } + + @Operation(summary = "获取用户会话列表", description = "获取当前登录用户的所有会话列表,按创建时间倒序排列") + @GetMapping("/sessions") + public Map getUserSessions( + @Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小,默认10") @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + List sessions = chatService.getUserSessions(userId); + // 实现简单分页 + int total = sessions.size(); + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, total); + List pageData = new ArrayList<>(); + if (start < total) { + pageData = sessions.subList(start, end); + } + + result.put("success", true); + result.put("data", pageData); + result.put("total", total); + result.put("page", page); + result.put("pageSize", pageSize); + result.put("pages", (total + pageSize - 1) / pageSize); + } catch (Exception e) { + log.error("获取会话列表失败", e); + result.put("success", false); + result.put("message", "获取会话列表失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取会话详情", description = "获取指定会话的完整交互记录,包括用户提问与AI回答") + @GetMapping("/session/{sessionId}") + public Map getSessionDetail( + @Parameter(description = "会话ID") @PathVariable String sessionId, + @Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小,默认20") @RequestParam(defaultValue = "20") Integer pageSize, + HttpServletRequest httpRequest) { + log.info("📨 收到获取会话详情请求,sessionId={}", sessionId); + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + log.warn("用户未登录,拒绝访问"); + return result; + } + + // 验证会话所属权(确保用户只能访问自己的会话) + List userSessions = chatService.getUserSessions(userId); + boolean isSessionBelongsToUser = userSessions.stream() + .anyMatch(session -> session.getSessionId().equals(sessionId)); + if (!isSessionBelongsToUser) { + result.put("success", false); + result.put("message", "无权访问该会话"); + log.warn("用户 {} 无权访问会话 {}", userId, sessionId); + return result; + } + + List messages = chatService.getSessionMessages(sessionId); + log.info("获取到 {} 条消息", messages.size()); + + // 打印第一条消息的详细信息用于调试 + if (!messages.isEmpty()) { + ChatMessage firstMessage = messages.get(0); + log.info("第一条消息,id={}, role={}, images={}", + firstMessage.getId(), firstMessage.getRole(), + firstMessage.getImages() != null ? firstMessage.getImages().substring(0, Math.min(200, firstMessage.getImages().length())) : "null"); + } + + // 实现简单分页 + int total = messages.size(); + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, total); + List pageData = new ArrayList<>(); + if (start < total) { + pageData = messages.subList(start, end); + } + + result.put("success", true); + result.put("data", pageData); + result.put("total", total); + result.put("page", page); + result.put("pageSize", pageSize); + result.put("pages", (total + pageSize - 1) / pageSize); + + log.info("✅ 返回会话详情成功,共 {} 条消息", pageData.size()); + } catch (Exception e) { + log.error("❌ 获取会话详情失败", e); + result.put("success", false); + result.put("message", "获取会话详情失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取消息文件来源列表", description = "获取指定消息引用的文件来源列表(去重),包含文件名称、类型、引用数量和页码范围") + @GetMapping("/message/{messageId}/sources") + public Map getMessageSources( + @Parameter(description = "消息ID") @PathVariable Long messageId, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + List references = chatService.getMessageReferences(messageId); + + Map docCountMap = new HashMap<>(); + Map docTypeMap = new HashMap<>(); + Map> docPageMap = new HashMap<>(); + + for (ChatReference ref : references) { + String docName = ref.getDocName(); + docCountMap.merge(docName, 1, Integer::sum); + if (ref.getDocType() != null) { + docTypeMap.put(docName, ref.getDocType()); + } + if (ref.getPage() != null) { + docPageMap.computeIfAbsent(docName, k -> new ArrayList<>()).add(ref.getPage()); + } + } + + List> sourcesList = new ArrayList<>(); + for (Map.Entry entry : docCountMap.entrySet()) { + Map source = new HashMap<>(); + source.put("docName", entry.getKey()); + source.put("docType", docTypeMap.getOrDefault(entry.getKey(), "")); + source.put("referenceCount", entry.getValue()); + + List pages = docPageMap.get(entry.getKey()); + if (pages != null && !pages.isEmpty()) { + int minPage = pages.stream().mapToInt(Integer::intValue).min().orElse(0); + int maxPage = pages.stream().mapToInt(Integer::intValue).max().orElse(0); + if (minPage == maxPage) { + source.put("pages", String.valueOf(minPage)); + } else { + source.put("pages", minPage + "-" + maxPage); + } + } + sourcesList.add(source); + } + + result.put("success", true); + result.put("data", sourcesList); + result.put("total", sourcesList.size()); + } catch (Exception e) { + log.error("获取文件来源列表失败", e); + result.put("success", false); + result.put("message", "获取文件来源列表失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取消息引用信息", description = "获取指定消息的引用信息,包括文档来源、页码、片段内容等,支持按文档名称过滤") + @GetMapping("/message/{messageId}/references") + public Map getMessageReferences( + @Parameter(description = "消息ID") @PathVariable Long messageId, + @Parameter(description = "文档名称(可选,用于过滤)") @RequestParam(required = false) String docName, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + List references = chatService.getMessageReferences(messageId); + + if (docName != null && !docName.isEmpty()) { + references = references.stream() + .filter(ref -> docName.equals(ref.getDocName())) + .collect(Collectors.toList()); + } + + result.put("success", true); + result.put("data", references); + result.put("total", references.size()); + } catch (Exception e) { + log.error("获取引用信息失败", e); + result.put("success", false); + result.put("message", "获取引用信息失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取会话引用信息", description = "获取指定会话的所有引用信息,包括文档来源、页码、片段内容等") + @GetMapping("/session/{sessionId}/references") + public Map getSessionReferences( + @Parameter(description = "会话ID") @PathVariable String sessionId, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + // 验证会话所属权(确保用户只能访问自己的会话) + List userSessions = chatService.getUserSessions(userId); + boolean isSessionBelongsToUser = userSessions.stream() + .anyMatch(session -> session.getSessionId().equals(sessionId)); + if (!isSessionBelongsToUser) { + result.put("success", false); + result.put("message", "无权访问该会话"); + return result; + } + + // 获取会话的所有引用信息 + List references = chatService.getSessionReferences(sessionId); + result.put("success", true); + result.put("data", references); + result.put("total", references.size()); + } catch (Exception e) { + log.error("获取会话引用信息失败", e); + result.put("success", false); + result.put("message", "获取会话引用信息失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "删除单个会话", description = "删除指定的会话及其所有相关消息和引用信息") + @DeleteMapping("/session/{sessionId}") + public Map deleteSession( + @Parameter(description = "会话ID") @PathVariable String sessionId, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + // 验证会话所属权(确保用户只能删除自己的会话) + List userSessions = chatService.getUserSessions(userId); + boolean isSessionBelongsToUser = userSessions.stream() + .anyMatch(session -> session.getSessionId().equals(sessionId)); + if (!isSessionBelongsToUser) { + result.put("success", false); + result.put("message", "无权删除该会话"); + return result; + } + + // 执行删除操作 + boolean success = chatService.deleteSession(sessionId, userId); + if (success) { + result.put("success", true); + result.put("message", "会话删除成功"); + } else { + result.put("success", false); + result.put("message", "会话删除失败"); + } + } catch (Exception e) { + log.error("删除会话失败", e); + result.put("success", false); + result.put("message", "删除会话失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "批量删除会话", description = "批量删除多个会话及其所有相关消息和引用信息") + @DeleteMapping("/sessions") + public Map deleteSessions( + @Parameter(description = "会话ID列表") @RequestBody List sessionIds, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + if (sessionIds == null || sessionIds.isEmpty()) { + result.put("success", false); + result.put("message", "会话ID列表不能为空"); + return result; + } + + // 验证所有会话的所属权 + List userSessions = chatService.getUserSessions(userId); + Set userSessionIds = userSessions.stream() + .map(ChatSession::getSessionId) + .collect(Collectors.toSet()); + + boolean allSessionsBelongToUser = sessionIds.stream() + .allMatch(userSessionIds::contains); + if (!allSessionsBelongToUser) { + result.put("success", false); + result.put("message", "无权删除部分会话"); + return result; + } + + // 执行批量删除操作 + boolean success = chatService.deleteSessions(sessionIds, userId); + if (success) { + result.put("success", true); + result.put("message", "会话批量删除成功"); + result.put("deletedCount", sessionIds.size()); + } else { + result.put("success", false); + result.put("message", "会话批量删除失败"); + } + } catch (Exception e) { + log.error("批量删除会话失败", e); + result.put("success", false); + result.put("message", "批量删除会话失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "中断AI流式问答", description = "中断指定会话的AI流式问答进程") + @PostMapping("/chat/stop") + public Map stopChatStream( + @Parameter(description = "会话ID") @RequestParam String sessionId, + HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + result.put("success", false); + result.put("message", "用户未登录"); + return result; + } + + log.info("收到中断请求: sessionId={}, userId={}", sessionId, userId); + + boolean success = sseEmitterManager.stop(sessionId); + if (success) { + result.put("success", true); + result.put("message", "已中断AI回答"); + } else { + result.put("success", false); + result.put("message", "未找到活跃的AI进程或已结束"); + } + } catch (Exception e) { + log.error("中断AI流式问答失败", e); + result.put("success", false); + result.put("message", "中断失败: " + e.getMessage()); + } + return result; + } + + private Long getCurrentUserId(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + return null; + } + + User user = authService.validateToken(token.substring(7)); + return user != null ? user.getId() : null; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/AdminExamController.java b/src/main/java/top/tqx/demo_1/controller/AdminExamController.java new file mode 100644 index 0000000..79bbd1c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/AdminExamController.java @@ -0,0 +1,219 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.enums.AnswerType; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.ExamPaperService; +import top.tqx.demo_1.service.ExamRecordService; +import top.tqx.demo_1.service.ExamService; + +import java.util.Map; + +/** + * 正式考管理接口 - 管理员端 + * 管理员预览、保存、发布正式考试试卷 + */ +@Slf4j +@RestController +@RequestMapping("/admin/exam") +@Tag(name = "正式考管理", description = "正式考管理接口,供管理员预览、保存、发布正式考试试卷") +public class AdminExamController extends BaseController { + + @Autowired + private ExamPaperService examPaperService; + + @Autowired + private ExamRecordService examRecordService; + + @Autowired + private ExamService examService; + + @Autowired + private UserMapper userMapper; + + @Operation(summary = "预览试卷", description = "管理员预览生成的试卷内容") + @PostMapping("/paper/preview") + public Result previewPaper( + @RequestBody ExamPaperGenerateRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + User user = userMapper.selectById(userId); + log.info("预览试卷请求: userId={}, deptId={}, userType={}", userId, user.getDeptId(), user.getUserType()); + ExamPaperGenerateResponse response = examPaperService.previewPaper(request, userId, user.getDeptId(), user.getUserType()); + return Result.success(response); + } catch (Exception e) { + log.error("预览试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "预览试卷详情", description = "管理员预览生成的试卷详细内容,包含完整的题目内容和选项") + @PostMapping("/paper/preview/detail") + public Result previewPaperWithDetails( + @RequestBody ExamPaperGenerateRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + User user = userMapper.selectById(userId); + log.info("预览试卷详情请求: userId={}, deptId={}, userType={}", userId, user.getDeptId(), user.getUserType()); + ExamPaperPreviewResponse response = examPaperService.previewPaperWithDetails(request, userId, user.getDeptId(), user.getUserType()); + return Result.success(response); + } catch (Exception e) { + log.error("预览试卷详情失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "保存试卷", description = "管理员保存试卷到数据库") + @PostMapping("/paper/save") + public Result savePaper( + @RequestBody ExamPaperSaveRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamPaperResponse response = examPaperService.savePaper(request, userId); + return Result.success(response); + } catch (Exception e) { + log.error("保存试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "发布试卷", description = "管理员发布试卷给指定部门或用户") + @PostMapping("/paper/{paperId}/publish") + public Result publishPaper( + @PathVariable String paperId, + @RequestBody ExamPublishRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + examPaperService.publishPaper(paperId, request, userId); + return Result.success(); + } catch (Exception e) { + log.error("发布试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "撤销试卷", description = "管理员撤销已发布的试卷") + @PostMapping("/paper/{paperId}/revoke") + public Result revokePaper(@PathVariable String paperId) { + try { + examPaperService.revokePaper(paperId); + return Result.success(); + } catch (Exception e) { + log.error("撤销试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "删除试卷", description = "管理员删除试卷") + @DeleteMapping("/paper/{paperId}") + public Result deletePaper(@PathVariable String paperId) { + try { + examPaperService.deletePaper(paperId); + return Result.success(); + } catch (Exception e) { + log.error("删除试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取试卷详情", description = "管理员获取试卷详细信息") + @GetMapping("/paper/{paperId}") + public Result getPaperById(@PathVariable String paperId) { + try { + ExamPaperResponse response = examPaperService.getPaperById(paperId); + if (response == null) { + return Result.error(404, "试卷不存在"); + } + return Result.success(response); + } catch (Exception e) { + log.error("获取试卷详情失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取试卷分配详情", description = "获取试卷的部门分配和用户分配信息") + @GetMapping("/paper/{paperId}/assignment") + public Result> getPaperAssignment(@PathVariable String paperId) { + try { + Map result = examPaperService.getPaperAssignment(paperId); + return Result.success(result); + } catch (Exception e) { + log.error("获取试卷分配详情失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取试卷列表", description = "管理员获取试卷列表,支持按状态筛选") + @GetMapping("/paper/list") + public Result> listPapers( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize) { + try { + PageResponse response = examPaperService.listPapers(status, page, pageSize); + return Result.success(response); + } catch (Exception e) { + log.error("查询试卷列表失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取考试记录列表", description = "管理员获取指定试卷的答题记录列表") + @GetMapping("/records/{paperId}") + public Result> listRecords( + @PathVariable String paperId, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize) { + try { + PageResponse response = examRecordService.listRecords(paperId, page, pageSize); + return Result.success(response); + } catch (Exception e) { + log.error("查询答题记录失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取考试记录详情", description = "管理员获取指定答题记录的详细信息") + @GetMapping("/record/{recordId}") + public Result getRecordById(@PathVariable String recordId) { + try { + ExamRecordResponse response = examRecordService.getRecordById(recordId); + if (response == null) { + return Result.error(404, "记录不存在"); + } + return Result.success(response); + } catch (Exception e) { + log.error("获取答题记录失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "提交试卷", description = "管理员代用户提交试卷并批改") + @PostMapping("/record/{recordId}/submit") + public Result submitExam( + @PathVariable String recordId, + @RequestBody ExamSaveRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examService.gradeAnswersForRecord( + recordId, request, userId, AnswerType.FORMAL_EXAM.getCode()); + return Result.success(response); + } catch (Exception e) { + log.error("提交试卷失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/AiDataController.java b/src/main/java/top/tqx/demo_1/controller/AiDataController.java new file mode 100644 index 0000000..2dc2ef4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/AiDataController.java @@ -0,0 +1,110 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.entity.ChatMessage; +import top.tqx.demo_1.entity.Feedback; +import top.tqx.demo_1.mapper.ChatMessageMapper; +import top.tqx.demo_1.mapper.ChatSessionMapper; +import top.tqx.demo_1.mapper.FeedbackMapper; +import top.tqx.demo_1.entity.ChatSession; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Tag(name = "AI数据接口", description = "专门为AI端提供的数据查询接口") +@RestController +@RequestMapping("/ai/data") +@Slf4j +public class AiDataController { + + @Autowired + private ChatSessionMapper chatSessionMapper; + + @Autowired + private ChatMessageMapper chatMessageMapper; + + @Autowired + private FeedbackMapper feedbackMapper; + + @Operation(summary = "获取会话历史消息", description = "AI端通过session_id获取完整的对话历史") + @GetMapping("/session/{sessionId}/messages") + public Result> getSessionMessages( + @Parameter(description = "会话ID") @PathVariable String sessionId) { + try { + log.info("AI端获取会话历史: sessionId={}", sessionId); + + ChatSession session = chatSessionMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChatSession::getSessionId, sessionId) + ); + + if (session == null) { + log.warn("会话不存在: sessionId={}", sessionId); + return Result.error("会话不存在"); + } + + List messages = chatMessageMapper.selectList( + new LambdaQueryWrapper() + .eq(ChatMessage::getConversationId, session.getId()) + .orderByAsc(ChatMessage::getCreateTime) + ); + + log.info("获取会话消息成功: sessionId={}, count={}", sessionId, messages.size()); + return Result.success(messages); + } catch (Exception e) { + log.error("获取会话历史失败: sessionId={}, error={}", sessionId, e.getMessage(), e); + return Result.error("获取会话历史失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取用户反馈列表", description = "AI端获取所有反馈数据用于分析") + @GetMapping("/feedbacks") + public Result> getFeedbacks( + @Parameter(description = "评分筛选:1=好评,-1=差评") @RequestParam(required = false) Integer rating) { + try { + log.info("AI端获取反馈列表: rating={}", rating); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (rating != null) { + wrapper.eq(Feedback::getRating, rating); + } + wrapper.orderByDesc(Feedback::getCreatedAt); + + List feedbacks = feedbackMapper.selectList(wrapper); + + log.info("获取反馈列表成功: count={}", feedbacks.size()); + return Result.success(feedbacks); + } catch (Exception e) { + log.error("获取反馈列表失败: error={}", e.getMessage(), e); + return Result.error("获取反馈列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取差评案例", description = "AI端获取差评案例用于分析改进") + @GetMapping("/feedbacks/bad-cases") + public Result> getBadCases() { + try { + log.info("AI端获取差评案例"); + + List badCases = feedbackMapper.selectList( + new LambdaQueryWrapper() + .eq(Feedback::getRating, -1) + .orderByDesc(Feedback::getCreatedAt) + ); + + log.info("获取差评案例成功: count={}", badCases.size()); + return Result.success(badCases); + } catch (Exception e) { + log.error("获取差评案例失败: error={}", e.getMessage(), e); + return Result.error("获取差评案例失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/AuthController.java b/src/main/java/top/tqx/demo_1/controller/AuthController.java new file mode 100644 index 0000000..5d68cbf --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/AuthController.java @@ -0,0 +1,84 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.annotation.RateLimit; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.LoginRequest; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.vo.LoginResponse; +import top.tqx.demo_1.vo.UserInfoVO; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Tag(name = "认证管理", description = "用户登录、登出、获取用户信息") +@RestController +@RequestMapping("/auth") +public class AuthController extends BaseController { + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @Operation(summary = "用户登录", description = "使用用户名和密码登录系统") + @PostMapping("/login") + @RateLimit(maxRequests = 5, timeWindow = 60, key = "ip") + public Result login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request.getUsername(), request.getPassword()); + return success(response); + } + + @Operation(summary = "用户登出", description = "退出登录,清除Token") + @PostMapping("/logout") + public Result logout(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + authService.logout(token.substring(7)); + } + return success(); + } + + @Operation(summary = "获取用户信息", description = "获取当前登录用户的详细信息") + @GetMapping("/user/info") + public Result getUserInfo(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + UserInfoVO userInfo = authService.getUserInfo(userId); + return success(userInfo); + } + + @Operation(summary = "获取用户角色", description = "获取当前用户的所有角色名称") + @GetMapping("/user/roles") + public Result> getUserRoles(HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + List roleNames = authService.getUserRoleNames(userId); + return success(roleNames); + } + + @Operation(summary = "获取主要角色", description = "获取当前用户的主要角色名称") + @GetMapping("/user/main-role") + public Result> getUserMainRole(HttpServletRequest request) { + System.out.println("开始处理获取主要角色请求"); + Long userId = getCurrentUserId(request); + System.out.println("获取到的用户ID: " + userId); + if (userId == null) { + System.out.println("用户未登录"); + return error("用户未登录"); + } + String mainRoleName = authService.getUserMainRoleName(userId); + System.out.println("获取到的主要角色名称: " + mainRoleName); + Map data = new HashMap<>(); + data.put("roleName", mainRoleName); + return success(data); + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/BaseController.java b/src/main/java/top/tqx/demo_1/controller/BaseController.java new file mode 100644 index 0000000..c69474d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/BaseController.java @@ -0,0 +1,39 @@ +package top.tqx.demo_1.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.service.AuthService; + +public class BaseController { + + @Autowired + protected AuthService authService; + + protected Long getCurrentUserId(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + return null; + } + + User user = authService.validateToken(token.substring(7)); + return user != null ? user.getId() : null; + } + + protected Result success(T data) { + return Result.success(data); + } + + protected Result success() { + return Result.success(); + } + + protected Result error(String message) { + return Result.error(message); + } + + protected Result error(Integer code, String message) { + return Result.error(code, message); + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/CollectionAccessController.java b/src/main/java/top/tqx/demo_1/controller/CollectionAccessController.java new file mode 100644 index 0000000..a76e724 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/CollectionAccessController.java @@ -0,0 +1,182 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.vo.CollectionVO; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Tag(name = "知识库访问控制", description = "查询collection表中的记录,获取用户可访问的知识库列表") +@RestController +@RequestMapping("/collection-access") +@Slf4j +public class CollectionAccessController extends BaseController { + + @Autowired + private KnowledgeBasePathMapper knowledgeBasePathMapper; + + @Autowired + private DepartmentService departmentService; + + @Operation(summary = "分页查询知识库列表", description = "分页获取知识库列表,支持条件过滤") + @GetMapping("/page") + public Result> pageCollections( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "知识库名称") @RequestParam(required = false) String name, + @Parameter(description = "部门") @RequestParam(required = false) String department, + @Parameter(description = "所有者类型") @RequestParam(required = false) String ownerType, + @Parameter(description = "可见性") @RequestParam(required = false) String visibility, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pageNum, pageSize); + + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + + if (name != null && !name.isEmpty()) { + wrapper.like(KnowledgeBasePath::getName, name); + } + if (department != null && !department.isEmpty()) { + wrapper.eq(KnowledgeBasePath::getDepartment, department); + } + if (ownerType != null && !ownerType.isEmpty()) { + wrapper.eq(KnowledgeBasePath::getOwnerType, ownerType); + } + if (visibility != null && !visibility.isEmpty()) { + wrapper.eq(KnowledgeBasePath::getVisibility, visibility); + } + + wrapper.orderByDesc(KnowledgeBasePath::getCreateTime); + + com.baomidou.mybatisplus.extension.plugins.pagination.Page resultPage = + knowledgeBasePathMapper.selectPage(page, wrapper); + + PageResult pageResult = PageResult.of( + resultPage.getRecords(), + resultPage.getTotal(), + resultPage.getSize(), + resultPage.getCurrent() + ); + + return success(pageResult); + } catch (Exception e) { + log.error("分页查询知识库列表失败", e); + return error("分页查询知识库列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取知识库详情", description = "根据ID获取知识库详细信息") + @GetMapping("/{id}") + public Result getCollectionById( + @Parameter(description = "知识库ID", required = true) @PathVariable Long id, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + KnowledgeBasePath knowledgeBasePath = knowledgeBasePathMapper.selectById(id); + if (knowledgeBasePath == null) { + return error("知识库不存在"); + } + + return success(knowledgeBasePath); + } catch (Exception e) { + log.error("获取知识库详情失败", e); + return error("获取知识库详情失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取用户可访问的知识库列表", description = "获取当前用户可访问的所有知识库") + @GetMapping("/accessible") + public Result> getAccessibleCollections(HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + List knowledgeBases; + + if (top.tqx.demo_1.constant.CommonConstant.RoleType.SUPER_ADMIN.equals(user.getUserType())) { + knowledgeBases = knowledgeBasePathMapper.selectList(null); + log.info("超级管理员获取所有知识库: userId={}, 数量={}", userId, knowledgeBases.size()); + } else { + String deptName = getDeptName(user); + + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + + wrapper.and(w -> w.eq(KnowledgeBasePath::getOwnerType, "department") + .and(w1 -> w1.eq(KnowledgeBasePath::getDepartment, deptName) + .or().eq(KnowledgeBasePath::getDepartment, "公共部门")) + .eq(KnowledgeBasePath::getVisibility, "public") + .or() + .eq(KnowledgeBasePath::getOwnerType, "personal") + .eq(KnowledgeBasePath::getUploader, userId.toString())); + + knowledgeBases = knowledgeBasePathMapper.selectList(wrapper); + } + + List collections = knowledgeBases.stream() + .map(kb -> { + CollectionVO vo = new CollectionVO(); + vo.setName(kb.getName()); + vo.setDisplayName(kb.getDisplayName() != null ? kb.getDisplayName() : kb.getName()); + vo.setDepartment(kb.getDepartment()); + vo.setDescription(kb.getDescription()); + vo.setCreateTime(kb.getCreateTime()); + return vo; + }) + .collect(Collectors.toList()); + + Map result = new HashMap<>(); + result.put("collections", collections); + result.put("total", collections.size()); + + return success(result); + } catch (Exception e) { + log.error("获取用户可访问知识库失败", e); + return error("获取用户可访问知识库失败: " + e.getMessage()); + } + } + + private String getDeptName(User user) { + if (user.getDeptId() == null) { + return "公共部门"; + } + try { + var dept = departmentService.getDepartmentById(user.getDeptId()); + return dept != null ? dept.getDeptName() : "公共部门"; + } catch (Exception e) { + log.warn("获取部门信息失败: deptId={}", user.getDeptId()); + return "公共部门"; + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/CollectionController.java b/src/main/java/top/tqx/demo_1/controller/CollectionController.java new file mode 100644 index 0000000..8f3a276 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/CollectionController.java @@ -0,0 +1,968 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.entity.CollectionFile; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; +import top.tqx.demo_1.service.CollectionFileService; +import top.tqx.demo_1.service.AsyncVectorizeService; +import top.tqx.demo_1.vo.CollectionVO; +import top.tqx.demo_1.common.Result; + +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.*; + +@Tag(name = "知识库管理", description = "向量库和文档的CRUD操作,与AI端同步") +@RestController +@RequestMapping("/collection") +@Slf4j +public class CollectionController extends BaseController { + + @Resource + private AiApiConfig aiApiConfig; + + @Resource + private RestTemplate restTemplate; + + @Resource + private KnowledgeBasePathMapper knowledgeBasePathMapper; + + @Resource + private DepartmentMapper departmentMapper; + + @Resource + private FileMapper fileMapper; + + @Resource + private CollectionFileService collectionFileService; + + @Resource + private AsyncVectorizeService asyncVectorizeService; + + private void triggerSync() { + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getSync().getTrigger()); + log.info("触发AI端文档同步: {}", url); + ResponseEntity response = restTemplate.postForEntity(url, null, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + log.info("文档同步触发成功"); + } else { + log.warn("文档同步触发失败: status={}, body={}", response.getStatusCode(), response.getBody()); + } + } catch (Exception e) { + log.warn("触发文档同步时发生异常: {}", e.getMessage()); + } + } + + @org.springframework.beans.factory.annotation.Value("${file.upload.path:./uploads/}") + private String uploadPath; + + @Operation(summary = "获取向量库列表", description = "从本地数据库获取所有向量库列表") + @GetMapping + public Result> getCollections(HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + log.info("从本地数据库获取向量库列表"); + + List kbPaths = knowledgeBasePathMapper.selectList(null); + List result = new ArrayList<>(); + + for (KnowledgeBasePath kbPath : kbPaths) { + CollectionVO vo = new CollectionVO(); + vo.setName(kbPath.getName()); + vo.setDisplayName(kbPath.getDisplayName() != null ? kbPath.getDisplayName() : kbPath.getName()); + vo.setDescription(kbPath.getDescription()); + vo.setDepartment(kbPath.getDepartment()); + vo.setDocumentCount(0); + vo.setCreateTime(kbPath.getCreateTime()); + result.add(vo); + } + + log.info("获取向量库列表成功,共 {} 个", result.size()); + return success(result); + } catch (Exception e) { + log.error("获取向量库列表失败", e); + return error("获取向量库列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "创建向量库", description = "在AI端创建向量库并同步到knowledge_base_path表") + @PostMapping + public Result createCollection( + @Parameter(description = "向量库名称", required = true) @RequestParam String name, + @Parameter(description = "显示名称") @RequestParam(required = false) String displayName, + @Parameter(description = "所属部门ID") @RequestParam(required = false) Long departmentId, + @Parameter(description = "描述") @RequestParam(required = false) String description, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + if (name == null || name.isEmpty()) { + return error("向量库名称不能为空"); + } + + KnowledgeBasePath existingKb = getKnowledgeBasePathByName(name); + if (existingKb != null) { + return error("向量库已存在: " + name); + } + + String departmentName = getDepartmentName(departmentId); + + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getCreate()); + log.info("调用RAG服务创建向量库: {}", url); + + Map requestBody = new HashMap<>(); + requestBody.put("name", name); + requestBody.put("display_name", displayName != null ? displayName : name); + if (departmentName != null) { + requestBody.put("department", departmentName); + } + if (description != null) { + requestBody.put("description", description); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + boolean aiSuccess = false; + if (response.getStatusCode().is2xxSuccessful()) { + aiSuccess = true; + } else if (response.getStatusCode().value() == 400) { + String body = response.getBody() != null ? response.getBody() : ""; + log.warn("AI服务返回400错误: {}", body); + if (body.contains("已存在") || body.contains("exists") || body.contains("already")) { + log.warn("AI端向量库已存在,同步本地记录: {}", name); + aiSuccess = true; + } else { + return error("创建向量库失败: " + response.getStatusCode() + " - " + body); + } + } + + if (!aiSuccess) { + return error("创建向量库失败: " + response.getStatusCode()); + } + + KnowledgeBasePath knowledgeBase = new KnowledgeBasePath(); + knowledgeBase.setName(name); + knowledgeBase.setDisplayName(displayName != null ? displayName : name); + knowledgeBase.setPath("/knowledge/" + name); + knowledgeBase.setDepartment(departmentName); + knowledgeBase.setDescription(description); + knowledgeBase.setUploader(userId.toString()); + knowledgeBase.setOwnerType(departmentName != null ? "department" : "personal"); + knowledgeBase.setVisibility("public"); + knowledgeBase.setCreateTime(LocalDateTime.now()); + knowledgeBase.setUpdateTime(LocalDateTime.now()); + + knowledgeBasePathMapper.insert(knowledgeBase); + + CollectionVO vo = new CollectionVO(); + vo.setName(knowledgeBase.getName()); + vo.setDisplayName(knowledgeBase.getDisplayName()); + vo.setDepartment(knowledgeBase.getDepartment()); + vo.setDescription(knowledgeBase.getDescription()); + vo.setOwnerType(knowledgeBase.getOwnerType()); + vo.setVisibility(knowledgeBase.getVisibility()); + vo.setUploader(knowledgeBase.getUploader()); + vo.setCreateTime(knowledgeBase.getCreateTime()); + + log.info("创建向量库成功: name={}, displayName={}, departmentName={}, departmentId={}, userId={}", + name, displayName, departmentName, departmentId, userId); + + return success(vo); + } catch (Exception e) { + log.error("创建向量库失败", e); + return error("创建向量库失败: " + e.getMessage()); + } + } + + private String getDepartmentName(Long deptId) { + if (deptId == null) { + return null; + } + try { + Department dept = departmentMapper.selectById(deptId); + if (dept != null && dept.getDeptName() != null) { + return dept.getDeptName(); + } + } catch (Exception e) { + log.warn("查询部门名称失败: deptId={}", deptId, e); + } + return null; + } + + @Operation(summary = "修改向量库", description = "更新AI端向量库信息并同步到knowledge_base_path表") + @PutMapping("/{name}") + public Result updateCollection( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "显示名称") @RequestParam(required = false) String displayName, + @Parameter(description = "所属部门") @RequestParam(required = false) String department, + @Parameter(description = "描述") @RequestParam(required = false) String description, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + KnowledgeBasePath knowledgeBase = getKnowledgeBasePathByName(name); + if (knowledgeBase == null) { + log.warn("本地数据库中未找到向量库: {}, 尝试从AI端同步", name); + knowledgeBase = new KnowledgeBasePath(); + knowledgeBase.setName(name); + knowledgeBase.setDisplayName(name); + knowledgeBase.setPath("/knowledge/" + name); + knowledgeBase.setDepartment(department); + knowledgeBase.setDescription(description); + knowledgeBase.setUploader(userId.toString()); + knowledgeBase.setOwnerType(department != null ? "department" : "personal"); + knowledgeBase.setVisibility("public"); + knowledgeBase.setCreateTime(LocalDateTime.now()); + knowledgeBase.setUpdateTime(LocalDateTime.now()); + knowledgeBasePathMapper.insert(knowledgeBase); + log.info("已同步向量库到本地数据库: {}", name); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getUpdate()); + url = url.replace("{name}", name); + log.info("调用RAG服务修改向量库: {}", url); + + Map requestBody = new HashMap<>(); + if (displayName != null) { + requestBody.put("display_name", displayName); + } + if (department != null) { + requestBody.put("department", department); + } + if (description != null) { + requestBody.put("description", description); + } + + if (requestBody.isEmpty()) { + return error("至少需要提供一个修改参数"); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.PUT, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("修改向量库失败: " + response.getStatusCode()); + } + + if (displayName != null) { + knowledgeBase.setDisplayName(displayName); + } + if (department != null) { + knowledgeBase.setDepartment(department); + } + if (description != null) { + knowledgeBase.setDescription(description); + } + knowledgeBase.setUpdateTime(LocalDateTime.now()); + + knowledgeBasePathMapper.updateById(knowledgeBase); + + CollectionVO vo = new CollectionVO(); + vo.setName(knowledgeBase.getName()); + vo.setDisplayName(knowledgeBase.getDisplayName()); + vo.setDepartment(knowledgeBase.getDepartment()); + vo.setDescription(knowledgeBase.getDescription()); + vo.setOwnerType(knowledgeBase.getOwnerType()); + vo.setVisibility(knowledgeBase.getVisibility()); + vo.setUploader(knowledgeBase.getUploader()); + vo.setCreateTime(knowledgeBase.getCreateTime()); + + log.info("修改向量库成功: name={}, userId={}", name, userId); + + return success(vo); + } catch (Exception e) { + log.error("修改向量库失败", e); + return error("修改向量库失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除向量库", description = "删除AI端向量库并从knowledge_base_path表移除") + @DeleteMapping("/{name}") + public Result deleteCollection( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getDelete()); + url = url.replace("{name}", name); + log.info("调用RAG服务删除向量库: {}", url); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("删除向量库失败: " + response.getStatusCode()); + } + + KnowledgeBasePath knowledgeBase = getKnowledgeBasePathByName(name); + if (knowledgeBase != null) { + knowledgeBasePathMapper.deleteById(knowledgeBase.getId()); + } + + log.info("删除向量库成功: name={}, userId={}", name, userId); + + return success(); + } catch (Exception e) { + log.error("删除向量库失败", e); + return error("删除向量库失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取向量库切片列表", description = "获取指定向量库的所有文档切片") + @GetMapping("/{name}/chunks") + public Result> getCollectionChunks( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "100") Integer limit, + @Parameter(description = "偏移量") @RequestParam(defaultValue = "0") Integer offset, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getChunks()); + url = url.replace("{name}", name) + "?limit=" + limit + "&offset=" + offset; + log.info("调用RAG服务获取向量库切片: {}", url); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + log.info("获取向量库切片成功: collection={}", name); + return success(body); + } + log.error("获取向量库切片失败: status={}, body={}", response.getStatusCode(), response.getBody()); + return error("获取向量库切片失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("获取向量库切片失败", e); + return error("获取向量库切片失败: " + e.getMessage()); + } + } + + @Operation(summary = "文件向量化", description = "将已上传的文件解析并存储到向量库") + @PostMapping("/{name}/vectorize") + public Result> vectorizeFile( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文件ID", required = true) @RequestParam Long fileId, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + log.info("触发文件向量化: collection={}, fileId={}, userId={}", name, fileId, userId); + + File file = fileMapper.selectById(fileId); + if (file == null) { + log.warn("文件不存在: fileId={}", fileId); + return error("文件不存在"); + } + + String filePath = file.getFilePath(); + if (filePath == null || filePath.isEmpty()) { + log.warn("文件路径为空: fileId={}", fileId); + return error("文件路径为空,请先上传文件"); + } + + Path path = Paths.get(uploadPath, filePath).normalize(); + if (!Files.exists(path)) { + log.warn("文件不存在于路径: fullPath={}", path.toAbsolutePath()); + return error("文件不存在于服务器: " + filePath); + } + + asyncVectorizeService.updateFileProcessStatus(fileId, + AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING, + "正在向量化,请稍后..."); + + asyncVectorizeService.vectorizeFileAsync(name, fileId, userId); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "向量化任务已提交,正在后台处理中"); + result.put("fileId", fileId); + result.put("fileName", file.getFileName()); + result.put("collection", name); + result.put("status", AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING); + + log.info("文件向量化任务已提交: collection={}, fileId={}, fileName={}", + name, fileId, file.getFileName()); + + return success(result); + } catch (Exception e) { + log.error("触发文件向量化失败: collection={}, fileId={}", name, fileId, e); + return error("触发文件向量化失败: " + e.getMessage()); + } + } + + @Operation(summary = "批量文件向量化", description = "批量将已上传的文件解析并存储到向量库") + @PostMapping("/{name}/vectorize/batch") + public Result> batchVectorizeFiles( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文件ID列表", required = true) @RequestParam List fileIds, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + log.info("触发批量文件向量化: collection={}, fileIds={}, userId={}", name, fileIds, userId); + + List> submittedFiles = new ArrayList<>(); + List> failedFiles = new ArrayList<>(); + String batchId = "BATCH_" + System.currentTimeMillis(); + + for (Long fileId : fileIds) { + try { + File file = fileMapper.selectById(fileId); + if (file == null) { + Map failed = new HashMap<>(); + failed.put("fileId", fileId); + failed.put("error", "文件不存在"); + failedFiles.add(failed); + continue; + } + + String filePath = file.getFilePath(); + if (filePath == null || filePath.isEmpty()) { + Map failed = new HashMap<>(); + failed.put("fileId", fileId); + failed.put("error", "文件路径为空"); + failedFiles.add(failed); + continue; + } + + Path path = Paths.get(uploadPath, filePath).normalize(); + if (!Files.exists(path)) { + Map failed = new HashMap<>(); + failed.put("fileId", fileId); + failed.put("error", "文件不存在于服务器"); + failedFiles.add(failed); + continue; + } + + asyncVectorizeService.updateFileProcessStatus(fileId, + AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING, + String.format("正在向量化,请稍后...(批次ID: %s)", batchId)); + + asyncVectorizeService.batchVectorizeFilesAsync(name, fileId, userId, batchId); + + Map submitted = new HashMap<>(); + submitted.put("fileId", fileId); + submitted.put("fileName", file.getFileName()); + submitted.put("collection", name); + submitted.put("status", AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING); + submitted.put("batchId", batchId); + submittedFiles.add(submitted); + + log.info("批量向量化任务已提交: collection={}, fileId={}, fileName={}, batchId={}", + name, fileId, file.getFileName(), batchId); + + } catch (Exception e) { + log.error("提交文件向量化失败: fileId={}", fileId, e); + Map failed = new HashMap<>(); + failed.put("fileId", fileId); + failed.put("error", e.getMessage()); + failedFiles.add(failed); + } + } + + Map result = new HashMap<>(); + result.put("success", true); + result.put("collection", name); + result.put("batchId", batchId); + result.put("submittedFiles", submittedFiles); + result.put("failedFiles", failedFiles); + result.put("total", fileIds.size()); + result.put("submittedCount", submittedFiles.size()); + result.put("failedCount", failedFiles.size()); + + String message = String.format("批量向量化任务已提交: 提交 %d 个, 无效 %d 个(批次ID: %s)", + submittedFiles.size(), failedFiles.size(), batchId); + log.info(message); + + return success(result); + } catch (Exception e) { + log.error("触发批量文件向量化失败: collection={}", name, e); + return error("触发批量文件向量化失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取文档列表", description = "从本地数据库获取指定向量库中的文档列表") + @GetMapping("/{name}/documents") + public Result>> getDocuments( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + log.info("从本地数据库获取文档列表: collection={}", name); + + List collectionFiles = collectionFileService.findByCollectionName(name); + List> documents = new ArrayList<>(); + + for (CollectionFile cf : collectionFiles) { + Map doc = new HashMap<>(); + doc.put("name", cf.getDocName()); + doc.put("type", cf.getDocType()); + doc.put("path", cf.getFilePath()); + doc.put("fileId", cf.getFileId()); + doc.put("status", cf.getStatus()); + doc.put("createTime", cf.getCreateTime()); + documents.add(doc); + } + + log.info("获取文档列表成功: collection={}, count={}", name, documents.size()); + return success(documents); + } catch (Exception e) { + log.error("获取文档列表失败", e); + return error("获取文档列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除文档", description = "从向量库中删除指定文档,同步更新本地数据库") + @DeleteMapping("/{name}/documents/{filename}") + public Result deleteDocument( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文档文件名", required = true) @PathVariable String filename, + @Parameter(description = "文件ID(优先使用,避免同名文件删除错误)") @RequestParam(required = false) Long fileId, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getDelete()); + url = url + "/" + name + "/" + filename; + log.info("调用RAG服务删除文档: {}", url); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("删除文档失败: " + response.getStatusCode()); + } + + CollectionFile cf = null; + if (fileId != null) { + cf = collectionFileService.findByFileId(fileId); + log.info("通过fileId查找collection_file: fileId={}, result={}", fileId, cf != null ? "找到" : "未找到"); + } + + if (cf == null) { + cf = collectionFileService.findByCollectionAndDocName(name, filename); + log.info("通过collection+filename查找collection_file: name={}, filename={}, result={}", name, filename, cf != null ? "找到" : "未找到"); + } + + if (cf != null) { + collectionFileService.delete(cf.getId()); + log.info("本地数据库文档关联已删除: collection={}, filename={}, fileId={}", name, filename, cf.getFileId()); + } + + log.info("删除文档成功: collection={}, filename={}, fileId={}, userId={}", name, filename, fileId, userId); + + triggerSync(); + + return success(); + } catch (Exception e) { + log.error("删除文档失败", e); + return error("删除文档失败: " + e.getMessage()); + } + } + + @Operation(summary = "废止文档", description = "将文档标记为废止状态,同步更新本地数据库") + @PostMapping("/{name}/documents/{filename}/deprecate") + public Result> deprecateDocument( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文档文件名", required = true) @PathVariable String filename, + @Parameter(description = "废止原因") @RequestParam(required = false) String reason, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getDeprecate()); + url = url.replace("{name}", name).replace("{filename}", filename); + log.info("调用RAG服务废止文档: {}", url); + + Map requestBody = new HashMap<>(); + if (reason != null && !reason.isEmpty()) { + requestBody.put("reason", reason); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + + CollectionFile cf = collectionFileService.findByCollectionAndDocName(name, filename); + if (cf != null) { + cf.setStatus(0); + collectionFileService.saveOrUpdate(name, filename, cf.getFileId(), cf.getFilePath(), cf.getDocType()); + log.info("本地数据库文档已标记为废止: collection={}, filename={}", name, filename); + } + + log.info("废止文档成功: collection={}, filename={}, userId={}", name, filename, userId); + return success(body); + } + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("废止文档失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("废止文档失败", e); + return error("废止文档失败: " + e.getMessage()); + } + } + + @Operation(summary = "恢复已废止文档", description = "将已废止的文档恢复为正常状态,同步更新本地数据库") + @PostMapping("/{name}/documents/{filename}/restore") + public Result> restoreDocument( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文档文件名", required = true) @PathVariable String filename, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getRestore()); + url = url.replace("{name}", name).replace("{filename}", filename); + log.info("调用RAG服务恢复文档: {}", url); + + ResponseEntity response = restTemplate.postForEntity(url, null, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + + CollectionFile cf = collectionFileService.findByCollectionAndDocName(name, filename); + if (cf != null) { + cf.setStatus(1); + collectionFileService.saveOrUpdate(name, filename, cf.getFileId(), cf.getFilePath(), cf.getDocType()); + log.info("本地数据库文档已恢复: collection={}, filename={}", name, filename); + } + + log.info("恢复文档成功: collection={}, filename={}, userId={}", name, filename, userId); + return success(body); + } + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("恢复文档失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("恢复文档失败", e); + return error("恢复文档失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取文档版本历史", description = "获取文档的版本历史记录") + @GetMapping("/{name}/documents/{filename}/versions") + public Result> getDocumentVersions( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文档文件名", required = true) @PathVariable String filename, + @Parameter(description = "返回数量限制") @RequestParam(defaultValue = "10") Integer limit, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getVersions()); + url = url.replace("{name}", name).replace("{filename}", filename) + "?limit=" + limit; + log.info("调用RAG服务获取文档版本历史: {}", url); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + log.info("获取文档版本历史成功: collection={}, filename={}", name, filename); + return success(body); + } + log.error("获取文档版本历史失败: status={}, body={}", response.getStatusCode(), response.getBody()); + return error("获取文档版本历史失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("获取文档版本历史失败", e); + return error("获取文档版本历史失败: " + e.getMessage()); + } + } + + private Map uploadToRAG(String collection, String filename, byte[] content) { + Map result = new HashMap<>(); + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getUpload()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + ByteArrayResource resource = new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }; + + LinkedMultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", resource); + body.add("collection", collection); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map responseBody = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + result.putAll(responseBody); + result.put("success", true); + } else { + String errorBody = response.getBody() != null ? response.getBody() : ""; + result.put("success", false); + result.put("message", "RAG服务返回错误: " + response.getStatusCode() + " - " + errorBody); + } + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + log.error("上传文件到RAG服务失败", e); + } + return result; + } + + @Operation(summary = "获取文档切片列表", description = "获取指定文档的所有切片内容") + @GetMapping("/{name}/documents/{filename}/chunks") + public Result> getDocumentChunks( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "文档文件名", required = true) @PathVariable String filename, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getChunk().getDocumentChunks()); + url = url.replace("{path}", name + "/" + filename) + "?page=" + page + "&page_size=" + pageSize; + log.info("调用RAG服务获取文档切片列表: {}", url); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + log.info("获取文档切片列表成功: collection={}, filename={}", name, filename); + return success(body); + } + log.error("获取文档切片列表失败: status={}, body={}", response.getStatusCode(), response.getBody()); + return error("获取文档切片列表失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("获取文档切片列表失败", e); + return error("获取文档切片列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "新增切片", description = "在指定向量库中创建新的切片") + @PostMapping("/{name}/chunks") + public Result> createChunk( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "切片内容", required = true) @RequestParam String content, + @Parameter(description = "章节信息") @RequestParam(required = false) String section, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + if (content == null || content.isEmpty()) { + return error("切片内容不能为空"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getChunk().getCreate()); + log.info("调用RAG服务新增切片: {}", url); + + Map requestBody = new HashMap<>(); + requestBody.put("collection", name); + requestBody.put("content", content); + if (section != null && !section.isEmpty()) { + Map metadata = new HashMap<>(); + metadata.put("section", section); + requestBody.put("metadata", metadata); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map body = objectMapper.readValue(response.getBody(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + log.info("新增切片成功: collection={}, userId={}", name, userId); + return success(body); + } + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("新增切片失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("新增切片失败", e); + return error("新增切片失败: " + e.getMessage()); + } + } + + @Operation(summary = "修改切片", description = "更新指定切片的内容或元数据") + @PutMapping("/{name}/chunks/{chunkId}") + public Result> updateChunk( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "切片ID", required = true) @PathVariable String chunkId, + @Parameter(description = "新内容") @RequestParam(required = false) String content, + @Parameter(description = "章节信息") @RequestParam(required = false) String section, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + if (content == null && section == null) { + return error("至少需要提供内容或章节信息"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getChunk().getUpdate()); + url = url.replace("{id}", chunkId); + log.info("调用RAG服务修改切片: {}", url); + + Map requestBody = new HashMap<>(); + requestBody.put("collection", name); + if (content != null && !content.isEmpty()) { + requestBody.put("content", content); + } + if (section != null && !section.isEmpty()) { + Map metadata = new HashMap<>(); + metadata.put("section", section); + requestBody.put("metadata", metadata); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.PUT, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + Map result = new HashMap<>(); + result.put("success", true); + result.put("message", "切片更新成功"); + result.put("chunkId", chunkId); + log.info("修改切片成功: chunkId={}, userId={}", chunkId, userId); + return success(result); + } + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("修改切片失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("修改切片失败", e); + return error("修改切片失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除切片", description = "从向量库中删除指定切片") + @DeleteMapping("/{name}/chunks/{chunkId}") + public Result deleteChunk( + @Parameter(description = "向量库名称", required = true) @PathVariable String name, + @Parameter(description = "切片ID", required = true) @PathVariable String chunkId, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + String url = aiApiConfig.getFullUrl(aiApiConfig.getChunk().getDelete()); + url = url.replace("{id}", chunkId); + log.info("调用RAG服务删除切片: {}", url); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("删除切片成功: chunkId={}, userId={}", chunkId, userId); + return success(); + } + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("AI服务返回错误: status={}, body={}", response.getStatusCode(), errorMsg); + return error("删除切片失败: " + response.getStatusCode()); + } catch (Exception e) { + log.error("删除切片失败", e); + return error("删除切片失败: " + e.getMessage()); + } + } + + private KnowledgeBasePath getKnowledgeBasePathByName(String name) { + Map params = new HashMap<>(); + params.put("name", name); + List list = knowledgeBasePathMapper.selectByMap(params); + return list.isEmpty() ? null : list.get(0); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/DataSyncController.java b/src/main/java/top/tqx/demo_1/controller/DataSyncController.java new file mode 100644 index 0000000..849058a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/DataSyncController.java @@ -0,0 +1,120 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.service.impl.WrongQuestionSyncValidator; + +import java.time.LocalDateTime; + +@Tag(name = "数据同步验证", description = "验证错题与批阅系统之间的数据同步机制") +@RestController +@RequestMapping("/data-sync") +@Slf4j +public class DataSyncController extends BaseController { + + @Autowired + private WrongQuestionSyncValidator syncValidator; + + @Operation(summary = "验证指定用户的数据同步", description = "验证指定用户的错题数据与批阅数据是否一致") + @GetMapping("/validate/user/{userId}") + public Result validateUserSync( + @PathVariable Long userId, + @RequestParam(required = false) String paperId, + @RequestParam(required = false) String sessionId) { + + log.info("验证用户数据同步: userId={}, paperId={}, sessionId={}", userId, paperId, sessionId); + + try { + WrongQuestionSyncValidator.ValidationResult result = + syncValidator.validateSyncIntegrity(userId, paperId, sessionId); + + if (result.isSuccess()) { + return Result.success("数据同步验证通过", result); + } else { + return Result.error(400, "数据同步验证失败", result); + } + } catch (Exception e) { + log.error("验证用户数据同步异常: {}", e.getMessage(), e); + return Result.error(500, "验证异常: " + e.getMessage()); + } + } + + @Operation(summary = "验证当前用户的数据同步", description = "验证当前登录用户的错题数据与批阅数据是否一致") + @GetMapping("/validate/me") + public Result validateCurrentUserSync( + HttpServletRequest httpRequest, + @RequestParam(required = false) String paperId, + @RequestParam(required = false) String sessionId) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + log.info("验证当前用户数据同步: userId={}, paperId={}, sessionId={}", userId, paperId, sessionId); + + try { + WrongQuestionSyncValidator.ValidationResult result = + syncValidator.validateSyncIntegrity(userId, paperId, sessionId); + + if (result.isSuccess()) { + return Result.success("数据同步验证通过", result); + } else { + return Result.error(400, "数据同步验证失败", result); + } + } catch (Exception e) { + log.error("验证当前用户数据同步异常: {}", e.getMessage(), e); + return Result.error(500, "验证异常: " + e.getMessage()); + } + } + + @Operation(summary = "全量验证所有用户数据同步", description = "验证系统中所有用户的错题数据与批阅数据是否一致") + @GetMapping("/validate/all") + public Result validateAllUsersSync( + @RequestParam(defaultValue = "100") int pageSize) { + + log.info("全量验证数据同步: pageSize={}", pageSize); + + try { + WrongQuestionSyncValidator.ValidationResult result = + syncValidator.validateAllUsers(pageSize); + + if (result.isSuccess()) { + return Result.success("全量数据同步验证通过", result); + } else { + return Result.error(400, "全量数据同步验证失败", result); + } + } catch (Exception e) { + log.error("全量验证数据同步异常: {}", e.getMessage(), e); + return Result.error(500, "验证异常: " + e.getMessage()); + } + } + + @Operation(summary = "验证最近答题记录", description = "验证指定时间之后的答题记录是否完整") + @GetMapping("/validate/recent") + public Result validateRecentAnswers( + @RequestParam(defaultValue = "24") int hoursAgo) { + + LocalDateTime startTime = LocalDateTime.now().minusHours(hoursAgo); + log.info("验证最近答题记录: startTime={}", startTime); + + try { + WrongQuestionSyncValidator.ValidationResult result = + syncValidator.validateRecentAnswers(startTime); + + if (result.isSuccess()) { + return Result.success("最近答题记录验证通过", result); + } else { + return Result.error(400, "最近答题记录验证失败", result); + } + } catch (Exception e) { + log.error("验证最近答题记录异常: {}", e.getMessage(), e); + return Result.error(500, "验证异常: " + e.getMessage()); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/DepartmentController.java b/src/main/java/top/tqx/demo_1/controller/DepartmentController.java new file mode 100644 index 0000000..8ac8ad4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/DepartmentController.java @@ -0,0 +1,73 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.DepartmentCreateRequest; +import top.tqx.demo_1.dto.DepartmentUpdateRequest; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.vo.DepartmentVO; +import top.tqx.demo_1.vo.UserVO; + +import java.util.List; + +@Tag(name = "部门管理", description = "部门的增删改查操作") +@RestController +@RequestMapping("/department") +public class DepartmentController { + + private final DepartmentService departmentService; + + public DepartmentController(DepartmentService departmentService) { + this.departmentService = departmentService; + } + + @Operation(summary = "查询部门列表", description = "获取所有部门信息") + @GetMapping + public Result> listDepartments() { + List departments = departmentService.listDepartments(); + return Result.success(departments); + } + + @Operation(summary = "创建部门", description = "创建新部门") + @PostMapping + public Result createDepartment(@RequestBody DepartmentCreateRequest request) { + departmentService.createDepartment(request); + return Result.success(); + } + + @Operation(summary = "更新部门", description = "更新部门信息") + @PutMapping("/{id}") + public Result updateDepartment( + @Parameter(description = "部门ID") @PathVariable Long id, + @RequestBody DepartmentUpdateRequest request) { + departmentService.updateDepartment(id, request); + return Result.success(); + } + + @Operation(summary = "删除部门", description = "根据ID删除部门") + @DeleteMapping("/{id}") + public Result deleteDepartment(@Parameter(description = "部门ID") @PathVariable Long id) { + departmentService.deleteDepartment(id); + return Result.success(); + } + + @Operation(summary = "设置部门管理员", description = "为部门设置管理员") + @PutMapping("/{id}/manager") + public Result setDepartmentManager( + @Parameter(description = "部门ID") @PathVariable Long id, + @Parameter(description = "管理员用户ID") @RequestParam Long managerId) { + departmentService.setDepartmentManager(id, managerId); + return Result.success(); + } + + @Operation(summary = "查询部门用户", description = "获取部门下的所有用户") + @GetMapping("/{id}/users") + public Result> getDepartmentUsers( + @Parameter(description = "部门ID") @PathVariable Long id) { + List users = departmentService.getDepartmentUsers(id); + return Result.success(users); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/ExamController.java b/src/main/java/top/tqx/demo_1/controller/ExamController.java new file mode 100644 index 0000000..535596f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/ExamController.java @@ -0,0 +1,539 @@ +package top.tqx.demo_1.controller; + +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.entity.UserAnswer; +import top.tqx.demo_1.enums.AnswerType; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.service.AsyncExamService; +import top.tqx.demo_1.service.ExamPaperService; +import top.tqx.demo_1.service.ExamService; +import top.tqx.demo_1.service.ExamRecordService; +import top.tqx.demo_1.service.UserAnswerService; +import top.tqx.demo_1.service.UserService; +import top.tqx.demo_1.vo.UserVO; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Tag(name = "考试管理", description = "题目生成与答案批改接口") +@RestController +@RequestMapping("/exam") +@Slf4j +public class ExamController extends BaseController { + + @Autowired + private ExamService examService; + + @Autowired + private ExamPaperService examPaperService; + + @Autowired + private AuthService authService; + + @Autowired + private UserService userService; + + @Autowired + private UserAnswerService userAnswerService; + + @Autowired + private top.tqx.demo_1.service.ExamRecordService examRecordService; + + @Autowired + private AsyncExamService asyncExamService; + + @Operation(summary = "获取试卷详情", description = "通过试卷ID获取试卷详情") + @GetMapping("/paper/{paperId}") + public Result getPaperById( + @PathVariable String paperId) { + try { + ExamPaperResponse response = examPaperService.getPaperById(paperId); + if (response == null) { + return Result.error(404, "试卷不存在"); + } + return Result.success(response); + } catch (Exception e) { + log.error("获取试卷详情失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "开始考试(获取题目)", description = "通过试卷ID开始模拟考试,同时返回试卷题目内容") + @PostMapping("/paper/{paperId}/start") + public Result startExamByPaperId( + @PathVariable String paperId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.startExamByPaperId(paperId, userId, AnswerType.PRACTICE.getCode()); + return Result.success(response); + } catch (Exception e) { + log.error("开始考试失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取考试结果", description = "获取模拟考试的完整结果,包括题目、答案、分数等信息") + @GetMapping("/record/{recordId}/result") + public Result getExamResult( + @PathVariable String recordId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.getRecordById(recordId); + + if (response == null) { + return Result.error(404, "记录不存在"); + } + + if (!response.getUserId().equals(userId)) { + return Result.error(403, "无权查看此记录"); + } + + return Result.success(response); + } catch (Exception e) { + log.error("获取考试结果失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "生成试卷", description = "根据权限范围从题库中动态生成试卷") + @PostMapping("/paper/generate") + public Result generatePaper( + @RequestBody ExamPaperGenerateRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + UserVO userVO = userService.getUserById(userId); + if (userVO == null) { + return Result.error(401, "用户不存在"); + } + + if (request.getIncludePersonal() == null) { + request.setIncludePersonal(false); + } + + log.info("生成试卷请求: userId={}, deptId={}, userType={}, includePersonal={}, singleChoiceCount={}, multipleChoiceCount={}, trueFalseCount={}, fillBlankCount={}, subjectiveCount={}, difficulty={}", + userId, userVO.getDeptId(), userVO.getUserType(), request.getIncludePersonal(), + request.getSingleChoiceCount(), request.getMultipleChoiceCount(), + request.getTrueFalseCount(), request.getFillBlankCount(), + request.getSubjectiveCount(), request.getDifficulty()); + + try { + ExamPaperGenerateResponse response = examService.generatePaper(request, userId, userVO.getDeptId(), userVO.getUserType()); + return Result.success("试卷生成成功", response); + } catch (Exception e) { + log.error("生成试卷异常: {}", e.getMessage(), e); + return Result.error(500, "生成试卷异常: " + e.getMessage()); + } + } + + @Operation(summary = "生成题目(异步)", description = "根据知识库内容生成考试题目,支持单选题、多选题、判断题、填空题、简答题。支持两种模式:1. 智能生成模式(推荐):不提供 question_types 参数,系统会调用 AI 智能出题接口 /exam/generate-smart,AI 会根据文档内容自动判断题型和数量;2. 指定题型模式:提供 question_types 参数,系统会调用传统出题接口 /exam/generate,按照指定的题型和数量生成题目。支持两种方式指定文件:通过 file_id 指定(推荐)或通过 file_path 指定。此接口为异步接口,请求后立即返回任务提交状态,实际生成在后台执行,需通过 GET /api/file/{fileId} 接口轮询查询生成进度和结果。") + @PostMapping("/generate") + public Result> generateQuestions( + @RequestBody ExamGenerateRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + Map feedback = new HashMap<>(); + + boolean hasFilePath = (request.getFilePath() != null && !request.getFilePath().isEmpty()); + boolean hasFileId = (request.getFileId() != null); + + if (!hasFilePath && !hasFileId) { + feedback.put("errorType", "VALIDATION_ERROR"); + feedback.put("errorDetail", "file_path 和 file_id 不能同时为空"); + feedback.put("suggestion", "请通过 file_path 指定文件路径,或通过 file_id 指定文件ID"); + return Result.error(400, "参数验证失败", feedback); + } + + if (hasFilePath && hasFileId) { + feedback.put("warning", "同时提供了 file_path 和 file_id,将优先使用 file_id"); + log.warn("同时提供了 file_path 和 file_id,将优先使用 file_id"); + } + + if (request.getRequestId() == null || request.getRequestId().isEmpty()) { + request.setRequestId(UUID.randomUUID().toString()); + } + + log.info("========== 生成题目请求开始 =========="); + log.info("请求参数: userId={}, requestId={}, fileId={}, filePath={}, questionTypes={}, difficulty={}", + userId, request.getRequestId(), request.getFileId(), request.getFilePath(), + request.getQuestionTypes(), request.getDifficulty()); + + try { + Map result = examService.generateQuestionsWithFileId(request); + + feedback.putAll(result); + feedback.put("status", "success"); + feedback.put("message", "收到请求,正在生成题目"); + + // 触发异步任务生成题目 + asyncExamService.generateQuestionsAsync(request); + + return Result.success("收到请求", feedback); + + } catch (IllegalArgumentException e) { + feedback.put("status", "error"); + feedback.put("errorType", "VALIDATION_ERROR"); + feedback.put("errorDetail", e.getMessage()); + feedback.put("suggestion", "请检查 file_id 是否正确或文件是否已上传"); + log.error("生成题目请求验证失败: {}", e.getMessage()); + return Result.error(400, "请求参数验证失败", feedback); + + } catch (Exception e) { + feedback.put("status", "error"); + feedback.put("errorType", "INTERNAL_ERROR"); + feedback.put("errorDetail", e.getMessage()); + feedback.put("suggestion", "请稍后重试或联系管理员"); + log.error("生成题目请求处理失败", e); + return Result.error(500, "服务器内部错误", feedback); + } + } + + @Operation(summary = "提交考试并批改(正式考试专用)", description = "通过考试记录ID提交正式考试答案并批改") + @PostMapping("/record/{recordId}/grade") + public Result gradeExamRecord( + @PathVariable String recordId, + @RequestBody ExamSaveRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + log.info("提交正式考试: userId={}, recordId={}", userId, recordId); + + try { + ExamRecordResponse response = examService.gradeAnswersForRecord(recordId, request, userId, "formal_exam"); + return Result.success("提交成功", response); + } catch (Exception e) { + log.error("提交正式考试失败: {}", e.getMessage(), e); + return Result.error(500, "提交失败: " + e.getMessage()); + } + } + + @Operation(summary = "批改答案(日常练习/模拟考/正式考试通用)", description = "对学生提交的答案进行批改,支持客观题本地批改和主观题LLM评分") + @PostMapping("/grade") + public Result gradeAnswers( + @RequestBody ExamGradeRequest request, + HttpServletRequest httpRequest) { + + log.info("========== 批阅接口请求开始 =========="); + log.info("请求完整内容: {}", JSON.toJSONString(request)); + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + log.error("用户未登录,无法进行批阅"); + return Result.error(401, "用户未登录,请先登录"); + } + log.info("当前用户ID: {}", userId); + + if (request.getAnswers() == null) { + log.warn("答案列表为null,视为空白试卷"); + request.setAnswers(new ArrayList<>()); + } + log.info("答案数量: {}", request.getAnswers().size()); + + if (request.getRecordId() != null && !request.getRecordId().isEmpty()) { + log.info("检测到正式考试模式: recordId={}", request.getRecordId()); + + ExamSaveRequest saveRequest = new ExamSaveRequest(); + List saveItems = request.getAnswers().stream().map(gradeItem -> { + ExamSaveRequest.AnswerItem saveItem = new ExamSaveRequest.AnswerItem(); + saveItem.setQuestionId(gradeItem.getQuestionId()); + saveItem.setQuestionType(gradeItem.getQuestionType()); + saveItem.setAnswer(gradeItem.getStudentAnswer()); + return saveItem; + }).collect(java.util.stream.Collectors.toList()); + saveRequest.setAnswers(saveItems); + + log.info("通过recordId提交考试: userId={}, recordId={}, 答案数量={}", userId, request.getRecordId(), saveItems.size()); + + try { + ExamRecordResponse response = examService.gradeAnswersForRecord( + request.getRecordId(), + saveRequest, + userId, + request.getAnswerType() != null ? request.getAnswerType() : "formal_exam"); + log.info("正式考试批阅成功: recordId={}", request.getRecordId()); + return Result.success("批阅成功", response); + } catch (Exception e) { + log.error("正式考试批改失败: recordId={}, 错误类型={}, 错误信息={}", request.getRecordId(), e.getClass().getName(), e.getMessage(), e); + String detailedError = String.format("批改失败: %s - %s", e.getClass().getSimpleName(), e.getMessage()); + return Result.error(500, detailedError); + } + } + + log.info("日常练习/模拟考模式,开始验证答案数据..."); + + // 检查是否为空试卷 + boolean isBlankSubmission = request.getAnswers().isEmpty(); + + if (!isBlankSubmission) { + for (int i = 0; i < request.getAnswers().size(); i++) { + ExamGradeRequest.AnswerItem answer = request.getAnswers().get(i); + log.debug("验证第{}个答案: questionId={}, questionType={}", i+1, answer.getQuestionId(), answer.getQuestionType()); + + if (answer.getQuestionId() == null || answer.getQuestionId().isEmpty()) { + log.error("第{}个答案缺少questionId", i+1); + return Result.error(400, String.format("第%d个答案的questionId不能为空", i+1)); + } + if (answer.getQuestionType() == null || answer.getQuestionType().isEmpty()) { + log.error("第{}个答案缺少questionType, questionId={}", i+1, answer.getQuestionId()); + return Result.error(400, String.format("第%d个答案的questionType不能为空 (questionId: %s)", i+1, answer.getQuestionId())); + } + if (answer.getQuestionContent() == null) { + log.error("第{}个答案缺少questionContent, questionId={}", i+1, answer.getQuestionId()); + return Result.error(400, String.format("第%d个答案的questionContent不能为空 (questionId: %s)", i+1, answer.getQuestionId())); + } + if (answer.getMaxScore() == null) { + log.error("第{}个答案缺少maxScore, questionId={}", i+1, answer.getQuestionId()); + return Result.error(400, String.format("第%d个答案的maxScore不能为空 (questionId: %s)", i+1, answer.getQuestionId())); + } + + String validTypes = "single_choice,multiple_choice,true_false,fill_blank,subjective"; + if (!validTypes.contains(answer.getQuestionType())) { + log.error("第{}个答案的题型无效: {}, 有效题型: {}", i+1, answer.getQuestionType(), validTypes); + return Result.error(400, String.format("第%d个答案的questionType无效: %s (有效值: %s)", i+1, answer.getQuestionType(), validTypes)); + } + } + } + log.info("答案数据验证通过,共{}个答案{}", request.getAnswers().size(), isBlankSubmission ? "(空白试卷)" : ""); + log.info("空试卷标记: isBlankSubmission={}", isBlankSubmission); + + if (request.getRequestId() == null || request.getRequestId().isEmpty()) { + request.setRequestId(UUID.randomUUID().toString()); + log.info("自动生成requestId: {}", request.getRequestId()); + } + + if (request.getPaperId() == null || request.getPaperId().isEmpty()) { + String generatedPaperId = UUID.randomUUID().toString(); + request.setPaperId(generatedPaperId); + log.info("自动生成试卷ID: paperId={}", generatedPaperId); + } + + if (request.getSessionId() == null || request.getSessionId().isEmpty()) { + request.setSessionId("grade_" + UUID.randomUUID().toString().substring(0, 8)); + log.info("自动生成sessionId: {}", request.getSessionId()); + } + + log.info("========== 准备调用批阅服务 =========="); + log.info("批改答案请求参数汇总:"); + log.info(" - userId: {}", userId); + log.info(" - requestId: {}", request.getRequestId()); + log.info(" - paperId: {}", request.getPaperId()); + log.info(" - sessionId: {}", request.getSessionId()); + log.info(" - answerType: {}", request.getAnswerType()); + log.info(" - answers count: {}", request.getAnswers().size()); + + try { + ExamGradeResponse response = examService.gradeAnswers(request, userId, request.getPaperId(), + request.getSessionId(), request.getAnswerType()); + + if (Boolean.TRUE.equals(response.getSuccess())) { + log.info("========== 批阅成功 =========="); + log.info("批阅结果: totalScore={}, totalMaxScore={}, scoreRate={}%", + response.getTotalScore(), response.getTotalMaxScore(), response.getScoreRate()); + + // 保存答题记录到数据库 + saveUserAnswers(request, response, userId); + + return Result.success("批阅成功", response); + } else { + String errorMessage = response.getError() != null ? response.getError() : "答案批改失败"; + log.error("========== 批阅失败 =========="); + log.error("失败原因: {}", errorMessage); + return Result.error(500, errorMessage); + } + + } catch (IOException e) { + log.error("========== 批改答案IO异常 =========="); + log.error("错误类型: IOException"); + log.error("错误信息: {}", e.getMessage()); + log.error("可能的原因:"); + log.error(" 1. AI服务连接超时"); + log.error(" 2. 网络不稳定"); + log.error(" 3. AI服务响应格式错误"); + log.error("详细堆栈: ", e); + String detailedError = String.format("批改答案失败(IO异常): %s", e.getMessage()); + return Result.error(500, detailedError); + } catch (Exception e) { + log.error("========== 批改答案发生未知异常 =========="); + log.error("错误类型: {}", e.getClass().getName()); + log.error("错误信息: {}", e.getMessage()); + log.error("可能的错误位置:"); + for (StackTraceElement element : e.getStackTrace()) { + if (element.getClassName().contains("top.tqx")) { + log.error(" at {}.{}:{} (行号: {})", + element.getClassName(), element.getMethodName(), + element.getFileName(), element.getLineNumber()); + } + } + log.error("完整堆栈: ", e); + + String detailedError = String.format("批改答案发生未知异常: %s - %s", + e.getClass().getSimpleName(), e.getMessage()); + return Result.error(500, detailedError); + } + } + + @Operation(summary = "查询用户答题记录", description = "根据试卷ID或会话ID查询用户答题记录,支持按试卷/练习维度分组展示") + @PostMapping("/answers/query") + public Result queryUserAnswers( + @RequestBody UserAnswerQueryRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + boolean hasPaperId = request.getPaperId() != null && !request.getPaperId().isEmpty(); + boolean hasSessionId = request.getSessionId() != null && !request.getSessionId().isEmpty(); + + if (!hasPaperId && !hasSessionId) { + return Result.error(400, "paper_id 或 session_id 不能为空"); + } + + if (hasPaperId && hasSessionId) { + return Result.error(400, "paper_id 和 session_id 不能同时提供"); + } + + request.setUserId(userId); + + log.info("查询用户答题记录: userId={}, paperId={}, sessionId={}", + userId, request.getPaperId(), request.getSessionId()); + + try { + UserAnswerQueryResponse response = userAnswerService.queryUserAnswers(request); + + if (response.getTotalCount() == 0) { + return Result.success("未找到答题记录", response); + } + + return Result.success("查询成功", response); + + } catch (Exception e) { + log.error("查询用户答题记录异常: {}", e.getMessage(), e); + return Result.error(500, "查询用户答题记录异常: " + e.getMessage()); + } + } + + @Operation(summary = "获取考试记录列表", description = "根据类型获取用户的考试/练习记录,支持日常练习(practice)、模拟考(mock_exam)、正式考(formal_exam)") + @GetMapping("/records") + public Result getExamRecords( + @Parameter(description = "记录类型:practice(日常练习), mock_exam(模拟考), formal_exam(正式考)", example = "practice") + @RequestParam(defaultValue = "practice") String type, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + log.info("查询考试记录: userId={}, type={}", userId, type); + + try { + if ("practice".equals(type)) { + UserAnswerQueryRequest queryRequest = new UserAnswerQueryRequest(); + queryRequest.setUserId(userId); + UserAnswerQueryResponse response = userAnswerService.queryUserAnswers(queryRequest); + return Result.success(response); + } else if ("mock_exam".equals(type) || "formal_exam".equals(type)) { + List records = examRecordService.getUserPapers(userId, type); + return Result.success(records); + } else { + return Result.error(400, "无效的类型参数,支持的值: practice, mock_exam, formal_exam"); + } + } catch (Exception e) { + log.error("查询考试记录失败: {}", e.getMessage(), e); + return Result.error(500, "查询考试记录失败: " + e.getMessage()); + } + } + + /** + * 保存用户答题记录到数据库 + */ + private void saveUserAnswers(ExamGradeRequest request, ExamGradeResponse response, Long userId) { + ObjectMapper objectMapper = new ObjectMapper(); + List userAnswers = new ArrayList<>(); + + List results = response.getResults(); + if (results == null || results.isEmpty()) { + log.warn("没有答题结果需要保存"); + return; + } + + for (int i = 0; i < results.size(); i++) { + ExamGradeResponse.GradeResult result = results.get(i); + ExamGradeRequest.AnswerItem answerItem = request.getAnswers().get(i); + + UserAnswer userAnswer = new UserAnswer(); + userAnswer.setUserId(userId); + userAnswer.setQuestionId(result.getQuestionId()); + userAnswer.setQuestionType(answerItem.getQuestionType()); + userAnswer.setAnswerType(request.getAnswerType() != null ? request.getAnswerType() : "practice"); + userAnswer.setPaperId(request.getPaperId()); + userAnswer.setSessionId(request.getSessionId()); + userAnswer.setScore(result.getScore()); + userAnswer.setMaxScore(result.getMaxScore() != null ? result.getMaxScore() : answerItem.getMaxScore()); + + // 转换学生答案为JSON + try { + JsonNode answerNode = objectMapper.valueToTree(answerItem.getStudentAnswer()); + userAnswer.setUserAnswer(answerNode); + } catch (Exception e) { + log.warn("转换学生答案为JSON失败", e); + } + + // 设置是否正确 + Boolean isCorrect = result.getCorrect() != null ? result.getCorrect() : result.getIsCorrect(); + if (Boolean.TRUE.equals(isCorrect)) { + userAnswer.setIsCorrect(1); + } else { + userAnswer.setIsCorrect(0); + } + + userAnswer.setGradingType("ai"); + userAnswer.setFeedback(result.getFeedback()); + userAnswer.setCreatedAt(LocalDateTime.now()); + userAnswer.setUpdatedAt(LocalDateTime.now()); + + userAnswers.add(userAnswer); + } + + // 批量保存答题记录 + userAnswerService.batchSaveAnswers(userAnswers); + log.info("成功保存 {} 条答题记录到数据库", userAnswers.size()); + } + +} diff --git a/src/main/java/top/tqx/demo_1/controller/FAQController.java b/src/main/java/top/tqx/demo_1/controller/FAQController.java new file mode 100644 index 0000000..9c0fdaa --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/FAQController.java @@ -0,0 +1,212 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.FAQCreateRequest; +import top.tqx.demo_1.dto.FAQListResponse; +import top.tqx.demo_1.dto.FAQUpdateRequest; +import top.tqx.demo_1.dto.FAQSuggestionListResponse; +import top.tqx.demo_1.entity.FAQ; +import top.tqx.demo_1.service.FAQService; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Tag(name = "FAQ管理", description = "常见问题及建议管理接口") +@RestController +@RequestMapping("/faq") +@Slf4j +public class FAQController { + + @Autowired + private FAQService faqService; + + @Operation(summary = "创建FAQ", description = "创建新的常见问题") + @PostMapping + public Result createFAQ(@RequestBody FAQCreateRequest request) { + if (request.getQuestion() == null || request.getQuestion().isEmpty()) { + return Result.error(400, "问题不能为空"); + } + if (request.getAnswer() == null || request.getAnswer().isEmpty()) { + return Result.error(400, "答案不能为空"); + } + + try { + FAQ faq = faqService.createFAQ(request); + log.info("创建FAQ成功: id={}", faq.getId()); + return Result.success("FAQ创建成功", faq); + } catch (Exception e) { + log.error("创建FAQ失败: {}", e.getMessage(), e); + return Result.error(500, "创建FAQ失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取FAQ列表", description = "分页获取常见问题列表") + @GetMapping + public Result getFAQList( + @Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小,默认20") @RequestParam(defaultValue = "20") Integer pageSize) { + try { + FAQListResponse response = faqService.getFAQList(page, pageSize); + return Result.success(response); + } catch (Exception e) { + log.error("获取FAQ列表失败", e); + return Result.error(500, "获取FAQ列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取FAQ详情", description = "根据ID获取FAQ详细信息") + @GetMapping("/{id}") + public Result getFAQById( + @Parameter(description = "FAQ ID") @PathVariable Long id) { + try { + FAQ faq = faqService.getFAQById(id); + if (faq == null) { + return Result.error(404, "FAQ不存在"); + } + return Result.success(faq); + } catch (Exception e) { + log.error("获取FAQ详情失败: {}", e.getMessage(), e); + return Result.error(500, "获取FAQ详情失败: " + e.getMessage()); + } + } + + @Operation(summary = "更新FAQ", description = "更新FAQ信息,支持部分字段更新") + @PutMapping("/{id}") + public Result updateFAQ( + @Parameter(description = "FAQ ID") @PathVariable Long id, + @RequestBody FAQUpdateRequest request) { + try { + FAQ existing = faqService.getFAQById(id); + if (existing == null) { + return Result.error(404, "FAQ不存在"); + } + + FAQ updated = faqService.updateFAQ(id, request); + log.info("更新FAQ成功: id={}", id); + return Result.success("FAQ更新成功", updated); + } catch (Exception e) { + log.error("更新FAQ失败: {}", e.getMessage(), e); + return Result.error(500, "更新FAQ失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除FAQ", description = "根据ID删除FAQ") + @DeleteMapping("/{id}") + public Result deleteFAQ( + @Parameter(description = "FAQ ID") @PathVariable Long id) { + try { + FAQ existing = faqService.getFAQById(id); + if (existing == null) { + return Result.error(404, "FAQ不存在"); + } + + boolean success = faqService.deleteFAQ(id); + if (success) { + log.info("删除FAQ成功: id={}", id); + return Result.success("FAQ删除成功"); + } else { + return Result.error(500, "删除FAQ失败"); + } + } catch (Exception e) { + log.error("删除FAQ失败: {}", e.getMessage(), e); + return Result.error(500, "删除FAQ失败: " + e.getMessage()); + } + } + + @Operation(summary = "批量删除FAQ", description = "根据ID列表批量删除FAQ") + @DeleteMapping("/batch") + public Result batchDeleteFAQs( + @Parameter(description = "FAQ ID列表") @RequestParam String ids) { + try { + List idList = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::parseLong) + .collect(Collectors.toList()); + + if (idList.isEmpty()) { + return Result.error(400, "ID列表不能为空"); + } + + int deletedCount = faqService.batchDeleteFAQs(idList); + log.info("批量删除FAQ成功: count={}", deletedCount); + return Result.success("批量删除成功", deletedCount); + } catch (NumberFormatException e) { + return Result.error(400, "ID格式不正确,请提供逗号分隔的数字ID"); + } catch (Exception e) { + log.error("批量删除FAQ失败: {}", e.getMessage(), e); + return Result.error(500, "批量删除失败: " + e.getMessage()); + } + } + + @Operation(summary = "条件查询FAQ列表", description = "根据状态查询FAQ列表") + @GetMapping("/list") + public Result> listFAQs( + @Parameter(description = "状态:pending/approved/rejected") @RequestParam(required = false) String status, + @Parameter(description = "返回数量限制") @RequestParam(defaultValue = "100") Integer limit) { + try { + List faqs = faqService.listFAQs(status, limit); + return Result.success(faqs); + } catch (Exception e) { + log.error("查询FAQ列表失败: {}", e.getMessage(), e); + return Result.error(500, "查询失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取FAQ建议列表", description = "分页获取用户反馈中产生的FAQ建议") + @GetMapping("/suggestions") + public Result getFAQSuggestions( + @Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小,默认20") @RequestParam(defaultValue = "20") Integer pageSize) { + try { + FAQSuggestionListResponse response = faqService.getFAQSuggestions(page, pageSize); + return Result.success(response); + } catch (Exception e) { + log.error("获取FAQ建议列表失败", e); + return Result.error(500, "获取FAQ建议列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "批准FAQ建议", description = "将FAQ建议批准为正式FAQ,可修改答案") + @PostMapping("/suggestions/{id}/approve") + public Result approveSuggestion( + @Parameter(description = "建议ID") @PathVariable Long id, + @RequestBody(required = false) java.util.Map requestBody) { + try { + String answer = requestBody != null ? requestBody.get("answer") : null; + boolean success = faqService.approveSuggestion(id, answer); + if (success) { + return Result.success("批准成功"); + } else { + return Result.error(500, "批准失败"); + } + } catch (Exception e) { + log.error("批准FAQ建议失败: {}", e.getMessage(), e); + return Result.error(500, "批准FAQ建议失败: " + e.getMessage()); + } + } + + @Operation(summary = "拒绝FAQ建议", description = "拒绝某个FAQ建议") + @PostMapping("/suggestions/{id}/reject") + public Result rejectSuggestion( + @Parameter(description = "建议ID") @PathVariable Long id) { + try { + boolean success = faqService.rejectSuggestion(id); + if (success) { + return Result.success("拒绝成功"); + } else { + return Result.error(500, "拒绝失败"); + } + } catch (Exception e) { + log.error("拒绝FAQ建议失败: {}", e.getMessage(), e); + return Result.error(500, "拒绝FAQ建议失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/FeedbackController.java b/src/main/java/top/tqx/demo_1/controller/FeedbackController.java new file mode 100644 index 0000000..055530d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/FeedbackController.java @@ -0,0 +1,109 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.dto.FeedbackCreateRequest; +import top.tqx.demo_1.dto.FeedbackListResponse; +import top.tqx.demo_1.dto.FeedbackResponse; +import top.tqx.demo_1.dto.FeedbackStatsResponse; +import top.tqx.demo_1.service.FeedbackService; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "反馈管理", description = "用户反馈相关接口") +@RestController +@RequestMapping("/feedback") +@Slf4j +public class FeedbackController { + + @Autowired + private FeedbackService feedbackService; + + @Operation(summary = "提交反馈", description = "用户提交对AI回答的反馈") + @PostMapping + public Map submitFeedback(@RequestBody FeedbackCreateRequest request) { + Map result = new HashMap<>(); + try { + log.info("提交反馈: {}", request); + FeedbackResponse response = feedbackService.submitFeedback(request); + result.put("success", response.getSuccess()); + if (response.getFeedbackId() != null) { + result.put("feedbackId", response.getFeedbackId()); + } + if (response.getFaqSuggested() != null) { + result.put("faqSuggested", response.getFaqSuggested()); + } + if (response.getSuggestionId() != null) { + result.put("suggestionId", response.getSuggestionId()); + } + result.put("message", "反馈提交成功"); + } catch (Exception e) { + log.error("提交反馈失败", e); + result.put("success", false); + result.put("message", "反馈提交失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取反馈列表", description = "分页获取所有反馈记录") + @GetMapping("/list") + public Map getFeedbackList( + @Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小,默认20") @RequestParam(defaultValue = "20") Integer pageSize) { + Map result = new HashMap<>(); + try { + log.info("获取反馈列表: page={}, pageSize={}", page, pageSize); + FeedbackListResponse response = feedbackService.getFeedbackList(page, pageSize); + result.put("success", response.getSuccess()); + result.put("data", response.getFeedbacks()); + result.put("total", response.getTotal()); + result.put("page", page); + result.put("pageSize", pageSize); + } catch (Exception e) { + log.error("获取反馈列表失败", e); + result.put("success", false); + result.put("message", "获取反馈列表失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取反馈统计", description = "获取反馈统计信息,包括总数量、好评差评数量、满意度等") + @GetMapping("/stats") + public Map getFeedbackStats() { + Map result = new HashMap<>(); + try { + log.info("获取反馈统计"); + FeedbackStatsResponse response = feedbackService.getFeedbackStats(); + result.put("success", true); + result.put("data", response); + } catch (Exception e) { + log.error("获取反馈统计失败", e); + result.put("success", false); + result.put("message", "获取反馈统计失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取差评案例", description = "获取所有差评反馈记录") + @GetMapping("/bad-cases") + public Map getBadCases() { + Map result = new HashMap<>(); + try { + log.info("获取差评案例"); + FeedbackListResponse response = feedbackService.getBadCases(); + result.put("success", response.getSuccess()); + result.put("data", response.getFeedbacks()); + result.put("total", response.getTotal()); + } catch (Exception e) { + log.error("获取差评案例失败", e); + result.put("success", false); + result.put("message", "获取差评案例失败: " + e.getMessage()); + } + return result; + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/FileController.java b/src/main/java/top/tqx/demo_1/controller/FileController.java new file mode 100644 index 0000000..3081fd1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/FileController.java @@ -0,0 +1,706 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import top.tqx.demo_1.annotation.RateLimit; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.FileUpdateRequest; +import top.tqx.demo_1.dto.RagRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.AiChatService; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.service.FileService; +import top.tqx.demo_1.util.FileUtil; +import top.tqx.demo_1.vo.FileVO; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Tag(name = "文件管理", description = "文件的上传、下载、删除等操作") +@RestController +@RequestMapping("/file") +@Slf4j +public class FileController extends BaseController { + + @Autowired + private FileService fileService; + + @Autowired + private AiChatService aiChatService; + + @Autowired + private DepartmentService departmentService; + + @Autowired + private UserMapper userMapper; + + @org.springframework.beans.factory.annotation.Value("${file.upload.path:./uploads/}") + private String uploadPath; + + @Operation(summary = "分页查询文件", description = "根据条件分页查询文件列表") + @GetMapping("/page") + public Result> pageFiles( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "文件名") @RequestParam(required = false) String fileName, + HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + PageResult result = fileService.pageFiles(pageNum, pageSize, fileName, user); + return success(result); + } + + @Operation(summary = "查询文件详情", description = "根据ID查询文件详细信息") + @GetMapping("/{id}") + public Result getFileById(@Parameter(description = "文件ID") @PathVariable Long id, HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + FileVO file = fileService.getFileById(id, user); + return success(file); + } + + @Operation(summary = "上传文件", description = "上传文件到服务器,支持个人文件和部门制度文件") + @PostMapping("/upload") + @RateLimit(maxRequests = 10, timeWindow = 60, key = "user") + public Result uploadFile( + @Parameter(description = "文件对象", required = true) @RequestParam("file") MultipartFile file, + @Parameter(description = "部门ID(管理员上传制度文件时指定)") @RequestParam(required = false) Long deptId, + @Parameter(description = "是否公开", example = "true") @RequestParam(required = false, defaultValue = "true") Boolean isPublic, + @Parameter(description = "文件描述") @RequestParam(required = false) String description, + HttpServletRequest request) { + try { + FileUtil.validateFile(file); + + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + FileVO fileVO = fileService.uploadFile(file, userId, user.getDeptId(), user.getUserType(), deptId, isPublic); + + if (description != null && !description.isEmpty()) { + fileService.updateFileDescription(fileVO.getId(), description); + fileVO.setDescription(description); + } + + return success(fileVO); + } catch (Exception e) { + log.error("文件上传失败", e); + return error("文件上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "RAG流式问答", description = "向RAG服务发起流式问答请求,实时返回处理过程,完成后自动保存到数据库") + @PostMapping(value = "/rag/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter ragStream( + @Parameter(description = "用户问题", required = true) @RequestParam("message") String message, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"用户未登录\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + User user = authService.validateToken(httpRequest.getHeader("Authorization").substring(7)); + if (user == null) { + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"用户信息获取失败\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + RagRequest request = new RagRequest(); + request.setMessage(message); + + // 硬编码 collections + List collections = new ArrayList<>(); + collections.add("public_kb"); + request.setCollections(collections); + + // 硬编码生成 session_id + String sessionId = "sess_" + System.currentTimeMillis(); + request.setSessionId(sessionId); + + request.setUserId(userId); + request.setDeptId(user.getDeptId()); + request.setUserRole(user.getUserType()); + + log.info("开始RAG流式问答: userId={}, deptId={}, role={}, message={}, collections={}, sessionId={}", + userId, user.getDeptId(), user.getUserType(), request.getMessage(), + request.getCollections(), request.getSessionId()); + return aiChatService.streamFromRagService(request, userId, user.getDeptId()); + + } catch (Exception e) { + log.error("RAG流式问答失败", e); + SseEmitter emitter = new SseEmitter(); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"message\":\"" + e.getMessage() + "\"}")); + emitter.complete(); + } catch (Exception ex) { + emitter.completeWithError(ex); + } + return emitter; + } + } + + @Autowired + private org.springframework.core.env.Environment environment; + + @Operation(summary = "下载文件", description = "根据ID下载文件") + @GetMapping("/download/{id}") + public ResponseEntity downloadFile( + @Parameter(description = "文件ID") @PathVariable Long id, + HttpServletResponse response, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + top.tqx.demo_1.entity.File fileEntity = fileService.getFileEntityById(id); + if (fileEntity == null) { + return ResponseEntity.notFound().build(); + } + + byte[] fileContent = fileService.getFileContent(id, user); + + String contentType = determineContentType(fileEntity); + String encodedFileName = URLEncoder.encode(fileEntity.getFileName(), StandardCharsets.UTF_8) + .replaceAll("\\+", "%20"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.setContentDisposition(ContentDisposition.attachment() + .filename(encodedFileName, StandardCharsets.UTF_8) + .build()); + headers.setContentLength(fileContent.length); + headers.setCacheControl("max-age=3600"); + + Resource resource = new org.springframework.core.io.ByteArrayResource(fileContent); + return new ResponseEntity<>(resource, headers, HttpStatus.OK); + } catch (RuntimeException e) { + if (e.getMessage().contains("无权限")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + log.error("下载文件失败: fileId={}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (Exception e) { + log.error("下载文件异常: fileId={}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "获取文件内容", description = "根据ID获取文件二进制内容,供后端服务使用") + @GetMapping("/content/{id}") + public Result getFileContent(@Parameter(description = "文件ID") @PathVariable Long id, HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + byte[] fileContent = fileService.getFileContent(id, user); + return success(fileContent); + } catch (RuntimeException e) { + if (e.getMessage().contains("无权限")) { + return error("无权限查看此文件"); + } + log.error("获取文件内容失败: fileId={}", id, e); + return error("获取文件内容失败: " + e.getMessage()); + } catch (Exception e) { + log.error("获取文件内容异常: fileId={}", id, e); + return error("获取文件内容失败"); + } + } + + @Operation(summary = "获取文件详细信息", description = "获取文件的所有信息包括内容,供后端服务使用") + @GetMapping("/info/{id}") + public Result getFileInfo(@Parameter(description = "文件ID") @PathVariable Long id, HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + FileVO fileInfo = fileService.getFileById(id, user); + return success(fileInfo); + } catch (Exception e) { + log.error("获取文件信息失败", e); + return error("获取文件信息失败"); + } + } + + @Operation(summary = "预览文件", description = "获取文件二进制内容用于前端预览,支持PDF、图片、文本等格式") + @GetMapping("/preview/{id}") + public ResponseEntity previewFile( + @Parameter(description = "文件ID") @PathVariable Long id, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + byte[] fileContent = fileService.getFileContent(id, user); + top.tqx.demo_1.entity.File fileEntity = fileService.getFileEntityById(id); + + if (fileEntity == null) { + return ResponseEntity.notFound().build(); + } + + String contentType = determineContentType(fileEntity); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.setContentDisposition(ContentDisposition.inline() + .filename(fileEntity.getFileName(), java.nio.charset.StandardCharsets.UTF_8) + .build()); + headers.setCacheControl("max-age=3600"); + headers.setContentLength(fileContent.length); + + return new ResponseEntity<>(fileContent, headers, HttpStatus.OK); + } catch (RuntimeException e) { + if (e.getMessage().contains("无权限")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + log.error("预览文件失败: fileId={}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (Exception e) { + log.error("预览文件异常: fileId={}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "获取文本文件内容", description = "获取文本类文件的字符串内容,适用于txt、json、xml、csv等格式") + @GetMapping("/text/{id}") + public Result getTextFileContent( + @Parameter(description = "文件ID") @PathVariable Long id, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + top.tqx.demo_1.entity.File fileEntity = fileService.getFileEntityById(id); + if (fileEntity == null) { + return error("文件不存在"); + } + + String extension = fileEntity.getExtension() != null ? fileEntity.getExtension().toLowerCase() : ""; + if (!isTextFile(extension)) { + return error("该文件不是文本格式,请使用预览接口"); + } + + byte[] fileContent = fileService.getFileContent(id, user); + String content = new String(fileContent, java.nio.charset.StandardCharsets.UTF_8); + + return success(content); + } catch (RuntimeException e) { + if (e.getMessage().contains("无权限")) { + return error("无权限查看此文件"); + } + log.error("获取文本文件内容失败: fileId={}", id, e); + return error("获取文件内容失败: " + e.getMessage()); + } catch (Exception e) { + log.error("获取文本文件内容异常: fileId={}", id, e); + return error("获取文件内容失败"); + } + } + + private String determineContentType(top.tqx.demo_1.entity.File fileEntity) { + String extension = fileEntity.getExtension() != null ? fileEntity.getExtension().toLowerCase() : ""; + + switch (extension) { + case ".pdf": + return "application/pdf"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".bmp": + return "image/bmp"; + case ".svg": + return "image/svg+xml"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".html": + case ".htm": + return "text/html; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".xml": + return "application/xml; charset=utf-8"; + case ".csv": + return "text/csv; charset=utf-8"; + case ".md": + return "text/markdown; charset=utf-8"; + case ".js": + return "application/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + default: + return "application/octet-stream"; + } + } + + private boolean isTextFile(String extension) { + return ".txt".equals(extension) || + ".json".equals(extension) || + ".xml".equals(extension) || + ".csv".equals(extension) || + ".html".equals(extension) || + ".htm".equals(extension) || + ".md".equals(extension) || + ".log".equals(extension) || + ".js".equals(extension) || + ".css".equals(extension); + } + + @Operation(summary = "删除文件", description = "根据ID删除文件") + @DeleteMapping("/{id}") + public Result deleteFile(@Parameter(description = "文件ID") @PathVariable Long id, HttpServletRequest request) { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + top.tqx.demo_1.entity.File file = fileService.getFileEntityById(id); + if (file == null) { + return error("文件不存在"); + } + + boolean canDelete = false; + if (user.getUserType() == 1) { + canDelete = true; + } else if (user.getUserType() == 2 && file.getDeptId().equals(user.getDeptId())) { + canDelete = true; + } else if (file.getUploadUserId().equals(userId)) { + canDelete = true; + } + + if (!canDelete) { + return error("无权限删除此文件"); + } + + fileService.deleteFile(id); + return success(); + } + + @Operation(summary = "审核通过文件", description = "管理员审核通过用户上传的公开文件") + @PostMapping("/audit/approve/{id}") + public Result approveFile( + @Parameter(description = "文件ID") @PathVariable Long id, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + if (user.getUserType() != 1 && user.getUserType() != 2) { + return error("无权限执行此操作"); + } + + fileService.approveFile(id, userId); + return success(); + } catch (Exception e) { + log.error("审核通过失败", e); + return error("审核通过失败: " + e.getMessage()); + } + } + + @Operation(summary = "审核拒绝文件", description = "管理员拒绝用户上传的公开文件") + @PostMapping("/audit/reject/{id}") + public Result rejectFile( + @Parameter(description = "文件ID") @PathVariable Long id, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + if (user.getUserType() != 1 && user.getUserType() != 2) { + return error("无权限执行此操作"); + } + + fileService.rejectFile(id, userId); + return success(); + } catch (Exception e) { + log.error("审核拒绝失败", e); + return error("审核拒绝失败: " + e.getMessage()); + } + } + + @Operation(summary = "查询待审核文件列表", description = "管理员查询所有待审核的文件") + @GetMapping("/audit/pending") + public Result> getPendingFiles( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest request) { + try { + Long userId = getCurrentUserId(request); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(request.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + if (user.getUserType() != 1 && user.getUserType() != 2) { + return error("无权限执行此操作"); + } + + PageResult result = fileService.getPendingFiles(pageNum, pageSize); + return success(result); + } catch (Exception e) { + log.error("查询待审核文件失败", e); + return error("查询待审核文件失败: " + e.getMessage()); + } + } + + @Operation(summary = "更新文件信息", description = "更新文件的公开状态、描述等信息") + @PutMapping("/{id}") + public Result updateFileInfo( + @Parameter(description = "文件ID") @PathVariable Long id, + @RequestBody FileUpdateRequest request, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return error("用户未登录"); + } + + User user = authService.validateToken(httpRequest.getHeader("Authorization").substring(7)); + if (user == null) { + return error("用户信息获取失败"); + } + + top.tqx.demo_1.entity.File file = fileService.getFileEntityById(id); + if (file == null) { + return error("文件不存在"); + } + + if (request.getIsPublic() != null) { + Integer newIsPublic = request.getIsPublic(); + if (!newIsPublic.equals(file.getIsPublic())) { + String oldPath = file.getFilePath(); + Boolean isPublicBool = newIsPublic == 1; + String newPath = rebuildFilePath(file, isPublicBool, user); + + Path fullOldPath = Paths.get(uploadPath, oldPath); + Path fullNewPath = Paths.get(uploadPath, newPath); + + if (Files.exists(fullOldPath)) { + Files.createDirectories(fullNewPath.getParent()); + Files.move(fullOldPath, fullNewPath); + + file.setFilePath(newPath); + log.info("文件路径已更新: {} -> {}", oldPath, newPath); + } + + file.setIsPublic(newIsPublic); + } + } + + if (request.getDescription() != null) { + file.setDescription(request.getDescription()); + } + + if (request.getAuditStatus() != null) { + file.setAuditStatus(request.getAuditStatus()); + } + + file.setUpdateTime(LocalDateTime.now()); + fileService.updateFileEntity(file); + + return success(convertToVO(file)); + } catch (Exception e) { + log.error("更新文件信息失败", e); + return error("更新文件信息失败: " + e.getMessage()); + } + } + + private String rebuildFilePath(top.tqx.demo_1.entity.File file, Boolean isPublic, User user) { + String fileNameWithoutExt = file.getFileName().contains(".") + ? file.getFileName().substring(0, file.getFileName().lastIndexOf(".")) + : file.getFileName(); + + Path basePath; + if (user.getUserType() == 1 || user.getUserType() == 2) { + String fullPath = departmentService.getDepartmentFullPath(user.getDeptId()); + if (fullPath == null || fullPath.isEmpty()) { + fullPath = "默认部门"; + } + basePath = Paths.get(uploadPath, "resources", "rule", fullPath, fileNameWithoutExt); + } else { + String username = getUserUsername(file.getUploadUserId()); + String privacyPath = isPublic ? "public" : "private"; + basePath = Paths.get(uploadPath, "resources", "user", username, privacyPath, fileNameWithoutExt); + } + + int version = 1; + while (Files.exists(basePath.resolve("v" + version))) { + version++; + } + + Path filePath = basePath.resolve("v" + version).resolve(file.getFileName()); + String relativePath = filePath.toString().replace(uploadPath, ""); + if (relativePath.startsWith(java.io.File.separator)) { + relativePath = relativePath.substring(1); + } + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + return relativePath; + } + + private String getUserUsername(Long userId) { + try { + User user = userMapper.selectById(userId); + if (user != null && user.getUsername() != null && !user.getUsername().isEmpty()) { + return user.getUsername(); + } + } catch (Exception e) { + log.warn("获取用户名失败: userId={}", userId, e); + } + return "user_" + userId; + } + + private FileVO convertToVO(top.tqx.demo_1.entity.File file) { + FileVO vo = new FileVO(); + org.springframework.beans.BeanUtils.copyProperties(file, vo); + + if (file.getUploadUserId() != null) { + try { + User user = userMapper.selectById(file.getUploadUserId()); + if (user != null && user.getUsername() != null) { + vo.setUploadUserName(user.getUsername()); + } else { + vo.setUploadUserName("user_" + file.getUploadUserId()); + } + } catch (Exception e) { + log.warn("获取上传用户名称失败: userId={}", file.getUploadUserId()); + vo.setUploadUserName("user_" + file.getUploadUserId()); + } + } + + if (file.getDeptId() != null) { + try { + Department department = departmentService.getDepartmentById(file.getDeptId()); + if (department != null) { + vo.setDeptName(department.getDeptName()); + } + } catch (Exception e) { + log.warn("获取部门名称失败: deptId={}", file.getDeptId()); + } + } + + return vo; + } + +} diff --git a/src/main/java/top/tqx/demo_1/controller/FilePermissionController.java b/src/main/java/top/tqx/demo_1/controller/FilePermissionController.java new file mode 100644 index 0000000..74d8956 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/FilePermissionController.java @@ -0,0 +1,68 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.ExpireTimeRequest; +import top.tqx.demo_1.dto.FilePermissionRequest; +import top.tqx.demo_1.entity.FilePermission; +import top.tqx.demo_1.service.FilePermissionService; + +import java.util.List; + +@Tag(name = "文件权限管理", description = "文件权限的授权和撤销") +@RestController +@RequestMapping("/file/permission") +public class FilePermissionController extends BaseController { + + private final FilePermissionService filePermissionService; + + public FilePermissionController(FilePermissionService filePermissionService) { + this.filePermissionService = filePermissionService; + } + + @Operation(summary = "授权文件权限", description = "为用户或部门授予文件访问权限") + @PostMapping + public Result grantPermission( + @RequestBody FilePermissionRequest request, + HttpServletRequest httpRequest) { + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return error("用户未登录"); + } + request.setGrantUserId(userId); + filePermissionService.grantFilePermission(request); + return success(); + } + + @Operation(summary = "撤销文件权限", description = "撤销用户或部门的文件访问权限") + @DeleteMapping("/{id}") + public Result revokePermission( + @Parameter(description = "权限ID") @PathVariable Long id) { + filePermissionService.revokeFilePermission(id); + return success(); + } + + @Operation(summary = "查询文件权限列表", description = "获取文件的所有权限信息") + @GetMapping("/file/{fileId}") + public Result> getFilePermissions( + @Parameter(description = "文件ID") @PathVariable Long fileId, + HttpServletRequest httpRequest) { + Long userId = getCurrentUserId(httpRequest); + List permissions = filePermissionService.getUserFilePermissions(userId, fileId); + return success(permissions); + } + + @Operation(summary = "更新权限过期时间", description = "修改权限的过期时间") + @PutMapping("/{id}/expire") + public Result updateExpireTime( + @Parameter(description = "权限ID") @PathVariable Long id, + @RequestBody ExpireTimeRequest request) { + filePermissionService.updatePermissionExpire(id, request.getExpireTime()); + return success(); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/FormalExamController.java b/src/main/java/top/tqx/demo_1/controller/FormalExamController.java new file mode 100644 index 0000000..2039d01 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/FormalExamController.java @@ -0,0 +1,62 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.enums.AnswerType; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/formal-exam") +@Tag(name = "正式考试", description = "正式考试相关接口,仅供被分配了正式试卷的用户使用") +public class FormalExamController extends BaseController { + + @Autowired + private top.tqx.demo_1.service.ExamRecordService examRecordService; + + @Operation(summary = "开始正式考试", description = "通过试卷ID开始正式考试,系统自动查找或创建考试记录,返回试卷题目内容") + @PostMapping("/paper/{paperId}/start") + public Result startExamByPaperId( + @PathVariable String paperId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.startExamByPaperId(paperId, userId, AnswerType.FORMAL_EXAM.getCode()); + return Result.success(response); + } catch (Exception e) { + log.error("开始考试失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } + + @Operation(summary = "获取正式考试结果", description = "获取正式考试的完整结果,包含每题得分和反馈") + @GetMapping("/record/{recordId}/result") + public Result getExamResult( + @PathVariable String recordId, + HttpServletRequest httpRequest) { + try { + Long userId = getCurrentUserId(httpRequest); + ExamRecordResponse response = examRecordService.getRecordById(recordId); + + if (response == null) { + return Result.error(404, "记录不存在"); + } + + if (!response.getUserId().equals(userId)) { + return Result.error(403, "无权查看此记录"); + } + + return Result.success(response); + } catch (Exception e) { + log.error("获取考试结果失败: {}", e.getMessage()); + return Result.error(500, e.getMessage()); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/ImageController.java b/src/main/java/top/tqx/demo_1/controller/ImageController.java new file mode 100644 index 0000000..1e56246 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/ImageController.java @@ -0,0 +1,198 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.ImageCreateRequest; +import top.tqx.demo_1.dto.ImageListResponse; +import top.tqx.demo_1.dto.ImageStatsResponse; +import top.tqx.demo_1.dto.ImageUpdateRequest; +import top.tqx.demo_1.entity.Image; +import top.tqx.demo_1.service.ImageService; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Tag(name = "图片管理", description = "知识库图片相关接口") +@RestController +@RequestMapping("/image") +@Slf4j +public class ImageController { + + @Autowired + private ImageService imageService; + + @Operation(summary = "创建图片记录", description = "创建新的图片记录") + @PostMapping + public Result createImage(@RequestBody ImageCreateRequest request) { + if (request.getImageId() == null || request.getImageId().isEmpty()) { + return Result.error(400, "图片ID不能为空"); + } + if (request.getPath() == null || request.getPath().isEmpty()) { + return Result.error(400, "图片路径不能为空"); + } + + try { + Image image = imageService.createImage(request); + log.info("创建图片记录成功: id={}, imageId={}", image.getId(), image.getImageId()); + return Result.success("图片记录创建成功", image); + } catch (Exception e) { + log.error("创建图片记录失败: {}", e.getMessage(), e); + return Result.error(500, "创建图片记录失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取图片列表", description = "分页获取知识库中的图片列表") + @GetMapping({"/list", "/images/list"}) + public Result getImageList( + @Parameter(description = "返回数量,默认50") @RequestParam(defaultValue = "50") Integer limit, + @Parameter(description = "偏移量,默认0") @RequestParam(defaultValue = "0") Integer offset) { + try { + ImageListResponse response = imageService.getImageList(limit, offset); + return Result.success(response); + } catch (Exception e) { + log.error("获取图片列表失败", e); + return Result.error(500, "获取图片列表失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取图片详情", description = "根据图片ID获取图片详细信息") + @GetMapping("/{id}") + public Result getImageById( + @Parameter(description = "图片ID") @PathVariable Long id) { + try { + Image image = imageService.getImageById(id); + if (image == null) { + return Result.error(404, "图片不存在"); + } + return Result.success(image); + } catch (Exception e) { + log.error("获取图片详情失败: {}", e.getMessage(), e); + return Result.error(500, "获取图片详情失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取单张图片", description = "通过图片ID获取图片文件") + @GetMapping(value = "/{id}/data", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_GIF_VALUE}) + public byte[] getImageData(@Parameter(description = "图片ID") @PathVariable String id) { + try { + log.info("获取图片: id={}", id); + byte[] imageData = imageService.getImage(id); + if (imageData == null || imageData.length == 0) { + log.warn("图片数据为空: id={}", id); + // 返回一个1x1的透明像素作为占位符 + return createPlaceholderImage(); + } + return imageData; + } catch (Exception e) { + log.error("获取图片失败: id={}, error={}", id, e.getMessage()); + // 返回一个1x1的透明像素作为占位符,避免页面报错 + return createPlaceholderImage(); + } + } + + /** + * 创建一个1x1的透明占位图 + */ + private byte[] createPlaceholderImage() { + // 这是一个1x1的透明PNG图片的字节数组 + return new byte[] { + (byte)0x89, (byte)0x50, (byte)0x4E, (byte)0x47, (byte)0x0D, (byte)0x0A, (byte)0x1A, (byte)0x0A, + (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x0D, (byte)0x49, (byte)0x48, (byte)0x44, (byte)0x52, + (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x01, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x01, + (byte)0x08, (byte)0x06, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x1F, (byte)0x15, (byte)0xC4, + (byte)0x89, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x0A, (byte)0x49, (byte)0x44, (byte)0x41, + (byte)0x54, (byte)0x08, (byte)0xD7, (byte)0x63, (byte)0x60, (byte)0x00, (byte)0x00, (byte)0x00, + (byte)0x02, (byte)0x00, (byte)0x01, (byte)0xE2, (byte)0x26, (byte)0x05, (byte)0x9B, (byte)0x00, + (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x49, (byte)0x45, (byte)0x4E, (byte)0x44, (byte)0xAE, + (byte)0x42, (byte)0x60, (byte)0x82 + }; + } + + @Operation(summary = "更新图片记录", description = "更新图片记录信息,支持部分字段更新") + @PutMapping("/{id}") + public Result updateImage( + @Parameter(description = "图片ID") @PathVariable Long id, + @RequestBody ImageUpdateRequest request) { + try { + Image existing = imageService.getImageById(id); + if (existing == null) { + return Result.error(404, "图片不存在"); + } + + Image updated = imageService.updateImage(id, request); + log.info("更新图片记录成功: id={}", id); + return Result.success("图片记录更新成功", updated); + } catch (Exception e) { + log.error("更新图片记录失败: {}", e.getMessage(), e); + return Result.error(500, "更新图片记录失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除图片", description = "根据ID删除图片记录") + @DeleteMapping("/{id}") + public Result deleteImage( + @Parameter(description = "图片ID") @PathVariable Long id) { + try { + Image existing = imageService.getImageById(id); + if (existing == null) { + return Result.error(404, "图片不存在"); + } + + boolean success = imageService.deleteImage(id); + if (success) { + log.info("删除图片成功: id={}", id); + return Result.success("图片删除成功"); + } else { + return Result.error(500, "删除图片失败"); + } + } catch (Exception e) { + log.error("删除图片失败: {}", e.getMessage(), e); + return Result.error(500, "删除图片失败: " + e.getMessage()); + } + } + + @Operation(summary = "批量删除图片", description = "根据ID列表批量删除图片") + @DeleteMapping("/batch") + public Result batchDeleteImages( + @Parameter(description = "图片ID列表") @RequestParam String ids) { + try { + List idList = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::parseLong) + .collect(Collectors.toList()); + + if (idList.isEmpty()) { + return Result.error(400, "ID列表不能为空"); + } + + int deletedCount = imageService.batchDeleteImages(idList); + log.info("批量删除图片成功: count={}", deletedCount); + return Result.success("批量删除成功", deletedCount); + } catch (NumberFormatException e) { + return Result.error(400, "ID格式不正确,请提供逗号分隔的数字ID"); + } catch (Exception e) { + log.error("批量删除图片失败: {}", e.getMessage(), e); + return Result.error(500, "批量删除失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取图片统计", description = "获取图片统计信息,包括总数量、总大小、格式分布等") + @GetMapping("/stats") + public Result getImageStats() { + try { + ImageStatsResponse response = imageService.getImageStats(); + return Result.success(response); + } catch (Exception e) { + log.error("获取图片统计失败", e); + return Result.error(500, "获取图片统计失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/KBRouteController.java b/src/main/java/top/tqx/demo_1/controller/KBRouteController.java new file mode 100644 index 0000000..33b28d1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/KBRouteController.java @@ -0,0 +1,44 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.dto.KBRouteRequest; +import top.tqx.demo_1.dto.KBRouteResponse; +import top.tqx.demo_1.service.KBRouteService; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "知识库路由", description = "知识库路由调试接口") +@RestController +@RequestMapping("/kb-route") +@Slf4j +public class KBRouteController { + + @Autowired + private KBRouteService kbRouteService; + + @Operation(summary = "测试知识库路由", description = "测试知识库路由逻辑,用于调试路由策略") + @PostMapping + public Map route(@RequestBody KBRouteRequest request) { + Map result = new HashMap<>(); + try { + log.info("知识库路由测试: query={}", request.getQuery()); + KBRouteResponse response = kbRouteService.route(request); + result.put("success", true); + result.put("query", response.getQuery()); + result.put("userRole", response.getUserRole()); + result.put("userDepartment", response.getUserDepartment()); + result.put("targetCollections", response.getTargetCollections()); + result.put("intent", response.getIntent()); + } catch (Exception e) { + log.error("知识库路由测试失败", e); + result.put("success", false); + result.put("message", "知识库路由测试失败: " + e.getMessage()); + } + return result; + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/LogController.java b/src/main/java/top/tqx/demo_1/controller/LogController.java new file mode 100644 index 0000000..e418cd8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/LogController.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.service.LogService; +import top.tqx.demo_1.vo.OperationLogVO; + +@Tag(name = "日志管理", description = "操作日志的查询") +@RestController +@RequestMapping("/log") +public class LogController { + + @Autowired + private LogService logService; + + @Operation(summary = "分页查询日志", description = "根据条件分页查询操作日志") + @GetMapping("/page") + public Result> pageLogs( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "用户名") @RequestParam(required = false) String username, + @Parameter(description = "操作类型") @RequestParam(required = false) String operationType) { + PageResult result = logService.pageLogs(pageNum, pageSize, username, operationType); + return Result.success(result); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/controller/QuestionController.java b/src/main/java/top/tqx/demo_1/controller/QuestionController.java new file mode 100644 index 0000000..c383d91 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/QuestionController.java @@ -0,0 +1,219 @@ +package top.tqx.demo_1.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.QuestionCreateRequest; +import top.tqx.demo_1.dto.QuestionStatsRequest; +import top.tqx.demo_1.dto.QuestionStatsResponse; +import top.tqx.demo_1.dto.QuestionUpdateRequest; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.service.QuestionService; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.servlet.http.HttpServletRequest; + +@Tag(name = "题目管理", description = "题目的增删改查管理接口") +@RestController +@RequestMapping("/question") +@Slf4j +public class QuestionController extends BaseController { + + @Autowired + private QuestionService questionService; + + @Operation(summary = "创建题目", description = "创建新的题目记录") + @PostMapping + public Result createQuestion(@RequestBody QuestionCreateRequest request) { + if (request.getQuestionType() == null || request.getQuestionType().isEmpty()) { + return Result.error(400, "题目类型不能为空"); + } + if (request.getDifficulty() == null || request.getDifficulty() < 1 || request.getDifficulty() > 5) { + return Result.error(400, "难度等级必须在1-5之间"); + } + if (request.getContent() == null) { + return Result.error(400, "题目内容不能为空"); + } + + try { + Question question = questionService.createQuestion(request); + log.info("创建题目成功: id={}, questionType={}", question.getId(), question.getQuestionType()); + return Result.success("题目创建成功", question); + } catch (Exception e) { + log.error("创建题目失败: {}", e.getMessage(), e); + return Result.error(500, "创建题目失败: " + e.getMessage()); + } + } + + @Operation(summary = "分页查询题目", description = "根据条件分页查询题目列表") + @GetMapping("/page") + public Result> pageQuestions( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "题目类型") @RequestParam(required = false) String questionType, + @Parameter(description = "难度等级") @RequestParam(required = false) Integer difficulty, + @Parameter(description = "审核状态") @RequestParam(required = false) String status) { + PageResult result = questionService.pageQuestions(pageNum, pageSize, questionType, difficulty, status); + return Result.success(result); + } + + @Operation(summary = "获取题目详情", description = "根据题目UUID获取题目详细信息") + @GetMapping("/{questionId}") + public Result getQuestionById( + @Parameter(description = "题目UUID") @PathVariable String questionId) { + Question question = questionService.getByQuestionId(questionId); + if (question == null) { + return Result.error(404, "题目不存在"); + } + return Result.success(question); + } + + @Operation(summary = "获取待审核题目列表", description = "获取所有待审核的题目") + @GetMapping("/pending") + public Result> getPendingQuestions() { + List questions = questionService.getPendingQuestions(); + return Result.success(questions); + } + + @Operation(summary = "更新题目", description = "更新题目信息,支持部分字段更新") + @PutMapping("/{questionId}") + public Result updateQuestion( + @Parameter(description = "题目UUID") @PathVariable String questionId, + @RequestBody QuestionUpdateRequest request) { + try { + Question existing = questionService.getByQuestionId(questionId); + if (existing == null) { + return Result.error(404, "题目不存在"); + } + + Question updated = questionService.updateQuestion(existing.getId(), request); + log.info("更新题目成功: questionId={}", questionId); + return Result.success("题目更新成功", updated); + } catch (Exception e) { + log.error("更新题目失败: {}", e.getMessage(), e); + return Result.error(500, "更新题目失败: " + e.getMessage()); + } + } + + @Operation(summary = "删除题目", description = "根据UUID删除题目") + @DeleteMapping("/{questionId}") + public Result deleteQuestion( + @Parameter(description = "题目UUID") @PathVariable String questionId) { + try { + Question existing = questionService.getByQuestionId(questionId); + if (existing == null) { + return Result.error(404, "题目不存在"); + } + + boolean success = questionService.deleteById(existing.getId()); + if (success) { + log.info("删除题目成功: questionId={}", questionId); + return Result.success("题目删除成功"); + } else { + return Result.error(500, "删除题目失败"); + } + } catch (Exception e) { + log.error("删除题目失败: {}", e.getMessage(), e); + return Result.error(500, "删除题目失败: " + e.getMessage()); + } + } + + @Operation(summary = "批量删除题目", description = "根据ID列表批量删除题目") + @DeleteMapping("/batch") + public Result batchDeleteQuestions( + @Parameter(description = "题目ID列表") @RequestParam String ids) { + try { + List idList = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::parseLong) + .collect(Collectors.toList()); + + if (idList.isEmpty()) { + return Result.error(400, "ID列表不能为空"); + } + + int deletedCount = questionService.batchDelete(idList); + log.info("批量删除题目成功: count={}", deletedCount); + return Result.success("批量删除成功", deletedCount); + } catch (NumberFormatException e) { + return Result.error(400, "ID格式不正确,请提供逗号分隔的数字ID"); + } catch (Exception e) { + log.error("批量删除题目失败: {}", e.getMessage(), e); + return Result.error(500, "批量删除失败: " + e.getMessage()); + } + } + + @Operation(summary = "审核题目", description = "审核通过或拒绝题目") + @PutMapping("/review/{questionId}") + public Result reviewQuestion( + @Parameter(description = "题目UUID") @PathVariable String questionId, + @Parameter(description = "审核状态:approved/rejected") @RequestParam String status, + @Parameter(description = "审核评论") @RequestParam(required = false) String reviewerComment) { + if (!"approved".equals(status) && !"rejected".equals(status)) { + return Result.error(400, "审核状态必须是approved或rejected"); + } + boolean success = questionService.updateReviewStatus(questionId, status, reviewerComment); + if (success) { + return Result.success("审核操作成功"); + } else { + return Result.error(500, "审核失败"); + } + } + + @Operation(summary = "条件查询题目列表", description = "根据多种条件查询题目,支持按用户权限过滤(本人库、本部门库、公共库)") + @GetMapping("/list") + public Result> listQuestions( + @Parameter(description = "题目类型") @RequestParam(required = false) String questionType, + @Parameter(description = "难度等级") @RequestParam(required = false) Integer difficulty, + @Parameter(description = "审核状态") @RequestParam(required = false) String status, + @Parameter(description = "文档名称") @RequestParam(required = false) String documentName, + @Parameter(description = "返回数量限制") @RequestParam(defaultValue = "100") Integer limit, + HttpServletRequest httpRequest) { + try { + String token = httpRequest.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + return Result.error(401, "用户未登录"); + } + top.tqx.demo_1.entity.User user = authService.validateToken(token.substring(7)); + if (user == null) { + return Result.error(401, "用户未登录"); + } + Long userId = user.getId(); + Long deptId = user.getDeptId(); + Integer userType = user.getUserType(); + log.info("条件查询题目列表: userId={}, deptId={}, userType={}, questionType={}, difficulty={}, status={}, documentName={}, limit={}", + userId, deptId, userType, questionType, difficulty, status, documentName, limit); + List questions = questionService.listQuestions(questionType, difficulty, status, documentName, limit, userId, deptId, userType); + return Result.success(questions); + } catch (Exception e) { + log.error("查询题目列表失败: {}", e.getMessage(), e); + return Result.error(500, "查询失败: " + e.getMessage()); + } + } + + @Operation(summary = "题目数量统计", description = "按不同维度统计题目数量") + @PostMapping("/stats") + public Result getQuestionStats(@RequestBody QuestionStatsRequest request) { + try { + log.info("题目数量统计请求: deptId={}, fileIds={}, collection={}, questionTypes={}, status={}", + request.getDeptId(), request.getFileIds(), request.getCollection(), + request.getQuestionTypes(), request.getStatus()); + + QuestionStatsResponse response = questionService.getQuestionStats(request); + return Result.success("统计成功", response); + } catch (Exception e) { + log.error("题目数量统计失败: {}", e.getMessage(), e); + return Result.error(500, "统计失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/ReportController.java b/src/main/java/top/tqx/demo_1/controller/ReportController.java new file mode 100644 index 0000000..c587f0f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/ReportController.java @@ -0,0 +1,56 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.dto.ReportResponse; +import top.tqx.demo_1.service.ReportService; + +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "报告管理", description = "反馈统计报告相关接口") +@RestController +@RequestMapping("/report") +@Slf4j +public class ReportController { + + @Autowired + private ReportService reportService; + + @Operation(summary = "获取周报", description = "获取最近一周的反馈统计报告") + @GetMapping("/weekly") + public Map getWeeklyReport() { + Map result = new HashMap<>(); + try { + log.info("获取周报"); + ReportResponse response = reportService.getWeeklyReport(); + result.put("success", response.getSuccess()); + result.put("data", response.getReport()); + } catch (Exception e) { + log.error("获取周报失败", e); + result.put("success", false); + result.put("message", "获取周报失败: " + e.getMessage()); + } + return result; + } + + @Operation(summary = "获取月报", description = "获取最近一个月的反馈统计报告") + @GetMapping("/monthly") + public Map getMonthlyReport() { + Map result = new HashMap<>(); + try { + log.info("获取月报"); + ReportResponse response = reportService.getMonthlyReport(); + result.put("success", response.getSuccess()); + result.put("data", response.getReport()); + } catch (Exception e) { + log.error("获取月报失败", e); + result.put("success", false); + result.put("message", "获取月报失败: " + e.getMessage()); + } + return result; + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/RoleController.java b/src/main/java/top/tqx/demo_1/controller/RoleController.java new file mode 100644 index 0000000..d410789 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/RoleController.java @@ -0,0 +1,73 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.RoleCreateRequest; +import top.tqx.demo_1.dto.RoleUpdateRequest; +import top.tqx.demo_1.service.RoleService; +import top.tqx.demo_1.vo.PermissionVO; +import top.tqx.demo_1.vo.RoleVO; + +import java.util.List; + +@Tag(name = "角色管理", description = "角色和权限的管理接口") +@RestController +@RequestMapping("/role") +public class RoleController { + + private final RoleService roleService; + + public RoleController(RoleService roleService) { + this.roleService = roleService; + } + + @Operation(summary = "查询角色列表", description = "获取所有角色信息") + @GetMapping + public Result> listRoles() { + List roles = roleService.listRoles(); + return Result.success(roles); + } + + @Operation(summary = "创建角色", description = "创建新角色") + @PostMapping + public Result createRole(@RequestBody RoleCreateRequest request) { + roleService.createRole(request); + return Result.success(); + } + + @Operation(summary = "更新角色", description = "更新角色信息") + @PutMapping("/{id}") + public Result updateRole( + @Parameter(description = "角色ID") @PathVariable Long id, + @RequestBody RoleUpdateRequest request) { + roleService.updateRole(id, request); + return Result.success(); + } + + @Operation(summary = "删除角色", description = "根据ID删除角色") + @DeleteMapping("/{id}") + public Result deleteRole(@Parameter(description = "角色ID") @PathVariable Long id) { + roleService.deleteRole(id); + return Result.success(); + } + + @Operation(summary = "分配权限", description = "为角色分配权限") + @PutMapping("/{id}/permissions") + public Result assignPermissions( + @Parameter(description = "角色ID") @PathVariable Long id, + @RequestBody List permissionIds) { + roleService.assignPermissions(id, permissionIds); + return Result.success(); + } + + @Operation(summary = "查询角色权限", description = "获取角色的所有权限") + @GetMapping("/{id}/permissions") + public Result> getRolePermissions( + @Parameter(description = "角色ID") @PathVariable Long id) { + List permissions = roleService.getRolePermissions(id); + return Result.success(permissions); + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/SearchController.java b/src/main/java/top/tqx/demo_1/controller/SearchController.java new file mode 100644 index 0000000..f5b97cd --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/SearchController.java @@ -0,0 +1,78 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.dto.SearchRequest; +import top.tqx.demo_1.dto.SearchResponse; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.service.KnowledgeBasePathService; +import top.tqx.demo_1.service.SearchService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Tag(name = "知识检索", description = "知识库检索相关接口") +@RestController +@RequestMapping("/search") +@Slf4j +public class SearchController { + + @Autowired + private SearchService searchService; + + @Autowired + private AuthService authService; + + @Autowired + private KnowledgeBasePathService knowledgeBasePathService; + + @Autowired + private DepartmentService departmentService; + + @Operation(summary = "混合检索", description = "在知识库中进行混合检索(向量+关键词)") + @PostMapping + public Map search(@RequestBody SearchRequest request, HttpServletRequest httpRequest) { + Map result = new HashMap<>(); + try { + log.info("知识检索: query={}, topK={}", request.getQuery(), request.getTopK()); + + // 获取用户可访问的知识库 + if (request.getCollections() == null || request.getCollections().isEmpty()) { + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + User user = authService.validateToken(authHeader.substring(7)); + if (user != null) { + String deptCode = null; + if (user.getDeptId() != null) { + top.tqx.demo_1.entity.Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept != null) { + deptCode = dept.getDeptCode(); + } + } + List collections = knowledgeBasePathService.getUserAccessibleKnowledgeBaseNames(user.getId(), user.getDeptId(), deptCode); + request.setCollections(collections); + } + } + } + + SearchResponse response = searchService.search(request); + result.put("success", true); + result.put("contexts", response.getContexts()); + result.put("metadatas", response.getMetadatas()); + result.put("scores", response.getScores()); + } catch (Exception e) { + log.error("知识检索失败", e); + result.put("success", false); + result.put("message", "知识检索失败: " + e.getMessage()); + } + return result; + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/UserController.java b/src/main/java/top/tqx/demo_1/controller/UserController.java new file mode 100644 index 0000000..171da5c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/UserController.java @@ -0,0 +1,89 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.UserCreateRequest; +import top.tqx.demo_1.dto.UserUpdateRequest; +import top.tqx.demo_1.service.UserService; +import top.tqx.demo_1.vo.UserVO; + +@Tag(name = "用户管理", description = "用户的增删改查操作") +@RestController +@RequestMapping("/user") +public class UserController extends BaseController { + + @Autowired + private UserService userService; + + @Operation(summary = "分页查询用户", description = "根据条件分页查询用户列表") + @GetMapping("/page") + public Result> pageUsers( + @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页大小", example = "10") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "用户名") @RequestParam(required = false) String username, + @Parameter(description = "部门ID") @RequestParam(required = false) Long deptId) { + PageResult result = userService.pageUsers(pageNum, pageSize, username, deptId); + return success(result); + } + + @Operation(summary = "查询用户详情", description = "根据ID查询用户详细信息") + @GetMapping("/{id}") + public Result getUserById(@Parameter(description = "用户ID") @PathVariable Long id) { + UserVO user = userService.getUserById(id); + return success(user); + } + + @Operation(summary = "创建用户", description = "创建新用户,默认密码为123456") + @PostMapping + public Result createUser(@Valid @RequestBody UserCreateRequest request) { + userService.createUser(request); + return success(); + } + + @Operation(summary = "更新用户", description = "更新用户基本信息") + @PutMapping + public Result updateUser(@Valid @RequestBody UserUpdateRequest request) { + userService.updateUser(request); + return success(); + } + + @Operation(summary = "删除用户", description = "根据ID删除用户") + @DeleteMapping("/{id}") + public Result deleteUser(@Parameter(description = "用户ID") @PathVariable Long id) { + userService.deleteUser(id); + return success(); + } + + @Operation(summary = "重置密码", description = "重置用户密码为默认密码123456") + @PutMapping("/{id}/reset-password") + public Result resetPassword(@Parameter(description = "用户ID") @PathVariable Long id) { + userService.resetPassword(id); + return success(); + } + + @Operation(summary = "更新用户状态", description = "启用或禁用用户") + @PutMapping("/{id}/status") + public Result updateStatus( + @Parameter(description = "用户ID") @PathVariable Long id, + @Parameter(description = "状态:1启用 0禁用") @RequestParam Integer status) { + userService.updateStatus(id, status); + return success(); + } + + @Operation(summary = "分配用户角色", description = "为用户分配角色") + @PutMapping("/{id}/roles") + public Result assignRoles( + @Parameter(description = "用户ID") @PathVariable Long id, + @Parameter(description = "角色ID列表") @RequestBody List roleIds) { + userService.assignRoles(id, roleIds); + return success(); + } +} diff --git a/src/main/java/top/tqx/demo_1/controller/WrongQuestionController.java b/src/main/java/top/tqx/demo_1/controller/WrongQuestionController.java new file mode 100644 index 0000000..865002e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/controller/WrongQuestionController.java @@ -0,0 +1,242 @@ +package top.tqx.demo_1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.service.UserAnswerService; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; + +@Tag(name = "错题管理", description = "错题查看、收藏、重练、导出接口") +@RestController +@RequestMapping("/wrong-questions") +@Slf4j +public class WrongQuestionController extends BaseController { + + @Autowired + private UserAnswerService userAnswerService; + + @Operation(summary = "获取错题列表", description = "分页获取用户错题列表,支持按题型、收藏状态、时间范围、知识库筛选") + @PostMapping("/list") + public Result getWrongQuestions( + @RequestBody WrongQuestionListRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (request.getPage() == null || request.getPage() < 1) { + request.setPage(1); + } + if (request.getPageSize() == null || request.getPageSize() < 1 || request.getPageSize() > 100) { + request.setPageSize(20); + } + + log.info("获取错题列表: userId={}, questionType={}, isCollected={}", + userId, request.getQuestionType(), request.getIsCollected()); + + try { + WrongQuestionListResponse response = userAnswerService.getWrongQuestions(userId, request); + return Result.success("获取错题列表成功", response); + } catch (Exception e) { + log.error("获取错题列表异常: {}", e.getMessage(), e); + return Result.error(500, "获取错题列表异常: " + e.getMessage()); + } + } + + @Operation(summary = "获取收藏的错题列表", description = "获取用户收藏的所有错题,支持分页") + @PostMapping("/collection/list") + public Result getCollectedWrongQuestions( + @RequestBody WrongQuestionListRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + // 强制设置为已收藏状态 + request.setIsCollected(true); + + if (request.getPage() == null || request.getPage() < 1) { + request.setPage(1); + } + if (request.getPageSize() == null || request.getPageSize() < 1 || request.getPageSize() > 100) { + request.setPageSize(20); + } + + log.info("获取收藏错题列表: userId={}, page={}, pageSize={}", + userId, request.getPage(), request.getPageSize()); + + try { + WrongQuestionListResponse response = userAnswerService.getWrongQuestions(userId, request); + return Result.success("获取收藏错题列表成功", response); + } catch (Exception e) { + log.error("获取收藏错题列表异常: {}", e.getMessage(), e); + return Result.error(500, "获取收藏错题列表异常: " + e.getMessage()); + } + } + + @Operation(summary = "检查题目是否已收藏", description = "检查指定题目是否已被当前用户收藏") + @GetMapping("/collection/check") + public Result checkCollectionStatus( + @RequestParam("question_id") String questionId, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (questionId == null || questionId.isEmpty()) { + return Result.error(400, "questionId不能为空"); + } + + log.info("检查收藏状态: userId={}, questionId={}", userId, questionId); + + try { + boolean isCollected = userAnswerService.checkQuestionCollected(userId, questionId); + return Result.success(isCollected); + } catch (Exception e) { + log.error("检查收藏状态异常: {}", e.getMessage(), e); + return Result.error(500, "检查收藏状态异常: " + e.getMessage()); + } + } + + @Operation(summary = "收藏/取消收藏错题", description = "收藏或取消收藏指定的错题,支持添加备注") + @PostMapping("/collection/toggle") + public Result toggleCollection( + @RequestBody WrongQuestionCollectRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (request.getAnswerId() == null && request.getQuestionId() == null) { + return Result.error(400, "answerId或questionId不能为空"); + } + if (request.getIsCollected() == null) { + return Result.error(400, "isCollected不能为空"); + } + + log.info("收藏错题: userId={}, answerId={}, questionId={}, isCollected={}", + userId, request.getAnswerId(), request.getQuestionId(), request.getIsCollected()); + + try { + boolean success = userAnswerService.toggleWrongQuestionCollection(userId, request); + if (success) { + return Result.success("操作成功", null); + } else { + return Result.error(500, "操作失败"); + } + } catch (Exception e) { + log.error("收藏错题异常: {}", e.getMessage(), e); + return Result.error(500, "收藏错题异常: " + e.getMessage()); + } + } + + @Operation(summary = "生成错题重练试卷", description = "根据错题生成重练试卷,支持按题型、收藏状态筛选") + @PostMapping("/redo") + public Result redoWrongQuestions( + @RequestBody WrongQuestionRedoRequest request, + HttpServletRequest httpRequest) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + return Result.error(401, "用户未登录"); + } + + if (request.getQuestionCount() == null || request.getQuestionCount() < 1) { + request.setQuestionCount(10); + } + if (request.getQuestionCount() > 100) { + request.setQuestionCount(100); + } + + log.info("生成错题重练试卷: userId={}, questionCount={}, questionType={}", + userId, request.getQuestionCount(), request.getQuestionType()); + + try { + ExamPaperGenerateResponse response = userAnswerService.redoWrongQuestions(userId, request); + return Result.success("生成试卷成功", response); + } catch (Exception e) { + log.error("生成错题重练试卷异常: {}", e.getMessage(), e); + return Result.error(500, "生成试卷异常: " + e.getMessage()); + } + } + + @Operation(summary = "导出错题", description = "导出用户错题为CSV格式") + @PostMapping("/export") + public void exportWrongQuestions( + @RequestBody WrongQuestionListRequest request, + HttpServletRequest httpRequest, + HttpServletResponse response) { + + Long userId = getCurrentUserId(httpRequest); + if (userId == null) { + response.setStatus(401); + return; + } + + log.info("导出错题: userId={}", userId); + + try { + List items = + userAnswerService.exportWrongQuestions(userId, request); + + response.setContentType("text/csv;charset=UTF-8"); + response.setHeader("Content-Disposition", + "attachment; filename=wrong-questions-" + System.currentTimeMillis() + ".csv"); + + PrintWriter writer = response.getWriter(); + writer.println("ID,题型,题目内容,用户答案,正确答案,得分,满分,难度,文档,错题次数,回答时间"); + + for (WrongQuestionListResponse.WrongQuestionItem item : items) { + writer.printf("%d,%s,\"%s\",\"%s\",\"%s\",%s,%s,%d,\"%s\",%d,%s%n", + item.getAnswerId(), + item.getQuestionTypeName(), + escapeCsv(item.getQuestionContent() != null ? item.getQuestionContent().toString() : ""), + escapeCsv(item.getUserAnswer() != null ? item.getUserAnswer().toString() : ""), + escapeCsv(item.getCorrectAnswer() != null ? item.getCorrectAnswer().toString() : ""), + item.getScore() != null ? item.getScore().toString() : "0", + item.getMaxScore() != null ? item.getMaxScore().toString() : "0", + item.getDifficulty() != null ? item.getDifficulty() : 3, + escapeCsv(item.getDocumentName() != null ? item.getDocumentName() : ""), + item.getWrongCount() != null ? item.getWrongCount() : 1, + item.getAnsweredAt() != null ? item.getAnsweredAt().toString() : "" + ); + } + + writer.flush(); + } catch (Exception e) { + log.error("导出错题异常: {}", e.getMessage(), e); + try { + response.sendError(500, "导出异常: " + e.getMessage()); + } catch (IOException ex) { + log.error("发送错误响应异常: {}", ex.getMessage()); + } + } + } + + private String escapeCsv(String value) { + if (value == null) { + return ""; + } + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/DepartmentCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/DepartmentCreateRequest.java new file mode 100644 index 0000000..719645f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/DepartmentCreateRequest.java @@ -0,0 +1,19 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class DepartmentCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String deptName; + + private Long parentId; + + private String deptType; + + private Integer orderNum; +} diff --git a/src/main/java/top/tqx/demo_1/dto/DepartmentUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/DepartmentUpdateRequest.java new file mode 100644 index 0000000..bad1499 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/DepartmentUpdateRequest.java @@ -0,0 +1,19 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class DepartmentUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String deptName; + + private Long parentId; + + private Integer orderNum; + + private Integer status; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ExamGenerateRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamGenerateRequest.java new file mode 100644 index 0000000..cfc7421 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamGenerateRequest.java @@ -0,0 +1,167 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +@Schema(description = "题目生成请求") +public class ExamGenerateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("request_id") + @Schema(description = "请求ID,用于幂等性") + private String requestId; + + @JsonProperty("question_count") + @Schema(description = "题目数量") + private Integer questionCount; + + @JsonProperty("question_types") + @Schema(description = "题型及数量配置(不提供则使用智能生成模式,AI自动判断题型和数量),支持 List 或 Map") + private Object questionTypes; // 支持 List 或 Map + + @JsonProperty("difficulty") + @Schema(description = "难度等级(1-5),默认3") + private Integer difficulty; + + @JsonProperty("collection_name") + @Schema(description = "向量库名称") + private String collectionName; + + @JsonProperty("collection") + @Schema(description = "向量库,支持单个字符串或字符串数组") + private Object collection; // 支持单个字符串或字符串数组 + + @JsonProperty("file_path") + @Schema(description = "文件路径(file_id 和 file_path 二选一)") + private String filePath; + + @JsonProperty("file_id") + @Schema(description = "文件ID(file_id 和 file_path 二选一,推荐使用)") + private Long fileId; // 通过文件ID指定文件 + + @JsonProperty("tags") + @Schema(description = "标签列表") + private List tags; + + @JsonProperty("question_scores") + @Schema(description = "自定义题型分数配置,仅在指定题型模式下有效") + private Map questionScores; // 各题型分数配置 + + @Data + public static class QuestionContent implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("stem") + private String stem; + + @JsonProperty("data") + private Map data; + + @JsonProperty("answer") + private Object answer; + + @JsonProperty("explanation") + private String explanation; + } + + @Data + public static class SourceInfo implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("chunk_id") + private String chunkId; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("section") + private String section; + + @JsonProperty("snippet") + private String snippet; + } + + @Data + public static class SourceTrace implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("chunk_ids") + private List chunkIds; + + @JsonProperty("chunks_count") + private Integer chunksCount; + + @JsonProperty("page_numbers") + private List pageNumbers; + + @JsonProperty("sources") + private List sources; + } + + @Data + public static class Question implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("type") + private String type; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("content") + private QuestionContent content; + + @JsonProperty("source_trace") + private SourceTrace sourceTrace; + + @JsonProperty("stem") + private String stem; + + @JsonProperty("answer") + private Object answer; + + @JsonProperty("explanation") + private String explanation; + + @JsonProperty("options") + private Object options; + + @JsonProperty("score") + private BigDecimal score; // 单个题目分数 + + public String getQuestionType() { + if (questionType != null && !questionType.isEmpty()) { + return questionType; + } + return type; + } + + public QuestionContent getContent() { + if (content != null && content.getStem() != null && !content.getStem().isEmpty()) { + return content; + } + QuestionContent newContent = new QuestionContent(); + newContent.setStem(this.stem); + newContent.setAnswer(this.answer); + newContent.setExplanation(this.explanation); + if (this.options != null) { + newContent.setData(java.util.Collections.singletonMap("options", this.options)); + } + return newContent; + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamGenerateResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamGenerateResponse.java new file mode 100644 index 0000000..2040c2a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamGenerateResponse.java @@ -0,0 +1,149 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "题目生成响应") +public class ExamGenerateResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + @Schema(description = "是否成功") + private Boolean success; + + @JsonProperty("message") + @Schema(description = "消息") + private String message; + + @JsonProperty("status") + @Schema(description = "状态") + private String status; + + @JsonProperty("status_code") + @Schema(description = "状态码") + private Integer statusCode; + + @JsonProperty("request_id") + @Schema(description = "请求ID") + private String requestId; + + @JsonProperty("total") + @Schema(description = "题目总数") + private Integer total; + + @JsonProperty("source_chunks_used") + @Schema(description = "使用的源文档块数量") + private Integer sourceChunksUsed; + + @JsonProperty("error") + @Schema(description = "错误信息") + private String error; + + @JsonProperty("error_code") + @Schema(description = "错误码") + private String errorCode; + + @JsonProperty("data") + @Schema(description = "响应数据") + private ResponseData data; + + @JsonProperty("ai_analysis") + @Schema(description = "AI分析结果,包含文档摘要、题型分布等信息") + private Object aiAnalysis; + + @JsonProperty("questions") + @Schema(description = "生成的题目列表") + private List questions; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResponseData implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("ai_analysis") + private Object aiAnalysis; + + @JsonProperty("questions") + private List questions; + + @JsonProperty("total") + private Integer total; + + @JsonProperty("source_chunks_used") + private Integer sourceChunksUsed; + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("success") + private Boolean success; + } + + public Object getAiAnalysis() { + if (aiAnalysis != null) { + return aiAnalysis; + } + if (data != null) { + return data.getAiAnalysis(); + } + return null; + } + + public List getQuestions() { + if (questions != null && !questions.isEmpty()) { + return questions; + } + if (data != null && data.getQuestions() != null) { + return data.getQuestions(); + } + return null; + } + + public Integer getTotal() { + if (total != null) { + return total; + } + if (data != null && data.getTotal() != null) { + return data.getTotal(); + } + return null; + } + + public Integer getSourceChunksUsed() { + if (sourceChunksUsed != null) { + return sourceChunksUsed; + } + if (data != null && data.getSourceChunksUsed() != null) { + return data.getSourceChunksUsed(); + } + return null; + } + + public String getRequestId() { + if (requestId != null) { + return requestId; + } + if (data != null && data.getRequestId() != null) { + return data.getRequestId(); + } + return null; + } + + public Boolean getSuccess() { + if (success != null) { + return success; + } + if (data != null && data.getSuccess() != null) { + return data.getSuccess(); + } + return null; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/ExamGradeRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamGradeRequest.java new file mode 100644 index 0000000..02aee4a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamGradeRequest.java @@ -0,0 +1,101 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +public class ExamGradeRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("request_id") + @JsonAlias("requestId") + private String requestId; + + @JsonProperty("paper_id") + @JsonAlias("paperId") + private String paperId; + + @JsonProperty("session_id") + @JsonAlias("sessionId") + private String sessionId; + + @JsonProperty("record_id") + @JsonAlias("recordId") + private String recordId; + + @JsonProperty("answer_type") + @JsonAlias("answerType") + private String answerType; + + @JsonProperty("answers") + private List answers; + + @Data + public static class AnswerItem implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("question_id") + @JsonAlias("questionId") + private String questionId; + + @JsonProperty("question_type") + @JsonAlias("questionType") + private String questionType; + + @JsonProperty("question_content") + @JsonAlias("questionContent") + private QuestionContent questionContent; + + @JsonProperty("student_answer") + @JsonAlias("studentAnswer") + private Object studentAnswer; + + @JsonProperty("max_score") + @JsonAlias("maxScore") + private BigDecimal maxScore; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class QuestionContent implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("stem") + private String stem; + + @JsonProperty("data") + private Map data; + + @JsonProperty("answer") + private Object answer; + + @JsonProperty("explanation") + private String explanation; + } + + public enum QuestionType { + SINGLE_CHOICE("single_choice"), + MULTIPLE_CHOICE("multiple_choice"), + TRUE_FALSE("true_false"), + FILL_BLANK("fill_blank"), + SUBJECTIVE("subjective"); + + private final String value; + + QuestionType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamGradeResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamGradeResponse.java new file mode 100644 index 0000000..f6d9804 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamGradeResponse.java @@ -0,0 +1,211 @@ + +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExamGradeResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("status") + private String status; + + @JsonProperty("status_code") + private Integer statusCode; + + @JsonProperty("message") + private String message; + + @JsonProperty("data") + private ExamGradeData data; + + @JsonProperty("error") + private String error; + + @JsonProperty("error_code") + private String errorCode; + + public Boolean getSuccess() { + if (success != null) { + return success; + } + if (data != null) { + return data.getSuccess(); + } + return null; + } + + public String getRequestId() { + if (data != null) { + return data.getRequestId(); + } + return requestId; + } + + public String getPaperId() { + if (data != null) { + return data.getPaperId(); + } + return paperId; + } + + public BigDecimal getTotalScore() { + if (data != null) { + return data.getTotalScore(); + } + return totalScore; + } + + public BigDecimal getTotalMaxScore() { + if (data != null) { + return data.getTotalMaxScore(); + } + return totalMaxScore; + } + + public BigDecimal getScoreRate() { + if (data != null) { + return data.getScoreRate(); + } + return scoreRate; + } + + public List getResults() { + if (data != null) { + return data.getResults(); + } + return results; + } + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("paper_id") + private String paperId; + + @JsonProperty("total_score") + private BigDecimal totalScore; + + @JsonProperty("total_max_score") + private BigDecimal totalMaxScore; + + @JsonProperty("score_rate") + private BigDecimal scoreRate; + + @JsonProperty("results") + private List results; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ExamGradeData implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("paper_id") + private String paperId; + + @JsonProperty("total_score") + private BigDecimal totalScore; + + @JsonProperty("total_max_score") + private BigDecimal totalMaxScore; + + @JsonProperty("score_rate") + private BigDecimal scoreRate; + + @JsonProperty("results") + private List results; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GradeResult implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("max_score") + private BigDecimal maxScore; + + @JsonProperty("correct") + private Boolean correct; + + @JsonProperty("is_correct") + private Boolean isCorrect; + + @JsonProperty("student_answer") + private Object studentAnswer; + + @JsonProperty("correct_answer") + private Object correctAnswer; + + @JsonProperty("correct_answers") + private List correctAnswers; + + @JsonProperty("feedback") + private String feedback; + + @JsonProperty("details") + private DetailInfo details; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DetailInfo implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("highlights") + private List highlights; + + @JsonProperty("overall_feedback") + private String overallFeedback; + + @JsonProperty("scoring_breakdown") + private List scoringBreakdown; + + @JsonProperty("shortcomings") + private List shortcomings; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ScoringBreakdown implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("point") + private String point; + + @JsonProperty("weight") + private Double weight; + + @JsonProperty("achieved") + private Double achieved; + + @JsonProperty("comment") + private String comment; + } + } +} + diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateRequest.java new file mode 100644 index 0000000..86a72a2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateRequest.java @@ -0,0 +1,43 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class ExamPaperGenerateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("single_choice_count") + private Integer singleChoiceCount; + + @JsonProperty("multiple_choice_count") + private Integer multipleChoiceCount; + + @JsonProperty("true_false_count") + private Integer trueFalseCount; + + @JsonProperty("fill_blank_count") + private Integer fillBlankCount; + + @JsonProperty("subjective_count") + private Integer subjectiveCount; + + @JsonProperty("include_personal") + private Boolean includePersonal = false; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("file_ids") + private List fileIds; + + @JsonProperty("collection") + private String collection; + + @JsonProperty("collection_name") + private String collectionName; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateResponse.java new file mode 100644 index 0000000..067a022 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPaperGenerateResponse.java @@ -0,0 +1,68 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Data +public class ExamPaperGenerateResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("paper_id") + private String paperId; + + @JsonProperty("paper_title") + private String paperTitle; + + @JsonProperty("total_score") + private BigDecimal totalScore; + + @JsonProperty("question_count") + private Integer questionCount; + + @JsonProperty("generated_at") + private LocalDateTime generatedAt; + + @JsonProperty("permission_scope") + private String permissionScope; + + @JsonProperty("warnings") + private Map warnings; + + private List questions; + + @Data + public static class ExamPaperQuestion implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("question_type_name") + private String questionTypeName; + + private Integer difficulty; + + private BigDecimal score; + + @JsonProperty("content") + private Object content; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPaperPreviewResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamPaperPreviewResponse.java new file mode 100644 index 0000000..f185a6a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPaperPreviewResponse.java @@ -0,0 +1,95 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Data +public class ExamPaperPreviewResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("paper_id") + private String paperId; + + @JsonProperty("paper_title") + private String paperTitle; + + @JsonProperty("description") + private String description; + + @JsonProperty("total_score") + private BigDecimal totalScore; + + @JsonProperty("question_count") + private Integer questionCount; + + @JsonProperty("duration") + private Integer duration; + + @JsonProperty("generated_at") + private LocalDateTime generatedAt; + + @JsonProperty("permission_scope") + private String permissionScope; + + @JsonProperty("warnings") + private Map warnings; + + private List questions; + + @Data + public static class ExamPaperQuestionDetail implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("question_type_name") + private String questionTypeName; + + private Integer difficulty; + + @JsonProperty("difficulty_name") + private String difficultyName; + + private BigDecimal score; + + private QuestionContent content; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("source_context") + private String sourceContext; + + @JsonProperty("page_numbers") + private List pageNumbers; + } + + @Data + public static class QuestionContent implements Serializable { + private static final long serialVersionUID = 1L; + + private String stem; + + private Object options; + + private Object answer; + + private String explanation; + + private Object data; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPaperResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamPaperResponse.java new file mode 100644 index 0000000..b45ebdb --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPaperResponse.java @@ -0,0 +1,62 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class ExamPaperResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private String paperId; + + private String title; + + private String description; + + private BigDecimal totalScore; + + private Integer duration; + + private Integer questionCount; + + private String status; + + private String permissionScope; + + private Long deptId; + + private List deptIds; + + private List userIds; + + private List excludeUserIds; + + private List questions; + + private Long createdBy; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private LocalDateTime publishTime; + + private LocalDateTime deadline; + + @Data + public static class ExamPaperQuestion implements Serializable { + private static final long serialVersionUID = 1L; + private String questionId; + private String questionType; + private Integer difficulty; + private BigDecimal score; + private String content; + private String answer; + private String analysis; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPaperSaveRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamPaperSaveRequest.java new file mode 100644 index 0000000..1da64c1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPaperSaveRequest.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +@Data +public class ExamPaperSaveRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String title; + + private String description; + + private BigDecimal totalScore; + + private Integer duration; + + private String permissionScope; + + private List questionIds; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamPublishRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamPublishRequest.java new file mode 100644 index 0000000..a82f231 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamPublishRequest.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class ExamPublishRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long deptId; + + private List deptIds; + + private List userIds; + + private List excludeUserIds; + + private LocalDateTime publishTime; + + private LocalDateTime deadline; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamRecordResponse.java b/src/main/java/top/tqx/demo_1/dto/ExamRecordResponse.java new file mode 100644 index 0000000..9ea9d70 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamRecordResponse.java @@ -0,0 +1,64 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class ExamRecordResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private String recordId; + + private String paperId; + + private String answerType; + + private String paperTitle; + + private Long userId; + + private String status; + + private BigDecimal totalScore; + + private Integer timeSpent; + + private LocalDateTime startTime; + + private LocalDateTime submitTime; + + private LocalDateTime gradedTime; + + private List questions; + + private List answers; + + @Data + public static class ExamPaperQuestion implements Serializable { + private static final long serialVersionUID = 1L; + private String questionId; + private String questionType; + private Integer difficulty; + private BigDecimal score; + private String content; + } + + @Data + public static class AnswerDetail implements Serializable { + private static final long serialVersionUID = 1L; + private String questionId; + private String questionType; + private String stem; + private Object userAnswer; + private Object correctAnswer; + private BigDecimal score; + private BigDecimal maxScore; + private Boolean isCorrect; + private String feedback; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExamSaveRequest.java b/src/main/java/top/tqx/demo_1/dto/ExamSaveRequest.java new file mode 100644 index 0000000..19acc00 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExamSaveRequest.java @@ -0,0 +1,22 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class ExamSaveRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private List answers; + + @Data + public static class AnswerItem implements Serializable { + private static final long serialVersionUID = 1L; + private String questionId; + private String questionType; + private Object answer; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ExpireTimeRequest.java b/src/main/java/top/tqx/demo_1/dto/ExpireTimeRequest.java new file mode 100644 index 0000000..fe0dadf --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ExpireTimeRequest.java @@ -0,0 +1,14 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class ExpireTimeRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private LocalDateTime expireTime; +} diff --git a/src/main/java/top/tqx/demo_1/dto/FAQCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/FAQCreateRequest.java new file mode 100644 index 0000000..efdddc4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQCreateRequest.java @@ -0,0 +1,44 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.List; + +public class FAQCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question") + private String question; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("source_documents") + private List sourceDocuments; + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public List getSourceDocuments() { + return sourceDocuments; + } + + public void setSourceDocuments(List sourceDocuments) { + this.sourceDocuments = sourceDocuments; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FAQItem.java b/src/main/java/top/tqx/demo_1/dto/FAQItem.java new file mode 100644 index 0000000..cce2713 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQItem.java @@ -0,0 +1,121 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.math.BigDecimal; + +public class FAQItem implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("id") + private Long id; + + @JsonProperty("question") + private String question; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("source_documents") + private String sourceDocuments; + + @JsonProperty("avg_rating") + private BigDecimal avgRating; + + @JsonProperty("avg_rating_dbl") + private Double avgRatingDbl; + + @JsonProperty("frequency") + private Integer frequency; + + @JsonProperty("status") + private String status; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("updated_at") + private String updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public String getSourceDocuments() { + return sourceDocuments; + } + + public void setSourceDocuments(String sourceDocuments) { + this.sourceDocuments = sourceDocuments; + } + + public BigDecimal getAvgRating() { + return avgRating; + } + + public void setAvgRating(BigDecimal avgRating) { + this.avgRating = avgRating; + } + + public Double getAvgRatingDbl() { + return avgRatingDbl; + } + + public void setAvgRatingDbl(Double avgRatingDbl) { + this.avgRatingDbl = avgRatingDbl; + } + + public Integer getFrequency() { + return frequency; + } + + public void setFrequency(Integer frequency) { + this.frequency = frequency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FAQListResponse.java b/src/main/java/top/tqx/demo_1/dto/FAQListResponse.java new file mode 100644 index 0000000..99d09b9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQListResponse.java @@ -0,0 +1,43 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.List; + +public class FAQListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("faqs") + private List faqs; + + @JsonProperty("total") + private Integer total; + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public List getFaqs() { + return faqs; + } + + public void setFaqs(List faqs) { + this.faqs = faqs; + } + + public Integer getTotal() { + return total; + } + + public void setTotal(Integer total) { + this.total = total; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FAQSuggestionItem.java b/src/main/java/top/tqx/demo_1/dto/FAQSuggestionItem.java new file mode 100644 index 0000000..8790f10 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQSuggestionItem.java @@ -0,0 +1,110 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.math.BigDecimal; + +public class FAQSuggestionItem implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("id") + private Long id; + + @JsonProperty("query") + private String query; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("avg_rating") + private BigDecimal avgRating; + + @JsonProperty("avg_rating_dbl") + private Double avgRatingDbl; + + @JsonProperty("frequency") + private Integer frequency; + + @JsonProperty("status") + private String status; + + @JsonProperty("faq_id") + private Long faqId; + + @JsonProperty("created_at") + private String createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public BigDecimal getAvgRating() { + return avgRating; + } + + public void setAvgRating(BigDecimal avgRating) { + this.avgRating = avgRating; + } + + public Double getAvgRatingDbl() { + return avgRatingDbl; + } + + public void setAvgRatingDbl(Double avgRatingDbl) { + this.avgRatingDbl = avgRatingDbl; + } + + public Integer getFrequency() { + return frequency; + } + + public void setFrequency(Integer frequency) { + this.frequency = frequency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getFaqId() { + return faqId; + } + + public void setFaqId(Long faqId) { + this.faqId = faqId; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FAQSuggestionListResponse.java b/src/main/java/top/tqx/demo_1/dto/FAQSuggestionListResponse.java new file mode 100644 index 0000000..ef02d73 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQSuggestionListResponse.java @@ -0,0 +1,43 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.List; + +public class FAQSuggestionListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("suggestions") + private List suggestions; + + @JsonProperty("total") + private Integer total; + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public List getSuggestions() { + return suggestions; + } + + public void setSuggestions(List suggestions) { + this.suggestions = suggestions; + } + + public Integer getTotal() { + return total; + } + + public void setTotal(Integer total) { + this.total = total; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FAQUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/FAQUpdateRequest.java new file mode 100644 index 0000000..e198a13 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FAQUpdateRequest.java @@ -0,0 +1,78 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +public class FAQUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question") + private String question; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("avg_rating") + private BigDecimal avgRating; + + @JsonProperty("frequency") + private Integer frequency; + + @JsonProperty("status") + private String status; + + @JsonProperty("source_documents") + private List sourceDocuments; + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public BigDecimal getAvgRating() { + return avgRating; + } + + public void setAvgRating(BigDecimal avgRating) { + this.avgRating = avgRating; + } + + public Integer getFrequency() { + return frequency; + } + + public void setFrequency(Integer frequency) { + this.frequency = frequency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public List getSourceDocuments() { + return sourceDocuments; + } + + public void setSourceDocuments(List sourceDocuments) { + this.sourceDocuments = sourceDocuments; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FeedbackCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/FeedbackCreateRequest.java new file mode 100644 index 0000000..cf52fb1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FeedbackCreateRequest.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class FeedbackCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("query") + private String query; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("rating") + private Integer rating; + + @JsonProperty("reason") + private String reason; + + @JsonProperty("sources") + private List sources; +} diff --git a/src/main/java/top/tqx/demo_1/dto/FeedbackItem.java b/src/main/java/top/tqx/demo_1/dto/FeedbackItem.java new file mode 100644 index 0000000..3efac2c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FeedbackItem.java @@ -0,0 +1,48 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class FeedbackItem implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("id") + private Long id; + + @JsonProperty("feedback_id") + private String feedbackId; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("user_id") + private Long userId; + + @JsonProperty("query") + private String query; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("rating") + private Integer rating; + + @JsonProperty("reason") + private String reason; + + @JsonProperty("sources") + private String sources; + + @JsonProperty("faq_suggested") + private Boolean faqSuggested; + + @JsonProperty("suggestion_id") + private String suggestionId; + + @JsonProperty("created_at") + private String createdAt; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FeedbackListResponse.java b/src/main/java/top/tqx/demo_1/dto/FeedbackListResponse.java new file mode 100644 index 0000000..9d8f7e4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FeedbackListResponse.java @@ -0,0 +1,22 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class FeedbackListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("feedbacks") + private List feedbacks; + + @JsonProperty("total") + private Integer total; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FeedbackResponse.java b/src/main/java/top/tqx/demo_1/dto/FeedbackResponse.java new file mode 100644 index 0000000..598345a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FeedbackResponse.java @@ -0,0 +1,23 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class FeedbackResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("feedback_id") + private String feedbackId; + + @JsonProperty("faq_suggested") + private Boolean faqSuggested; + + @JsonProperty("suggestion_id") + private String suggestionId; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FeedbackStatsResponse.java b/src/main/java/top/tqx/demo_1/dto/FeedbackStatsResponse.java new file mode 100644 index 0000000..d676a8e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FeedbackStatsResponse.java @@ -0,0 +1,29 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class FeedbackStatsResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("total") + private Integer total; + + @JsonProperty("positive") + private Integer positive; + + @JsonProperty("negative") + private Integer negative; + + @JsonProperty("avg_rating") + private Double avgRating; + + @JsonProperty("satisfaction_rate") + private Double satisfactionRate; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/FilePermissionRequest.java b/src/main/java/top/tqx/demo_1/dto/FilePermissionRequest.java new file mode 100644 index 0000000..0c71b81 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FilePermissionRequest.java @@ -0,0 +1,27 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class FilePermissionRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long fileId; + + private Long userId; + + private Long deptId; + + private List deptIds; + + private Integer permissionType; + + private LocalDateTime expireTime; + + private Long grantUserId; +} diff --git a/src/main/java/top/tqx/demo_1/dto/FileUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/FileUpdateRequest.java new file mode 100644 index 0000000..84e9a51 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FileUpdateRequest.java @@ -0,0 +1,19 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class FileUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String fileName; + + private String description; + + private Integer isPublic; + + private Integer auditStatus; +} diff --git a/src/main/java/top/tqx/demo_1/dto/FileUploadRequest.java b/src/main/java/top/tqx/demo_1/dto/FileUploadRequest.java new file mode 100644 index 0000000..3332313 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/FileUploadRequest.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +@Data +@Schema(description = "文件上传请求") +public class FileUploadRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "部门ID(管理员上传制度文件时指定)") + private Long deptId; + + @Schema(description = "是否公开:true-公开,false-私密", example = "true") + private Boolean isPublic; + + @Schema(description = "文件分类:rule-制度文件,personal-个人文件", example = "personal") + private String fileCategory; + + @Schema(description = "文件描述") + private String description; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ImageCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/ImageCreateRequest.java new file mode 100644 index 0000000..e6fb5a9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageCreateRequest.java @@ -0,0 +1,61 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; + +@Data +public class ImageCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("image_id") + private String imageId; + + @JsonProperty("filename") + private String filename; + + @JsonProperty("path") + private String path; + + @JsonProperty("size_bytes") + private Long sizeBytes; + + @JsonProperty("format") + private String format; + + @JsonProperty("source") + private String source; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("page_end") + private String pageEnd; + + @JsonProperty("page_range") + private String pageRange; + + @JsonProperty("section") + private String section; + + @JsonProperty("chunk_type") + private String chunkType; + + @JsonProperty("type") + private String type; + + @JsonProperty("description") + private String description; + + @JsonProperty("full_description") + private String fullDescription; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("collection") + private String collection; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ImageItem.java b/src/main/java/top/tqx/demo_1/dto/ImageItem.java new file mode 100644 index 0000000..a0014b3 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageItem.java @@ -0,0 +1,39 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class ImageItem implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("image_id") + private String imageId; + + @JsonProperty("filename") + private String filename; + + @JsonProperty("url") + private String url; + + @JsonProperty("size") + private Long size; + + @JsonProperty("size_bytes") + private Long sizeBytes; + + @JsonProperty("format") + private String format; + + @JsonProperty("collection") + private String collection; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("created_at") + private String createdAt; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ImageListResponse.java b/src/main/java/top/tqx/demo_1/dto/ImageListResponse.java new file mode 100644 index 0000000..8a98e18 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageListResponse.java @@ -0,0 +1,27 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class ImageListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("images") + private List images; + + @JsonProperty("total") + private Integer total; + + @JsonProperty("limit") + private Integer limit; + + @JsonProperty("offset") + private Integer offset; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ImageResponse.java b/src/main/java/top/tqx/demo_1/dto/ImageResponse.java new file mode 100644 index 0000000..459a7eb --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageResponse.java @@ -0,0 +1,30 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class ImageResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("image_data") + private byte[] imageData; + + @JsonProperty("image_id") + private String imageId; + + @JsonProperty("filename") + private String filename; + + @JsonProperty("format") + private String format; + + @JsonProperty("size") + private Long size; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ImageStatsResponse.java b/src/main/java/top/tqx/demo_1/dto/ImageStatsResponse.java new file mode 100644 index 0000000..4c36ac4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageStatsResponse.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class ImageStatsResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("total") + private Integer total; + + @JsonProperty("total_size") + private Long totalSize; + + @JsonProperty("total_size_bytes") + private Long totalSizeBytes; + + @JsonProperty("total_size_mb") + private Double totalSizeMb; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ImageUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/ImageUpdateRequest.java new file mode 100644 index 0000000..ba3784e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ImageUpdateRequest.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class ImageUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("filename") + private String filename; + + @JsonProperty("path") + private String path; + + @JsonProperty("size_bytes") + private Long sizeBytes; + + @JsonProperty("format") + private String format; + + @JsonProperty("source") + private String source; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("collection") + private String collection; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/KBRouteRequest.java b/src/main/java/top/tqx/demo_1/dto/KBRouteRequest.java new file mode 100644 index 0000000..a0f35ef --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/KBRouteRequest.java @@ -0,0 +1,15 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class KBRouteRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("query") + private String query; +} diff --git a/src/main/java/top/tqx/demo_1/dto/KBRouteResponse.java b/src/main/java/top/tqx/demo_1/dto/KBRouteResponse.java new file mode 100644 index 0000000..1760c23 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/KBRouteResponse.java @@ -0,0 +1,48 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class KBRouteResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("query") + private String query; + + @JsonProperty("user_role") + private String userRole; + + @JsonProperty("user_department") + private String userDepartment; + + @JsonProperty("target_collections") + private List targetCollections; + + @JsonProperty("intent") + private IntentData intent; + + @Data + public static class IntentData implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("is_general") + private Boolean isGeneral; + + @JsonProperty("department") + private String department; + + @JsonProperty("confidence") + private Double confidence; + + @JsonProperty("keywords") + private List keywords; + + @JsonProperty("reason") + private String reason; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/LoginRequest.java b/src/main/java/top/tqx/demo_1/dto/LoginRequest.java new file mode 100644 index 0000000..7da121e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/LoginRequest.java @@ -0,0 +1,15 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class LoginRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String username; + + private String password; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ManagerRequest.java b/src/main/java/top/tqx/demo_1/dto/ManagerRequest.java new file mode 100644 index 0000000..b145aa4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ManagerRequest.java @@ -0,0 +1,13 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class ManagerRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long userId; +} diff --git a/src/main/java/top/tqx/demo_1/dto/PageResponse.java b/src/main/java/top/tqx/demo_1/dto/PageResponse.java new file mode 100644 index 0000000..827d616 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/PageResponse.java @@ -0,0 +1,27 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class PageResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private List records; + + private Long total; + + private Integer page; + + private Integer pageSize; + + public PageResponse(List records, Long total, Integer page, Integer pageSize) { + this.records = records; + this.total = total; + this.page = page; + this.pageSize = pageSize; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/QuestionCreateRequest.java new file mode 100644 index 0000000..598b9da --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionCreateRequest.java @@ -0,0 +1,51 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +public class QuestionCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("content") + private Map content; + + @JsonProperty("tags") + private List tags; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("document_id") + private String documentId; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("source_context") + private String sourceContext; + + @JsonProperty("chunk_ids") + private List chunkIds; + + @JsonProperty("source_chunks_used") + private Integer sourceChunksUsed; + + @JsonProperty("page_numbers") + private List pageNumbers; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionListRequest.java b/src/main/java/top/tqx/demo_1/dto/QuestionListRequest.java new file mode 100644 index 0000000..fa76d9f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionListRequest.java @@ -0,0 +1,27 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class QuestionListRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("status") + private String status; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("page") + private Integer page = 1; + + @JsonProperty("page_size") + private Integer pageSize = 20; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionListResponse.java b/src/main/java/top/tqx/demo_1/dto/QuestionListResponse.java new file mode 100644 index 0000000..4c92c34 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionListResponse.java @@ -0,0 +1,68 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +@Data +public class QuestionListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("data") + private List data; + + @JsonProperty("total") + private Long total; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("page_size") + private Integer pageSize; + + @JsonProperty("total_pages") + private Integer totalPages; + + @Data + public static class QuestionItem implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("id") + private Long id; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("status") + private String status; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("source_chunks_used") + private Integer sourceChunksUsed; + + @JsonProperty("created_at") + private String createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionStatsRequest.java b/src/main/java/top/tqx/demo_1/dto/QuestionStatsRequest.java new file mode 100644 index 0000000..b1845e8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionStatsRequest.java @@ -0,0 +1,28 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class QuestionStatsRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("dept_id") + private Long deptId; + + @JsonProperty("file_ids") + private List fileIds; + + @JsonProperty("collection") + private String collection; + + @JsonProperty("question_types") + private List questionTypes; + + @JsonProperty("status") + private String status; +} diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionStatsResponse.java b/src/main/java/top/tqx/demo_1/dto/QuestionStatsResponse.java new file mode 100644 index 0000000..13ae9dd --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionStatsResponse.java @@ -0,0 +1,51 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; + +@Data +public class QuestionStatsResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("total_count") + private Long totalCount; + + @JsonProperty("single_choice_count") + private Long singleChoiceCount; + + @JsonProperty("multiple_choice_count") + private Long multipleChoiceCount; + + @JsonProperty("true_false_count") + private Long trueFalseCount; + + @JsonProperty("fill_blank_count") + private Long fillBlankCount; + + @JsonProperty("subjective_count") + private Long subjectiveCount; + + @JsonProperty("single_choice_score") + private BigDecimal singleChoiceScore; + + @JsonProperty("multiple_choice_score") + private BigDecimal multipleChoiceScore; + + @JsonProperty("true_false_score") + private BigDecimal trueFalseScore; + + @JsonProperty("fill_blank_score") + private BigDecimal fillBlankScore; + + @JsonProperty("subjective_score") + private BigDecimal subjectiveScore; + + @JsonProperty("total_score") + private BigDecimal totalScore; +} diff --git a/src/main/java/top/tqx/demo_1/dto/QuestionUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/QuestionUpdateRequest.java new file mode 100644 index 0000000..0498b6d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/QuestionUpdateRequest.java @@ -0,0 +1,57 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +public class QuestionUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("content") + private Map content; + + @JsonProperty("tags") + private List tags; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("status") + private String status; + + @JsonProperty("reviewer_comment") + private String reviewerComment; + + @JsonProperty("document_id") + private String documentId; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("source_context") + private String sourceContext; + + @JsonProperty("chunk_ids") + private List chunkIds; + + @JsonProperty("source_chunks_used") + private Integer sourceChunksUsed; + + @JsonProperty("page_numbers") + private List pageNumbers; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/RagRequest.java b/src/main/java/top/tqx/demo_1/dto/RagRequest.java new file mode 100644 index 0000000..1486730 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/RagRequest.java @@ -0,0 +1,57 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +public class RagRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("message") + private String message; + + @JsonProperty("collections") + private List collections; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("history") + private List history; + + @JsonProperty("id") + private String id; + + @JsonIgnore + private Long userId; + + @JsonIgnore + private Long deptId; + + @JsonIgnore + private Integer userRole; + + @Data + public static class HistoryItem { + private String role; + private String content; + } + + @JsonProperty("inputs") + public Map getInputs() { + Map inputs = new HashMap<>(); + inputs.put("ques", message != null ? message : ""); + inputs.put("id", userId != null ? userId.toString() : ""); + inputs.put("role", userRole != null ? userRole.toString() : ""); + inputs.put("dep", deptId != null ? deptId.toString() : ""); + return inputs; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/dto/ReferenceResponse.java b/src/main/java/top/tqx/demo_1/dto/ReferenceResponse.java new file mode 100644 index 0000000..e13448c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ReferenceResponse.java @@ -0,0 +1,24 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class ReferenceResponse { + + private String chunkId; + private String source; + private String docType; + private Integer page; + private Integer pageEnd; + private String pageRange; + private String section; + private String chunkType; + private Integer sectionChunkId; + private String preview; + private String bbox; + private String bboxMode; + private BigDecimal score; + private String excerpt; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ReportResponse.java b/src/main/java/top/tqx/demo_1/dto/ReportResponse.java new file mode 100644 index 0000000..a98dc76 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ReportResponse.java @@ -0,0 +1,92 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +@Data +public class ReportResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("success") + private Boolean success; + + @JsonProperty("report") + private ReportData report; + + @Data + public static class ReportData implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("report_type") + private String reportType; + + @JsonProperty("start_date") + private String startDate; + + @JsonProperty("end_date") + private String endDate; + + @JsonProperty("total_queries") + private Integer totalQueries; + + @JsonProperty("total_feedback") + private Integer totalFeedback; + + @JsonProperty("positive_count") + private Integer positiveCount; + + @JsonProperty("negative_count") + private Integer negativeCount; + + @JsonProperty("satisfaction_rate") + private BigDecimal satisfactionRate; + + @JsonProperty("avg_rating") + private BigDecimal avgRating; + + @JsonProperty("high_freq_queries") + private List highFreqQueries; + + @JsonProperty("low_rating_queries") + private List lowRatingQueries; + + @JsonProperty("improvement_suggestions") + private List improvementSuggestions; + + @JsonProperty("created_at") + private String createdAt; + } + + @Data + public static class HighFreqQueryItem implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("query") + private String query; + + @JsonProperty("frequency") + private Integer frequency; + + @JsonProperty("sample_answer") + private String sampleAnswer; + } + + @Data + public static class LowRatingQueryItem implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("query") + private String query; + + @JsonProperty("rating") + private Integer rating; + + @JsonProperty("reason") + private String reason; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/RoleCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/RoleCreateRequest.java new file mode 100644 index 0000000..4b9f422 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/RoleCreateRequest.java @@ -0,0 +1,18 @@ +package top.tqx.demo_1.dto; + + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class RoleCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String roleName; + + private String roleCode; + + private String description; +} diff --git a/src/main/java/top/tqx/demo_1/dto/RoleUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/RoleUpdateRequest.java new file mode 100644 index 0000000..c1fd878 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/RoleUpdateRequest.java @@ -0,0 +1,21 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class RoleUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String roleName; + + private String roleCode; + + private Integer roleType; + + private String description; + + private Integer status; +} diff --git a/src/main/java/top/tqx/demo_1/dto/SearchRequest.java b/src/main/java/top/tqx/demo_1/dto/SearchRequest.java new file mode 100644 index 0000000..9a551e6 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/SearchRequest.java @@ -0,0 +1,22 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class SearchRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("query") + private String query; + + @JsonProperty("top_k") + private Integer topK; + + @JsonProperty("collections") + private List collections; +} diff --git a/src/main/java/top/tqx/demo_1/dto/SearchResponse.java b/src/main/java/top/tqx/demo_1/dto/SearchResponse.java new file mode 100644 index 0000000..ccf0fc7 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/SearchResponse.java @@ -0,0 +1,23 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +@Data +public class SearchResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("contexts") + private List contexts; + + @JsonProperty("metadatas") + private List> metadatas; + + @JsonProperty("scores") + private List scores; +} diff --git a/src/main/java/top/tqx/demo_1/dto/SseMessageDTO.java b/src/main/java/top/tqx/demo_1/dto/SseMessageDTO.java new file mode 100644 index 0000000..787d516 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/SseMessageDTO.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class SseMessageDTO { + + @JsonProperty("type") + private String type; + + @JsonProperty("message") + private String message; + + @JsonProperty("step") + private String step; + + @JsonProperty("content") + private String content; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("sources") + private Object sources; +} diff --git a/src/main/java/top/tqx/demo_1/dto/ThirdPartyJsonRequest.java b/src/main/java/top/tqx/demo_1/dto/ThirdPartyJsonRequest.java new file mode 100644 index 0000000..f87abf3 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/ThirdPartyJsonRequest.java @@ -0,0 +1,22 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Map; + +@Data +public class ThirdPartyJsonRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String source; + + private String fileName; + + private Map data; + + private String description; + + private String timestamp; +} diff --git a/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryRequest.java b/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryRequest.java new file mode 100644 index 0000000..6bc87d2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryRequest.java @@ -0,0 +1,24 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class UserAnswerQueryRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("paper_id") + private String paperId; + + @JsonProperty("session_id") + private String sessionId; + + @JsonProperty("user_id") + private Long userId; +} diff --git a/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryResponse.java b/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryResponse.java new file mode 100644 index 0000000..f4ff39d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/UserAnswerQueryResponse.java @@ -0,0 +1,114 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class UserAnswerQueryResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean success; + + @JsonProperty("groups") + private List groups; + + @JsonProperty("total_count") + private Integer totalCount; + + @Data + public static class AnswerGroup implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("group_type") + private String groupType; + + @JsonProperty("group_id") + private String groupId; + + @JsonProperty("group_name") + private String groupName; + + @JsonProperty("total_score") + private BigDecimal totalScore; + + @JsonProperty("total_max_score") + private BigDecimal totalMaxScore; + + @JsonProperty("score_rate") + private BigDecimal scoreRate; + + @JsonProperty("answer_count") + private Integer answerCount; + + @JsonProperty("correct_count") + private Integer correctCount; + + @JsonProperty("created_at") + private LocalDateTime createdAt; + + @JsonProperty("answers") + private List answers; + } + + @Data + public static class AnswerDetail implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("answer_id") + private Long answerId; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("question_type_name") + private String questionTypeName; + + @JsonProperty("user_answer") + private Object userAnswer; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("max_score") + private BigDecimal maxScore; + + @JsonProperty("is_correct") + private Integer isCorrect; + + @JsonProperty("is_correct_text") + private String isCorrectText; + + @JsonProperty("feedback") + private String feedback; + + @JsonProperty("grading_type") + private String gradingType; + + @JsonProperty("time_spent") + private Integer timeSpent; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("chunk_ids") + private List chunkIds; + + @JsonProperty("page_numbers") + private List pageNumbers; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("answered_at") + private LocalDateTime answeredAt; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/UserCreateRequest.java b/src/main/java/top/tqx/demo_1/dto/UserCreateRequest.java new file mode 100644 index 0000000..9509573 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/UserCreateRequest.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class UserCreateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private String username; + + private String password; + + private String realName; + + private String email; + + private String phone; + + private Long deptId; + + private List roleIds; +} diff --git a/src/main/java/top/tqx/demo_1/dto/UserUpdateRequest.java b/src/main/java/top/tqx/demo_1/dto/UserUpdateRequest.java new file mode 100644 index 0000000..fc7bad1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/UserUpdateRequest.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class UserUpdateRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String realName; + + private String email; + + private String phone; + + private Long deptId; + + private Integer status; + + private List roleIds; +} diff --git a/src/main/java/top/tqx/demo_1/dto/WrongQuestionCollectRequest.java b/src/main/java/top/tqx/demo_1/dto/WrongQuestionCollectRequest.java new file mode 100644 index 0000000..a9bbfc8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/WrongQuestionCollectRequest.java @@ -0,0 +1,24 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; + +@Data +public class WrongQuestionCollectRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("answer_id") + private Long answerId; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("is_collected") + private Boolean isCollected; + + @JsonProperty("note") + private String note; +} diff --git a/src/main/java/top/tqx/demo_1/dto/WrongQuestionListRequest.java b/src/main/java/top/tqx/demo_1/dto/WrongQuestionListRequest.java new file mode 100644 index 0000000..6127446 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/WrongQuestionListRequest.java @@ -0,0 +1,35 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class WrongQuestionListRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("is_collected") + private Boolean isCollected; + + @JsonProperty("start_time") + private LocalDateTime startTime; + + @JsonProperty("end_time") + private LocalDateTime endTime; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("page") + private Integer page = 1; + + @JsonProperty("page_size") + private Integer pageSize = 20; +} diff --git a/src/main/java/top/tqx/demo_1/dto/WrongQuestionListResponse.java b/src/main/java/top/tqx/demo_1/dto/WrongQuestionListResponse.java new file mode 100644 index 0000000..873a32d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/WrongQuestionListResponse.java @@ -0,0 +1,104 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import top.tqx.demo_1.common.PageResult; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class WrongQuestionListResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("page") + private PageResult page; + + @JsonProperty("stats") + private WrongQuestionStats stats; + + @Data + public static class WrongQuestionItem implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("answer_id") + private Long answerId; + + @JsonProperty("question_id") + private String questionId; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("question_type_name") + private String questionTypeName; + + @JsonProperty("question_content") + private Object questionContent; + + @JsonProperty("user_answer") + private Object userAnswer; + + @JsonProperty("correct_answer") + private Object correctAnswer; + + @JsonProperty("score") + private BigDecimal score; + + @JsonProperty("max_score") + private BigDecimal maxScore; + + @JsonProperty("difficulty") + private Integer difficulty; + + @JsonProperty("feedback") + private String feedback; + + @JsonProperty("document_name") + private String documentName; + + @JsonProperty("knowledge_base_path") + private String knowledgeBasePath; + + @JsonProperty("tags") + private List tags; + + @JsonProperty("is_collected") + private Boolean isCollected; + + @JsonProperty("wrong_count") + private Integer wrongCount; + + @JsonProperty("answered_at") + private LocalDateTime answeredAt; + } + + @Data + public static class WrongQuestionStats implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("total_wrong_count") + private Integer totalWrongCount; + + @JsonProperty("single_choice_count") + private Integer singleChoiceCount; + + @JsonProperty("multiple_choice_count") + private Integer multipleChoiceCount; + + @JsonProperty("true_false_count") + private Integer trueFalseCount; + + @JsonProperty("fill_blank_count") + private Integer fillBlankCount; + + @JsonProperty("subjective_count") + private Integer subjectiveCount; + + @JsonProperty("collected_count") + private Integer collectedCount; + } +} diff --git a/src/main/java/top/tqx/demo_1/dto/WrongQuestionRedoRequest.java b/src/main/java/top/tqx/demo_1/dto/WrongQuestionRedoRequest.java new file mode 100644 index 0000000..3182fba --- /dev/null +++ b/src/main/java/top/tqx/demo_1/dto/WrongQuestionRedoRequest.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +@Data +public class WrongQuestionRedoRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("answer_ids") + private List answerIds; + + @JsonProperty("question_type") + private String questionType; + + @JsonProperty("question_count") + private Integer questionCount = 10; + + @JsonProperty("is_collected") + private Boolean isCollected; +} diff --git a/src/main/java/top/tqx/demo_1/entity/ChatMessage.java b/src/main/java/top/tqx/demo_1/entity/ChatMessage.java new file mode 100644 index 0000000..53aa2bd --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ChatMessage.java @@ -0,0 +1,155 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@TableName("context_message") +public class ChatMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long conversationId; + + private String role; + + private String content; + + private String messageType; + + private Integer isFinished; + + private String summary; + + private String kbPaths; + + private String answer; + + private String mode; + + private Long durationMs; + + private String images; + + private String sources; + + private LocalDateTime createTime; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getConversationId() { + return conversationId; + } + + public void setConversationId(Long conversationId) { + this.conversationId = conversationId; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + public Integer getIsFinished() { + return isFinished; + } + + public void setIsFinished(Integer isFinished) { + this.isFinished = isFinished; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getKbPaths() { + return kbPaths; + } + + public void setKbPaths(String kbPaths) { + this.kbPaths = kbPaths; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public Long getDurationMs() { + return durationMs; + } + + public void setDurationMs(Long durationMs) { + this.durationMs = durationMs; + } + + public String getImages() { + return images; + } + + public void setImages(String images) { + this.images = images; + } + + public String getSources() { + return sources; + } + + public void setSources(String sources) { + this.sources = sources; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/ChatReference.java b/src/main/java/top/tqx/demo_1/entity/ChatReference.java new file mode 100644 index 0000000..df1f140 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ChatReference.java @@ -0,0 +1,54 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("context_reference") +public class ChatReference implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long messageId; + + private String chunkId; + + private String docPath; + + private String docName; + + private Integer page; + + private Integer pageEnd; + + private String pageRange; + + private String section; + + private String chunkType; + + private String docType; + + private Integer sectionChunkId; + + private String excerpt; + + private String preview; + + private String bbox; + + private String bboxMode; + + private BigDecimal score; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/ChatSession.java b/src/main/java/top/tqx/demo_1/entity/ChatSession.java new file mode 100644 index 0000000..b092683 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ChatSession.java @@ -0,0 +1,65 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@TableName("context_conversation") +public class ChatSession implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String sessionId; + + private Long userId; + + private String summary; + + private LocalDateTime createTime; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/CollectionFile.java b/src/main/java/top/tqx/demo_1/entity/CollectionFile.java new file mode 100644 index 0000000..f10e0c5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/CollectionFile.java @@ -0,0 +1,41 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("collection_file") +public class CollectionFile { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("collection_name") + private String collectionName; + + @TableField("doc_name") + private String docName; + + @TableField("file_id") + private Long fileId; + + @TableField("file_path") + private String filePath; + + @TableField("doc_type") + private String docType; + + @TableField("status") + private Integer status; + + @TableField("create_time") + private LocalDateTime createTime; + + @TableField("update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/DataDict.java b/src/main/java/top/tqx/demo_1/entity/DataDict.java new file mode 100644 index 0000000..a7fa57a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/DataDict.java @@ -0,0 +1,39 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("data_dict") +public class DataDict implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String dictType; + + private String dictCode; + + private String dictName; + + private String dictValue; + + private Long parentId; + + private Integer sortOrder; + + private Integer status; + + private String remark; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/Department.java b/src/main/java/top/tqx/demo_1/entity/Department.java new file mode 100644 index 0000000..659a5e7 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Department.java @@ -0,0 +1,41 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("department") +public class Department implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String deptName; + + private String deptCode; + + private Long parentId; + + private Integer deptLevel; + + private String deptType; + + private Long managerId; + + private Integer status; + + private Integer sortOrder; + + private String remark; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/DeptAdmin.java b/src/main/java/top/tqx/demo_1/entity/DeptAdmin.java new file mode 100644 index 0000000..aa49f8f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/DeptAdmin.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("dept_admin") +public class DeptAdmin implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long deptId; + + private Integer isPrimary; + + private Integer status; + + private String remark; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/ExamAssignment.java b/src/main/java/top/tqx/demo_1/entity/ExamAssignment.java new file mode 100644 index 0000000..2ace77c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ExamAssignment.java @@ -0,0 +1,52 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName(value = "exam_assignment", autoResultMap = true) +public class ExamAssignment implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField(value = "paper_id") + private String paperId; + + @TableField(value = "dept_id") + private Long deptId; + + @TableField(value = "dept_ids", typeHandler = JacksonTypeHandler.class) + private JsonNode deptIds; + + @TableField(value = "user_ids", typeHandler = JacksonTypeHandler.class) + private JsonNode userIds; + + @TableField(value = "exclude_user_ids", typeHandler = JacksonTypeHandler.class) + private JsonNode excludeUserIds; + + @TableField(value = "status") + private String status; + + @TableField(value = "created_at") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + private LocalDateTime updatedAt; + + @TableField(value = "publish_time") + private LocalDateTime publishTime; + + @TableField(value = "deadline") + private LocalDateTime deadline; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/ExamPaper.java b/src/main/java/top/tqx/demo_1/entity/ExamPaper.java new file mode 100644 index 0000000..34412d5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ExamPaper.java @@ -0,0 +1,68 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName(value = "exam_paper", autoResultMap = true) +public class ExamPaper implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField(value = "paper_id") + private String paperId; + + @TableField(value = "title") + private String title; + + @TableField(value = "description") + private String description; + + @TableField(value = "total_score") + private BigDecimal totalScore; + + @TableField(value = "duration") + private Integer duration; + + @TableField(value = "question_count") + private Integer questionCount; + + @TableField(value = "status") + private String status; + + @TableField(value = "permission_scope") + private String permissionScope; + + @TableField(value = "dept_id") + private Long deptId; + + @TableField(value = "question_ids", typeHandler = JacksonTypeHandler.class) + private JsonNode questionIds; + + @TableField(value = "created_by") + private Long createdBy; + + @TableField(value = "created_at") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + private LocalDateTime updatedAt; + + @TableField(value = "publish_time") + private LocalDateTime publishTime; + + @TableField(value = "deadline") + private LocalDateTime deadline; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/ExamRecord.java b/src/main/java/top/tqx/demo_1/entity/ExamRecord.java new file mode 100644 index 0000000..1a4bcfe --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ExamRecord.java @@ -0,0 +1,63 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName(value = "exam_record", autoResultMap = true) +public class ExamRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField(value = "record_id") + private String recordId; + + @TableField(value = "paper_id") + private String paperId; + + @TableField(value = "answer_type") + private String answerType; + + @TableField(value = "user_id") + private Long userId; + + @TableField(value = "status") + private String status; + + @TableField(value = "total_score") + private BigDecimal totalScore; + + @TableField(value = "submit_time") + private LocalDateTime submitTime; + + @TableField(value = "graded_time") + private LocalDateTime gradedTime; + + @TableField(value = "graded_by") + private Long gradedBy; + + @TableField(value = "time_spent") + private Integer timeSpent; + + @TableField(value = "start_time") + private LocalDateTime startTime; + + @TableField(value = "last_active_time") + private LocalDateTime lastActiveTime; + + @TableField(value = "created_at") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/FAQ.java b/src/main/java/top/tqx/demo_1/entity/FAQ.java new file mode 100644 index 0000000..dd01304 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/FAQ.java @@ -0,0 +1,105 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@TableName("faq") +public class FAQ { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String question; + + private String answer; + + private BigDecimal avgRating; + + private Integer frequency; + + private String status; + + private String sourceDocuments; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + // Getters + public Long getId() { + return id; + } + + public String getQuestion() { + return question; + } + + public String getAnswer() { + return answer; + } + + public BigDecimal getAvgRating() { + return avgRating; + } + + public Integer getFrequency() { + return frequency; + } + + public String getStatus() { + return status; + } + + public String getSourceDocuments() { + return sourceDocuments; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + // Setters + public void setId(Long id) { + this.id = id; + } + + public void setQuestion(String question) { + this.question = question; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public void setAvgRating(BigDecimal avgRating) { + this.avgRating = avgRating; + } + + public void setFrequency(Integer frequency) { + this.frequency = frequency; + } + + public void setStatus(String status) { + this.status = status; + } + + public void setSourceDocuments(String sourceDocuments) { + this.sourceDocuments = sourceDocuments; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/FAQSuggestion.java b/src/main/java/top/tqx/demo_1/entity/FAQSuggestion.java new file mode 100644 index 0000000..589411a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/FAQSuggestion.java @@ -0,0 +1,93 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@TableName("faq_suggestion") +public class FAQSuggestion { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String query; + + private String answer; + + private BigDecimal avgRating; + + private Integer frequency; + + private String status; + + private Long faqId; + + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public BigDecimal getAvgRating() { + return avgRating; + } + + public void setAvgRating(BigDecimal avgRating) { + this.avgRating = avgRating; + } + + public Integer getFrequency() { + return frequency; + } + + public void setFrequency(Integer frequency) { + this.frequency = frequency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getFaqId() { + return faqId; + } + + public void setFaqId(Long faqId) { + this.faqId = faqId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/Feedback.java b/src/main/java/top/tqx/demo_1/entity/Feedback.java new file mode 100644 index 0000000..b2f90ee --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Feedback.java @@ -0,0 +1,132 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +@TableName("feedback") +public class Feedback { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String feedbackId; + + private String sessionId; + + private Long userId; + + private String query; + + private String answer; + + private Integer rating; + + private String reason; + + private String sources; + + private Integer faqSuggested; + + private Long suggestionId; + + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFeedbackId() { + return feedbackId; + } + + public void setFeedbackId(String feedbackId) { + this.feedbackId = feedbackId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getSources() { + return sources; + } + + public void setSources(String sources) { + this.sources = sources; + } + + public Integer getFaqSuggested() { + return faqSuggested; + } + + public void setFaqSuggested(Integer faqSuggested) { + this.faqSuggested = faqSuggested; + } + + public Long getSuggestionId() { + return suggestionId; + } + + public void setSuggestionId(Long suggestionId) { + this.suggestionId = suggestionId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/File.java b/src/main/java/top/tqx/demo_1/entity/File.java new file mode 100644 index 0000000..579d1c6 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/File.java @@ -0,0 +1,67 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName(value = "file", autoResultMap = true) +public class File implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String fileName; + + private String filePath; + + private Long fileSize; + + private String fileType; + + private String extension; + + private String fileMd5; + + private Long uploadUserId; + + private Long deptId; + + @TableField(value = "dept_ids", typeHandler = JacksonTypeHandler.class) + private JsonNode deptIds; + + private Integer isPublic; + + private String fileCategory; + + private String description; + + private Integer status; + + private Integer auditStatus; + + private String vectorDbAddress; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + private String processStatus; + + private String processMessage; + + private String examStatus; + + private String processStepStatus; + + private String processStepMessage; +} diff --git a/src/main/java/top/tqx/demo_1/entity/FilePermission.java b/src/main/java/top/tqx/demo_1/entity/FilePermission.java new file mode 100644 index 0000000..f1b06ff --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/FilePermission.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("file_permission") +public class FilePermission implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long fileId; + + private Long userId; + + private Long deptId; + + private Integer permissionType; + + private Long grantUserId; + + private LocalDateTime expireTime; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/Image.java b/src/main/java/top/tqx/demo_1/entity/Image.java new file mode 100644 index 0000000..7809e36 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Image.java @@ -0,0 +1,51 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("context_image") +public class Image { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String imageId; + + private String filename; + + private String path; + + private Long sizeBytes; + + private String format; + + private String source; + + private Integer page; + + private String pageEnd; + + private String pageRange; + + private String section; + + private String chunkType; + + private String type; + + private String description; + + private String fullDescription; + + private BigDecimal score; + + private String collection; + + private LocalDateTime createTime; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/ImageEntity.java b/src/main/java/top/tqx/demo_1/entity/ImageEntity.java new file mode 100644 index 0000000..c33ac9e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/ImageEntity.java @@ -0,0 +1,35 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("image") +public class ImageEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String imageId; + + private String url; + + private Long sizeBytes; + + private String format; + + private String description; + + private String sourceDocument; + + private Integer pageNumber; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/top/tqx/demo_1/entity/KnowledgeBasePath.java b/src/main/java/top/tqx/demo_1/entity/KnowledgeBasePath.java new file mode 100644 index 0000000..11d8387 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/KnowledgeBasePath.java @@ -0,0 +1,35 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("collection") +public class KnowledgeBasePath { + + @TableId(type = IdType.AUTO) + private Long id; + private String name; + private String path; + private String uploader; + private String department; + private String ownerType; + private String visibility; + private Long fileSize; + private String displayName; + private String description; + @TableField("create_time") + private LocalDateTime createTime; + @TableField("update_time") + private LocalDateTime updateTime; + @TableField("sync_status") + private String syncStatus; + @TableField("last_sync_time") + private LocalDateTime lastSyncTime; + @TableField("sync_message") + private String syncMessage; +} diff --git a/src/main/java/top/tqx/demo_1/entity/OperationLog.java b/src/main/java/top/tqx/demo_1/entity/OperationLog.java new file mode 100644 index 0000000..f8a23ee --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/OperationLog.java @@ -0,0 +1,43 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("operation_log") +public class OperationLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private String username; + + private Integer operatorType; + + private String operationType; + + private String operationDesc; + + private Long targetId; + + private String targetType; + + private String result; + + private String detail; + + private String ipAddress; + + private String userAgent; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/Permission.java b/src/main/java/top/tqx/demo_1/entity/Permission.java new file mode 100644 index 0000000..2c76d58 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Permission.java @@ -0,0 +1,41 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("permission") +public class Permission implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String uuid; + + private String permissionName; + + private String permissionCode; + + private Long parentId; + + private Integer type; + + private String path; + + private String icon; + + private Integer sortOrder; + + private Integer status; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/Question.java b/src/main/java/top/tqx/demo_1/entity/Question.java new file mode 100644 index 0000000..67223c7 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Question.java @@ -0,0 +1,74 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName(value = "question", autoResultMap = true) +public class Question implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String questionId; + + private String questionType; + + private Integer difficulty; + + @TableField(typeHandler = JacksonTypeHandler.class) + private JsonNode tags; + + private BigDecimal score; + + private String version; + + private String documentId; + + private String documentName; + + private String knowledgeBasePath; + + private String sourceContext; + + @TableField(typeHandler = JacksonTypeHandler.class) + private JsonNode chunkIds; + + private Integer sourceChunksUsed; + + private Integer examCount; + + private Integer correctCount; + + private LocalDateTime lastExamTime; + + @TableField(typeHandler = JacksonTypeHandler.class) + private JsonNode pageNumbers; + + @TableField(typeHandler = JacksonTypeHandler.class) + private JsonNode content; + + private String status; + + private String reviewerComment; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/top/tqx/demo_1/entity/Report.java b/src/main/java/top/tqx/demo_1/entity/Report.java new file mode 100644 index 0000000..88dc6d5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Report.java @@ -0,0 +1,61 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("report") +public class Report implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("report_type") + private String reportType; + + @TableField("start_date") + private LocalDate startDate; + + @TableField("end_date") + private LocalDate endDate; + + @TableField("total_queries") + private Integer totalQueries; + + @TableField("total_feedback") + private Integer totalFeedback; + + @TableField("positive_count") + private Integer positiveCount; + + @TableField("negative_count") + private Integer negativeCount; + + @TableField("satisfaction_rate") + private BigDecimal satisfactionRate; + + @TableField("avg_rating") + private BigDecimal avgRating; + + @TableField("high_freq_queries") + private String highFreqQueries; + + @TableField("low_rating_queries") + private String lowRatingQueries; + + @TableField("improvement_suggestions") + private String improvementSuggestions; + + @TableField("created_at") + private LocalDateTime createdAt; +} diff --git a/src/main/java/top/tqx/demo_1/entity/Role.java b/src/main/java/top/tqx/demo_1/entity/Role.java new file mode 100644 index 0000000..494bd17 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/Role.java @@ -0,0 +1,37 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("role") +public class Role implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String uuid; + + private String roleName; + + private String roleCode; + + private Integer roleType; + + private String description; + + private Integer status; + + private Integer sortOrder; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/RolePermission.java b/src/main/java/top/tqx/demo_1/entity/RolePermission.java new file mode 100644 index 0000000..559184e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/RolePermission.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("role_permission") +public class RolePermission implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long roleId; + + private Long permissionId; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/SystemConfig.java b/src/main/java/top/tqx/demo_1/entity/SystemConfig.java new file mode 100644 index 0000000..bd0a318 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/SystemConfig.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("system_config") +public class SystemConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String configKey; + + private String configValue; + + private String configType; + + private String description; + + private Integer isSystem; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/User.java b/src/main/java/top/tqx/demo_1/entity/User.java new file mode 100644 index 0000000..0735534 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/User.java @@ -0,0 +1,145 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@TableName("user") +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String username; + + private String password; + + private String realName; + + private String email; + + private String phone; + + private String avatar; + + private Integer userType; + + private Integer status; + + private Long deptId; + + private LocalDateTime lastLoginTime; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getRealName() { + return realName; + } + + public void setRealName(String realName) { + this.realName = realName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getUserType() { + return userType; + } + + public void setUserType(Integer userType) { + this.userType = userType; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Long getDeptId() { + return deptId; + } + + public void setDeptId(Long deptId) { + this.deptId = deptId; + } + + public LocalDateTime getLastLoginTime() { + return lastLoginTime; + } + + public void setLastLoginTime(LocalDateTime lastLoginTime) { + this.lastLoginTime = lastLoginTime; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/entity/UserAnswer.java b/src/main/java/top/tqx/demo_1/entity/UserAnswer.java new file mode 100644 index 0000000..d95e80c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/UserAnswer.java @@ -0,0 +1,68 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName(value = "user_answer", autoResultMap = true) +public class UserAnswer implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long userId; + + @TableField(value = "question_id") + private String questionId; + + @TableField(value = "question_type") + private String questionType; + + @TableField(value = "answer_type") + private String answerType; + + @TableField(value = "paper_id") + private String paperId; + + @TableField(value = "session_id") + private String sessionId; + + @TableField(value = "user_answer", typeHandler = JacksonTypeHandler.class) + private JsonNode userAnswer; + + private BigDecimal score; + + @TableField(value = "max_score") + private BigDecimal maxScore; + + @TableField(value = "is_correct") + private Integer isCorrect; + + @TableField(value = "grading_type") + private String gradingType; + + private String feedback; + + @TableField(value = "time_spent") + private Integer timeSpent; + + @TableField(value = "paper_time_spent") + private Integer paperTimeSpent; + + @TableField(value = "created_at") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/top/tqx/demo_1/entity/UserRole.java b/src/main/java/top/tqx/demo_1/entity/UserRole.java new file mode 100644 index 0000000..f0325e2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/UserRole.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("user_role") +public class UserRole implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long roleId; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/entity/WrongQuestionCollection.java b/src/main/java/top/tqx/demo_1/entity/WrongQuestionCollection.java new file mode 100644 index 0000000..39a9802 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/entity/WrongQuestionCollection.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName(value = "wrong_question_collection", autoResultMap = true) +public class WrongQuestionCollection implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long answerId; + + private String questionId; + + private String note; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/top/tqx/demo_1/enums/AnswerType.java b/src/main/java/top/tqx/demo_1/enums/AnswerType.java new file mode 100644 index 0000000..50e8bd8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/enums/AnswerType.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.enums; + +import lombok.Getter; + +/** + * 答题类型枚举 + */ +@Getter +public enum AnswerType { + FORMAL_EXAM("formal_exam", "正式考试"), + MOCK_EXAM("mock_exam", "模拟考"), + PRACTICE("practice", "日常练习"); + + private final String code; + private final String desc; + + AnswerType(String code, String desc) { + this.code = code; + this.desc = desc; + } + + /** + * 根据code获取枚举 + */ + public static AnswerType fromCode(String code) { + for (AnswerType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + return null; + } +} diff --git a/src/main/java/top/tqx/demo_1/enums/DeptTypeEnum.java b/src/main/java/top/tqx/demo_1/enums/DeptTypeEnum.java new file mode 100644 index 0000000..a6b6da3 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/enums/DeptTypeEnum.java @@ -0,0 +1,38 @@ +package top.tqx.demo_1.enums; + +public enum DeptTypeEnum { + HEADQUARTERS("HEADQUARTERS", "总公司", 1), + BRANCH("BRANCH", "分公司", 2), + DEPARTMENT("DEPARTMENT", "部门", 3); + + private final String code; + private final String name; + private final int level; + + DeptTypeEnum(String code, String name, int level) { + this.code = code; + this.name = name; + this.level = level; + } + + public String getCode() { + return code; + } + + public String getName() { + return name; + } + + public int getLevel() { + return level; + } + + public static DeptTypeEnum getByCode(String code) { + for (DeptTypeEnum type : values()) { + if (type.code.equals(code)) { + return type; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/enums/FileProcessStepStatus.java b/src/main/java/top/tqx/demo_1/enums/FileProcessStepStatus.java new file mode 100644 index 0000000..7a018cf --- /dev/null +++ b/src/main/java/top/tqx/demo_1/enums/FileProcessStepStatus.java @@ -0,0 +1,56 @@ +package top.tqx.demo_1.enums; + +/** + * 文件处理流程状态枚举 + * 用于跟踪从文件上传到题目生成的完整流程 + */ +public enum FileProcessStepStatus { + + UPLOADED("UPLOADED", "文件已上传", 1), + VECTORIZING("VECTORIZING", "正在向量化", 2), + VECTORIZED("VECTORIZED", "向量化完成", 3), + VECTORIZE_FAILED("VECTORIZE_FAILED", "向量化失败", 4), + EXAM_GENERATING("EXAM_GENERATING", "正在生成题目", 5), + EXAM_GENERATED("EXAM_GENERATED", "题目生成完成", 6), + EXAM_GENERATE_FAILED("EXAM_GENERATE_FAILED", "题目生成失败", 7), + COMPLETED("COMPLETED", "全部流程完成", 8); + + private final String code; + private final String description; + private final int order; + + FileProcessStepStatus(String code, String description, int order) { + this.code = code; + this.description = description; + this.order = order; + } + + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public int getOrder() { + return order; + } + + public static FileProcessStepStatus fromCode(String code) { + for (FileProcessStepStatus status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } + + public boolean isFailed() { + return this == VECTORIZE_FAILED || this == EXAM_GENERATE_FAILED; + } + + public boolean isCompleted() { + return this == COMPLETED; + } +} diff --git a/src/main/java/top/tqx/demo_1/enums/ResultCode.java b/src/main/java/top/tqx/demo_1/enums/ResultCode.java new file mode 100644 index 0000000..27fcc59 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/enums/ResultCode.java @@ -0,0 +1,33 @@ +package top.tqx.demo_1.enums; + +public enum ResultCode { + + SUCCESS(200, "操作成功"), + ERROR(500, "操作失败"), + UNAUTHORIZED(401, "未授权,请登录"), + FORBIDDEN(403, "禁止访问,权限不足"), + NOT_FOUND(404, "资源不存在"), + PARAM_ERROR(400, "参数错误"), + USER_NOT_EXIST(1001, "用户不存在"), + PASSWORD_ERROR(1002, "密码错误"), + USER_DISABLED(1003, "用户已被禁用"), + FILE_NOT_EXIST(2001, "文件不存在"), + FILE_UPLOAD_ERROR(2002, "文件上传失败"), + PERMISSION_DENIED(3001, "权限不足"); + + private final Integer code; + private final String message; + + ResultCode(Integer code, String message) { + this.code = code; + this.message = message; + } + + public Integer getCode() { + return code; + } + + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/exception/PermissionDeniedException.java b/src/main/java/top/tqx/demo_1/exception/PermissionDeniedException.java new file mode 100644 index 0000000..3d71f6d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/exception/PermissionDeniedException.java @@ -0,0 +1,12 @@ +package top.tqx.demo_1.exception; + +public class PermissionDeniedException extends RuntimeException { + + public PermissionDeniedException(String message) { + super(message); + } + + public PermissionDeniedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/top/tqx/demo_1/handler/GlobalExceptionHandler.java b/src/main/java/top/tqx/demo_1/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..b67865b --- /dev/null +++ b/src/main/java/top/tqx/demo_1/handler/GlobalExceptionHandler.java @@ -0,0 +1,109 @@ +package top.tqx.demo_1.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import top.tqx.demo_1.common.Result; +import top.tqx.demo_1.exception.PermissionDeniedException; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(AsyncRequestTimeoutException.class) + @ResponseStatus(HttpStatus.GONE) + public void handleAsyncRequestTimeoutException(AsyncRequestTimeoutException e) { + log.warn("SSE连接超时,已自动断开: {}", e.getMessage()); + } + + @ExceptionHandler(AsyncRequestNotUsableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException e) { + if (e.getMessage() != null && e.getMessage().contains("Connection reset")) { + log.debug("客户端提前断开连接: Connection reset by peer"); + } else { + log.warn("异步请求不可用: {}", e.getMessage()); + } + } + + @ExceptionHandler(IOException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public void handleIOException(IOException e) { + if (e.getMessage() != null && e.getMessage().contains("Connection reset")) { + log.debug("客户端提前断开连接: Connection reset by peer"); + } else { + log.warn("IO异常: {}", e.getMessage()); + } + } + + @ExceptionHandler(PermissionDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result handlePermissionDeniedException(PermissionDeniedException e) { + log.warn("权限拒绝: {}", e.getMessage()); + return Result.error(403, "无权限访问"); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public Result handleAccessDeniedException(AccessDeniedException e) { + log.warn("访问拒绝: {}", e.getMessage()); + return Result.error(403, "访问被拒绝"); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .findFirst() + .orElse("参数验证失败"); + log.warn("参数验证失败: {}", message); + return Result.error(400, message); + } + + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBindException(BindException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .findFirst() + .orElse("参数绑定失败"); + log.warn("参数绑定失败: {}", message); + return Result.error(400, message); + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleRuntimeException(RuntimeException e) { + log.error("运行时异常: type={}, message={}", e.getClass().getSimpleName(), e.getMessage()); + if (e.getMessage() != null && (e.getMessage().contains("SQL") || e.getMessage().contains("connection"))) { + return Result.error(500, "数据库操作失败"); + } + if (e.getMessage() != null && e.getMessage().contains("Access Denied")) { + return Result.error(403, "无权限访问"); + } + return Result.error(500, "服务器内部错误"); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) { + log.error("系统异常: type={}, message={}", e.getClass().getSimpleName(), e.getMessage(), e); + if (e.getCause() != null) { + String causeMsg = e.getCause().getMessage(); + if (causeMsg != null && causeMsg.contains("Access Denied")) { + return Result.error(403, "无权限访问"); + } + } + return Result.error(500, "系统异常"); + } +} diff --git a/src/main/java/top/tqx/demo_1/interceptor/PermissionInterceptor.java b/src/main/java/top/tqx/demo_1/interceptor/PermissionInterceptor.java new file mode 100644 index 0000000..84e558e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/interceptor/PermissionInterceptor.java @@ -0,0 +1,60 @@ +package top.tqx.demo_1.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import top.tqx.demo_1.annotation.RequiresPermission; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.exception.PermissionDeniedException; +import top.tqx.demo_1.service.AuthService; +import top.tqx.demo_1.service.PermissionService; + +@Component +public class PermissionInterceptor implements HandlerInterceptor { + + @Autowired + private PermissionService permissionService; + + @Autowired + private AuthService authService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod)) { + return true; + } + + HandlerMethod method = (HandlerMethod) handler; + + // 获取当前用户(从 Token 中) + User currentUser = getCurrentUser(request); + if (currentUser == null) { + throw new PermissionDeniedException("用户未登录"); + } + + // 获取请求的权限码 + RequiresPermission annotation = method.getMethodAnnotation(RequiresPermission.class); + if (annotation != null) { + String permissionCode = annotation.value(); + + // 检查权限 + if (!permissionService.hasPermission(currentUser.getId(), permissionCode)) { + throw new PermissionDeniedException("无权限访问:" + permissionCode); + } + } + + return true; + } + + private User getCurrentUser(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + return null; + } + + return authService.validateToken(token.substring(7)); + } +} diff --git a/src/main/java/top/tqx/demo_1/mapper/ChatMessageMapper.java b/src/main/java/top/tqx/demo_1/mapper/ChatMessageMapper.java new file mode 100644 index 0000000..5d7de53 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ChatMessageMapper.java @@ -0,0 +1,14 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import top.tqx.demo_1.entity.ChatMessage; + +@Mapper +public interface ChatMessageMapper extends BaseMapper { + + @Select("SELECT * FROM context_message WHERE conversation_id = #{sessionId} AND role = 'assistant' AND content = #{content} ORDER BY create_time DESC LIMIT 1") + ChatMessage findAssistantMessageByContent(@Param("sessionId") Long sessionId, @Param("content") String content); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/ChatReferenceMapper.java b/src/main/java/top/tqx/demo_1/mapper/ChatReferenceMapper.java new file mode 100644 index 0000000..1cc5d85 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ChatReferenceMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.ChatReference; + +@Mapper +public interface ChatReferenceMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/ChatSessionMapper.java b/src/main/java/top/tqx/demo_1/mapper/ChatSessionMapper.java new file mode 100644 index 0000000..05736f6 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ChatSessionMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.ChatSession; + +@Mapper +public interface ChatSessionMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/CollectionFileMapper.java b/src/main/java/top/tqx/demo_1/mapper/CollectionFileMapper.java new file mode 100644 index 0000000..5f4f4e4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/CollectionFileMapper.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import top.tqx.demo_1.entity.CollectionFile; + +import java.util.List; + +@Mapper +public interface CollectionFileMapper extends BaseMapper { + + @Select("SELECT cf.*, f.file_name, f.file_path as storage_path, f.file_size, f.dept_id " + + "FROM collection_file cf " + + "LEFT JOIN file f ON cf.file_id = f.id " + + "WHERE cf.doc_name = #{docName} AND cf.status = 1") + List findByDocNameWithFileInfo(@Param("docName") String docName); + + @Select("SELECT cf.*, f.file_name, f.file_path as storage_path, f.file_size, f.dept_id " + + "FROM collection_file cf " + + "LEFT JOIN file f ON cf.file_id = f.id " + + "WHERE cf.collection_name = #{collectionName} AND cf.status = 1") + List findByCollectionName(@Param("collectionName") String collectionName); + + @Select("SELECT * FROM collection_file WHERE collection_name = #{collectionName} AND doc_name = #{docName}") + CollectionFile findByCollectionAndDocName(@Param("collectionName") String collectionName, @Param("docName") String docName); + + @Select("SELECT * FROM collection_file WHERE file_id = #{fileId}") + CollectionFile findByFileId(@Param("fileId") Long fileId); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/DataDictMapper.java b/src/main/java/top/tqx/demo_1/mapper/DataDictMapper.java new file mode 100644 index 0000000..23849bf --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/DataDictMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.DataDict; + +@Mapper +public interface DataDictMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/DepartmentMapper.java b/src/main/java/top/tqx/demo_1/mapper/DepartmentMapper.java new file mode 100644 index 0000000..71a4be7 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/DepartmentMapper.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.Department; + +@Mapper +public interface DepartmentMapper extends BaseMapper { + +} diff --git a/src/main/java/top/tqx/demo_1/mapper/DeptAdminMapper.java b/src/main/java/top/tqx/demo_1/mapper/DeptAdminMapper.java new file mode 100644 index 0000000..547e1cc --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/DeptAdminMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.DeptAdmin; + +@Mapper +public interface DeptAdminMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/ExamAssignmentMapper.java b/src/main/java/top/tqx/demo_1/mapper/ExamAssignmentMapper.java new file mode 100644 index 0000000..2967460 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ExamAssignmentMapper.java @@ -0,0 +1,7 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import top.tqx.demo_1.entity.ExamAssignment; + +public interface ExamAssignmentMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/ExamPaperMapper.java b/src/main/java/top/tqx/demo_1/mapper/ExamPaperMapper.java new file mode 100644 index 0000000..7cd8dc8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ExamPaperMapper.java @@ -0,0 +1,7 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import top.tqx.demo_1.entity.ExamPaper; + +public interface ExamPaperMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/ExamRecordMapper.java b/src/main/java/top/tqx/demo_1/mapper/ExamRecordMapper.java new file mode 100644 index 0000000..802325a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ExamRecordMapper.java @@ -0,0 +1,7 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import top.tqx.demo_1.entity.ExamRecord; + +public interface ExamRecordMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/FAQMapper.java b/src/main/java/top/tqx/demo_1/mapper/FAQMapper.java new file mode 100644 index 0000000..86ab022 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/FAQMapper.java @@ -0,0 +1,20 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import top.tqx.demo_1.entity.FAQ; + +import java.util.List; + +@Mapper +public interface FAQMapper extends BaseMapper { + + List selectByStatus(String status); + + List searchByQuestion(String keyword); + + int updateFrequency(@Param("id") Long id, @Param("frequency") Integer frequency); + + int updateAvgRating(@Param("id") Long id, @Param("avgRating") Double avgRating); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/FAQSuggestionMapper.java b/src/main/java/top/tqx/demo_1/mapper/FAQSuggestionMapper.java new file mode 100644 index 0000000..dca5f19 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/FAQSuggestionMapper.java @@ -0,0 +1,20 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import top.tqx.demo_1.entity.FAQSuggestion; + +import java.util.List; + +@Mapper +public interface FAQSuggestionMapper extends BaseMapper { + + List selectByStatus(String status); + + FAQSuggestion selectByQuery(String query); + + int updateFrequency(@Param("id") Long id, @Param("frequency") Integer frequency); + + int updateStatus(@Param("id") Long id, @Param("status") String status); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/FeedbackMapper.java b/src/main/java/top/tqx/demo_1/mapper/FeedbackMapper.java new file mode 100644 index 0000000..59c042c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/FeedbackMapper.java @@ -0,0 +1,24 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import top.tqx.demo_1.entity.Feedback; + +import java.util.List; + +@Mapper +public interface FeedbackMapper extends BaseMapper { + + List selectByUserId(Long userId); + + List selectBySessionId(String sessionId); + + List selectBadCases(); + + int countByRating(@Param("rating") Integer rating); + + double avgRating(); + + List selectRecent(int limit); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/FileMapper.java b/src/main/java/top/tqx/demo_1/mapper/FileMapper.java new file mode 100644 index 0000000..95e7195 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/FileMapper.java @@ -0,0 +1,23 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import top.tqx.demo_1.entity.File; + +import java.util.List; + +@Mapper +public interface FileMapper extends BaseMapper { + + List selectByPermission(@Param("condition") String condition, IPage page); + + List selectByDeptId(@Param("deptId") Long deptId); + + List selectByUploadUserId(@Param("uploadUserId") Long uploadUserId); + + @Select("SELECT * FROM file WHERE file_name = #{fileName} LIMIT 1") + File findByFileName(@Param("fileName") String fileName); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/FilePermissionMapper.java b/src/main/java/top/tqx/demo_1/mapper/FilePermissionMapper.java new file mode 100644 index 0000000..5265e9d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/FilePermissionMapper.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import top.tqx.demo_1.entity.FilePermission; + +import java.util.List; + +@Mapper +public interface FilePermissionMapper extends BaseMapper { + + FilePermission selectByUserAndFile(@Param("userId") Long userId, @Param("fileId") Long fileId); + + FilePermission selectByDeptAndFile(@Param("deptId") Long deptId, @Param("fileId") Long fileId); + + List selectByUserId(@Param("userId") Long userId); + + List selectByFileId(@Param("fileId") Long fileId); + + List selectByUserIdAndFileId(@Param("userId") Long userId, @Param("fileId") Long fileId); + + int deleteByFileId(@Param("fileId") Long fileId); + + int deleteByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/ImageMapper.java b/src/main/java/top/tqx/demo_1/mapper/ImageMapper.java new file mode 100644 index 0000000..c81978f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ImageMapper.java @@ -0,0 +1,15 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.Image; + +import java.util.List; + +@Mapper +public interface ImageMapper extends BaseMapper { + + List selectByCollection(String collection); + + int countByCollection(String collection); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/mapper/KnowledgeBasePathMapper.java b/src/main/java/top/tqx/demo_1/mapper/KnowledgeBasePathMapper.java new file mode 100644 index 0000000..447fee9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/KnowledgeBasePathMapper.java @@ -0,0 +1,7 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import top.tqx.demo_1.entity.KnowledgeBasePath; + +public interface KnowledgeBasePathMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/OperationLogMapper.java b/src/main/java/top/tqx/demo_1/mapper/OperationLogMapper.java new file mode 100644 index 0000000..cbf64cc --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/OperationLogMapper.java @@ -0,0 +1,15 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.OperationLog; + +import java.util.List; + +@Mapper +public interface OperationLogMapper extends BaseMapper { + + List selectByUserId(Long userId); + + List selectByTargetId(Long targetId, String targetType); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/PermissionMapper.java b/src/main/java/top/tqx/demo_1/mapper/PermissionMapper.java new file mode 100644 index 0000000..46f6f32 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/PermissionMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.Permission; + +@Mapper +public interface PermissionMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/QuestionMapper.java b/src/main/java/top/tqx/demo_1/mapper/QuestionMapper.java new file mode 100644 index 0000000..0e57eec --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/QuestionMapper.java @@ -0,0 +1,7 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import top.tqx.demo_1.entity.Question; + +public interface QuestionMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/ReportMapper.java b/src/main/java/top/tqx/demo_1/mapper/ReportMapper.java new file mode 100644 index 0000000..d0a885a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/ReportMapper.java @@ -0,0 +1,12 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import top.tqx.demo_1.entity.Report; + +@Mapper +public interface ReportMapper extends BaseMapper { + + Report selectByType(@Param("reportType") String reportType); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/RoleMapper.java b/src/main/java/top/tqx/demo_1/mapper/RoleMapper.java new file mode 100644 index 0000000..7cb7aab --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/RoleMapper.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.Role; + +@Mapper +public interface RoleMapper extends BaseMapper { + +} diff --git a/src/main/java/top/tqx/demo_1/mapper/RolePermissionMapper.java b/src/main/java/top/tqx/demo_1/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..fcccc1f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/RolePermissionMapper.java @@ -0,0 +1,18 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import top.tqx.demo_1.entity.Permission; +import top.tqx.demo_1.entity.RolePermission; + +import java.util.List; + +@Mapper +public interface RolePermissionMapper extends BaseMapper { + + @Select("SELECT p.* FROM permission p " + + "INNER JOIN role_permission rp ON p.id = rp.permission_id " + + "WHERE rp.role_id = #{roleId}") + List selectPermissionsByRoleId(Long roleId); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/SystemConfigMapper.java b/src/main/java/top/tqx/demo_1/mapper/SystemConfigMapper.java new file mode 100644 index 0000000..ef260b2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/SystemConfigMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.SystemConfig; + +@Mapper +public interface SystemConfigMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/UserAnswerMapper.java b/src/main/java/top/tqx/demo_1/mapper/UserAnswerMapper.java new file mode 100644 index 0000000..5efbdd5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/UserAnswerMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.UserAnswer; + +@Mapper +public interface UserAnswerMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/mapper/UserMapper.java b/src/main/java/top/tqx/demo_1/mapper/UserMapper.java new file mode 100644 index 0000000..0ce2a39 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/UserMapper.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.User; + +@Mapper +public interface UserMapper extends BaseMapper { + +} diff --git a/src/main/java/top/tqx/demo_1/mapper/UserRoleMapper.java b/src/main/java/top/tqx/demo_1/mapper/UserRoleMapper.java new file mode 100644 index 0000000..f1037bc --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/UserRoleMapper.java @@ -0,0 +1,18 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.entity.UserRole; + +import java.util.List; + +@Mapper +public interface UserRoleMapper extends BaseMapper { + + @Select("SELECT r.* FROM role r " + + "INNER JOIN user_role ur ON r.id = ur.role_id " + + "WHERE ur.user_id = #{userId}") + List selectRolesByUserId(Long userId); +} diff --git a/src/main/java/top/tqx/demo_1/mapper/WrongQuestionCollectionMapper.java b/src/main/java/top/tqx/demo_1/mapper/WrongQuestionCollectionMapper.java new file mode 100644 index 0000000..7955e7f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/mapper/WrongQuestionCollectionMapper.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.tqx.demo_1.entity.WrongQuestionCollection; + +@Mapper +public interface WrongQuestionCollectionMapper extends BaseMapper { +} diff --git a/src/main/java/top/tqx/demo_1/service/AiChatService.java b/src/main/java/top/tqx/demo_1/service/AiChatService.java new file mode 100644 index 0000000..3b5e224 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/AiChatService.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.RagRequest; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +public interface AiChatService { + + SseEmitter streamFromRagService(RagRequest request, Long userId, Long deptId) throws Exception; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/AsyncExamService.java b/src/main/java/top/tqx/demo_1/service/AsyncExamService.java new file mode 100644 index 0000000..d69bf3a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/AsyncExamService.java @@ -0,0 +1,217 @@ +package top.tqx.demo_1.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.ExamGenerateRequest; +import top.tqx.demo_1.dto.ExamGenerateResponse; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.enums.FileProcessStepStatus; +import top.tqx.demo_1.mapper.FileMapper; + +import java.time.LocalDateTime; + +@Slf4j +@Service +public class AsyncExamService { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private ExamService examService; + + @Autowired + private FileMapper fileMapper; + + public static final String EXAM_STATUS_UNGENERATED = "UNGENERATED"; + public static final String EXAM_STATUS_GENERATING = "GENERATING"; + public static final String EXAM_STATUS_GENERATED = "GENERATED"; + public static final String EXAM_STATUS_FAILED = "FAILED"; + + @Async("examTaskExecutor") + public void generateQuestionsAsync(ExamGenerateRequest request) { + log.info("========== 异步任务开始执行 =========="); + log.info("requestId={}, filePath={}, fileId={}, 当前线程: {}", + request.getRequestId(), request.getFilePath(), request.getFileId(), Thread.currentThread().getName()); + + String filePath = request.getFilePath(); + Long fileId = null; + String errorMessage = null; + + try { + log.info("步骤1: 查找文件ID"); + + // 优先使用 request 中的 fileId(来自 collection_file) + if (request.getFileId() != null) { + log.info("优先使用 request 中的 fileId: {}", request.getFileId()); + fileId = request.getFileId(); + } + + // 如果没有 request 中的 fileId,尝试通过路径查询 + if (fileId == null && filePath != null && !filePath.isEmpty()) { + fileId = findFileIdByPath(filePath, null); + log.info("通过路径查询得到 fileId: {}", fileId); + } + + log.info("最终确定的 fileId: {}", fileId); + + // 步骤2: 更新状态为 GENERATING(只要有 fileId 就更新) + if (fileId != null) { + log.info("步骤2: 更新状态为 GENERATING"); + updateFileExamStatus(fileId, EXAM_STATUS_GENERATING, "正在生成题目,请稍后..."); + } + + log.info("步骤3: 调用 generateQuestions"); + ExamGenerateResponse response = examService.generateQuestions(request); + log.info("步骤3完成: total={}", response.getTotal()); + + // 步骤4: 更新状态为完成 + if (fileId != null) { + log.info("步骤4: 更新状态为 GENERATED"); + if (response.getTotal() == null || response.getTotal() == 0) { + log.warn("RAG服务返回0道题目,记录警告信息"); + updateFileExamStatus(fileId, EXAM_STATUS_GENERATED, "警告:未生成任何题目。可能原因:文档内容为空、文档内容无法提取题目、或文档格式不支持。source_chunks_used=" + response.getSourceChunksUsed()); + } else { + String processMessage = "生成成功,共" + response.getTotal() + "道题"; + log.info("response.getAiAnalysis() = {}", response.getAiAnalysis()); + log.info("response.getAiAnalysis() == null? {}", response.getAiAnalysis() == null); + if (response.getAiAnalysis() != null) { + try { + String aiAnalysisJson = objectMapper.writeValueAsString(response.getAiAnalysis()); + log.info("序列化成功的 aiAnalysisJson = {}", aiAnalysisJson); + processMessage += "\n\n【AI分析结果】\n" + aiAnalysisJson; + } catch (Exception e) { + log.warn("序列化 aiAnalysis 失败: {}", e.getMessage(), e); + String aiAnalysisString = response.getAiAnalysis().toString(); + log.info("使用 toString(): {}", aiAnalysisString); + processMessage += "\n\n【AI分析结果】" + aiAnalysisString; + } + } else { + log.warn("response.getAiAnalysis() 为 null,不追加 AI 分析结果"); + } + log.info("最终 processMessage = {}", processMessage); + updateFileExamStatus(fileId, EXAM_STATUS_GENERATED, processMessage); + } + } + + log.info("========== 异步任务成功完成 =========="); + } catch (Exception e) { + String errorType = e.getMessage() != null && (e.getMessage().contains("500") || e.getMessage().contains("INTERNAL SERVER ERROR")) + ? "[AI端错误]" : "[本地错误]"; + errorMessage = String.format("生成失败%s: %s", errorType, e.getMessage()); + log.error("========== 异步任务执行失败 ==========", e); + if (fileId != null) { + updateFileExamStatus(fileId, EXAM_STATUS_FAILED, errorMessage); + } + // 不抛出RuntimeException,避免产生"Unexpected exception occurred invoking async method"日志 + log.info("异步任务已优雅结束,文件状态已更新为FAILED"); + } + } + + private Long findFileIdByPath(String filePath, Long requestFileId) { + try { + log.info("开始查询 file 表: filePath={}, requestFileId={}", filePath, requestFileId); + + // 优先通过 ID 查询(如果提供了 requestFileId) + if (requestFileId != null) { + log.info("尝试通过 ID 查询: fileId={}", requestFileId); + File fileById = fileMapper.selectById(requestFileId); + if (fileById != null) { + log.info("通过 ID 查询成功: id={}, fileName={}, filePath={}", + fileById.getId(), fileById.getFileName(), fileById.getFilePath()); + return fileById.getId(); + } else { + log.warn("通过 ID 查询失败,尝试通过路径查询"); + } + } + + // 其次通过路径查询 + log.info("尝试通过路径查询: filePath={}", filePath); + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + wrapper.eq(File::getFilePath, filePath); + File file = fileMapper.selectOne(wrapper); + + if (file != null) { + log.info("通过路径查询成功: id={}, fileName={}, filePath={}", + file.getId(), file.getFileName(), file.getFilePath()); + return file.getId(); + } else { + log.warn("通过路径查询失败: filePath={}", filePath); + + // 尝试模糊查询,看看是否有相似的路径 + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper likeWrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + likeWrapper.like(File::getFilePath, "3.docx"); + java.util.List similarFiles = fileMapper.selectList(likeWrapper); + if (similarFiles != null && !similarFiles.isEmpty()) { + log.info("找到 {} 条相似的文件记录:", similarFiles.size()); + for (File f : similarFiles) { + log.info(" - id={}, fileName={}, filePath={}", + f.getId(), f.getFileName(), f.getFilePath()); + } + } + + return null; + } + } catch (Exception e) { + log.error("查询文件失败: filePath={}, requestFileId={}, error={}", + filePath, requestFileId, e.getMessage(), e); + return null; + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void updateFileExamStatus(Long fileId, String status, String message) { + if (fileId == null) { + log.warn("更新文件状态失败: fileId为null"); + return; + } + try { + File file = fileMapper.selectById(fileId); + if (file != null) { + log.info("更新文件状态: fileId={}, 从 {} 变为 {}", fileId, file.getExamStatus(), status); + file.setExamStatus(status); + if (message != null) { + file.setProcessMessage(message); + } + + // 更新流程状态 + if (EXAM_STATUS_GENERATING.equals(status)) { + file.setProcessStepStatus(FileProcessStepStatus.EXAM_GENERATING.getCode()); + file.setProcessStepMessage(FileProcessStepStatus.EXAM_GENERATING.getDescription()); + } else if (EXAM_STATUS_GENERATED.equals(status)) { + // 如果向量化已完成且题目生成完成,则标记为全部完成 + if (FileProcessStepStatus.VECTORIZED.getCode().equals(file.getProcessStepStatus())) { + file.setProcessStepStatus(FileProcessStepStatus.COMPLETED.getCode()); + file.setProcessStepMessage(FileProcessStepStatus.COMPLETED.getDescription()); + } else { + file.setProcessStepStatus(FileProcessStepStatus.EXAM_GENERATED.getCode()); + file.setProcessStepMessage(FileProcessStepStatus.EXAM_GENERATED.getDescription()); + } + } else if (EXAM_STATUS_FAILED.equals(status)) { + file.setProcessStepStatus(FileProcessStepStatus.EXAM_GENERATE_FAILED.getCode()); + String stepMsg = FileProcessStepStatus.EXAM_GENERATE_FAILED.getDescription() + ": " + (message != null ? message : ""); + file.setProcessStepMessage(stepMsg.length() > 500 ? stepMsg.substring(0, 500) : stepMsg); + } + + file.setUpdateTime(LocalDateTime.now()); + int result = fileMapper.updateById(file); + if (result > 0) { + log.info("文件状态更新成功: fileId={}", fileId); + } else { + log.warn("文件状态更新失败: fileId={}, status={}, 未影响任何行", fileId, status); + } + } else { + log.warn("更新文件状态失败: fileId={} 对应的文件不存在", fileId); + } + } catch (Exception e) { + log.error("更新文件题目状态失败: fileId={}, status={}, error={}", fileId, status, e.getMessage(), e); + throw new RuntimeException("更新文件状态失败", e); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/AsyncVectorizeService.java b/src/main/java/top/tqx/demo_1/service/AsyncVectorizeService.java new file mode 100644 index 0000000..3ff38a1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/AsyncVectorizeService.java @@ -0,0 +1,534 @@ +package top.tqx.demo_1.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.core.io.ByteArrayResource; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.ExamGenerateRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.enums.FileProcessStepStatus; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +public class AsyncVectorizeService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private FileMapper fileMapper; + + @Autowired + private KnowledgeBasePathMapper knowledgeBasePathMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + @Autowired + private CollectionFileService collectionFileService; + + @Autowired + private AsyncExamService asyncExamService; + + @Value("${file.upload.path:./uploads/}") + private String uploadPath; + + public static final String VECTORIZE_STATUS_PENDING = "PENDING"; + public static final String VECTORIZE_STATUS_VECTORIZING = "VECTORIZING"; + public static final String VECTORIZE_STATUS_INDEXED = "INDEXED"; + public static final String VECTORIZE_STATUS_FAILED = "FAILED"; + + @Async("vectorizeTaskExecutor") + public void vectorizeFileAsync(String collection, Long fileId, Long userId) { + log.info("========== 异步文件向量化任务开始 =========="); + log.info("collection={}, fileId={}, userId={}, 当前线程: {}", + collection, fileId, userId, Thread.currentThread().getName()); + + long startTime = System.currentTimeMillis(); + + try { + File file = waitForFileExists(fileId, 5); + if (file == null) { + log.warn("文件不存在,跳过向量化: fileId={}", fileId); + return; + } + + ensureCollectionExists(collection, file.getDeptId(), userId); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_VECTORIZING, "正在向量化,请稍后..."); + + String filePath = file.getFilePath(); + if (filePath == null || filePath.isEmpty()) { + log.error("文件路径为空: fileId={}, fileName={}", fileId, file.getFileName()); + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, "文件路径为空"); + return; + } + + Path path = Paths.get(uploadPath, filePath).normalize(); + if (!Files.exists(path)) { + log.error("文件不存在于服务器: fileId={}, fileName={}, path={}", + fileId, file.getFileName(), path.toAbsolutePath()); + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, "文件不存在于服务器"); + return; + } + + byte[] fileContent = Files.readAllBytes(path); + String originalFilename = file.getFileName(); + + Map uploadResult = uploadToRAG(collection, originalFilename, fileContent); + + Boolean uploadSuccess = (Boolean) uploadResult.get("success"); + if (uploadSuccess == null || !uploadSuccess) { + String message = (String) uploadResult.get("message"); + throw new RuntimeException("文件上传到向量库失败: " + message); + } + + if (file.getVectorDbAddress() == null || file.getVectorDbAddress().isEmpty()) { + file.setVectorDbAddress(collection + "/" + originalFilename); + file.setUpdateTime(LocalDateTime.now()); + fileMapper.updateById(file); + } + + collectionFileService.saveOrUpdate(collection, originalFilename, fileId, file.getFilePath(), file.getExtension()); + + long duration = System.currentTimeMillis() - startTime; + String successMessage = String.format("向量化成功,耗时 %d 毫秒,生成 %d 个切片", + duration, uploadResult.get("chunkCount")); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_INDEXED, successMessage); + + log.info("文件向量化成功: collection={}, fileId={}, fileName={}, duration={}ms", + collection, fileId, originalFilename, duration); + + triggerSync(); + + triggerExamGeneration(fileId, collection, originalFilename, userId); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + String errorType = e.getMessage() != null && (e.getMessage().contains("500") || e.getMessage().contains("INTERNAL SERVER ERROR")) + ? "[AI端错误]" : "[本地错误]"; + String errorMessage = String.format("向量化失败%s: %s (耗时 %d 毫秒)", errorType, e.getMessage(), duration); + log.error("========== 异步文件向量化任务失败 ==========", e); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, errorMessage); + + throw new RuntimeException(errorMessage, e); + } + } + + @Async("vectorizeTaskExecutor") + public void batchVectorizeFilesAsync(String collection, Long fileId, Long userId, String requestBatchId) { + log.info("========== 异步批量文件向量化子任务开始 =========="); + log.info("collection={}, fileId={}, userId={}, requestBatchId={}, 当前线程: {}", + collection, fileId, userId, requestBatchId, Thread.currentThread().getName()); + + long startTime = System.currentTimeMillis(); + + try { + File file = waitForFileExists(fileId, 5); + if (file == null) { + log.warn("文件不存在,跳过向量化: fileId={}, batchId={}", fileId, requestBatchId); + return; + } + + ensureCollectionExists(collection, file.getDeptId(), userId); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_VECTORIZING, + String.format("正在向量化(批次ID: %s),请稍后...", requestBatchId)); + + String filePath = file.getFilePath(); + if (filePath == null || filePath.isEmpty()) { + log.error("文件路径为空: fileId={}, fileName={}, batchId={}", + fileId, file.getFileName(), requestBatchId); + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, + String.format("文件路径为空(批次ID: %s)", requestBatchId)); + return; + } + + Path path = Paths.get(uploadPath, filePath).normalize(); + if (!Files.exists(path)) { + log.error("文件不存在于服务器: fileId={}, fileName={}, path={}, batchId={}", + fileId, file.getFileName(), path.toAbsolutePath(), requestBatchId); + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, + String.format("文件不存在于服务器(批次ID: %s)", requestBatchId)); + return; + } + + byte[] fileContent = Files.readAllBytes(path); + String originalFilename = file.getFileName(); + + Map uploadResult = uploadToRAG(collection, originalFilename, fileContent); + + Boolean uploadSuccess = (Boolean) uploadResult.get("success"); + if (uploadSuccess == null || !uploadSuccess) { + String message = (String) uploadResult.get("message"); + throw new RuntimeException("文件上传到向量库失败: " + message); + } + + if (file.getVectorDbAddress() == null || file.getVectorDbAddress().isEmpty()) { + file.setVectorDbAddress(collection + "/" + originalFilename); + file.setUpdateTime(LocalDateTime.now()); + fileMapper.updateById(file); + } + + collectionFileService.saveOrUpdate(collection, originalFilename, fileId, file.getFilePath(), file.getExtension()); + + long duration = System.currentTimeMillis() - startTime; + String successMessage = String.format("向量化成功,耗时 %d 毫秒,生成 %d 个切片(批次ID: %s)", + duration, uploadResult.get("chunkCount"), requestBatchId); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_INDEXED, successMessage); + + log.info("文件向量化成功: collection={}, fileId={}, fileName={}, duration={}ms", + collection, fileId, originalFilename, duration); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + String errorType = e.getMessage() != null && (e.getMessage().contains("500") || e.getMessage().contains("INTERNAL SERVER ERROR")) + ? "[AI端错误]" : "[本地错误]"; + String errorMessage = String.format("向量化失败%s: %s (耗时 %d 毫秒,批次ID: %s)", + errorType, e.getMessage(), duration, requestBatchId); + log.error("========== 异步批量文件向量化子任务失败 ==========", e); + + updateFileProcessStatus(fileId, VECTORIZE_STATUS_FAILED, errorMessage); + + throw new RuntimeException(errorMessage, e); + } + } + + private File waitForFileExists(Long fileId, int maxRetries) { + for (int i = 0; i < maxRetries; i++) { + File file = fileMapper.selectById(fileId); + if (file != null) { + if (i > 0) { + log.info("文件查询成功,重试次数: {}", i); + } + return file; + } + + if (i < maxRetries - 1) { + try { + long waitTime = 100 * (i + 1); + log.debug("文件暂未找到,等待 {}ms 后重试 ({}/{})", waitTime, i + 1, maxRetries); + Thread.sleep(waitTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("等待被中断: fileId={}", fileId); + return null; + } + } + } + + log.error("文件查询超时,已重试 {} 次: fileId={}", maxRetries, fileId); + return null; + } + + private void ensureCollectionExists(String collectionName, Long deptId, Long userId) { + try { + log.info("检查collection是否存在: collectionName={}", collectionName); + + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getList()); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map responseBody = objectMapper.readValue( + response.getBody(), + new com.fasterxml.jackson.core.type.TypeReference>() {}); + + @SuppressWarnings("unchecked") + java.util.List> collections = + (java.util.List>) responseBody.get("collections"); + + boolean exists = false; + if (collections != null) { + for (Map coll : collections) { + if (collectionName.equals(coll.get("name"))) { + exists = true; + log.info("Collection已存在: {}", collectionName); + break; + } + } + } + + if (!exists) { + log.info("Collection不存在,准备创建: collectionName={}, deptId={}", collectionName, deptId); + createCollection(collectionName, deptId, userId); + } + } + } catch (org.springframework.web.client.ResourceAccessException e) { + log.warn("RAG服务不可用,跳过collection检查: collectionName={}, 错误: {}", + collectionName, e.getMessage()); + log.warn("请确保RAG服务已启动并在配置中指定正确的地址 (当前配置: http://{}:{})", + aiApiConfig.getHost(), aiApiConfig.getPort()); + } catch (Exception e) { + log.error("检查或创建collection失败: collectionName={}", collectionName, e); + throw new RuntimeException("确保collection存在失败: " + e.getMessage(), e); + } + } + + private void createCollection(String collectionName, Long deptId, Long userId) { + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getCreate()); + + String departmentName = getDepartmentName(deptId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map requestBody = new HashMap<>(); + requestBody.put("name", collectionName); + requestBody.put("display_name", generateDisplayName(collectionName, deptId)); + requestBody.put("department", departmentName); + requestBody.put("description", "自动创建的向量库 - " + collectionName); + requestBody.put("uploader", userId != null ? userId.toString() : "system"); + requestBody.put("owner_type", deptId != null ? "department" : "personal"); + requestBody.put("visibility", "public"); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("Collection创建成功: collectionName={}, deptId={}, departmentName={}", collectionName, deptId, departmentName); + + saveKnowledgeBasePath(collectionName, deptId, userId); + } else if (response.getStatusCode().value() == 400) { + String body = response.getBody() != null ? response.getBody() : ""; + log.warn("RAG服务返回400错误,可能collection已存在: collectionName={}, body={}", collectionName, body); + + if (body.contains("已存在") || body.contains("exists") || body.contains("already")) { + log.info("Collection已存在,直接使用: {}", collectionName); + + KnowledgeBasePath existingKb = knowledgeBasePathMapper.selectOne( + new LambdaQueryWrapper().eq(KnowledgeBasePath::getName, collectionName) + ); + + if (existingKb == null) { + log.info("本地记录不存在,创建知识库路径记录: {}", collectionName); + saveKnowledgeBasePath(collectionName, deptId, userId); + } + } else { + log.error("Collection创建失败: status={}, body={}", response.getStatusCode(), body); + throw new RuntimeException("Collection创建失败: " + body); + } + } else { + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.error("Collection创建失败: status={}, body={}", response.getStatusCode(), errorMsg); + throw new RuntimeException("Collection创建失败: " + errorMsg); + } + } catch (org.springframework.web.client.ResourceAccessException e) { + log.warn("RAG服务不可用,无法创建collection: collectionName={}, 错误: {}", + collectionName, e.getMessage()); + log.warn("请手动在RAG服务中创建collection,或稍后重试"); + } catch (Exception e) { + log.error("创建collection异常: collectionName={}, deptId={}", collectionName, deptId, e); + throw new RuntimeException("创建collection失败: " + e.getMessage(), e); + } + } + + private String getDepartmentName(Long deptId) { + if (deptId == null) { + return "公共部门"; + } + try { + Department dept = departmentMapper.selectById(deptId); + if (dept != null && dept.getDeptName() != null) { + return dept.getDeptName(); + } + } catch (Exception e) { + log.warn("查询部门名称失败: deptId={}", deptId, e); + } + return "公共部门"; + } + + private String generateDisplayName(String collectionName, Long deptId) { + if (collectionName.startsWith("dept_") && collectionName.endsWith("_kb")) { + String deptIdStr = collectionName.substring(5, collectionName.length() - 3); + return "部门" + deptIdStr + "知识库"; + } else if ("public_kb".equals(collectionName)) { + return "公共知识库"; + } else if ("private_kb".equals(collectionName)) { + return "个人私有知识库"; + } + return collectionName; + } + + private void saveKnowledgeBasePath(String collectionName, Long deptId, Long userId) { + try { + String departmentName = getDepartmentName(deptId); + + KnowledgeBasePath kbPath = new KnowledgeBasePath(); + kbPath.setName(collectionName); + kbPath.setDisplayName(generateDisplayName(collectionName, deptId)); + kbPath.setPath("/knowledge/" + collectionName); + kbPath.setDepartment(departmentName); + kbPath.setUploader(userId != null ? userId.toString() : "system"); + kbPath.setOwnerType(deptId != null ? "department" : "personal"); + kbPath.setVisibility("public"); + kbPath.setDescription("自动创建的向量库"); + kbPath.setCreateTime(LocalDateTime.now()); + kbPath.setUpdateTime(LocalDateTime.now()); + + knowledgeBasePathMapper.insert(kbPath); + log.info("保存知识库路径记录成功: collectionName={}, departmentName={}", collectionName, departmentName); + } catch (Exception e) { + log.error("保存知识库路径记录失败: collectionName={}", collectionName, e); + } + } + + private Map uploadToRAG(String collection, String filename, byte[] content) { + Map result = new HashMap<>(); + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getUpload()); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.MULTIPART_FORM_DATA); + + ByteArrayResource resource = new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }; + + LinkedMultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", resource); + body.add("collection", collection); + + org.springframework.http.HttpEntity> entity = + new org.springframework.http.HttpEntity<>(body, headers); + + org.springframework.http.ResponseEntity response = + restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = + new com.fasterxml.jackson.databind.ObjectMapper(); + Map responseBody = objectMapper.readValue( + response.getBody(), + new com.fasterxml.jackson.core.type.TypeReference>() {}); + result.putAll(responseBody); + result.put("success", true); + } else { + String errorBody = response.getBody() != null ? response.getBody() : ""; + result.put("success", false); + result.put("message", "RAG服务返回错误: " + response.getStatusCode() + " - " + errorBody); + } + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + log.error("上传文件到RAG服务失败", e); + } + return result; + } + + private void triggerSync() { + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getSync().getTrigger()); + log.info("触发AI端文档同步: {}", url); + org.springframework.http.ResponseEntity response = + restTemplate.postForEntity(url, null, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + log.info("文档同步触发成功"); + } else { + log.warn("文档同步触发失败: status={}, body={}", + response.getStatusCode(), response.getBody()); + } + } catch (Exception e) { + log.warn("触发文档同步时发生异常: {}", e.getMessage()); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void updateFileProcessStatus(Long fileId, String status, String message) { + if (fileId == null) { + log.warn("更新文件状态失败: fileId为null"); + return; + } + try { + File file = fileMapper.selectById(fileId); + if (file != null) { + log.info("更新文件向量化状态: fileId={}, 从 {} 变为 {}", fileId, file.getProcessStatus(), status); + file.setProcessStatus(status); + if (message != null) { + file.setProcessMessage(message.length() > 500 ? message.substring(0, 500) : message); + } + + // 更新流程状态 + if (VECTORIZE_STATUS_VECTORIZING.equals(status)) { + file.setProcessStepStatus(FileProcessStepStatus.VECTORIZING.getCode()); + file.setProcessStepMessage(FileProcessStepStatus.VECTORIZING.getDescription()); + } else if (VECTORIZE_STATUS_INDEXED.equals(status)) { + file.setProcessStepStatus(FileProcessStepStatus.VECTORIZED.getCode()); + file.setProcessStepMessage(FileProcessStepStatus.VECTORIZED.getDescription()); + } else if (VECTORIZE_STATUS_FAILED.equals(status)) { + file.setProcessStepStatus(FileProcessStepStatus.VECTORIZE_FAILED.getCode()); + String stepMsg = FileProcessStepStatus.VECTORIZE_FAILED.getDescription() + ": " + (message != null ? message : ""); + file.setProcessStepMessage(stepMsg.length() > 500 ? stepMsg.substring(0, 500) : stepMsg); + } + + file.setUpdateTime(LocalDateTime.now()); + int result = fileMapper.updateById(file); + if (result > 0) { + log.debug("文件状态更新成功: fileId={}", fileId); + } else { + log.warn("文件状态更新失败: fileId={}, status={}, 未影响任何行", fileId, status); + } + } else { + log.warn("更新文件状态失败: fileId={} 对应的文件不存在", fileId); + } + } catch (Exception e) { + log.error("更新文件向量化状态失败: fileId={}, status={}, error={}", fileId, status, e.getMessage(), e); + throw new RuntimeException("更新文件状态失败", e); + } + } + + private void triggerExamGeneration(Long fileId, String collection, String filename, Long userId) { + try { + log.info("触发出题任务: fileId={}, collection={}, filename={}", fileId, collection, filename); + + ExamGenerateRequest request = new ExamGenerateRequest(); + request.setFileId(fileId); + request.setCollection(collection); + request.setFilePath(collection + "/" + filename); + request.setDifficulty(3); + request.setRequestId("auto_gen_" + fileId + "_" + System.currentTimeMillis()); + + asyncExamService.generateQuestionsAsync(request); + + log.info("出题任务已触发: requestId={}", request.getRequestId()); + } catch (Exception e) { + log.error("触发出题任务失败: fileId={}", fileId, e); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/AuthService.java b/src/main/java/top/tqx/demo_1/service/AuthService.java new file mode 100644 index 0000000..088bb17 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/AuthService.java @@ -0,0 +1,191 @@ +package top.tqx.demo_1.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.JwtProperties; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.mapper.UserRoleMapper; +import top.tqx.demo_1.util.RedisUtil; +import top.tqx.demo_1.vo.LoginResponse; +import top.tqx.demo_1.vo.UserInfoVO; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class AuthService { + + @Autowired + private UserMapper userMapper; + + @Autowired + private UserRoleMapper userRoleMapper; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired(required = false) + private RedisUtil redisUtil; + + private SecretKey secretKey; + + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @PostConstruct + public void init() { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + public LoginResponse login(String username, String password) { + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + + if (user == null) { + log.warn("登录失败 - 用户不存在: {}", username); + throw new RuntimeException("用户名或密码错误"); + } + + if (!passwordEncoder.matches(password, user.getPassword())) { + log.warn("登录失败 - 密码错误: {}", username); + throw new RuntimeException("用户名或密码错误"); + } + + if ("0".equals(user.getStatus())) { + log.warn("登录失败 - 用户已被禁用: {}", username); + throw new RuntimeException("用户已被禁用"); + } + + String token = generateToken(user); + String refreshToken = generateRefreshToken(user); + + LoginResponse response = new LoginResponse(); + response.setToken(token); + response.setRefreshToken(refreshToken); + response.setExpiresIn(jwtProperties.getExpiration()); + response.setUserInfo(convertToUserInfoVO(user)); + + log.info("用户登录成功: {}", username); + return response; + } + + public void logout(String token) { + try { + var claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + Date expiration = claims.getExpiration(); + long ttl = expiration.getTime() - System.currentTimeMillis(); + + if (ttl > 0 && redisUtil != null) { + try { + String blacklistKey = "token:blacklist:" + token; + redisUtil.setIfAbsent(blacklistKey, "1", ttl, TimeUnit.MILLISECONDS); + log.info("Token 已加入黑名单"); + } catch (Exception e) { + log.debug("Redis 设置 Token 黑名单失败(Redis可能未启动): {}", e.getMessage()); + } + } + } catch (Exception e) { + log.warn("Token 解析失败", e); + } + } + + public UserInfoVO getUserInfo(Long userId) { + User user = userMapper.selectById(userId); + return convertToUserInfoVO(user); + } + + public List getUserRoleNames(Long userId) { + List roles = userRoleMapper.selectRolesByUserId(userId); + return roles.stream() + .map(Role::getRoleName) + .collect(Collectors.toList()); + } + + public String getUserMainRoleName(Long userId) { + List roleNames = getUserRoleNames(userId); + return roleNames.isEmpty() ? "普通用户" : roleNames.get(0); + } + + public User validateToken(String token) { + try { + if (redisUtil != null) { + try { + String blacklistKey = "token:blacklist:" + token; + if (redisUtil.hasKey(blacklistKey)) { + log.warn("Token 已在黑名单中"); + return null; + } + } catch (Exception e) { + log.debug("Redis 检查 Token 黑名单失败(Redis可能未启动): {}", e.getMessage()); + } + } + + var claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + Long userId = Long.parseLong(claims.getSubject()); + return userMapper.selectById(userId); + } catch (Exception e) { + return null; + } + } + + private String generateToken(User user) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.getExpiration()); + + return Jwts.builder() + .subject(String.valueOf(user.getId())) + .claim("username", user.getUsername()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + private String generateRefreshToken(User user) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.getExpiration() * 2); + + return Jwts.builder() + .subject(String.valueOf(user.getId())) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + private UserInfoVO convertToUserInfoVO(User user) { + UserInfoVO vo = new UserInfoVO(); + vo.setId(user.getId()); + vo.setUsername(user.getUsername()); + vo.setRealName(user.getRealName()); + vo.setEmail(user.getEmail()); + vo.setPhone(user.getPhone()); + vo.setAvatar(user.getAvatar()); + vo.setUserType(user.getUserType()); + vo.setLastLoginTime(user.getLastLoginTime()); + return vo; + } +} diff --git a/src/main/java/top/tqx/demo_1/service/ChatService.java b/src/main/java/top/tqx/demo_1/service/ChatService.java new file mode 100644 index 0000000..b82dedb --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ChatService.java @@ -0,0 +1,39 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.RagRequest; +import top.tqx.demo_1.entity.ChatMessage; +import top.tqx.demo_1.entity.ChatReference; +import top.tqx.demo_1.entity.ChatSession; + +import java.util.List; + +public interface ChatService { + + ChatSession createSession(Long userId, String sessionId); + + ChatSession getOrCreateSession(Long userId, String sessionId); + + ChatSession updateSessionSummary(Long sessionId, String summary); + + ChatMessage saveUserMessage(Long sessionId, String content, String kbPaths); + + ChatMessage saveAssistantMessage(Long sessionId, String content, String answer, String kbPaths, String messageType, Integer isFinished, Long durationMs, String images, String sources); + + ChatReference saveReference(Long messageId, String chunkId, String docPath, String docName, Integer page, String excerpt, Double score); + + List saveReferences(Long messageId, List references); + + List getUserSessions(Long userId); + + List getSessionMessages(String sessionId); + + List getSessionMessagesAsHistory(String sessionId); + + List getMessageReferences(Long messageId); + + List getSessionReferences(String sessionId); + + boolean deleteSession(String sessionId, Long userId); + + boolean deleteSessions(List sessionIds, Long userId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/CollectionFileService.java b/src/main/java/top/tqx/demo_1/service/CollectionFileService.java new file mode 100644 index 0000000..ed255fb --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/CollectionFileService.java @@ -0,0 +1,20 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.entity.CollectionFile; + +import java.util.List; + +public interface CollectionFileService { + + CollectionFile saveOrUpdate(String collectionName, String docName, Long fileId, String filePath, String docType); + + List findByDocName(String docName); + + List findByCollectionName(String collectionName); + + CollectionFile findByCollectionAndDocName(String collectionName, String docName); + + CollectionFile findByFileId(Long fileId); + + void delete(Long id); +} diff --git a/src/main/java/top/tqx/demo_1/service/DataPermissionService.java b/src/main/java/top/tqx/demo_1/service/DataPermissionService.java new file mode 100644 index 0000000..96d9825 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/DataPermissionService.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.service; + +import java.util.List; + +public interface DataPermissionService { + + List getUserAuthorizedFileIds(Long userId); + + List getAllSubDepartmentIds(Long deptId); +} diff --git a/src/main/java/top/tqx/demo_1/service/DepartmentService.java b/src/main/java/top/tqx/demo_1/service/DepartmentService.java new file mode 100644 index 0000000..9eee3d1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/DepartmentService.java @@ -0,0 +1,31 @@ +package top.tqx.demo_1.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import top.tqx.demo_1.dto.DepartmentCreateRequest; +import top.tqx.demo_1.dto.DepartmentUpdateRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.vo.DepartmentVO; +import top.tqx.demo_1.vo.UserVO; + +import java.util.List; + +public interface DepartmentService extends IService { + + List listDepartments(); + + void createDepartment(DepartmentCreateRequest request); + + void updateDepartment(Long id, DepartmentUpdateRequest request); + + void deleteDepartment(Long id); + + void setDepartmentManager(Long id, Long managerId); + + List getDepartmentUsers(Long deptId); + + Department getDepartmentById(Long id); + + String getDepartmentFullPath(Long deptId); + + List getDepartmentAncestors(Long deptId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/ExamLockService.java b/src/main/java/top/tqx/demo_1/service/ExamLockService.java new file mode 100644 index 0000000..469fd30 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ExamLockService.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.service; + +public interface ExamLockService { + + boolean tryAcquireLock(String paperId, Long userId, String sessionId); + + void releaseLock(String paperId, Long userId); + + boolean isLocked(String paperId, Long userId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/ExamPaperService.java b/src/main/java/top/tqx/demo_1/service/ExamPaperService.java new file mode 100644 index 0000000..61e9f77 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ExamPaperService.java @@ -0,0 +1,29 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.*; + +import java.util.List; +import java.util.Map; + +public interface ExamPaperService { + + ExamPaperGenerateResponse previewPaper(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType); + + ExamPaperPreviewResponse previewPaperWithDetails(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType); + + ExamPaperResponse savePaper(ExamPaperSaveRequest request, Long userId); + + void publishPaper(String paperId, ExamPublishRequest request, Long userId); + + void revokePaper(String paperId); + + void deletePaper(String paperId); + + ExamPaperResponse getPaperById(String paperId); + + PageResponse listPapers(String status, Integer page, Integer pageSize); + + List listPapersByDept(Long deptId); + + Map getPaperAssignment(String paperId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/ExamRecordService.java b/src/main/java/top/tqx/demo_1/service/ExamRecordService.java new file mode 100644 index 0000000..6242be1 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ExamRecordService.java @@ -0,0 +1,20 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.ExamRecordResponse; +import top.tqx.demo_1.dto.ExamSaveRequest; +import top.tqx.demo_1.dto.PageResponse; + +import java.util.List; + +public interface ExamRecordService { + + List getUserPapers(Long userId, String answerType); + + ExamRecordResponse startExamByPaperId(String paperId, Long userId, String answerType); + + ExamRecordResponse getRecordById(String recordId); + + PageResponse listRecords(String paperId, Integer page, Integer pageSize); + + void deleteRecordsByPaperId(String paperId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/ExamService.java b/src/main/java/top/tqx/demo_1/service/ExamService.java new file mode 100644 index 0000000..77c910f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ExamService.java @@ -0,0 +1,25 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.Question; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public interface ExamService { + + ExamGenerateResponse generateQuestions(ExamGenerateRequest request) throws IOException; + + Map generateQuestionsWithFileId(ExamGenerateRequest request); + + ExamGradeResponse gradeAnswers(ExamGradeRequest request) throws IOException; + + ExamGradeResponse gradeAnswers(ExamGradeRequest request, Long userId, String paperId) throws IOException; + + ExamGradeResponse gradeAnswers(ExamGradeRequest request, Long userId, String paperId, String sessionId, String answerType) throws IOException; + + ExamRecordResponse gradeAnswersForRecord(String recordId, ExamSaveRequest request, Long userId, String answerType); + + ExamPaperGenerateResponse generatePaper(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/FAQService.java b/src/main/java/top/tqx/demo_1/service/FAQService.java new file mode 100644 index 0000000..bca362d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/FAQService.java @@ -0,0 +1,29 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.FAQ; + +import java.util.List; + +public interface FAQService { + + FAQListResponse getFAQList(Integer page, Integer pageSize); + + FAQSuggestionListResponse getFAQSuggestions(Integer page, Integer pageSize); + + boolean approveSuggestion(Long id, String answer); + + boolean rejectSuggestion(Long id); + + FAQ createFAQ(FAQCreateRequest request); + + FAQ getFAQById(Long id); + + FAQ updateFAQ(Long id, FAQUpdateRequest request); + + boolean deleteFAQ(Long id); + + int batchDeleteFAQs(List ids); + + List listFAQs(String status, Integer limit); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/FAQSuggestionService.java b/src/main/java/top/tqx/demo_1/service/FAQSuggestionService.java new file mode 100644 index 0000000..92fb82c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/FAQSuggestionService.java @@ -0,0 +1,22 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.entity.FAQSuggestion; + +import java.util.List; + +public interface FAQSuggestionService { + + FAQSuggestion save(FAQSuggestion suggestion); + + FAQSuggestion findById(Long id); + + List findByStatus(String status); + + FAQSuggestion approve(Long id, String answer); + + void reject(Long id); + + void updateFrequency(Long id, Integer frequency); + + FAQSuggestion findOrCreate(String query, String answer); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/FeedbackService.java b/src/main/java/top/tqx/demo_1/service/FeedbackService.java new file mode 100644 index 0000000..265a66e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/FeedbackService.java @@ -0,0 +1,17 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.FeedbackCreateRequest; +import top.tqx.demo_1.dto.FeedbackListResponse; +import top.tqx.demo_1.dto.FeedbackResponse; +import top.tqx.demo_1.dto.FeedbackStatsResponse; + +public interface FeedbackService { + + FeedbackResponse submitFeedback(FeedbackCreateRequest request); + + FeedbackListResponse getFeedbackList(Integer page, Integer pageSize); + + FeedbackStatsResponse getFeedbackStats(); + + FeedbackListResponse getBadCases(); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/FilePermissionService.java b/src/main/java/top/tqx/demo_1/service/FilePermissionService.java new file mode 100644 index 0000000..6216769 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/FilePermissionService.java @@ -0,0 +1,23 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.FilePermissionRequest; +import top.tqx.demo_1.entity.FilePermission; +import top.tqx.demo_1.entity.User; + +import java.time.LocalDateTime; +import java.util.List; + +public interface FilePermissionService { + + boolean checkFilePermission(Long userId, Long fileId, Integer requiredPermission); + + void grantFilePermission(FilePermissionRequest request); + + void revokeFilePermission(Long permissionId); + + void updatePermissionExpire(Long permissionId, LocalDateTime expireTime); + + List getUserFilePermissions(Long userId, Long fileId); + + List getFileAuthorizedUsers(Long fileId); +} diff --git a/src/main/java/top/tqx/demo_1/service/FileService.java b/src/main/java/top/tqx/demo_1/service/FileService.java new file mode 100644 index 0000000..3ed108c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/FileService.java @@ -0,0 +1,34 @@ +package top.tqx.demo_1.service; + +import org.springframework.web.multipart.MultipartFile; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.vo.FileVO; + +import java.io.InputStream; + +public interface FileService { + + FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic); + + PageResult pageFiles(Integer pageNum, Integer pageSize, String fileName, top.tqx.demo_1.entity.User user); + + FileVO getFileById(Long id, top.tqx.demo_1.entity.User user); + + void deleteFile(Long id); + + byte[] getFileContent(Long id, top.tqx.demo_1.entity.User user); + + FileVO uploadStreamFromBackend(InputStream inputStream, String fileName, String fileType, Long userId, Long deptId) throws Exception; + + void updateFileDescription(Long fileId, String description); + + top.tqx.demo_1.entity.File getFileEntityById(Long id); + + void updateFileEntity(top.tqx.demo_1.entity.File file); + + void approveFile(Long fileId, Long auditorId); + + void rejectFile(Long fileId, Long auditorId); + + PageResult getPendingFiles(Integer pageNum, Integer pageSize); +} diff --git a/src/main/java/top/tqx/demo_1/service/ImageService.java b/src/main/java/top/tqx/demo_1/service/ImageService.java new file mode 100644 index 0000000..a09d2b9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ImageService.java @@ -0,0 +1,55 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.ImageCreateRequest; +import top.tqx.demo_1.dto.ImageListResponse; +import top.tqx.demo_1.dto.ImageStatsResponse; +import top.tqx.demo_1.dto.ImageUpdateRequest; +import top.tqx.demo_1.entity.Image; + +import java.util.List; +import java.util.Map; + +public interface ImageService { + + ImageListResponse getImageList(Integer limit, Integer offset); + + byte[] getImage(String imageId); + + ImageStatsResponse getImageStats(); + + Image createImage(ImageCreateRequest request); + + Image getImageById(Long id); + + Image updateImage(Long id, ImageUpdateRequest request); + + boolean deleteImage(Long id); + + int batchDeleteImages(List ids); + + Image getImageByImageId(String imageId); + + boolean existsByImageId(String imageId); + + Image downloadAndSaveImage(String imageId); + + Image downloadAndSaveImageWithMetadata(Map imageMetadata); + + Map getImageInfo(String imageId); + + /** + * 根据图片ID获取Nginx格式的URL + * @param imageId 图片ID + * @return Nginx格式的URL + */ + String getNginxUrlByImageId(String imageId); + + /** + * 根据文档名称、页码、片段ID等信息查找图片 + * @param docName 文档名称 + * @param page 页码 + * @param chunkId 片段ID + * @return 找到的图片列表 + */ + List findImagesByReference(String docName, Integer page, String chunkId); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/KBRouteService.java b/src/main/java/top/tqx/demo_1/service/KBRouteService.java new file mode 100644 index 0000000..9aa25d2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/KBRouteService.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.KBRouteRequest; +import top.tqx.demo_1.dto.KBRouteResponse; + +import java.io.IOException; + +public interface KBRouteService { + KBRouteResponse route(KBRouteRequest request) throws IOException; +} diff --git a/src/main/java/top/tqx/demo_1/service/KnowledgeBasePathService.java b/src/main/java/top/tqx/demo_1/service/KnowledgeBasePathService.java new file mode 100644 index 0000000..a2204f5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/KnowledgeBasePathService.java @@ -0,0 +1,11 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.entity.KnowledgeBasePath; +import java.util.List; + +public interface KnowledgeBasePathService { + + List getUserAccessibleKnowledgeBases(Long userId, Long deptId, String deptName); + + List getUserAccessibleKnowledgeBaseNames(Long userId, Long deptId, String deptName); +} diff --git a/src/main/java/top/tqx/demo_1/service/KnowledgeBaseService.java b/src/main/java/top/tqx/demo_1/service/KnowledgeBaseService.java new file mode 100644 index 0000000..5534da9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/KnowledgeBaseService.java @@ -0,0 +1,38 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.vo.CollectionVO; +import top.tqx.demo_1.vo.ChunkVO; +import top.tqx.demo_1.vo.DocumentVO; + +import java.util.List; +import java.util.Map; + +public interface KnowledgeBaseService { + + List getCollections(Long userId, Long deptId, String deptCode); + + CollectionVO createCollection(String name, String displayName, String department, String description, Long userId); + + CollectionVO updateCollection(String name, String displayName, String department, String description); + + void deleteCollection(String name); + + List getCollectionChunks(String kbName, String documentId, Integer limit, Integer offset); + + Map deprecateDocument(String kbName, String filename, String reason, Long userId); + + Map restoreDocument(String kbName, String filename); + + List> getDocumentVersions(String kbName, String filename, Integer limit); + + DocumentVO uploadDocument(String collection, String filename, byte[] content, Long userId); + + List batchUploadDocuments(String collection, List filenames, List contents, Long userId); + + List getDocumentList(String collection, Long userId); + + void deleteDocument(String path); + + List getDocumentChunks(String path); +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/LogService.java b/src/main/java/top/tqx/demo_1/service/LogService.java new file mode 100644 index 0000000..6117d15 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/LogService.java @@ -0,0 +1,17 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.vo.OperationLogVO; + +import java.util.List; + +public interface LogService { + + void recordLog(Long userId, String operation, String content); + + PageResult pageLogs(Integer pageNum, Integer pageSize, String username, String operationType); + + List getUserLogs(Long userId); + + List getFileLogs(Long fileId); +} diff --git a/src/main/java/top/tqx/demo_1/service/PermissionService.java b/src/main/java/top/tqx/demo_1/service/PermissionService.java new file mode 100644 index 0000000..88bde28 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/PermissionService.java @@ -0,0 +1,14 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.entity.Permission; + +import java.util.List; + +public interface PermissionService { + + boolean hasPermission(Long userId, String permissionCode); + + List getUserPermissions(Long userId); + + List getUserPermissionDetails(Long userId); +} diff --git a/src/main/java/top/tqx/demo_1/service/QuestionSaveService.java b/src/main/java/top/tqx/demo_1/service/QuestionSaveService.java new file mode 100644 index 0000000..6b344fa --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/QuestionSaveService.java @@ -0,0 +1,9 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.ExamGenerateRequest; +import top.tqx.demo_1.dto.ExamGenerateResponse; + +public interface QuestionSaveService { + + void saveQuestionsToDatabase(ExamGenerateResponse response, ExamGenerateRequest request); +} diff --git a/src/main/java/top/tqx/demo_1/service/QuestionService.java b/src/main/java/top/tqx/demo_1/service/QuestionService.java new file mode 100644 index 0000000..ed301a6 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/QuestionService.java @@ -0,0 +1,41 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.dto.QuestionCreateRequest; +import top.tqx.demo_1.dto.QuestionStatsRequest; +import top.tqx.demo_1.dto.QuestionStatsResponse; +import top.tqx.demo_1.dto.QuestionUpdateRequest; +import top.tqx.demo_1.entity.Question; +import com.baomidou.mybatisplus.extension.service.IService; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +public interface QuestionService extends IService { + + String uploadAndProcessFile(MultipartFile file, String user, String customFileName) throws IOException; + + boolean saveQuestion(Question question); + + Question getByQuestionId(String questionId); + + boolean updateReviewStatus(String questionId, String status, String reviewerComment); + + PageResult pageQuestions(Integer pageNum, Integer pageSize, + String questionType, Integer difficulty, String status); + + List getPendingQuestions(); + + Question createQuestion(QuestionCreateRequest request); + + Question updateQuestion(Long id, QuestionUpdateRequest request); + + boolean deleteById(Long id); + + int batchDelete(List ids); + + List listQuestions(String questionType, Integer difficulty, String status, String documentName, Integer limit, Long userId, Long deptId, Integer userType); + + QuestionStatsResponse getQuestionStats(QuestionStatsRequest request); +} diff --git a/src/main/java/top/tqx/demo_1/service/RagService.java b/src/main/java/top/tqx/demo_1/service/RagService.java new file mode 100644 index 0000000..678ec19 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/RagService.java @@ -0,0 +1,217 @@ +package top.tqx.demo_1.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.AiApiConfig; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Slf4j +@Service +public class RagService { + + @Autowired + private AiApiConfig aiApiConfig; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public String postJsonRequest(String url, Map requestBody, int timeoutMs) throws IOException { + try { + ObjectMapper mapper = new ObjectMapper(); + String jsonBody = mapper.writeValueAsString(requestBody); + return doPostJsonRequest(url, jsonBody, timeoutMs); + } catch (Exception e) { + throw new IOException("序列化请求体失败: " + e.getMessage(), e); + } + } + + public String postJsonRequest(String url, String jsonBody, int timeoutMs) throws IOException { + return doPostJsonRequest(url, jsonBody, timeoutMs); + } + + private String doPostJsonRequest(String url, String jsonBody, int timeoutMs) throws IOException { + try { + java.net.URL requestUrl = new java.net.URL(url); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) requestUrl.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setDoInput(true); + connection.setUseCaches(false); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(30000); + connection.setReadTimeout(timeoutMs); + + connection.connect(); + + try (java.io.OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + log.info("RAG服务响应状态码: {}", responseCode); + + if (responseCode != 200) { + StringBuilder errorResponse = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + errorResponse.append(line); + } + } catch (Exception e) { + log.warn("读取RAG服务错误响应失败: {}", e.getMessage()); + } + String errorMsg = "RAG服务返回错误状态码: " + responseCode; + if (errorResponse.length() > 0) { + errorMsg += ", 错误信息: " + errorResponse.toString(); + log.error("RAG服务错误响应内容: {}", errorResponse.toString()); + } + throw new IOException(errorMsg); + } + + StringBuilder response = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + connection.disconnect(); + return response.toString(); + + } catch (java.net.SocketTimeoutException e) { + throw new IOException("请求超时: " + e.getMessage(), e); + } catch (java.net.UnknownHostException e) { + throw new IOException("无法连接RAG服务,请检查服务地址: " + e.getMessage(), e); + } catch (Exception e) { + throw new IOException("调用RAG服务失败: " + e.getMessage(), e); + } + } + + public String getRequest(String url, int timeoutMs) throws IOException { + try { + java.net.URL requestUrl = new java.net.URL(url); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) requestUrl.openConnection(); + connection.setRequestMethod("GET"); + connection.setDoInput(true); + connection.setUseCaches(false); + connection.setConnectTimeout(30000); + connection.setReadTimeout(timeoutMs); + + connection.connect(); + + int responseCode = connection.getResponseCode(); + log.info("RAG服务响应状态码: {}", responseCode); + + if (responseCode != 200) { + StringBuilder errorResponse = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + errorResponse.append(line); + } + } catch (Exception e) { + log.warn("读取RAG服务错误响应失败: {}", e.getMessage()); + } + String errorMsg = "RAG服务返回错误状态码: " + responseCode; + if (errorResponse.length() > 0) { + errorMsg += ", 错误信息: " + errorResponse.toString(); + log.error("RAG服务错误响应内容: {}", errorResponse.toString()); + } + throw new IOException(errorMsg); + } + + StringBuilder response = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + connection.disconnect(); + return response.toString(); + + } catch (java.net.SocketTimeoutException e) { + throw new IOException("请求超时: " + e.getMessage(), e); + } catch (java.net.UnknownHostException e) { + throw new IOException("无法连接RAG服务,请检查服务地址: " + e.getMessage(), e); + } catch (Exception e) { + throw new IOException("调用RAG服务失败: " + e.getMessage(), e); + } + } + + public String getJsonRequest(String url, int timeoutMs) throws IOException { + return getRequest(url, timeoutMs); + } + + public byte[] getBinaryRequest(String url, int timeoutMs) throws IOException { + try { + java.net.URL requestUrl = new java.net.URL(url); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) requestUrl.openConnection(); + connection.setRequestMethod("GET"); + connection.setDoInput(true); + connection.setUseCaches(false); + connection.setConnectTimeout(30000); + connection.setReadTimeout(timeoutMs); + + connection.connect(); + + int responseCode = connection.getResponseCode(); + log.info("RAG服务响应状态码: {}", responseCode); + + if (responseCode != 200) { + StringBuilder errorResponse = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + errorResponse.append(line); + } + } catch (Exception e) { + log.warn("读取RAG服务错误响应失败: {}", e.getMessage()); + } + String errorMsg = "RAG服务返回错误状态码: " + responseCode; + if (errorResponse.length() > 0) { + errorMsg += ", 错误信息: " + errorResponse.toString(); + log.error("RAG服务错误响应内容: {}", errorResponse.toString()); + } + throw new IOException(errorMsg); + } + + try (java.io.InputStream is = connection.getInputStream()) { + return is.readAllBytes(); + } + + } catch (java.net.SocketTimeoutException e) { + throw new IOException("请求超时: " + e.getMessage(), e); + } catch (java.net.UnknownHostException e) { + throw new IOException("无法连接RAG服务,请检查服务地址: " + e.getMessage(), e); + } catch (Exception e) { + throw new IOException("调用RAG服务失败: " + e.getMessage(), e); + } + } + + public JsonNode parseJson(String json) throws IOException { + return objectMapper.readTree(json); + } + + public ObjectNode createObjectNode() { + return objectMapper.createObjectNode(); + } + + public ObjectMapper getObjectMapper() { + return objectMapper; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/ReportService.java b/src/main/java/top/tqx/demo_1/service/ReportService.java new file mode 100644 index 0000000..6280a51 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/ReportService.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.ReportResponse; + +import java.io.IOException; + +public interface ReportService { + ReportResponse getWeeklyReport() throws IOException; + ReportResponse getMonthlyReport() throws IOException; +} diff --git a/src/main/java/top/tqx/demo_1/service/RoleService.java b/src/main/java/top/tqx/demo_1/service/RoleService.java new file mode 100644 index 0000000..0ce12d9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/RoleService.java @@ -0,0 +1,24 @@ + +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.RoleCreateRequest; +import top.tqx.demo_1.dto.RoleUpdateRequest; +import top.tqx.demo_1.vo.PermissionVO; +import top.tqx.demo_1.vo.RoleVO; + +import java.util.List; + +public interface RoleService { + + List listRoles(); + + void createRole(RoleCreateRequest request); + + void updateRole(Long id, RoleUpdateRequest request); + + void deleteRole(Long id); + + void assignPermissions(Long roleId, List permissionIds); + + List getRolePermissions(Long roleId); +} diff --git a/src/main/java/top/tqx/demo_1/service/SearchService.java b/src/main/java/top/tqx/demo_1/service/SearchService.java new file mode 100644 index 0000000..98505f0 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/SearchService.java @@ -0,0 +1,10 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.SearchRequest; +import top.tqx.demo_1.dto.SearchResponse; + +import java.io.IOException; + +public interface SearchService { + SearchResponse search(SearchRequest request) throws IOException; +} diff --git a/src/main/java/top/tqx/demo_1/service/SseEmitterManager.java b/src/main/java/top/tqx/demo_1/service/SseEmitterManager.java new file mode 100644 index 0000000..7b422ae --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/SseEmitterManager.java @@ -0,0 +1,72 @@ +package top.tqx.demo_1.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class SseEmitterManager { + + private final Map activeEmitters = new ConcurrentHashMap<>(); + + public void register(String sessionId, SseEmitter emitter) { + if (sessionId == null || emitter == null) { + return; + } + unregister(sessionId); + activeEmitters.put(sessionId, emitter); + log.info("📝 SSEEmitter registered: sessionId={}, activeCount={}", sessionId, activeEmitters.size()); + } + + public void unregister(String sessionId) { + if (sessionId == null) { + return; + } + SseEmitter removed = activeEmitters.remove(sessionId); + if (removed != null) { + log.info("📝 SSEEmitter unregistered: sessionId={}, activeCount={}", sessionId, activeEmitters.size()); + } + } + + public boolean stop(String sessionId) { + if (sessionId == null) { + return false; + } + + SseEmitter emitter = activeEmitters.get(sessionId); + if (emitter == null) { + log.warn("⚠️ SSEEmitter not found for stop: sessionId={}", sessionId); + return false; + } + + try { + emitter.send(SseEmitter.event() + .name("stop") + .data("{\"type\":\"stopped\",\"message\":\"用户主动中断\"}")); + emitter.complete(); + activeEmitters.remove(sessionId); + log.info("✅ SSEEmitter stopped: sessionId={}", sessionId); + return true; + } catch (Exception e) { + log.error("❌ Failed to stop SSEEmitter: sessionId={}, error={}", sessionId, e.getMessage()); + activeEmitters.remove(sessionId); + return false; + } + } + + public SseEmitter get(String sessionId) { + return sessionId != null ? activeEmitters.get(sessionId) : null; + } + + public boolean isActive(String sessionId) { + return sessionId != null && activeEmitters.containsKey(sessionId); + } + + public int getActiveCount() { + return activeEmitters.size(); + } +} diff --git a/src/main/java/top/tqx/demo_1/service/UserAnswerService.java b/src/main/java/top/tqx/demo_1/service/UserAnswerService.java new file mode 100644 index 0000000..39f2ef2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/UserAnswerService.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.UserAnswer; + +import java.util.List; + +public interface UserAnswerService { + + boolean saveAnswer(UserAnswer userAnswer); + + void batchSaveAnswers(List userAnswers); + + UserAnswerQueryResponse queryUserAnswers(UserAnswerQueryRequest request); + + WrongQuestionListResponse getWrongQuestions(Long userId, WrongQuestionListRequest request); + + boolean toggleWrongQuestionCollection(Long userId, WrongQuestionCollectRequest request); + + ExamPaperGenerateResponse redoWrongQuestions(Long userId, WrongQuestionRedoRequest request); + + List exportWrongQuestions(Long userId, WrongQuestionListRequest request); + + // 新增方法:检查题目是否已收藏 + boolean checkQuestionCollected(Long userId, String questionId); +} diff --git a/src/main/java/top/tqx/demo_1/service/UserService.java b/src/main/java/top/tqx/demo_1/service/UserService.java new file mode 100644 index 0000000..bc19ec2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/UserService.java @@ -0,0 +1,27 @@ +package top.tqx.demo_1.service; + +import top.tqx.demo_1.common.PageResult; + +import java.util.List; +import top.tqx.demo_1.dto.UserCreateRequest; +import top.tqx.demo_1.dto.UserUpdateRequest; +import top.tqx.demo_1.vo.UserVO; + +public interface UserService { + + PageResult pageUsers(Integer pageNum, Integer pageSize, String username, Long deptId); + + void createUser(UserCreateRequest request); + + void updateUser(UserUpdateRequest request); + + void deleteUser(Long id); + + void resetPassword(Long id); + + void updateStatus(Long id, Integer status); + + UserVO getUserById(Long id); + + void assignRoles(Long userId, List roleIds); +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/AiChatServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/AiChatServiceImpl.java new file mode 100644 index 0000000..4172623 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/AiChatServiceImpl.java @@ -0,0 +1,964 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.RagRequest; +import top.tqx.demo_1.dto.ReferenceResponse; +import top.tqx.demo_1.entity.ChatMessage; +import top.tqx.demo_1.entity.ChatReference; +import top.tqx.demo_1.entity.ChatSession; +import top.tqx.demo_1.entity.Image; +import top.tqx.demo_1.service.AiChatService; +import top.tqx.demo_1.service.ChatService; +import top.tqx.demo_1.service.ImageService; +import top.tqx.demo_1.service.SseEmitterManager; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.service.KnowledgeBasePathService; + +import org.springframework.beans.factory.annotation.Value; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@SuppressWarnings("unchecked") +public class AiChatServiceImpl implements AiChatService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private ChatService chatService; + + @Autowired + private SseEmitterManager sseEmitterManager; + + @Autowired + private ImageService imageService; + + // Nginx 图片访问前缀(从配置文件读取) + @Value("${file.upload.url-prefix:/files/}") + private String imageUrlPrefix; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public SseEmitter streamFromRagService(RagRequest request, Long userId, Long deptId) throws Exception { + SseEmitter emitter = new SseEmitter(300000L); + + String sessionId = request.getSessionId(); + + if (sessionId != null) { + sseEmitterManager.register(sessionId, emitter); + emitter.onCompletion(() -> sseEmitterManager.unregister(sessionId)); + emitter.onTimeout(() -> sseEmitterManager.unregister(sessionId)); + emitter.onError(e -> sseEmitterManager.unregister(sessionId)); + } + + final Long[] chatSessionId = {null}; + final ChatSession[] chatSession = {null}; + final ChatMessage[] savedAssistantMessage = {null}; + final List[] referenceList = new List[1]; + + CompletableFuture.runAsync(() -> { + final boolean[] emitterActive = {true}; + try { + String ragStreamUrl = aiApiConfig.getFullUrl(aiApiConfig.getChat().getRagStream()); + log.info("开始调用RAG服务: url={}, userId={}, message={}, collections={}, sessionId={}", + ragStreamUrl, userId, request.getMessage(), request.getCollections(), sessionId); + + chatSession[0] = chatService.getOrCreateSession(userId, sessionId); + chatSessionId[0] = chatSession[0].getId(); + + String kbPathsJson = objectMapper.writeValueAsString(request.getCollections()); + + ObjectNode requestBody = objectMapper.createObjectNode(); + requestBody.put("message", request.getMessage()); + requestBody.set("collections", objectMapper.valueToTree(request.getCollections())); + + List history = request.getHistory(); + if (history == null) { + history = new ArrayList<>(); + } + requestBody.set("chat_history", objectMapper.valueToTree(history)); + + if (sessionId != null) { + requestBody.put("session_id", sessionId); + } + if (request.getId() != null) { + requestBody.put("id", request.getId()); + } + + final StringBuilder fullAnswer = new StringBuilder(); + final String[] finalFullAnswer = {""}; + final List[] finalReferences = new List[1]; + finalReferences[0] = new ArrayList<>(); + referenceList[0] = new ArrayList<>(); + final List[] citationList = new List[1]; + citationList[0] = new ArrayList<>(); + final List[] imageIds = new List[1]; + imageIds[0] = new ArrayList<>(); + final List>[] imageMetadataList = new List[1]; + imageMetadataList[0] = new java.util.ArrayList<>(); + final java.util.Set[] processedChunkIds = new java.util.Set[1]; + processedChunkIds[0] = new java.util.HashSet<>(); + long startTime = System.currentTimeMillis(); + + log.info("开始流式调用RAG服务..."); + + try { + java.net.URL url = new java.net.URL(ragStreamUrl); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setDoInput(true); + connection.setUseCaches(false); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(30000); + connection.setReadTimeout(300000); + + connection.connect(); + + try (java.io.OutputStream os = connection.getOutputStream()) { + byte[] input = requestBody.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + log.info("RAG服务响应状态码: {}", responseCode); + + if (responseCode == 200) { + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(connection.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("📥 收到RAG原始数据: {}", line); + + if (line.trim().isEmpty()) { + continue; + } + + if (line.startsWith("data: ")) { + String jsonData = line.substring(6).trim(); + + if (jsonData.equals("[DONE]")) { + log.info("RAG服务流式响应结束"); + break; + } + + try { + JsonNode jsonNode = objectMapper.readTree(jsonData); + String type = jsonNode.has("type") ? jsonNode.get("type").asText() : ""; + + SseEmitter.SseEventBuilder event = SseEmitter.event() + .data(jsonNode); + + switch (type) { + case "start": + log.info("开始处理: {}", jsonNode.has("message") ? jsonNode.get("message").asText() : ""); + break; + + case "connected": + log.info("RAG连接成功: {}", jsonNode.has("message") ? jsonNode.get("message").asText() : ""); + break; + + case "thinking": + log.debug("RAG思考中: step={}, content={}", + jsonNode.has("step") ? jsonNode.get("step").asText() : "", + jsonNode.has("content") ? jsonNode.get("content").asText() : ""); + break; + + case "searching": + log.debug("RAG检索知识库: step={}, collection={}", + jsonNode.has("step") ? jsonNode.get("step").asText() : "", + jsonNode.has("collection") ? jsonNode.get("collection").asText() : ""); + break; + + case "sources": + log.info("收到检索来源信息"); + if (jsonNode.has("sources")) { + JsonNode sourcesNode = jsonNode.get("sources"); + if (sourcesNode.isArray()) { + for (JsonNode sourceItem : sourcesNode) { + ChatReference ref = parseSourceToReference(sourceItem); + String chunkId = ref.getChunkId(); + if (chunkId == null || chunkId.isEmpty()) { + chunkId = ref.getDocName() + "_" + ref.getPage() + "_" + ref.getSectionChunkId(); + } + if (!processedChunkIds[0].contains(chunkId)) { + processedChunkIds[0].add(chunkId); + referenceList[0].add(ref); + } + } + } + } + break; + + case "result": + case "finish": + log.info("RAG返回最终结果: type={}", type); + String answerFromFinish = ""; + if (jsonNode.has("answer")) { + answerFromFinish = jsonNode.get("answer").asText(); + finalFullAnswer[0] = answerFromFinish; + log.info("提取到finish事件的answer: length={}", answerFromFinish.length()); + } + if (jsonNode.has("sources")) { + JsonNode sourcesNode = jsonNode.get("sources"); + if (sourcesNode.isArray()) { + for (JsonNode sourceItem : sourcesNode) { + ChatReference ref = parseSourceToReference(sourceItem); + String chunkId = ref.getChunkId(); + if (chunkId == null || chunkId.isEmpty()) { + chunkId = ref.getDocName() + "_" + ref.getPage() + "_" + ref.getSectionChunkId(); + } + if (!processedChunkIds[0].contains(chunkId)) { + processedChunkIds[0].add(chunkId); + referenceList[0].add(ref); + } + } + log.info("从finish事件提取sources: count={}", sourcesNode.size()); + } + } + if (jsonNode.has("citations")) { + JsonNode citationsNode = jsonNode.get("citations"); + if (citationsNode.isArray()) { + for (JsonNode citationItem : citationsNode) { + ChatReference ref = parseCitationToReference(citationItem); + String chunkId = ref.getChunkId(); + if (chunkId == null || chunkId.isEmpty()) { + chunkId = ref.getDocName() + "_" + ref.getPage() + "_" + ref.getSectionChunkId(); + } + if (!processedChunkIds[0].contains(chunkId)) { + processedChunkIds[0].add(chunkId); + citationList[0].add(ref); + referenceList[0].add(ref); + } + } + log.info("从finish事件提取citations: count={}", citationsNode.size()); + } + } + if (jsonNode.has("images")) { + JsonNode imagesNode = jsonNode.get("images"); + log.info("[{}] [图片提取] 开始 - 检测到images数组, size={}", java.time.LocalDateTime.now(), imagesNode.isArray() ? imagesNode.size() : "not array"); + if (imagesNode.isArray()) { + int processedCount = 0; + int skippedCount = 0; + for (JsonNode imageNode : imagesNode) { + String imageId = null; + if (imageNode.has("id")) { + imageId = imageNode.get("id").asText(); + } else if (imageNode.has("image_id")) { + imageId = imageNode.get("image_id").asText(); + } + + if (imageId == null) { + log.warn("[{}] [图片提取] 跳过 - 图片ID为空", java.time.LocalDateTime.now()); + skippedCount++; + continue; + } + + if (imageIds[0].contains(imageId)) { + log.debug("[{}] [图片提取] 跳过 - 图片ID已存在, imageId={}", java.time.LocalDateTime.now(), imageId); + skippedCount++; + continue; + } + + imageIds[0].add(imageId); + log.info("[{}] [图片提取] 处理 - 提取图片ID: {}", java.time.LocalDateTime.now(), imageId); + + java.util.Map imageMetadata = new java.util.HashMap<>(); + imageMetadata.put("id", imageId); + + if (imageNode.has("source")) { + imageMetadata.put("source", imageNode.get("source").asText()); + log.debug("[{}] [图片提取] 设置source={}", java.time.LocalDateTime.now(), imageMetadata.get("source")); + } else { + log.warn("[{}] [图片提取] 警告 - 缺少source字段, imageId={}", java.time.LocalDateTime.now(), imageId); + } + + if (imageNode.has("page")) { + imageMetadata.put("page", imageNode.get("page").asInt()); + log.debug("[{}] [图片提取] 设置page={}", java.time.LocalDateTime.now(), imageMetadata.get("page")); + } else { + log.warn("[{}] [图片提取] 警告 - 缺少page字段, imageId={}", java.time.LocalDateTime.now(), imageId); + } + + if (imageNode.has("caption")) { + imageMetadata.put("description", imageNode.get("caption").asText()); + } else if (imageNode.has("description")) { + imageMetadata.put("description", imageNode.get("description").asText()); + } + if (imageMetadata.containsKey("description")) { + String desc = (String) imageMetadata.get("description"); + log.debug("[{}] [图片提取] 设置description(前50字符)={}", java.time.LocalDateTime.now(), + desc.length() > 50 ? desc.substring(0, 50) + "..." : desc); + } + + if (imageNode.has("full_description")) { + imageMetadata.put("full_description", imageNode.get("full_description").asText()); + log.debug("[{}] [图片提取] 设置full_description长度={}", java.time.LocalDateTime.now(), + imageMetadata.get("full_description").toString().length()); + } + + if (imageNode.has("type")) { + imageMetadata.put("type", imageNode.get("type").asText()); + log.debug("[{}] [图片提取] 设置type={}", java.time.LocalDateTime.now(), imageMetadata.get("type")); + } else { + log.warn("[{}] [图片提取] 警告 - 缺少type字段, imageId={}", java.time.LocalDateTime.now(), imageId); + } + + if (imageNode.has("score")) { + imageMetadata.put("score", imageNode.get("score").asDouble()); + log.debug("[{}] [图片提取] 设置score={}", java.time.LocalDateTime.now(), imageMetadata.get("score")); + } + + if (imageNode.has("url")) { + imageMetadata.put("url", imageNode.get("url").asText()); + } else { + imageMetadata.put("url", "/api/image/" + imageId + "/data"); + } + log.debug("[{}] [图片提取] 设置url={}", java.time.LocalDateTime.now(), imageMetadata.get("url")); + + imageMetadataList[0].add(imageMetadata); + processedCount++; + log.info("[{}] [图片提取] 完成 - 图片元数据已添加, imageId={}, metadataKeys={}", + java.time.LocalDateTime.now(), imageId, imageMetadata.keySet()); + } + log.info("[{}] [图片提取] 结束 - 处理完成, 总数={}, 已处理={}, 跳过={}", + java.time.LocalDateTime.now(), imagesNode.size(), processedCount, skippedCount); + } + } + break; // 发送finish事件给前端 + + case "chunk": + if (jsonNode.has("content")) { + String chunkContent = jsonNode.get("content").asText(); + fullAnswer.append(chunkContent); + log.debug("📤 收到chunk, 累计长度: {}", fullAnswer.length()); + } + break; + + case "image": + log.info("收到图片信息"); + if (jsonNode.has("image_id")) { + String imageId = jsonNode.get("image_id").asText(); + imageIds[0].add(imageId); + log.info("收集到图片ID: {}", imageId); + } + break; + + case "images": + log.info("收到图片列表"); + if (jsonNode.has("images") && jsonNode.get("images").isArray()) { + for (JsonNode imageNode : jsonNode.get("images")) { + if (imageNode.has("image_id")) { + String imageId = imageNode.get("image_id").asText(); + imageIds[0].add(imageId); + log.info("收集到图片ID: {}", imageId); + } + } + } + break; + + case "error": + log.error("RAG返回错误: {}", jsonNode); + break; + + default: + log.debug("RAG未知事件类型: {}", type); + break; + } + + emitter.send(event); + log.debug("📤 SSE事件已发送: type={}", type); + + } catch (Exception e) { + log.warn("解析RAG SSE数据失败: {}, data={}", e.getMessage(), jsonData); + if (e.getMessage() != null && (e.getMessage().contains("Broken pipe") || e.getMessage().contains("has already completed") || e.getMessage().contains("failed to flush"))) { + log.error("SSE连接已断开,停止处理: {}", e.getMessage()); + emitterActive[0] = false; + break; + } + } + } + } + } + } else { + log.error("RAG服务返回错误状态码: {}", responseCode); + } + + connection.disconnect(); + + } catch (Exception e) { + log.error("调用RAG服务失败: {}", e.getMessage(), e); + + if (emitterActive[0]) { + try { + ObjectNode errorNode = objectMapper.createObjectNode(); + errorNode.put("type", "error"); + + if (e instanceof java.net.SocketTimeoutException) { + errorNode.put("message", "RAG服务响应超时,请稍后重试或简化问题"); + } else if (e.getMessage() != null && e.getMessage().contains("has already completed")) { + log.warn("SSE连接已提前关闭,跳过发送错误事件"); + return; + } else { + errorNode.put("message", "AI服务暂时不可用,请稍后重试"); + } + + if (e.getMessage() != null && !e.getMessage().contains("has already completed")) { + errorNode.put("detail", e.getMessage()); + } + + SseEmitter.SseEventBuilder errorEvent = SseEmitter.event() + .name("error") + .data(errorNode); + emitter.send(errorEvent); + } catch (Exception ex) { + log.error("发送错误事件失败", ex); + } + try { + emitter.complete(); + } catch (Exception ex) { + log.warn("emitter.complete()失败: {}", ex.getMessage()); + } + } + return; + } + + String finalAnswer = finalFullAnswer[0]; + log.info("检查回答内容: finalFullAnswer.length={}, fullAnswer.length={}", + finalFullAnswer[0].length(), fullAnswer.length()); + + if (finalAnswer.isEmpty()) { + log.info("finalFullAnswer为空,使用fullAnswer"); + finalAnswer = fullAnswer.toString(); + } + long durationMs = System.currentTimeMillis() - startTime; + + log.info("准备保存消息: finalAnswer.length={}, references.size={}, images.size={}", + finalAnswer.length(), referenceList[0].size(), imageIds[0].size()); + + if (!finalAnswer.isEmpty()) { + try { + String saveOperationId = java.util.UUID.randomUUID().toString().substring(0, 8); + log.info("[{}] [{}] ========== 图片保存流程开始 ==========", java.time.LocalDateTime.now(), saveOperationId); + + if (!referenceList[0].isEmpty()) { + log.info("[{}] [{}] [来源图片处理] 开始 - 检查来源中的图片信息, referenceCount={}", + java.time.LocalDateTime.now(), saveOperationId, referenceList[0].size()); + int chartImageCount = 0; + for (ChatReference ref : referenceList[0]) { + if ("chart".equals(ref.getChunkType()) || "image".equals(ref.getChunkType())) { + chartImageCount++; + if (ref.getChunkId() != null && !ref.getChunkId().isEmpty()) { + try { + log.info("[{}] [{}] [来源图片处理] 处理 - chunkId={}, docName={}, page={}, chunkType={}", + java.time.LocalDateTime.now(), saveOperationId, + ref.getChunkId(), ref.getDocName(), ref.getPage(), ref.getChunkType()); + + java.util.Map imageMetadata = new java.util.HashMap<>(); + imageMetadata.put("id", ref.getChunkId()); + imageMetadata.put("source", ref.getDocName()); + imageMetadata.put("page", ref.getPage()); + imageMetadata.put("page_end", ref.getPageEnd()); + imageMetadata.put("page_range", ref.getPageRange()); + imageMetadata.put("section", ref.getSection()); + imageMetadata.put("chunk_type", ref.getChunkType()); + imageMetadata.put("score", ref.getScore() != null ? ref.getScore().doubleValue() : null); + imageMetadata.put("url", "/api/image/" + ref.getChunkId() + "/data"); + + log.debug("[{}] [{}] [来源图片处理] 调用downloadAndSaveImageWithMetadata, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkId()); + Image savedImage = imageService.downloadAndSaveImageWithMetadata(imageMetadata); + + if (savedImage != null) { + // 尝试获取Nginx格式的URL并更新到metadata + String nginxUrl = imageService.getNginxUrlByImageId(ref.getChunkId()); + if (nginxUrl != null) { + imageMetadata.put("url", nginxUrl); + log.info("[{}] [{}] [来源图片处理] 更新 - 图片URL已更新为Nginx格式, imageId={}, url={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkId(), nginxUrl); + } + imageIds[0].add(ref.getChunkId()); + imageMetadataList[0].add(imageMetadata); + log.info("[{}] [{}] [来源图片处理] 成功 - 图片信息已保存到数据库, imageId={}, dbId={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkId(), savedImage.getId()); + } else { + log.warn("[{}] [{}] [来源图片处理] 失败 - 图片保存返回null, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkId()); + } + } catch (Exception e) { + log.error("[{}] [{}] [来源图片处理] 异常 - 保存图片失败, imageId={}, error={}, stackTrace={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkId(), + e.getMessage(), org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(e)); + } + } else { + log.warn("[{}] [{}] [来源图片处理] 跳过 - chunkId为空, chunkType={}, docName={}", + java.time.LocalDateTime.now(), saveOperationId, ref.getChunkType(), ref.getDocName()); + } + } + } + log.info("[{}] [{}] [来源图片处理] 结束 - 完成处理, 图表/图片数量={}", + java.time.LocalDateTime.now(), saveOperationId, chartImageCount); + } + + if (!imageIds[0].isEmpty()) { + log.info("[{}] [{}] [图片处理] 开始 - 处理images列表中的图片, imageCount={}", + java.time.LocalDateTime.now(), saveOperationId, imageIds[0].size()); + + // 1. 首先处理有元数据的图片(来自finish事件中的images数组) + for (java.util.Map imageMetadata : imageMetadataList[0]) { + try { + String imageId = String.valueOf(imageMetadata.get("id")); + log.info("[{}] [{}] [有元数据图片处理] 处理 - imageId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId); + log.debug("[{}] [{}] [有元数据图片处理] 调用downloadAndSaveImageWithMetadata, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId); + + Image savedImage = imageService.downloadAndSaveImageWithMetadata(imageMetadata); + + if (savedImage != null) { + // 尝试获取Nginx格式的URL并更新到metadata + String nginxUrl = imageService.getNginxUrlByImageId(imageId); + if (nginxUrl != null) { + imageMetadata.put("url", nginxUrl); + log.info("[{}] [{}] [有元数据图片处理] 更新 - 图片URL已更新为Nginx格式, imageId={}, url={}", + java.time.LocalDateTime.now(), saveOperationId, imageId, nginxUrl); + } + log.info("[{}] [{}] [有元数据图片处理] 成功 - 图片已保存到数据库, imageId={}, dbId={}, path={}", + java.time.LocalDateTime.now(), saveOperationId, imageId, savedImage.getId(), savedImage.getPath()); + } else { + log.warn("[{}] [{}] [有元数据图片处理] 失败 - 图片保存返回null, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId); + } + } catch (Exception e) { + log.error("[{}] [{}] [有元数据图片处理] 异常 - 保存图片失败, error={}, stackTrace={}", + java.time.LocalDateTime.now(), saveOperationId, + e.getMessage(), org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(e)); + } + } + + // 2. 再处理没有元数据的图片(备用逻辑保留) + for (String imageId : imageIds[0]) { + if (imageMetadataList[0].stream().noneMatch(m -> imageId.equals(m.get("id")))) { + try { + log.info("[{}] [{}] [无元数据图片处理] 处理 - imageId={} (无元数据,需直接下载)", + java.time.LocalDateTime.now(), saveOperationId, imageId); + log.debug("[{}] [{}] [无元数据图片处理] 调用downloadAndSaveImage, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId); + Image savedImage = imageService.downloadAndSaveImage(imageId); + + if (savedImage != null) { + log.info("[{}] [{}] [无元数据图片处理] 成功 - 图片已保存到数据库, imageId={}, dbId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId, savedImage.getId()); + } else { + log.warn("[{}] [{}] [无元数据图片处理] 失败 - 图片保存返回null, imageId={}", + java.time.LocalDateTime.now(), saveOperationId, imageId); + } + } catch (Exception e) { + log.error("[{}] [{}] [无元数据图片处理] 异常 - 保存图片失败, imageId={}, error={}, stackTrace={}", + java.time.LocalDateTime.now(), saveOperationId, imageId, + e.getMessage(), org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(e)); + } + } + } + log.info("[{}] [{}] [图片处理] 结束 - 完成处理", java.time.LocalDateTime.now(), saveOperationId); + } + + log.info("[{}] [{}] ========== 图片保存流程结束 ==========", java.time.LocalDateTime.now(), saveOperationId); + + String imagesJson = null; + if (!imageIds[0].isEmpty()) { + java.util.List imageList = new java.util.ArrayList<>(); + for (String imageId : imageIds[0]) { + ObjectNode imageNode = objectMapper.createObjectNode(); + imageNode.put("image_id", imageId); + + // 尝试获取Nginx格式的URL + String nginxUrl = getNginxUrlForImage(imageId); + if (nginxUrl != null) { + imageNode.put("url", nginxUrl); + } else { + // 回退到旧的API格式 + imageNode.put("url", "/api/image/" + imageId + "/data"); + } + + imageList.add(imageNode); + } + imagesJson = objectMapper.writeValueAsString(imageList); + } + + String sourcesJson = null; + if (!referenceList[0].isEmpty()) { + java.util.Map docCountMap = new java.util.HashMap<>(); + java.util.Map docTypeMap = new java.util.HashMap<>(); + java.util.Map> docPageMap = new java.util.HashMap<>(); + + for (ChatReference ref : referenceList[0]) { + String docName = ref.getDocName(); + docCountMap.merge(docName, 1, Integer::sum); + if (ref.getDocType() != null) { + docTypeMap.put(docName, ref.getDocType()); + } + if (ref.getPage() != null) { + docPageMap.computeIfAbsent(docName, k -> new java.util.ArrayList()).add(ref.getPage()); + } + } + + java.util.List sourcesList = new java.util.ArrayList<>(); + for (java.util.Map.Entry entry : docCountMap.entrySet()) { + ObjectNode sourceNode = objectMapper.createObjectNode(); + sourceNode.put("docName", entry.getKey()); + sourceNode.put("docType", docTypeMap.getOrDefault(entry.getKey(), "")); + sourceNode.put("referenceCount", entry.getValue()); + + java.util.List pages = docPageMap.get(entry.getKey()); + if (pages != null && !pages.isEmpty()) { + int minPage = pages.stream().mapToInt(Integer::intValue).min().orElse(0); + int maxPage = pages.stream().mapToInt(Integer::intValue).max().orElse(0); + if (minPage == maxPage) { + sourceNode.put("pages", String.valueOf(minPage)); + } else { + sourceNode.put("pages", minPage + "-" + maxPage); + } + } + sourcesList.add(sourceNode); + } + sourcesJson = objectMapper.writeValueAsString(sourcesList); + } + + savedAssistantMessage[0] = chatService.saveAssistantMessage( + chatSessionId[0], request.getMessage(), finalAnswer, kbPathsJson, "rag", 1, durationMs, imagesJson, sourcesJson); + + if (savedAssistantMessage[0] != null) { + log.info("助手消息已保存: messageId={}, chatSessionId={}", savedAssistantMessage[0].getId(), chatSessionId[0]); + + if (!referenceList[0].isEmpty()) { + chatService.saveReferences(savedAssistantMessage[0].getId(), referenceList[0]); + log.info("引用信息已保存: count={}, messageId={}", referenceList[0].size(), savedAssistantMessage[0].getId()); + } + } else { + log.info("助手消息已存在或内容为空,跳过保存"); + } + + chatService.updateSessionSummary(chatSessionId[0], + "用户问: " + (request.getMessage().length() > 50 + ? request.getMessage().substring(0, 50) + "..." + : request.getMessage())); + + } catch (Exception e) { + log.error("保存聊天消息到数据库失败", e); + } + } else { + log.error("跳过保存:finalAnswer 为空!fullAnswer.length={}, finalFullAnswer.length={}", + fullAnswer.length(), finalFullAnswer[0].length()); + } + + try { + if (!emitterActive[0]) { + log.warn("SSE连接已断开,跳过发送finish事件"); + return; + } + ObjectNode finishNode = objectMapper.createObjectNode(); + finishNode.put("type", "finish"); + finishNode.put("answer", finalAnswer); + finishNode.put("mode", "rag"); + finishNode.put("session_id", sessionId); + finishNode.put("duration_ms", durationMs); + + if (!referenceList[0].isEmpty()) { + java.util.List responseList = new java.util.ArrayList<>(); + for (ChatReference ref : referenceList[0]) { + ReferenceResponse response = new ReferenceResponse(); + response.setChunkId(ref.getChunkId()); + response.setSource(ref.getDocName()); + response.setDocType(ref.getDocType()); + response.setPage(ref.getPage()); + response.setPageEnd(ref.getPageEnd()); + response.setPageRange(ref.getPageRange()); + response.setSection(ref.getSection()); + response.setChunkType(ref.getChunkType()); + response.setSectionChunkId(ref.getSectionChunkId()); + response.setPreview(ref.getPreview()); + response.setBbox(ref.getBbox()); + response.setBboxMode(ref.getBboxMode()); + response.setScore(ref.getScore()); + response.setExcerpt(ref.getExcerpt()); + responseList.add(response); + } + finishNode.set("sources", objectMapper.valueToTree(responseList)); + } + + if (!citationList[0].isEmpty()) { + java.util.List citationResponseList = new java.util.ArrayList<>(); + for (ChatReference ref : citationList[0]) { + ReferenceResponse response = new ReferenceResponse(); + response.setChunkId(ref.getChunkId()); + response.setSource(ref.getDocName()); + response.setDocType(ref.getDocType()); + response.setPage(ref.getPage()); + response.setPageEnd(ref.getPageEnd()); + response.setPageRange(ref.getPageRange()); + response.setSection(ref.getSection()); + response.setChunkType(ref.getChunkType()); + response.setSectionChunkId(ref.getSectionChunkId()); + response.setPreview(ref.getPreview()); + response.setBbox(ref.getBbox()); + response.setBboxMode(ref.getBboxMode()); + response.setScore(ref.getScore()); + response.setExcerpt(ref.getExcerpt()); + citationResponseList.add(response); + } + finishNode.set("citations", objectMapper.valueToTree(citationResponseList)); + log.info("finish事件包含citations: count={}", citationResponseList.size()); + } + + if (!imageIds[0].isEmpty()) { + java.util.List imageList = new java.util.ArrayList<>(); + java.util.Set processedImageIds = new java.util.HashSet<>(); + for (java.util.Map metadata : imageMetadataList[0]) { + ObjectNode imageNode = objectMapper.createObjectNode(); + imageNode.put("id", String.valueOf(metadata.get("id"))); + imageNode.put("url", String.valueOf(metadata.get("url"))); + if (metadata.containsKey("type") && metadata.get("type") != null) { + imageNode.put("type", String.valueOf(metadata.get("type"))); + } + if (metadata.containsKey("source") && metadata.get("source") != null) { + imageNode.put("source", String.valueOf(metadata.get("source"))); + } + if (metadata.containsKey("page") && metadata.get("page") != null) { + Object pageValue = metadata.get("page"); + if (pageValue instanceof Number) { + imageNode.put("page", ((Number) pageValue).intValue()); + } else if (pageValue != null) { + imageNode.put("page", pageValue.toString()); + } + } + if (metadata.containsKey("description") && metadata.get("description") != null) { + imageNode.put("description", String.valueOf(metadata.get("description"))); + } + if (metadata.containsKey("full_description") && metadata.get("full_description") != null) { + imageNode.put("full_description", String.valueOf(metadata.get("full_description"))); + } + if (metadata.containsKey("score") && metadata.get("score") != null) { + Object scoreValue = metadata.get("score"); + if (scoreValue instanceof Number) { + imageNode.put("score", ((Number) scoreValue).doubleValue()); + } + } + imageList.add(imageNode); + processedImageIds.add(String.valueOf(metadata.get("id"))); + } + for (String imageId : imageIds[0]) { + if (!processedImageIds.contains(imageId)) { + ObjectNode imageNode = objectMapper.createObjectNode(); + imageNode.put("id", imageId); + // 尝试获取Nginx格式的URL + String nginxUrl = imageService.getNginxUrlByImageId(imageId); + if (nginxUrl != null) { + imageNode.put("url", nginxUrl); + } else { + imageNode.put("url", "/api/image/" + imageId + "/data"); + } + imageList.add(imageNode); + } + } + finishNode.set("images", objectMapper.valueToTree(imageList)); + log.info("finish事件包含图片: count={}", imageList.size()); + } + + SseEmitter.SseEventBuilder finishEvent = SseEmitter.event() + .data(finishNode); + emitter.send(finishEvent); + log.info("📤 SSE finish事件已发送: answerLength={}, sourcesCount={}", finalAnswer.length(), referenceList[0].size()); + } catch (Exception e) { + log.error("发送finish事件失败", e); + } + + try { + if (emitterActive[0]) { + emitter.complete(); + } + } catch (Exception e) { + log.warn("emitter.complete()失败,可能是连接已断开: {}", e.getMessage()); + } + + } catch (Exception e) { + log.error("SSE流式处理失败", e); + if (emitterActive[0]) { + try { + ObjectNode errorNode = objectMapper.createObjectNode(); + errorNode.put("type", "error"); + errorNode.put("message", e.getMessage()); + + SseEmitter.SseEventBuilder errorEvent = SseEmitter.event() + .name("error") + .data(errorNode); + emitter.send(errorEvent); + } catch (Exception ex) { + log.error("发送错误事件失败", ex); + } + try { + emitter.complete(); + } catch (Exception ex) { + log.warn("emitter.complete()失败: {}", ex.getMessage()); + } + } else { + log.warn("SSE连接已断开,跳过发送错误事件"); + } + try { + emitter.completeWithError(e); + } catch (Exception ex) { + log.warn("emitter.completeWithError()失败: {}", ex.getMessage()); + } + } + }); + + return emitter; + } + + private ChatReference parseSourceToReference(JsonNode sourceItem) { + ChatReference ref = new ChatReference(); + ref.setChunkId(sourceItem.has("chunk_id") ? sourceItem.get("chunk_id").asText() : ""); + ref.setDocName(sourceItem.has("source") ? sourceItem.get("source").asText() : ""); + + JsonNode pageNode = sourceItem.path("page"); + ref.setPage(!pageNode.isNull() ? pageNode.asInt() : null); + + JsonNode pageEndNode = sourceItem.path("page_end"); + ref.setPageEnd(!pageEndNode.isNull() ? pageEndNode.asInt() : null); + + ref.setPageRange(sourceItem.has("page_range") ? sourceItem.get("page_range").asText() : null); + ref.setSection(sourceItem.has("section") ? sourceItem.get("section").asText() : null); + ref.setChunkType(sourceItem.has("chunk_type") ? sourceItem.get("chunk_type").asText() : null); + ref.setDocType(sourceItem.has("doc_type") ? sourceItem.get("doc_type").asText() : null); + + JsonNode sectionChunkIdNode = sourceItem.path("section_chunk_id"); + ref.setSectionChunkId(!sectionChunkIdNode.isNull() ? sectionChunkIdNode.asInt() : null); + + ref.setPreview(sourceItem.has("preview") ? sourceItem.get("preview").asText() : null); + ref.setExcerpt(sourceItem.has("preview") ? sourceItem.get("preview").asText() : + (sourceItem.has("content") ? sourceItem.get("content").asText() : + (sourceItem.has("excerpt") ? sourceItem.get("excerpt").asText() : ""))); + if (sourceItem.has("score")) { + JsonNode scoreNode = sourceItem.get("score"); + if (scoreNode.isNumber()) { + ref.setScore(BigDecimal.valueOf(scoreNode.asDouble())); + } + } + return ref; + } + + private ChatReference parseCitationToReference(JsonNode citationItem) { + ChatReference ref = new ChatReference(); + ref.setChunkId(citationItem.has("chunk_id") ? citationItem.get("chunk_id").asText() : ""); + ref.setDocName(citationItem.has("source") ? citationItem.get("source").asText() : ""); + ref.setDocType(citationItem.has("doc_type") ? citationItem.get("doc_type").asText() : null); + ref.setSection(citationItem.has("section") ? citationItem.get("section").asText() : null); + ref.setPreview(citationItem.has("preview") ? citationItem.get("preview").asText() : null); + + JsonNode pageNode = citationItem.path("page"); + ref.setPage(!pageNode.isNull() ? pageNode.asInt() : null); + + JsonNode pageEndNode = citationItem.path("page_end"); + ref.setPageEnd(!pageEndNode.isNull() ? pageEndNode.asInt() : null); + + JsonNode sectionChunkIdNode = citationItem.path("section_chunk_id"); + ref.setSectionChunkId(!sectionChunkIdNode.isNull() ? sectionChunkIdNode.asInt() : null); + + if (citationItem.has("bbox")) { + ref.setBbox(citationItem.get("bbox").toString()); + } + ref.setBboxMode(citationItem.has("bbox_mode") ? citationItem.get("bbox_mode").asText() : null); + ref.setExcerpt(citationItem.has("preview") ? citationItem.get("preview").asText() : ""); + if (citationItem.has("score")) { + JsonNode scoreNode = citationItem.get("score"); + if (scoreNode.isNumber()) { + ref.setScore(BigDecimal.valueOf(scoreNode.asDouble())); + } + } + return ref; + } + + /** + * 根据imageId获取Nginx访问格式的URL + */ + private String getNginxUrlForImage(String imageId) { + try { + // 从数据库查询图片记录 + Image image = imageService.getImageByImageId(imageId); + + // 如果没找到,尝试带.jpg后缀 + if (image == null && !imageId.toLowerCase().endsWith(".jpg")) { + image = imageService.getImageByImageId(imageId + ".jpg"); + } + + // 找到图片记录,提取文件名并构造Nginx URL + if (image != null && image.getPath() != null) { + String dbPath = image.getPath(); + log.debug("从数据库获取到图片路径: imageId={}, path={}", imageId, dbPath); + + // 从路径中提取文件名 + String filename = extractFilenameFromPath(dbPath); + if (filename != null) { + // 构造Nginx URL: /files/img/{filename} + String nginxUrl = imageUrlPrefix + "img/" + filename; + log.info("图片URL已转换: imageId={}, nginxUrl={}", imageId, nginxUrl); + return nginxUrl; + } + } + + log.warn("无法从数据库获取图片信息,将使用旧格式URL: imageId={}", imageId); + } catch (Exception e) { + log.warn("获取图片Nginx URL失败: imageId={}, error={}", imageId, e.getMessage()); + } + + return null; + } + + /** + * 从文件路径中提取文件名 + * 支持格式: /app/uploads/img/xxx.jpg 或 /opt/deploy/uploads/img/xxx.jpg + */ + private String extractFilenameFromPath(String path) { + if (path == null || path.isEmpty()) { + return null; + } + + try { + // 使用Paths处理路径,兼容不同操作系统的分隔符 + Path filePath = Paths.get(path); + return filePath.getFileName().toString(); + } catch (Exception e) { + log.debug("从路径提取文件名失败: path={}, error={}", path, e.getMessage()); + + // 备用方案:字符串分割 + try { + // 查找最后一个 / 或 \ + int lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (lastSlash >= 0 && lastSlash < path.length() - 1) { + return path.substring(lastSlash + 1); + } + } catch (Exception ex) { + log.debug("备用方案提取文件名也失败: {}", ex.getMessage()); + } + } + + return null; + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/ChatServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ChatServiceImpl.java new file mode 100644 index 0000000..0b07ea9 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ChatServiceImpl.java @@ -0,0 +1,678 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.RagRequest; +import top.tqx.demo_1.entity.ChatMessage; +import top.tqx.demo_1.entity.ChatReference; +import top.tqx.demo_1.entity.ChatSession; +import top.tqx.demo_1.entity.Image; +import top.tqx.demo_1.mapper.ChatMessageMapper; +import top.tqx.demo_1.mapper.ChatReferenceMapper; +import top.tqx.demo_1.mapper.ChatSessionMapper; +import top.tqx.demo_1.service.ChatService; +import top.tqx.demo_1.service.ImageService; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.DigestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +public class ChatServiceImpl implements ChatService { + + @Autowired + private ChatSessionMapper chatSessionMapper; + + @Autowired + private ChatMessageMapper chatMessageMapper; + + @Autowired + private ChatReferenceMapper chatReferenceMapper; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ImageService imageService; + + // Nginx 图片访问前缀(从配置文件读取) + @Value("${file.upload.url-prefix:/files/}") + private String imageUrlPrefix; + + // 图片访问路径(兼容旧版,使用现有的图片服务接口 /api/image/{imageId}/data) + private static final String IMAGE_ACCESS_PATH = "/api/image/"; + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatSession createSession(Long userId, String sessionId) { + ChatSession session = new ChatSession(); + session.setSessionId(sessionId != null && !sessionId.isEmpty() ? sessionId : generateSessionId()); + session.setUserId(userId); + session.setSummary(""); + session.setCreateTime(LocalDateTime.now()); + chatSessionMapper.insert(session); + log.info("创建会话: sessionId={}, userId={}", session.getSessionId(), userId); + return session; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatSession getOrCreateSession(Long userId, String sessionId) { + if (sessionId != null && !sessionId.isEmpty()) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChatSession::getSessionId, sessionId) + .eq(ChatSession::getUserId, userId); + ChatSession existing = chatSessionMapper.selectOne(wrapper); + if (existing != null) { + return existing; + } + } + return createSession(userId, sessionId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatSession updateSessionSummary(Long sessionId, String summary) { + ChatSession session = chatSessionMapper.selectById(sessionId); + if (session != null) { + session.setSummary(summary); + chatSessionMapper.updateById(session); + log.info("更新会话摘要: sessionId={}, summary={}", sessionId, summary); + } + return session; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatMessage saveUserMessage(Long sessionId, String content, String kbPaths) { + ChatMessage message = new ChatMessage(); + message.setConversationId(sessionId); + message.setRole("user"); + message.setContent(content); + message.setMessageType("text"); + message.setIsFinished(1); + message.setKbPaths(kbPaths); + message.setCreateTime(LocalDateTime.now()); + chatMessageMapper.insert(message); + log.info("保存用户消息: messageId={}, sessionId={}, contentLength={}", message.getId(), sessionId, content.length()); + return message; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatMessage saveAssistantMessage(Long sessionId, String content, String answer, String kbPaths, String messageType, Integer isFinished, Long durationMs, String images, String sources) { + if (content == null || content.isEmpty()) { + log.warn("助手消息内容为空,跳过保存"); + return null; + } + + if (answer == null || answer.isEmpty()) { + log.warn("助手回答内容为空,跳过保存"); + return null; + } + + ChatMessage existing = chatMessageMapper.findAssistantMessageByContent(sessionId, content); + if (existing != null) { + log.warn("相同内容的助手消息已存在,跳过保存: sessionId={}, content={}", sessionId, content); + return existing; + } + + ChatMessage message = new ChatMessage(); + message.setConversationId(sessionId); + message.setRole("assistant"); + message.setContent(content); + message.setAnswer(answer); + message.setKbPaths(kbPaths); + message.setMessageType(messageType != null ? messageType : "text"); + message.setIsFinished(isFinished != null ? isFinished : 1); + message.setDurationMs(durationMs); + message.setImages(images); + message.setSources(sources); + message.setCreateTime(LocalDateTime.now()); + chatMessageMapper.insert(message); + log.info("保存助手消息: messageId={}, sessionId={}, type={}, finished={}, duration={}ms, images={}, sources={}", message.getId(), sessionId, messageType, isFinished, durationMs, images != null ? "[" + images.length() + " chars]" : "null", sources != null ? "[" + sources.length() + " chars]" : "null"); + return message; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ChatReference saveReference(Long messageId, String chunkId, String docPath, String docName, Integer page, String excerpt, Double score) { + ChatReference reference = new ChatReference(); + reference.setMessageId(messageId); + reference.setChunkId(chunkId); + reference.setDocPath(docPath); + reference.setDocName(docName); + reference.setPage(page); + reference.setExcerpt(excerpt); + reference.setCreateTime(LocalDateTime.now()); + chatReferenceMapper.insert(reference); + log.info("保存引用: referenceId={}, messageId={}, docName={}", reference.getId(), messageId, docName); + return reference; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List saveReferences(Long messageId, List references) { + List saved = new ArrayList<>(); + for (ChatReference ref : references) { + ref.setMessageId(messageId); + ref.setCreateTime(LocalDateTime.now()); + chatReferenceMapper.insert(ref); + saved.add(ref); + } + log.info("批量保存引用: messageId={}, count={}", messageId, saved.size()); + return saved; + } + + @Override + public List getUserSessions(Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChatSession::getUserId, userId) + .orderByDesc(ChatSession::getCreateTime); + return chatSessionMapper.selectList(wrapper); + } + + @Override + public List getSessionMessages(String sessionId) { + log.info("开始获取会话消息,sessionId={}", sessionId); + + // 先通过 sessionId 查找会话,获取会话的 id + LambdaQueryWrapper sessionWrapper = new LambdaQueryWrapper<>(); + sessionWrapper.eq(ChatSession::getSessionId, sessionId); + ChatSession session = chatSessionMapper.selectOne(sessionWrapper); + + if (session == null) { + log.warn("未找到会话,sessionId={}", sessionId); + return new ArrayList<>(); + } + + log.info("找到会话,id={}, sessionId={}", session.getId(), sessionId); + + // 然后通过会话 id 查找消息 + LambdaQueryWrapper messageWrapper = new LambdaQueryWrapper<>(); + messageWrapper.eq(ChatMessage::getConversationId, session.getId()) + .orderByAsc(ChatMessage::getCreateTime); + List messages = chatMessageMapper.selectList(messageWrapper); + + log.info("查询到 {} 条消息", messages.size()); + + // 为每条消息关联查询引用信息并处理图片URL + for (ChatMessage message : messages) { + // 如果是AI助手消息,关联查询引用信息 + if ("assistant".equals(message.getRole())) { + List references = getMessageReferences(message.getId()); + log.info("消息 id={} 有 {} 条引用", message.getId(), references.size()); + + // 首先从 image 类型的引用中提取图片信息 + extractImagesFromReferences(message, references); + + // 将引用信息添加到消息中(通过sources字段扩展) + enrichMessageWithReferences(message, references); + } + + // 处理图片URL转换 + processImageUrls(message); + } + + log.info("会话消息处理完成,共 {} 条消息", messages.size()); + return messages; + } + + /** + * 从引用中提取 image 类型的图片信息,添加到消息的 images 字段 + */ + private void extractImagesFromReferences(ChatMessage message, List references) { + log.info("开始从引用中提取图片,messageId={}", message.getId()); + + try { + List> imagesList = new ArrayList<>(); + + // 如果消息已经有图片数据,先解析出来 + if (message.getImages() != null && !message.getImages().isEmpty()) { + imagesList = objectMapper.readValue( + message.getImages(), + new TypeReference>>() {} + ); + log.info("消息已有 {} 张图片", imagesList.size()); + } + + // 查找 chunkType 为 'image' 的引用 + for (ChatReference reference : references) { + if ("image".equals(reference.getChunkType())) { + log.info("找到 image 类型的引用: docName={}, page={}, chunkId={}", + reference.getDocName(), reference.getPage(), reference.getChunkId()); + + // 从 context_image 表中查找对应的图片 + List foundImages = imageService.findImagesByReference( + reference.getDocName(), + reference.getPage(), + reference.getChunkId() + ); + + if (foundImages != null && !foundImages.isEmpty()) { + log.info("从引用中找到 {} 张图片", foundImages.size()); + + for (Image image : foundImages) { + Map imageMap = new HashMap<>(); + imageMap.put("image_id", image.getImageId()); + imageMap.put("doc_name", image.getSource()); + imageMap.put("page", image.getPage()); + + // 检查是否已经有这个 image_id 的图片了,避免重复 + boolean alreadyExists = false; + for (Map existingImg : imagesList) { + if (image.getImageId().equals(existingImg.get("image_id"))) { + alreadyExists = true; + break; + } + } + + if (!alreadyExists) { + imagesList.add(imageMap); + log.info("添加图片到消息: imageId={}", image.getImageId()); + } + } + } else { + log.warn("未能从引用中找到对应的图片"); + } + } + } + + // 更新消息的 images 字段 + if (!imagesList.isEmpty()) { + String imagesJson = objectMapper.writeValueAsString(imagesList); + message.setImages(imagesJson); + log.info("成功更新消息的 images 字段,共 {} 张图片,数据: {}", + imagesList.size(), + imagesJson.substring(0, Math.min(300, imagesJson.length()))); + } + + } catch (IOException e) { + log.error("从引用中提取图片失败: messageId={}, error={}", message.getId(), e.getMessage(), e); + } + } + + /** + * 处理消息中的图片URL,转换为Nginx静态路径URL + */ + private void processImageUrls(ChatMessage message) { + log.info("开始处理图片URL,messageId={}, images={}", + message.getId(), + message.getImages() != null ? message.getImages().substring(0, Math.min(200, message.getImages().length())) : "null"); + + if (message.getImages() == null || message.getImages().isEmpty()) { + log.debug("消息没有图片数据,跳过处理"); + return; + } + + try { + List> images = objectMapper.readValue( + message.getImages(), + new TypeReference>>() {}); + + log.info("解析到 {} 张图片", images.size()); + + boolean updated = false; + for (int i = 0; i < images.size(); i++) { + Map image = images.get(i); + String imageId = (String) image.get("image_id"); + log.info("处理第 {} 张图片,imageId={}, 现有数据={}", + i + 1, imageId, image); + + if (imageId != null) { + String nginxUrl = findNginxImageUrl(imageId); + if (nginxUrl != null) { + image.put("url", nginxUrl); + updated = true; + log.info("✅ 成功转换图片URL: imageId={}, url={}", imageId, nginxUrl); + } else { + // 如果没有找到Nginx URL,提供一个回退URL + String fallbackUrl = getFallbackImageUrl(imageId); + image.put("url", fallbackUrl); + updated = true; + log.warn("⚠️ 使用回退图片URL: imageId={}, url={}", imageId, fallbackUrl); + } + } + } + + if (updated) { + String newImagesJson = objectMapper.writeValueAsString(images); + message.setImages(newImagesJson); + log.info("✅ 图片URL处理完成,更新后的数据: {}", + newImagesJson.substring(0, Math.min(300, newImagesJson.length()))); + } else { + log.info("没有需要更新的图片URL"); + } + } catch (IOException e) { + log.error("❌ 处理图片URL失败: {}", e.getMessage(), e); + } + } + + /** + * 获取回退的图片URL + */ + private String getFallbackImageUrl(String imageId) { + // 使用API接口作为回退方案 + return "/api/image/" + imageId + "/data"; + } + + /** + * 根据imageId查找本地图片,返回Nginx访问路径 + */ + private String findNginxImageUrl(String imageId) { + log.info("🔍 开始查找图片Nginx URL,imageId={}", imageId); + try { + // 从数据库查询图片记录 + log.debug("尝试查询原始imageId: {}", imageId); + Image image = imageService.getImageByImageId(imageId); + + // 如果没找到,尝试带.jpg后缀 + if (image == null && !imageId.toLowerCase().endsWith(".jpg")) { + String jpgImageId = imageId + ".jpg"; + log.debug("未找到,尝试带.jpg后缀: {}", jpgImageId); + image = imageService.getImageByImageId(jpgImageId); + } + + // 如果还没找到,尝试去掉后缀(如果有) + if (image == null) { + String imageIdWithoutExt = removeExtension(imageId); + if (!imageIdWithoutExt.equals(imageId)) { + log.debug("仍未找到,尝试去掉后缀: {}", imageIdWithoutExt); + image = imageService.getImageByImageId(imageIdWithoutExt); + } + } + + // 找到图片记录,提取文件名并构造Nginx URL + if (image != null) { + log.info("✅ 找到图片记录,imageId={}, 数据库ID={}, path={}", + imageId, image.getId(), image.getPath()); + + if (image.getPath() != null) { + String dbPath = image.getPath(); + + // 从路径中提取文件名 + String filename = extractFilenameFromPath(dbPath); + if (filename != null) { + // 构造Nginx URL: /files/img/{filename} + String nginxUrl = imageUrlPrefix + "img/" + filename; + log.info("✅ 成功构造Nginx URL: {}", nginxUrl); + return nginxUrl; + } else { + log.warn("⚠️ 无法从路径中提取文件名: {}", dbPath); + } + } else { + log.warn("⚠️ 图片记录中path字段为空"); + } + } else { + log.warn("⚠️ 数据库中未找到图片记录,imageId={}", imageId); + } + } catch (Exception e) { + log.error("❌ 查找图片Nginx URL失败: imageId={}, error={}", imageId, e.getMessage(), e); + } + + return null; + } + + /** + * 去掉文件名的扩展名 + */ + private String removeExtension(String filename) { + if (filename == null) { + return null; + } + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0) { + return filename.substring(0, lastDotIndex); + } + return filename; + } + + /** + * 从文件路径中提取文件名 + * 支持格式: /app/uploads/img/xxx.jpg 或 /opt/deploy/uploads/img/xxx.jpg + */ + private String extractFilenameFromPath(String path) { + if (path == null || path.isEmpty()) { + return null; + } + + try { + // 使用Paths处理路径,兼容不同操作系统的分隔符 + Path filePath = Paths.get(path); + return filePath.getFileName().toString(); + } catch (Exception e) { + log.debug("从路径提取文件名失败: path={}, error={}", path, e.getMessage()); + + // 备用方案:字符串分割 + try { + // 查找最后一个 / 或 \ + int lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (lastSlash >= 0 && lastSlash < path.length() - 1) { + return path.substring(lastSlash + 1); + } + } catch (Exception ex) { + log.debug("备用方案提取文件名也失败: {}", ex.getMessage()); + } + } + + return null; + } + + /** + * 从图片URL中提取图片ID + * 支持的URL格式示例: + * - http://localhost:5001/images/dcbee1500209 + * - http://localhost:5001/images/dcbee1500209.jpg + */ + private String extractImageIdFromUrl(String url) { + if (url == null || url.isEmpty()) { + return null; + } + + try { + // 移除查询参数 + int queryIndex = url.indexOf('?'); + if (queryIndex > 0) { + url = url.substring(0, queryIndex); + } + + // 获取路径部分 + int pathStart = url.indexOf("/", 8); // 跳过 http:// 或 https:// + if (pathStart < 0) { + return null; + } + + String path = url.substring(pathStart); + + // 分割路径,获取最后一段 + String[] pathParts = path.split("/"); + if (pathParts.length >= 2) { + return pathParts[pathParts.length - 1]; + } + } catch (Exception e) { + log.debug("解析图片URL失败: url={}, error={}", url, e.getMessage()); + } + + return null; + } + + /** + * 将引用信息丰富到消息中 + */ + private void enrichMessageWithReferences(ChatMessage message, List references) { + if (references == null || references.isEmpty()) { + return; + } + + try { + // 获取现有的sources + List> sourcesList = new ArrayList<>(); + if (message.getSources() != null && !message.getSources().isEmpty()) { + sourcesList = objectMapper.readValue( + message.getSources(), + new TypeReference>>() {}); + } + + // 创建引用详情映射 + Map citationsMap = new HashMap<>(); + citationsMap.put("citations", references); + + // 如果sources为空,创建一个默认的sources列表 + if (sourcesList.isEmpty()) { + // 从引用中提取文档信息 + Map sourceInfo = new HashMap<>(); + sourceInfo.put("docName", "引用文档"); + sourceInfo.put("referenceCount", references.size()); + sourcesList.add(sourceInfo); + } + + // 更新sources字段,添加引用详情 + message.setSources(objectMapper.writeValueAsString(sourcesList)); + + // 不修改answer字段,保持原有内容(不添加引用来源列表) + } catch (IOException e) { + log.warn("丰富引用信息失败: {}", e.getMessage()); + } + } + + @Override + public List getSessionMessagesAsHistory(String sessionId) { + List messages = getSessionMessages(sessionId); + List history = new ArrayList<>(); + for (ChatMessage message : messages) { + RagRequest.HistoryItem item = new RagRequest.HistoryItem(); + item.setRole(message.getRole()); + item.setContent(message.getContent()); + history.add(item); + } + log.info("获取历史记录: sessionId={}, count={}", sessionId, history.size()); + return history; + } + + @Override + public List getMessageReferences(Long messageId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChatReference::getMessageId, messageId); + return chatReferenceMapper.selectList(wrapper); + } + + @Override + public List getSessionReferences(String sessionId) { + // 先通过 sessionId 查找会话,获取会话的 id + LambdaQueryWrapper sessionWrapper = new LambdaQueryWrapper<>(); + sessionWrapper.eq(ChatSession::getSessionId, sessionId); + ChatSession session = chatSessionMapper.selectOne(sessionWrapper); + + if (session == null) { + return new ArrayList<>(); + } + + // 查找该会话的所有消息 + LambdaQueryWrapper messageWrapper = new LambdaQueryWrapper<>(); + messageWrapper.eq(ChatMessage::getConversationId, session.getId()); + List messages = chatMessageMapper.selectList(messageWrapper); + + if (messages.isEmpty()) { + return new ArrayList<>(); + } + + // 收集所有消息ID + List messageIds = new ArrayList<>(); + for (ChatMessage message : messages) { + messageIds.add(message.getId()); + } + + // 查找所有引用 + LambdaQueryWrapper referenceWrapper = new LambdaQueryWrapper<>(); + referenceWrapper.in(ChatReference::getMessageId, messageIds); + return chatReferenceMapper.selectList(referenceWrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteSession(String sessionId, Long userId) { + try { + // 先通过 sessionId 和 userId 查找会话,确保只能删除自己的会话 + LambdaQueryWrapper sessionWrapper = new LambdaQueryWrapper<>(); + sessionWrapper.eq(ChatSession::getSessionId, sessionId) + .eq(ChatSession::getUserId, userId); + ChatSession session = chatSessionMapper.selectOne(sessionWrapper); + + if (session == null) { + log.warn("会话不存在或无权删除: sessionId={}, userId={}", sessionId, userId); + return false; + } + + // 查找该会话的所有消息 + LambdaQueryWrapper messageWrapper = new LambdaQueryWrapper<>(); + messageWrapper.eq(ChatMessage::getConversationId, session.getId()); + List messages = chatMessageMapper.selectList(messageWrapper); + + // 收集所有消息ID + List messageIds = new ArrayList<>(); + for (ChatMessage message : messages) { + messageIds.add(message.getId()); + } + + // 删除所有引用 + if (!messageIds.isEmpty()) { + LambdaQueryWrapper referenceWrapper = new LambdaQueryWrapper<>(); + referenceWrapper.in(ChatReference::getMessageId, messageIds); + chatReferenceMapper.delete(referenceWrapper); + } + + // 删除所有消息 + if (!messages.isEmpty()) { + chatMessageMapper.delete(messageWrapper); + } + + // 删除会话 + chatSessionMapper.delete(sessionWrapper); + + log.info("删除会话成功: sessionId={}, userId={}", sessionId, userId); + return true; + } catch (Exception e) { + log.error("删除会话失败: sessionId={}, userId={}", sessionId, userId, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteSessions(List sessionIds, Long userId) { + try { + for (String sessionId : sessionIds) { + boolean success = deleteSession(sessionId, userId); + if (!success) { + log.warn("批量删除会话失败: sessionId={}", sessionId); + // 继续删除其他会话,不中断整个操作 + } + } + log.info("批量删除会话完成: count={}, userId={}", sessionIds.size(), userId); + return true; + } catch (Exception e) { + log.error("批量删除会话失败: userId={}", userId, e); + return false; + } + } + + private String generateSessionId() { + return "sess_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/CollectionFileServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/CollectionFileServiceImpl.java new file mode 100644 index 0000000..6fae202 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/CollectionFileServiceImpl.java @@ -0,0 +1,73 @@ +package top.tqx.demo_1.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.entity.CollectionFile; +import top.tqx.demo_1.mapper.CollectionFileMapper; +import top.tqx.demo_1.service.CollectionFileService; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CollectionFileServiceImpl implements CollectionFileService { + + private final CollectionFileMapper collectionFileMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public CollectionFile saveOrUpdate(String collectionName, String docName, Long fileId, String filePath, String docType) { + CollectionFile existing = collectionFileMapper.findByCollectionAndDocName(collectionName, docName); + + if (existing != null) { + existing.setFileId(fileId); + existing.setFilePath(filePath); + existing.setDocType(docType); + existing.setStatus(1); + collectionFileMapper.updateById(existing); + log.info("更新向量库文件关联: collection={}, docName={}", collectionName, docName); + return existing; + } + + CollectionFile collectionFile = new CollectionFile(); + collectionFile.setCollectionName(collectionName); + collectionFile.setDocName(docName); + collectionFile.setFileId(fileId); + collectionFile.setFilePath(filePath); + collectionFile.setDocType(docType); + collectionFile.setStatus(1); + collectionFileMapper.insert(collectionFile); + log.info("创建向量库文件关联: collection={}, docName={}", collectionName, docName); + return collectionFile; + } + + @Override + public List findByDocName(String docName) { + return collectionFileMapper.findByDocNameWithFileInfo(docName); + } + + @Override + public List findByCollectionName(String collectionName) { + return collectionFileMapper.findByCollectionName(collectionName); + } + + @Override + public CollectionFile findByCollectionAndDocName(String collectionName, String docName) { + return collectionFileMapper.findByCollectionAndDocName(collectionName, docName); + } + + @Override + public CollectionFile findByFileId(Long fileId) { + return collectionFileMapper.findByFileId(fileId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + collectionFileMapper.deleteById(id); + log.info("删除向量库文件关联: id={}", id); + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/DataPermissionServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/DataPermissionServiceImpl.java new file mode 100644 index 0000000..2019e7a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/DataPermissionServiceImpl.java @@ -0,0 +1,102 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.FilePermission; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.FilePermissionMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.DataPermissionService; +import top.tqx.demo_1.service.DepartmentService; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class DataPermissionServiceImpl implements DataPermissionService { + + @Autowired + private FileMapper fileMapper; + + @Autowired + private FilePermissionMapper filePermissionMapper; + + @Autowired + private UserMapper userMapper; + + @Autowired + private DepartmentService departmentService; + + @Override + public List getUserAuthorizedFileIds(Long userId) { + List authorizedFileIds = new ArrayList<>(); + + User user = userMapper.selectById(userId); + if (user == null) { + return authorizedFileIds; + } + + if (CommonConstant.RoleType.SUPER_ADMIN.equals(user.getUserType())) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + List files = fileMapper.selectList(wrapper); + for (File file : files) { + authorizedFileIds.add(file.getId()); + } + return authorizedFileIds; + } + + if (CommonConstant.RoleType.DEPT_ADMIN.equals(user.getUserType())) { + List deptIds = getAllSubDepartmentIds(user.getDeptId()); + deptIds.add(user.getDeptId()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.in(File::getDeptId, deptIds); + List files = fileMapper.selectList(wrapper); + for (File file : files) { + authorizedFileIds.add(file.getId()); + } + } else { + LambdaQueryWrapper ownFilesWrapper = new LambdaQueryWrapper<>(); + ownFilesWrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + ownFilesWrapper.eq(File::getUploadUserId, userId); + List ownFiles = fileMapper.selectList(ownFilesWrapper); + for (File file : ownFiles) { + authorizedFileIds.add(file.getId()); + } + + LambdaQueryWrapper permissionWrapper = new LambdaQueryWrapper<>(); + permissionWrapper.eq(FilePermission::getUserId, userId); + List permissions = filePermissionMapper.selectList(permissionWrapper); + for (FilePermission permission : permissions) { + authorizedFileIds.add(permission.getFileId()); + } + } + + return authorizedFileIds; + } + + @Override + public List getAllSubDepartmentIds(Long deptId) { + List subDeptIds = new ArrayList<>(); + findSubDepartments(deptId, subDeptIds); + return subDeptIds; + } + + private void findSubDepartments(Long deptId, List subDeptIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(top.tqx.demo_1.entity.Department::getParentId, deptId); + wrapper.eq(top.tqx.demo_1.entity.Department::getStatus, CommonConstant.STATUS_ENABLE); + + List subDepartments = departmentService.list(wrapper); + for (top.tqx.demo_1.entity.Department dept : subDepartments) { + subDeptIds.add(dept.getId()); + findSubDepartments(dept.getId(), subDeptIds); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/DepartmentServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/DepartmentServiceImpl.java new file mode 100644 index 0000000..6532105 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/DepartmentServiceImpl.java @@ -0,0 +1,231 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.dto.DepartmentCreateRequest; +import top.tqx.demo_1.dto.DepartmentUpdateRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.enums.DeptTypeEnum; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.vo.DepartmentVO; +import top.tqx.demo_1.vo.UserVO; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class DepartmentServiceImpl extends ServiceImpl implements DepartmentService { + + @Autowired + private UserMapper userMapper; + + @Override + public List listDepartments() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Department::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.orderByAsc(Department::getDeptLevel, Department::getId); + + List departments = this.list(wrapper); + return departments.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createDepartment(DepartmentCreateRequest request) { + if (request.getDeptType() == null || request.getDeptType().isEmpty()) { + throw new RuntimeException("部门类型不能为空"); + } + + DeptTypeEnum deptType = DeptTypeEnum.getByCode(request.getDeptType()); + if (deptType == null) { + throw new RuntimeException("部门类型无效"); + } + + validateDepartmentHierarchy(request.getParentId(), deptType); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Department::getDeptName, request.getDeptName()); + wrapper.eq(Department::getParentId, request.getParentId() != null ? request.getParentId() : 0); + if (this.count(wrapper) > 0) { + throw new RuntimeException("同级部门名称已存在"); + } + + Department department = new Department(); + BeanUtils.copyProperties(request, department); + department.setStatus(CommonConstant.STATUS_ENABLE); + department.setCreateTime(LocalDateTime.now()); + + if (request.getParentId() != null && request.getParentId() > 0) { + Department parent = this.getById(request.getParentId()); + if (parent != null) { + department.setDeptLevel(parent.getDeptLevel() + 1); + } + } else { + department.setDeptLevel(deptType.getLevel()); + } + + this.save(department); + } + + private void validateDepartmentHierarchy(Long parentId, DeptTypeEnum deptType) { + if (deptType == DeptTypeEnum.HEADQUARTERS) { + if (parentId != null && parentId > 0) { + throw new RuntimeException("总公司必须是顶级部门"); + } + } else if (deptType == DeptTypeEnum.BRANCH) { + if (parentId == null || parentId <= 0) { + throw new RuntimeException("分公司必须隶属于总公司"); + } + Department parent = this.getById(parentId); + if (parent == null) { + throw new RuntimeException("父部门不存在"); + } + if (!DeptTypeEnum.HEADQUARTERS.getCode().equals(parent.getDeptType())) { + throw new RuntimeException("分公司只能隶属于总公司"); + } + } else if (deptType == DeptTypeEnum.DEPARTMENT) { + if (parentId == null || parentId <= 0) { + throw new RuntimeException("部门必须隶属于分公司"); + } + Department parent = this.getById(parentId); + if (parent == null) { + throw new RuntimeException("父部门不存在"); + } + if (!DeptTypeEnum.BRANCH.getCode().equals(parent.getDeptType())) { + throw new RuntimeException("部门只能隶属于分公司"); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDepartment(Long id, DepartmentUpdateRequest request) { + Department department = this.getById(id); + if (department == null) { + throw new RuntimeException("部门不存在"); + } + + BeanUtils.copyProperties(request, department, "createTime", "deptLevel"); + this.updateById(department); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDepartment(Long id) { + Department department = this.getById(id); + if (department == null) { + throw new RuntimeException("部门不存在"); + } + + if (DeptTypeEnum.HEADQUARTERS.getCode().equals(department.getDeptType())) { + throw new RuntimeException("总公司不可删除"); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Department::getParentId, id); + long count = this.count(wrapper); + if (count > 0) { + throw new RuntimeException("该部门下存在子部门,无法删除"); + } + + this.removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void setDepartmentManager(Long id, Long managerId) { + Department department = this.getById(id); + if (department == null) { + throw new RuntimeException("部门不存在"); + } + + department.setManagerId(managerId); + this.updateById(department); + } + + @Override + public List getDepartmentUsers(Long deptId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getDeptId, deptId); + wrapper.eq(User::getStatus, CommonConstant.STATUS_ENABLE); + + List users = userMapper.selectList(wrapper); + return users.stream() + .map(this::convertToUserVO) + .collect(Collectors.toList()); + } + + @Override + public Department getDepartmentById(Long id) { + return this.getById(id); + } + + @Override + public String getDepartmentFullPath(Long deptId) { + List ancestors = getDepartmentAncestors(deptId); + StringBuilder path = new StringBuilder(); + for (Department dept : ancestors) { + path.append(dept.getDeptName()).append("/"); + } + Department current = this.getById(deptId); + if (current != null) { + path.append(current.getDeptName()); + } + return path.toString(); + } + + @Override + public List getDepartmentAncestors(Long deptId) { + List ancestors = new ArrayList<>(); + Department current = this.getById(deptId); + while (current != null && current.getParentId() != null && current.getParentId() > 0) { + Department parent = this.getById(current.getParentId()); + if (parent != null) { + ancestors.add(0, parent); + current = parent; + } else { + break; + } + } + return ancestors; + } + + private DepartmentVO convertToVO(Department department) { + DepartmentVO vo = new DepartmentVO(); + BeanUtils.copyProperties(department, vo); + + if (department.getDeptType() != null) { + DeptTypeEnum deptType = DeptTypeEnum.getByCode(department.getDeptType()); + if (deptType != null) { + vo.setDeptTypeName(deptType.getName()); + } + } + + if (department.getParentId() != null && department.getParentId() > 0) { + Department parent = this.getById(department.getParentId()); + if (parent != null) { + vo.setParentName(parent.getDeptName()); + } + } + + return vo; + } + + private UserVO convertToUserVO(User user) { + UserVO vo = new UserVO(); + BeanUtils.copyProperties(user, vo); + return vo; + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/ExamLockServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ExamLockServiceImpl.java new file mode 100644 index 0000000..21df315 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ExamLockServiceImpl.java @@ -0,0 +1,61 @@ +package top.tqx.demo_1.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.service.ExamLockService; + +import java.time.Duration; + +@Slf4j +@Service +public class ExamLockServiceImpl implements ExamLockService { + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String EXAM_LOCK_KEY = "exam:lock:%s:%s"; + private static final long LOCK_EXPIRE_HOURS = 2; + + @Override + public boolean tryAcquireLock(String paperId, Long userId, String sessionId) { + String lockKey = String.format(EXAM_LOCK_KEY, paperId, userId); + String existingSession = redisTemplate.opsForValue().get(lockKey); + + if (existingSession != null && !existingSession.equals(sessionId)) { + log.warn("用户 {} 尝试获取试卷 {} 的锁失败,已有会话 {} 在进行中", userId, paperId, existingSession); + return false; + } + + Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, sessionId, Duration.ofHours(LOCK_EXPIRE_HOURS)); + boolean result = Boolean.TRUE.equals(success); + + if (result) { + log.info("用户 {} 成功获取试卷 {} 的锁,会话ID: {}", userId, paperId, sessionId); + } else { + redisTemplate.opsForValue().set(lockKey, sessionId, Duration.ofHours(LOCK_EXPIRE_HOURS)); + log.info("用户 {} 续期试卷 {} 的锁,会话ID: {}", userId, paperId, sessionId); + } + + return true; + } + + @Override + public void releaseLock(String paperId, Long userId) { + String lockKey = String.format(EXAM_LOCK_KEY, paperId, userId); + Boolean deleted = redisTemplate.delete(lockKey); + + if (Boolean.TRUE.equals(deleted)) { + log.info("用户 {} 释放试卷 {} 的锁成功", userId, paperId); + } else { + log.debug("用户 {} 释放试卷 {} 的锁,锁已不存在", userId, paperId); + } + } + + @Override + public boolean isLocked(String paperId, Long userId) { + String lockKey = String.format(EXAM_LOCK_KEY, paperId, userId); + return Boolean.TRUE.equals(redisTemplate.hasKey(lockKey)); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/ExamPaperServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ExamPaperServiceImpl.java new file mode 100644 index 0000000..58df176 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ExamPaperServiceImpl.java @@ -0,0 +1,585 @@ +package top.tqx.demo_1.service.impl; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.*; +import top.tqx.demo_1.mapper.*; +import top.tqx.demo_1.service.ExamPaperService; +import top.tqx.demo_1.service.ExamService; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class ExamPaperServiceImpl implements ExamPaperService { + + @Autowired + private ExamPaperMapper examPaperMapper; + + @Autowired + private ExamAssignmentMapper examAssignmentMapper; + + @Autowired + private ExamRecordMapper examRecordMapper; + + @Autowired + private QuestionMapper questionMapper; + + @Autowired + private UserMapper userMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + @Autowired + private ExamService examService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public ExamPaperGenerateResponse previewPaper(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType) { + return examService.generatePaper(request, userId, deptId, userType); + } + + @Override + public ExamPaperPreviewResponse previewPaperWithDetails(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType) { + log.info("生成试卷预览详情: userId={}, deptId={}, userType={}", userId, deptId, userType); + + ExamPaperGenerateResponse generateResponse = examService.generatePaper(request, userId, deptId, userType); + + ExamPaperPreviewResponse previewResponse = new ExamPaperPreviewResponse(); + previewResponse.setPaperId(generateResponse.getPaperId()); + previewResponse.setPaperTitle(generateResponse.getPaperTitle()); + previewResponse.setTotalScore(generateResponse.getTotalScore()); + previewResponse.setQuestionCount(generateResponse.getQuestionCount()); + previewResponse.setGeneratedAt(generateResponse.getGeneratedAt()); + previewResponse.setPermissionScope(generateResponse.getPermissionScope()); + previewResponse.setWarnings(generateResponse.getWarnings()); + + List detailedQuestions = new ArrayList<>(); + + if (generateResponse.getQuestions() != null) { + for (ExamPaperGenerateResponse.ExamPaperQuestion q : generateResponse.getQuestions()) { + ExamPaperPreviewResponse.ExamPaperQuestionDetail detail = convertToDetailedQuestion(q); + detailedQuestions.add(detail); + } + } + + previewResponse.setQuestions(detailedQuestions); + + log.info("试卷预览详情生成成功: paperId={}, questionCount={}", + previewResponse.getPaperId(), previewResponse.getQuestionCount()); + + return previewResponse; + } + + private ExamPaperPreviewResponse.ExamPaperQuestionDetail convertToDetailedQuestion(ExamPaperGenerateResponse.ExamPaperQuestion q) { + ExamPaperPreviewResponse.ExamPaperQuestionDetail detail = new ExamPaperPreviewResponse.ExamPaperQuestionDetail(); + detail.setQuestionId(q.getQuestionId()); + detail.setQuestionType(q.getQuestionType()); + detail.setQuestionTypeName(q.getQuestionTypeName()); + detail.setDifficulty(q.getDifficulty()); + detail.setDifficultyName(getDifficultyName(q.getDifficulty())); + detail.setScore(q.getScore()); + detail.setDocumentName(q.getDocumentName()); + detail.setKnowledgeBasePath(q.getKnowledgeBasePath()); + + if (q.getContent() != null) { + try { + String contentJson = objectMapper.writeValueAsString(q.getContent()); + JSONObject contentObj = JSONObject.parseObject(contentJson); + + ExamPaperPreviewResponse.QuestionContent questionContent = new ExamPaperPreviewResponse.QuestionContent(); + + if (contentObj.containsKey("stem")) { + questionContent.setStem(contentObj.getString("stem")); + } + if (contentObj.containsKey("options")) { + questionContent.setOptions(contentObj.get("options")); + } + if (contentObj.containsKey("answer")) { + questionContent.setAnswer(contentObj.get("answer")); + } + if (contentObj.containsKey("explanation")) { + questionContent.setExplanation(contentObj.getString("explanation")); + } + if (contentObj.containsKey("data")) { + questionContent.setData(contentObj.get("data")); + } + + detail.setContent(questionContent); + } catch (Exception e) { + log.warn("解析题目内容失败: {}", e.getMessage()); + } + } + + return detail; + } + + private String getDifficultyName(Integer difficulty) { + if (difficulty == null) { + return "中等"; + } + switch (difficulty) { + case 1: + return "简单"; + case 2: + return "较易"; + case 3: + return "中等"; + case 4: + return "较难"; + case 5: + return "困难"; + default: + return "中等"; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ExamPaperResponse savePaper(ExamPaperSaveRequest request, Long userId) { + User user = userMapper.selectById(userId); + + ExamPaper paper = new ExamPaper(); + paper.setPaperId(UUID.randomUUID().toString()); + paper.setTitle(request.getTitle()); + paper.setDescription(request.getDescription()); + + // 设置时长,默认120分钟 + paper.setDuration(request.getDuration() != null ? request.getDuration() : 120); + + paper.setStatus("draft"); + paper.setPermissionScope(request.getPermissionScope()); + paper.setDeptId(user.getDeptId()); + + try { + String jsonStr = JSONArray.toJSONString(request.getQuestionIds()); + paper.setQuestionIds(objectMapper.readTree(jsonStr)); + } catch (Exception e) { + throw new RuntimeException("解析题目ID列表失败", e); + } + + // 自动计算总分和题数 + if (request.getQuestionIds() != null && !request.getQuestionIds().isEmpty()) { + List questions = questionMapper.selectList( + new LambdaQueryWrapper() + .in(Question::getQuestionId, request.getQuestionIds()) + ); + + // 计算总分 + BigDecimal totalScore = BigDecimal.ZERO; + for (Question q : questions) { + if (q.getScore() != null) { + totalScore = totalScore.add(q.getScore()); + } + } + paper.setTotalScore(totalScore); + + // 计算题数 + paper.setQuestionCount(request.getQuestionIds().size()); + } else { + paper.setQuestionCount(0); + paper.setTotalScore(BigDecimal.ZERO); + } + + paper.setCreatedBy(userId); + paper.setCreatedAt(LocalDateTime.now()); + paper.setUpdatedAt(LocalDateTime.now()); + + examPaperMapper.insert(paper); + + return convertToResponse(paper, false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void publishPaper(String paperId, ExamPublishRequest request, Long userId) { + ExamPaper paper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + + if (paper == null) { + throw new RuntimeException("试卷不存在"); + } + + paper.setStatus("published"); + paper.setPublishTime(request.getPublishTime() != null ? request.getPublishTime() : LocalDateTime.now()); + paper.setDeadline(request.getDeadline()); + paper.setUpdatedAt(LocalDateTime.now()); + examPaperMapper.updateById(paper); + + ExamAssignment assignment = new ExamAssignment(); + assignment.setPaperId(paperId); + + // 兼容旧版:如果传了deptId,优先使用deptId + if (request.getDeptId() != null) { + assignment.setDeptId(request.getDeptId()); + } + + // 支持新版多部门:如果传了deptIds列表,使用deptIds + if (request.getDeptIds() != null && !request.getDeptIds().isEmpty()) { + try { + assignment.setDeptIds(objectMapper.readTree(JSONArray.toJSONString(request.getDeptIds()))); + } catch (Exception e) { + throw new RuntimeException("解析部门ID列表失败", e); + } + } + + try { + if (request.getUserIds() != null) { + assignment.setUserIds(objectMapper.readTree(JSONArray.toJSONString(request.getUserIds()))); + } + if (request.getExcludeUserIds() != null) { + assignment.setExcludeUserIds(objectMapper.readTree(JSONArray.toJSONString(request.getExcludeUserIds()))); + } + } catch (Exception e) { + throw new RuntimeException("解析用户ID列表失败", e); + } + + assignment.setStatus("active"); + assignment.setPublishTime(paper.getPublishTime()); + assignment.setDeadline(paper.getDeadline()); + assignment.setCreatedAt(LocalDateTime.now()); + assignment.setUpdatedAt(LocalDateTime.now()); + examAssignmentMapper.insert(assignment); + + List targetUserIds = getTargetUserIds(request); + + for (Long uid : targetUserIds) { + ExamRecord record = new ExamRecord(); + record.setRecordId(UUID.randomUUID().toString()); + record.setPaperId(paperId); + record.setAnswerType("formal_exam"); + record.setUserId(uid); + record.setStatus("not_started"); + record.setCreatedAt(LocalDateTime.now()); + record.setUpdatedAt(LocalDateTime.now()); + examRecordMapper.insert(record); + } + } + + private List getTargetUserIds(ExamPublishRequest request) { + Set userIds = new HashSet<>(); + + // 兼容旧版:单个部门 + if (request.getDeptId() != null) { + List deptUsers = userMapper.selectList( + new LambdaQueryWrapper() + .eq(User::getDeptId, request.getDeptId()) + .eq(User::getStatus, 1)); + userIds.addAll(deptUsers.stream().map(User::getId).collect(Collectors.toList())); + } + + // 支持新版:多个部门 + if (request.getDeptIds() != null && !request.getDeptIds().isEmpty()) { + List multiDeptUsers = userMapper.selectList( + new LambdaQueryWrapper() + .in(User::getDeptId, request.getDeptIds()) + .eq(User::getStatus, 1)); + userIds.addAll(multiDeptUsers.stream().map(User::getId).collect(Collectors.toList())); + } + + // 指定用户 + if (request.getUserIds() != null) { + userIds.addAll(request.getUserIds()); + } + + // 排除用户 + if (request.getExcludeUserIds() != null) { + userIds.removeAll(request.getExcludeUserIds()); + } + + return new ArrayList<>(userIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void revokePaper(String paperId) { + ExamPaper paper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + + if (paper != null) { + paper.setStatus("draft"); + paper.setPublishTime(null); + paper.setDeadline(null); + paper.setUpdatedAt(LocalDateTime.now()); + examPaperMapper.updateById(paper); + } + + ExamAssignment updateAssignment = new ExamAssignment(); + updateAssignment.setStatus("revoked"); + LambdaQueryWrapper assignmentWrapper = + new LambdaQueryWrapper().eq(ExamAssignment::getPaperId, paperId); + examAssignmentMapper.update(updateAssignment, assignmentWrapper); + + examRecordMapper.delete(new LambdaQueryWrapper().eq(ExamRecord::getPaperId, paperId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deletePaper(String paperId) { + examRecordMapper.delete(new LambdaQueryWrapper().eq(ExamRecord::getPaperId, paperId)); + examAssignmentMapper.delete(new LambdaQueryWrapper().eq(ExamAssignment::getPaperId, paperId)); + examPaperMapper.delete(new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + } + + @Override + public ExamPaperResponse getPaperById(String paperId) { + ExamPaper paper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + + if (paper == null) { + return null; + } + + return convertToResponse(paper, true); + } + + @Override + public PageResponse listPapers(String status, Integer page, Integer pageSize) { + Page pageQuery = new Page<>(page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (status != null && !status.isEmpty()) { + wrapper.eq(ExamPaper::getStatus, status); + } + wrapper.orderByDesc(ExamPaper::getCreatedAt); + + Page resultPage = examPaperMapper.selectPage(pageQuery, wrapper); + + List responses = resultPage.getRecords().stream() + .map(p -> convertToResponse(p, false)) + .collect(Collectors.toList()); + + return new PageResponse<>(responses, resultPage.getTotal(), page, pageSize); + } + + @Override + public List listPapersByDept(Long deptId) { + List papers = examPaperMapper.selectList( + new LambdaQueryWrapper() + .eq(ExamPaper::getDeptId, deptId) + .eq(ExamPaper::getStatus, "published")); + + return papers.stream() + .map(p -> convertToResponse(p, false)) + .collect(Collectors.toList()); + } + + @Override + public Map getPaperAssignment(String paperId) { + Map result = new HashMap<>(); + + // 获取分配记录 + List assignments = examAssignmentMapper.selectList( + new LambdaQueryWrapper() + .eq(ExamAssignment::getPaperId, paperId) + ); + + if (assignments == null || assignments.isEmpty()) { + result.put("deptIds", new ArrayList<>()); + result.put("deptNames", new ArrayList<>()); + result.put("userIds", new ArrayList<>()); + result.put("excludeUserIds", new ArrayList<>()); + result.put("targetUserCount", 0); + return result; + } + + ExamAssignment assignment = assignments.get(0); + + // 获取部门ID列表 + List deptIdList = new ArrayList<>(); + if (assignment.getDeptIds() != null && !assignment.getDeptIds().isNull()) { + if (assignment.getDeptIds().isArray()) { + for (JsonNode node : assignment.getDeptIds()) { + deptIdList.add(node.asLong()); + } + } + } else if (assignment.getDeptId() != null) { + // 兼容旧数据:单个部门 + deptIdList.add(assignment.getDeptId()); + } + result.put("deptIds", deptIdList); + + // 获取部门名称 + List> deptInfoList = new ArrayList<>(); + if (!deptIdList.isEmpty()) { + List departments = departmentMapper.selectBatchIds(deptIdList); + for (Department dept : departments) { + Map deptInfo = new HashMap<>(); + deptInfo.put("id", dept.getId()); + deptInfo.put("name", dept.getDeptName()); + deptInfo.put("code", dept.getDeptCode()); + deptInfoList.add(deptInfo); + } + } + result.put("depts", deptInfoList); + + // 获取用户ID列表 + List userIdList = new ArrayList<>(); + if (assignment.getUserIds() != null && !assignment.getUserIds().isNull()) { + if (assignment.getUserIds().isArray()) { + for (JsonNode node : assignment.getUserIds()) { + userIdList.add(node.asLong()); + } + } + } + result.put("userIds", userIdList); + + // 获取排除用户ID列表 + List excludeUserIdList = new ArrayList<>(); + if (assignment.getExcludeUserIds() != null && !assignment.getExcludeUserIds().isNull()) { + if (assignment.getExcludeUserIds().isArray()) { + for (JsonNode node : assignment.getExcludeUserIds()) { + excludeUserIdList.add(node.asLong()); + } + } + } + result.put("excludeUserIds", excludeUserIdList); + + // 计算目标用户数量 + Set targetUserIds = new HashSet<>(); + if (!deptIdList.isEmpty()) { + List deptUsers = userMapper.selectList( + new LambdaQueryWrapper() + .in(User::getDeptId, deptIdList) + .eq(User::getStatus, 1)); + targetUserIds.addAll(deptUsers.stream().map(User::getId).collect(Collectors.toList())); + } + if (!userIdList.isEmpty()) { + targetUserIds.addAll(userIdList); + } + targetUserIds.removeAll(excludeUserIdList); + result.put("targetUserCount", targetUserIds.size()); + + return result; + } + + private ExamPaperResponse convertToResponse(ExamPaper paper, boolean includeQuestions) { + ExamPaperResponse response = new ExamPaperResponse(); + response.setPaperId(paper.getPaperId()); + response.setTitle(paper.getTitle()); + response.setDescription(paper.getDescription()); + response.setTotalScore(paper.getTotalScore()); + response.setDuration(paper.getDuration()); + response.setQuestionCount(paper.getQuestionCount()); + response.setStatus(paper.getStatus()); + response.setPermissionScope(paper.getPermissionScope()); + response.setDeptId(paper.getDeptId()); + response.setCreatedBy(paper.getCreatedBy()); + response.setCreatedAt(paper.getCreatedAt()); + response.setUpdatedAt(paper.getUpdatedAt()); + response.setPublishTime(paper.getPublishTime()); + response.setDeadline(paper.getDeadline()); + + // 添加分配信息 + if (paper.getPaperId() != null) { + ExamAssignment assignment = examAssignmentMapper.selectOne( + new LambdaQueryWrapper() + .eq(ExamAssignment::getPaperId, paper.getPaperId()) + .eq(ExamAssignment::getStatus, "active") + ); + + if (assignment != null) { + // 设置部门ID列表(多部门) + if (assignment.getDeptIds() != null && !assignment.getDeptIds().isNull()) { + try { + List deptIdList = new ArrayList<>(); + if (assignment.getDeptIds().isArray()) { + for (JsonNode node : assignment.getDeptIds()) { + deptIdList.add(node.asLong()); + } + } + response.setDeptIds(deptIdList); + } catch (Exception e) { + log.warn("解析deptIds失败: {}", e.getMessage()); + } + } + + // 设置用户ID列表 + if (assignment.getUserIds() != null && !assignment.getUserIds().isNull()) { + try { + List userIdList = new ArrayList<>(); + if (assignment.getUserIds().isArray()) { + for (JsonNode node : assignment.getUserIds()) { + userIdList.add(node.asLong()); + } + } + response.setUserIds(userIdList); + } catch (Exception e) { + log.warn("解析userIds失败: {}", e.getMessage()); + } + } + + // 设置排除用户ID列表 + if (assignment.getExcludeUserIds() != null && !assignment.getExcludeUserIds().isNull()) { + try { + List excludeUserIdList = new ArrayList<>(); + if (assignment.getExcludeUserIds().isArray()) { + for (JsonNode node : assignment.getExcludeUserIds()) { + excludeUserIdList.add(node.asLong()); + } + } + response.setExcludeUserIds(excludeUserIdList); + } catch (Exception e) { + log.warn("解析excludeUserIds失败: {}", e.getMessage()); + } + } + } + } + + if (includeQuestions && paper.getQuestionIds() != null) { + List questionIdList = new ArrayList<>(); + if (paper.getQuestionIds().isArray()) { + for (JsonNode node : paper.getQuestionIds()) { + questionIdList.add(node.asText()); + } + } + + List questions = new ArrayList<>(); + for (String qId : questionIdList) { + Question question = questionMapper.selectOne( + new LambdaQueryWrapper().eq(Question::getQuestionId, qId)); + + if (question != null) { + ExamPaperResponse.ExamPaperQuestion q = new ExamPaperResponse.ExamPaperQuestion(); + q.setQuestionId(question.getQuestionId()); + q.setQuestionType(question.getQuestionType()); + q.setDifficulty(question.getDifficulty()); + q.setScore(question.getScore()); + q.setContent(question.getContent() != null ? question.getContent().toString() : ""); + + if (question.getContent() != null) { + try { + JSONObject contentObj = JSONObject.parseObject(q.getContent()); + if (contentObj != null) { + q.setAnswer(contentObj.getString("answer")); + q.setAnalysis(contentObj.getString("analysis")); + } + } catch (Exception e) { + log.warn("解析题目内容失败: {}", e.getMessage()); + } + } + + questions.add(q); + } + } + + response.setQuestions(questions); + } + + return response; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/ExamRecordServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ExamRecordServiceImpl.java new file mode 100644 index 0000000..1e9ccaa --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ExamRecordServiceImpl.java @@ -0,0 +1,258 @@ +package top.tqx.demo_1.service.impl; + +import com.alibaba.fastjson.JSONArray; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.*; +import top.tqx.demo_1.mapper.*; +import top.tqx.demo_1.service.ExamRecordService; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.*; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class ExamRecordServiceImpl implements ExamRecordService { + + @Autowired + private ExamRecordMapper examRecordMapper; + + @Autowired + private ExamPaperMapper examPaperMapper; + + @Autowired + private QuestionMapper questionMapper; + + @Autowired + private UserAnswerMapper userAnswerMapper; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public List getUserPapers(Long userId, String answerType) { + List records = examRecordMapper.selectList( + new LambdaQueryWrapper() + .eq(ExamRecord::getUserId, userId) + .eq(ExamRecord::getAnswerType, answerType) + .ne(ExamRecord::getStatus, "revoked") + .orderByDesc(ExamRecord::getCreatedAt)); + + List responses = new ArrayList<>(); + for (ExamRecord record : records) { + ExamRecordResponse response = convertToResponse(record); + response.setPaperTitle(getPaperTitle(record.getPaperId())); + responses.add(response); + } + + return responses; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ExamRecordResponse startExamByPaperId(String paperId, Long userId, String answerType) { + log.info("通过试卷ID开始考试: paperId={}, userId={}, answerType={}", paperId, userId, answerType); + + ExamPaper paper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + + if (paper == null) { + throw new RuntimeException("试卷不存在"); + } + + ExamRecord record = examRecordMapper.selectOne( + new LambdaQueryWrapper() + .eq(ExamRecord::getPaperId, paperId) + .eq(ExamRecord::getUserId, userId) + .eq(ExamRecord::getAnswerType, answerType)); + + if (record == null) { + log.info("创建新考试记录: paperId={}, userId={}, answerType={}", paperId, userId, answerType); + record = new ExamRecord(); + record.setRecordId(UUID.randomUUID().toString()); + record.setPaperId(paperId); + record.setAnswerType(answerType); + record.setUserId(userId); + record.setStatus("not_started"); + record.setCreatedAt(LocalDateTime.now()); + record.setUpdatedAt(LocalDateTime.now()); + examRecordMapper.insert(record); + } else { + log.info("找到现有考试记录: recordId={}, currentStatus={}", record.getRecordId(), record.getStatus()); + } + + String recordId = record.getRecordId(); + log.info("开始考试: recordId={}, currentStatus={}", recordId, record.getStatus()); + + String status = record.getStatus(); + + if ("in_progress".equals(status)) { + log.info("考试已在进行中,直接返回考试信息: recordId={}", recordId); + ExamRecordResponse response = convertToResponse(record); + response.setPaperTitle(paper.getTitle()); + response.setQuestions(getPaperQuestions(paper)); + return response; + } + + if (!"not_started".equals(status) && !"pending".equals(status) && !"draft".equals(status)) { + log.warn("考试状态不允许开始: recordId={}, currentStatus={}", recordId, status); + throw new RuntimeException("考试状态不允许开始,当前状态: " + status); + } + + record.setStatus("in_progress"); + record.setStartTime(LocalDateTime.now()); + record.setLastActiveTime(LocalDateTime.now()); + record.setUpdatedAt(LocalDateTime.now()); + + examRecordMapper.updateById(record); + + log.info("考试状态更新成功: recordId={}", recordId); + + ExamRecordResponse response = convertToResponse(record); + response.setPaperTitle(paper.getTitle()); + response.setQuestions(getPaperQuestions(paper)); + + return response; + } + + + + private List getPaperQuestions(ExamPaper paper) { + List questions = new ArrayList<>(); + + if (paper.getQuestionIds() != null) { + List questionIdList = JSONArray.parseArray(paper.getQuestionIds().toString(), String.class); + for (String qId : questionIdList) { + Question question = questionMapper.selectOne( + new LambdaQueryWrapper().eq(Question::getQuestionId, qId)); + if (question != null) { + ExamRecordResponse.ExamPaperQuestion detail = new ExamRecordResponse.ExamPaperQuestion(); + detail.setQuestionId(question.getQuestionId()); + detail.setQuestionType(question.getQuestionType()); + detail.setDifficulty(question.getDifficulty()); + detail.setScore(question.getScore()); + detail.setContent(question.getContent() != null ? question.getContent().toString() : null); + questions.add(detail); + } + } + } + + return questions; + } + + @Override + public ExamRecordResponse getRecordById(String recordId) { + ExamRecord record = examRecordMapper.selectOne( + new LambdaQueryWrapper().eq(ExamRecord::getRecordId, recordId)); + + if (record == null) { + return null; + } + + ExamRecordResponse response = convertToResponse(record); + response.setPaperTitle(getPaperTitle(record.getPaperId())); + + if ("submitted".equals(record.getStatus()) || "graded".equals(record.getStatus())) { + response.setAnswers(getUserAnswers(record.getPaperId(), record.getUserId())); + } + + return response; + } + + private List getUserAnswers(String paperId, Long userId) { + List userAnswers = userAnswerMapper.selectList( + new LambdaQueryWrapper() + .eq(UserAnswer::getPaperId, paperId) + .eq(UserAnswer::getUserId, userId)); + + return userAnswers.stream() + .map(ua -> { + ExamRecordResponse.AnswerDetail detail = new ExamRecordResponse.AnswerDetail(); + detail.setQuestionId(ua.getQuestionId()); + detail.setQuestionType(ua.getQuestionType()); + detail.setScore(ua.getScore()); + detail.setMaxScore(ua.getMaxScore()); + detail.setIsCorrect(Boolean.TRUE.equals(ua.getIsCorrect())); + detail.setFeedback(ua.getFeedback()); + + // 获取用户答案 + detail.setUserAnswer(ua.getUserAnswer()); + + // 从question表获取题目内容和正确答案 + Question question = questionMapper.selectOne( + new LambdaQueryWrapper().eq(Question::getQuestionId, ua.getQuestionId())); + if (question != null && question.getContent() != null) { + try { + ExamGradeRequest.QuestionContent content = + objectMapper.treeToValue(question.getContent(), ExamGradeRequest.QuestionContent.class); + detail.setStem(content.getStem()); + detail.setCorrectAnswer(content.getAnswer()); + } catch (Exception e) { + log.warn("解析题目内容失败: questionId={}", ua.getQuestionId(), e); + } + } + + return detail; + }) + .collect(Collectors.toList()); + } + + @Override + public PageResponse listRecords(String paperId, Integer page, Integer pageSize) { + Page pageQuery = new Page<>(page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (paperId != null && !paperId.isEmpty()) { + wrapper.eq(ExamRecord::getPaperId, paperId); + } + wrapper.orderByDesc(ExamRecord::getSubmitTime); + + Page resultPage = examRecordMapper.selectPage(pageQuery, wrapper); + + List responses = resultPage.getRecords().stream() + .map(record -> { + ExamRecordResponse r = convertToResponse(record); + r.setPaperTitle(getPaperTitle(record.getPaperId())); + return r; + }) + .collect(Collectors.toList()); + + return new PageResponse<>(responses, resultPage.getTotal(), page, pageSize); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRecordsByPaperId(String paperId) { + examRecordMapper.delete(new LambdaQueryWrapper().eq(ExamRecord::getPaperId, paperId)); + } + + private ExamRecordResponse convertToResponse(ExamRecord record) { + ExamRecordResponse response = new ExamRecordResponse(); + response.setRecordId(record.getRecordId()); + response.setPaperId(record.getPaperId()); + response.setAnswerType(record.getAnswerType()); + response.setUserId(record.getUserId()); + response.setStatus(record.getStatus()); + response.setTotalScore(record.getTotalScore()); + response.setTimeSpent(record.getTimeSpent()); + response.setStartTime(record.getStartTime()); + response.setSubmitTime(record.getSubmitTime()); + response.setGradedTime(record.getGradedTime()); + return response; + } + + private String getPaperTitle(String paperId) { + ExamPaper paper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, paperId)); + return paper != null ? paper.getTitle() : ""; + } + + } \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/ExamServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ExamServiceImpl.java new file mode 100644 index 0000000..3f03635 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ExamServiceImpl.java @@ -0,0 +1,1099 @@ + +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.CollectionFile; +import top.tqx.demo_1.entity.ExamPaper; +import top.tqx.demo_1.entity.ExamRecord; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.entity.UserAnswer; +import top.tqx.demo_1.mapper.ExamPaperMapper; +import top.tqx.demo_1.mapper.ExamRecordMapper; +import top.tqx.demo_1.mapper.QuestionMapper; +import top.tqx.demo_1.mapper.UserAnswerMapper; +import top.tqx.demo_1.mapper.CollectionFileMapper; +import top.tqx.demo_1.service.ExamService; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class ExamServiceImpl implements ExamService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private QuestionMapper questionMapper; + + @Autowired + private ExamRecordMapper examRecordMapper; + + @Autowired + private ExamPaperMapper examPaperMapper; + + @Autowired + private UserAnswerMapper userAnswerMapper; + + @Autowired + private CollectionFileMapper collectionFileMapper; + + @Override + public ExamGenerateResponse generateQuestions(ExamGenerateRequest request) throws IOException { + boolean useSmartApi = (request.getQuestionTypes() == null); + String url = useSmartApi ? + aiApiConfig.getFullUrl(aiApiConfig.getExam().getGenerateSmart()) : + aiApiConfig.getFullUrl(aiApiConfig.getExam().getGenerate()); + + log.info("开始生成题目"); + log.info("请求参数: requestId={}, fileId={}, filePath={}, questionTypes={}, difficulty={}", + request.getRequestId(), request.getFileId(), request.getFilePath(), + request.getQuestionTypes(), request.getDifficulty()); + log.info("使用API: {}", useSmartApi ? "智能生成" : "指定题型"); + log.info("请求URL: {}", url); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String jsonResponse = postJsonRequest(url, objectMapper.writeValueAsString(request), 120000); + log.info("RAG服务响应: {}", jsonResponse); + + ExamGenerateResponse response = parseExamGenerateResponse(jsonResponse); + log.info("RAG服务题目生成完成: success={}, questions count={}", + response.getSuccess(), response.getQuestions() != null ? response.getQuestions().size() : 0); + + if (Boolean.TRUE.equals(response.getSuccess()) && response.getQuestions() != null && !response.getQuestions().isEmpty()) { + log.info("开始保存 {} 道题目到数据库", response.getQuestions().size()); + int savedCount = saveQuestionsToDatabase(response.getQuestions(), request.getFileId(), request.getCollectionName(), response.getSourceChunksUsed()); + log.info("成功保存 {} 道题目到数据库", savedCount); + } else { + log.warn("未生成任何题目,无需保存"); + } + + return response; + + } catch (Exception e) { + log.error("生成题目失败: {}", e.getMessage(), e); + throw new IOException("生成题目失败: " + e.getMessage(), e); + } + } + + @Override + public Map generateQuestionsWithFileId(ExamGenerateRequest request) { + log.info("generateQuestionsWithFileId 开始"); + log.info("请求参数: fileId={}, filePath={}, questionTypes={}", + request.getFileId(), request.getFilePath(), request.getQuestionTypes()); + + Map result = new HashMap<>(); + + try { + String collectionName = "test1"; + String filePath = "test1/1.docx"; + String documentName = "1.docx"; + + if (request.getFileId() != null) { + log.info("根据 fileId 查询 collection_file 表: fileId={}", request.getFileId()); + + // 真正从数据库查询 collection_file 表 + List collectionFiles = collectionFileMapper.selectList( + new LambdaQueryWrapper() + .eq(CollectionFile::getFileId, request.getFileId()) + .eq(CollectionFile::getStatus, 1)); + + if (!collectionFiles.isEmpty()) { + CollectionFile cf = collectionFiles.get(0); + collectionName = cf.getCollectionName(); + // 外部API需要的是向量库路径格式: collection_name/doc_name + // 而不是数据库中存储的服务器路径 + filePath = collectionName + "/" + cf.getDocName(); + documentName = cf.getDocName(); + log.info("查询到文件信息: collectionName={}, filePath={}, documentName={}", + collectionName, filePath, documentName); + } else { + log.warn("未在 collection_file 表中找到 fileId={} 的记录,使用默认值", request.getFileId()); + } + + // 如果 request 中已经提供了,优先使用 request 中的值 + if (request.getCollectionName() != null) { + collectionName = request.getCollectionName(); + } + if (request.getFilePath() != null) { + filePath = request.getFilePath(); + documentName = request.getFilePath(); + } + + // 更新 request 对象,供后续调用使用 + request.setCollection(collectionName); + request.setFilePath(filePath); + } else if (request.getFilePath() != null) { + log.info("直接使用 filePath: {}", request.getFilePath()); + filePath = request.getFilePath(); + if (request.getCollectionName() != null) { + collectionName = request.getCollectionName(); + } + documentName = filePath; + + // 更新 request 对象,供后续调用使用 + request.setCollection(collectionName); + } else { + result.put("success", false); + result.put("message", "file_id 或 file_path 不能同时为空"); + return result; + } + + result.put("requestId", request.getRequestId()); + result.put("fileId", request.getFileId()); + result.put("fileMatchStatus", "success"); + result.put("matchedCollection", collectionName); + result.put("matchedFilePath", filePath); + result.put("matchedDocumentName", documentName); + + boolean useSmartApi = (request.getQuestionTypes() == null); + String apiPath = useSmartApi ? + aiApiConfig.getExam().getGenerateSmart() : + aiApiConfig.getExam().getGenerate(); + + log.info("使用API: {}, API路径: {}", useSmartApi ? "智能生成" : "指定题型", apiPath); + + Map generateParams = new HashMap<>(); + generateParams.put("file_path", filePath); + generateParams.put("difficulty", request.getDifficulty()); + + if (request.getQuestionTypes() != null) { + generateParams.put("question_types", request.getQuestionTypes()); + } + + result.put("aiRequestParams", generateParams); + result.put("aiApiPath", apiPath); + result.put("success", true); + result.put("message", "参数准备完成,等待异步处理"); + + return result; + + } catch (Exception e) { + log.error("generateQuestionsWithFileId 异常: {}", e.getMessage(), e); + result.put("success", false); + result.put("message", "处理失败: " + e.getMessage()); + return result; + } + } + + @Override + public ExamGradeResponse gradeAnswers(ExamGradeRequest request) throws IOException { + return gradeAnswers(request, null, null, null, null); + } + + @Override + public ExamGradeResponse gradeAnswers(ExamGradeRequest request, Long userId, String paperId) throws IOException { + return gradeAnswers(request, userId, paperId, null, null); + } + + @Override + public ExamGradeResponse gradeAnswers(ExamGradeRequest request, Long userId, String paperId, + String sessionId, String answerType) throws IOException { + String url = aiApiConfig.getFullUrl(aiApiConfig.getExam().getGrade()); + + log.info("Service层:开始批改题目"); + log.info("Service层参数详情:"); + log.info(" - userId: {}", userId); + log.info(" - paperId: {}", paperId); + log.info(" - sessionId: {}", sessionId); + log.info(" - answerType: {}", answerType); + log.info(" - 答案数量: {}", request.getAnswers() != null ? request.getAnswers().size() : 0); + log.info(" - AI服务地址: {}", url); + + // 检查是否为空试卷 + boolean isBlankSubmission = request.getAnswers() == null || request.getAnswers().isEmpty(); + if (!isBlankSubmission) { + isBlankSubmission = request.getAnswers().stream().allMatch(item -> + item.getStudentAnswer() == null || + (item.getStudentAnswer() instanceof String && ((String) item.getStudentAnswer()).trim().isEmpty()) || + (item.getStudentAnswer() instanceof List && ((List) item.getStudentAnswer()).isEmpty()) + ); + } + + if (isBlankSubmission) { + log.info("检测到空白试卷,跳过AI批改,直接返回0分"); + ExamGradeResponse response = new ExamGradeResponse(); + response.setSuccess(true); + response.setTotalScore(BigDecimal.ZERO); + response.setTotalMaxScore(BigDecimal.ZERO); + response.setScoreRate(BigDecimal.ZERO); + response.setResults(new ArrayList<>()); + return response; + } + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + log.info("Service层:准备发送请求到AI服务"); + log.info("请求体内容: {}", objectMapper.writeValueAsString(request)); + log.info("请求超时时间: 60000ms (60秒)"); + + try { + String jsonResponse = postJsonRequest(url, objectMapper.writeValueAsString(request), 60000); + log.info("Service层:收到AI服务响应"); + log.info("响应原始内容: {}", jsonResponse); + + if (jsonResponse == null || jsonResponse.trim().isEmpty()) { + log.error("AI服务返回空响应"); + throw new RuntimeException("AI服务返回空响应"); + } + + // 1. 先解析响应(不处理 feedback) + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(jsonResponse); + com.fasterxml.jackson.databind.JsonNode dataNode = rootNode.has("data") ? rootNode.get("data") : rootNode; + + ExamGradeResponse response = parseExamGradeResponseBasic(dataNode); + + // 2. 从原始请求中补全 question_type 信息(AI响应中不包含此字段) + if (response.getResults() != null && request.getAnswers() != null) { + for (int i = 0; i < response.getResults().size() && i < request.getAnswers().size(); i++) { + ExamGradeResponse.GradeResult result = response.getResults().get(i); + ExamGradeRequest.AnswerItem answerItem = request.getAnswers().get(i); + + if (result.getQuestionType() == null && answerItem.getQuestionType() != null) { + result.setQuestionType(answerItem.getQuestionType()); + log.info("从请求中补全 question_type: {}", answerItem.getQuestionType()); + } + } + } + + // 3. 最后处理 feedback(此时 question_type 已经补全) + processGradeResults(response, dataNode); + + log.info("Service层:AI服务批改完成"); + log.info("批改结果: success={}, totalScore={}, totalMaxScore={}, scoreRate={}", + response.getSuccess(), + response.getTotalScore(), + response.getTotalMaxScore(), + response.getScoreRate()); + + if (response.getResults() != null) { + log.info("各题目批改结果:"); + for (int i = 0; i < response.getResults().size(); i++) { + ExamGradeResponse.GradeResult result = response.getResults().get(i); + Boolean correctFlag = result.getCorrect() != null ? result.getCorrect() : result.getIsCorrect(); + log.info(" 题目{}({}): correct={}, score={}/{}, feedback={}", + i + 1, + result.getQuestionId(), + correctFlag, + result.getScore(), + result.getMaxScore(), + result.getFeedback()); + } + } + + return response; + + } catch (Exception e) { + log.error("Service层:调用AI服务失败"); + log.error("错误类型: {}", e.getClass().getName()); + log.error("错误信息: {}", e.getMessage()); + log.error("错误详情:"); + + if (e instanceof java.net.SocketTimeoutException) { + log.error(" 可能原因: AI服务连接超时"); + log.error(" 建议: 检查AI服务是否正常运行,或增加超时时间"); + } else if (e instanceof java.net.ConnectException) { + log.error(" 可能原因: 无法连接到AI服务"); + log.error(" 建议: 检查AI服务地址和端口是否正确"); + } else if (e instanceof com.fasterxml.jackson.core.JsonProcessingException) { + log.error(" 可能原因: AI服务响应格式错误"); + log.error(" 建议: 检查AI服务返回的JSON格式"); + } + + log.error("错误堆栈: ", e); + throw new RuntimeException("调用AI服务失败: " + e.getMessage(), e); + } + + } catch (Exception e) { + log.error("Service层:批改题目发生未知异常"); + log.error("错误类型: {}", e.getClass().getName()); + log.error("错误信息: {}", e.getMessage()); + log.error("详细堆栈: ", e); + throw new RuntimeException("Service层批改题目失败: " + e.getMessage(), e); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ExamRecordResponse gradeAnswersForRecord(String recordId, ExamSaveRequest request, Long userId, String answerType) { + log.info("gradeAnswersForRecord 开始"); + log.info("请求参数: recordId={}, userId={}, answerType={}, answers count={}", + recordId, userId, answerType, request.getAnswers() != null ? request.getAnswers().size() : 0); + + // 1. 查找考试记录 + ExamRecord examRecord = examRecordMapper.selectOne( + new LambdaQueryWrapper().eq(ExamRecord::getRecordId, recordId)); + + if (examRecord == null) { + log.error("找不到考试记录: recordId={}", recordId); + throw new RuntimeException("考试记录不存在"); + } + + // 2. 查找试卷 + ExamPaper examPaper = examPaperMapper.selectOne( + new LambdaQueryWrapper().eq(ExamPaper::getPaperId, examRecord.getPaperId())); + + if (examPaper == null) { + log.error("找不到试卷: paperId={}", examRecord.getPaperId()); + throw new RuntimeException("试卷不存在"); + } + + // 3. 准备调用AI服务 + ExamGradeRequest gradeRequest = new ExamGradeRequest(); + gradeRequest.setRecordId(recordId); + gradeRequest.setPaperId(examRecord.getPaperId()); + gradeRequest.setAnswerType(answerType); + + // 从试卷中加载题目详情 + List answerItems = new ArrayList<>(); + + // 检查是否为空试卷 + boolean isBlankSubmission = request.getAnswers() == null || request.getAnswers().isEmpty(); + + if (!isBlankSubmission) { + // 检查是否所有答案都为空 + isBlankSubmission = request.getAnswers().stream().allMatch(item -> + item.getAnswer() == null || + (item.getAnswer() instanceof String && ((String) item.getAnswer()).trim().isEmpty()) || + (item.getAnswer() instanceof List && ((List) item.getAnswer()).isEmpty()) + ); + } + + log.info("检测到空试卷提交: isBlankSubmission={}", isBlankSubmission); + + if (!isBlankSubmission) { + for (ExamSaveRequest.AnswerItem saveItem : request.getAnswers()) { + ExamGradeRequest.AnswerItem gradeItem = new ExamGradeRequest.AnswerItem(); + gradeItem.setQuestionId(saveItem.getQuestionId()); + gradeItem.setQuestionType(saveItem.getQuestionType()); + gradeItem.setStudentAnswer(saveItem.getAnswer()); + + // 从题库加载题目内容 + Question question = questionMapper.selectOne( + new LambdaQueryWrapper().eq(Question::getQuestionId, saveItem.getQuestionId())); + + // 设置题目内容(确保不为null) + if (question != null && question.getContent() != null) { + try { + ExamGradeRequest.QuestionContent questionContent = objectMapper.treeToValue( + question.getContent(), ExamGradeRequest.QuestionContent.class); + gradeItem.setQuestionContent(questionContent); + } catch (Exception e) { + log.warn("转换题目内容失败", e); + } + } + + // 设置满分(确保不为null) + if (question != null && question.getScore() != null) { + gradeItem.setMaxScore(question.getScore()); + } else { + gradeItem.setMaxScore(new BigDecimal(5)); + } + + // 确保questionContent不为null,避免AI服务报错 + if (gradeItem.getQuestionContent() == null) { + ExamGradeRequest.QuestionContent questionContent = new ExamGradeRequest.QuestionContent(); + questionContent.setStem(""); + questionContent.setAnswer(""); + gradeItem.setQuestionContent(questionContent); + log.warn("题目内容为空,已设置默认值: questionId={}", saveItem.getQuestionId()); + } + + answerItems.add(gradeItem); + } + } else { + // 空答案提交时,也要复制基础信息到gradeRequest,确保后续保存时长度一致 + for (ExamSaveRequest.AnswerItem saveItem : request.getAnswers()) { + ExamGradeRequest.AnswerItem gradeItem = new ExamGradeRequest.AnswerItem(); + gradeItem.setQuestionId(saveItem.getQuestionId()); + gradeItem.setQuestionType(saveItem.getQuestionType()); + gradeItem.setStudentAnswer(saveItem.getAnswer()); + answerItems.add(gradeItem); + } + } + gradeRequest.setAnswers(answerItems); + + // 4. 调用AI服务批改(空试卷直接返回0分,不调用AI) + ExamGradeResponse gradeResponse; + if (isBlankSubmission) { + log.info("检测到空白试卷,跳过AI批改,直接返回0分"); + gradeResponse = createBlankGradeResponse(request.getAnswers()); + } else { + try { + gradeResponse = gradeAnswers( + gradeRequest, userId, examRecord.getPaperId(), null, answerType); + } catch (IOException e) { + log.error("调用AI服务失败: {}", e.getMessage(), e); + throw new RuntimeException("AI服务调用失败: " + e.getMessage(), e); + } + } + + if (!Boolean.TRUE.equals(gradeResponse.getSuccess())) { + log.error("AI批改失败: {}", gradeResponse.getError()); + throw new RuntimeException("AI批改失败: " + gradeResponse.getError()); + } + + // 5. 更新考试记录 + examRecord.setStatus("graded"); + examRecord.setTotalScore(gradeResponse.getTotalScore()); + examRecord.setSubmitTime(LocalDateTime.now()); + examRecord.setGradedTime(LocalDateTime.now()); + + // 计算答题耗时 + examRecord.setTimeSpent(calculateTimeSpent(examRecord)); + + examRecord.setUpdatedAt(LocalDateTime.now()); + examRecordMapper.updateById(examRecord); + + // 6. 保存答题记录 + saveUserAnswersForExam(gradeRequest, gradeResponse, userId, examRecord); + + // 7. 准备返回结果 + ExamRecordResponse response = new ExamRecordResponse(); + response.setRecordId(recordId); + response.setPaperId(examRecord.getPaperId()); + response.setUserId(userId); + response.setStatus("graded"); + response.setTotalScore(gradeResponse.getTotalScore()); + response.setPaperTitle(examPaper.getTitle()); + response.setAnswerType(answerType); + + // 获取答题详情 + response.setAnswers(getAnswerDetails(gradeResponse)); + + log.info("gradeAnswersForRecord 完成,总分: {}", gradeResponse.getTotalScore()); + + return response; + } + + /** + * 计算考试耗时 + */ + private Integer calculateTimeSpent(ExamRecord examRecord) { + if (examRecord.getStartTime() != null) { + Duration duration = Duration.between(examRecord.getStartTime(), LocalDateTime.now()); + return (int) duration.toSeconds(); + } + return null; + } + + /** + * 保存正式考试的答题记录 + */ + private void saveUserAnswersForExam(ExamGradeRequest request, ExamGradeResponse response, Long userId, ExamRecord examRecord) { + ObjectMapper objectMapper = new ObjectMapper(); + List results = response.getResults(); + + if (results == null) return; + + for (int i = 0; i < results.size(); i++) { + ExamGradeResponse.GradeResult result = results.get(i); + ExamGradeRequest.AnswerItem answerItem = request.getAnswers().get(i); + + UserAnswer userAnswer = new UserAnswer(); + userAnswer.setUserId(userId); + userAnswer.setQuestionId(result.getQuestionId()); + userAnswer.setQuestionType(answerItem.getQuestionType()); + userAnswer.setAnswerType(examRecord.getAnswerType()); + userAnswer.setPaperId(examRecord.getPaperId()); + userAnswer.setScore(result.getScore()); + userAnswer.setMaxScore(result.getMaxScore()); + + try { + JsonNode answerNode = objectMapper.valueToTree(answerItem.getStudentAnswer()); + userAnswer.setUserAnswer(answerNode); + } catch (Exception e) { + log.warn("转换学生答案失败", e); + } + + Boolean isCorrect = result.getCorrect() != null ? result.getCorrect() : result.getIsCorrect(); + + // 判断答题状态:0=错误,1=正确,2=半对 + Integer correctStatus; + if (Boolean.TRUE.equals(isCorrect)) { + correctStatus = 1; // 完全正确 + } else { + // 检查是否为半对(得分>0但<满分,适用于主观题) + BigDecimal score = result.getScore(); + BigDecimal maxScore = result.getMaxScore(); + if (score != null && maxScore != null && + score.compareTo(BigDecimal.ZERO) > 0 && + score.compareTo(maxScore) < 0) { + correctStatus = 2; // 半对 + } else { + correctStatus = 0; // 错误 + } + } + userAnswer.setIsCorrect(correctStatus); + userAnswer.setGradingType("ai"); + userAnswer.setFeedback(result.getFeedback()); + userAnswer.setCreatedAt(LocalDateTime.now()); + userAnswer.setUpdatedAt(LocalDateTime.now()); + + try { + userAnswerMapper.insert(userAnswer); + } catch (org.springframework.dao.DuplicateKeyException e) { + log.warn("检测到重复提交: userId={}, questionId={}, paperId={}", + userId, result.getQuestionId(), examRecord.getPaperId()); + throw new RuntimeException("不允许重复做题:您已完成此题的作答,请勿重复提交"); + } + } + + log.info("保存了{}条考试答题记录", results.size()); + } + + /** + * 构建答题详情列表 + */ + private List getAnswerDetails(ExamGradeResponse response) { + List details = new ArrayList<>(); + + if (response.getResults() == null) return details; + + for (ExamGradeResponse.GradeResult result : response.getResults()) { + ExamRecordResponse.AnswerDetail detail = new ExamRecordResponse.AnswerDetail(); + detail.setQuestionId(result.getQuestionId()); + detail.setScore(result.getScore()); + Boolean isCorrect = result.getCorrect() != null ? result.getCorrect() : result.getIsCorrect(); + detail.setIsCorrect(isCorrect); + detail.setFeedback(result.getFeedback()); + details.add(detail); + } + + return details; + } + + @Override + public ExamPaperGenerateResponse generatePaper(ExamPaperGenerateRequest request, Long userId, Long deptId, Integer userType) { + ExamPaperGenerateResponse response = new ExamPaperGenerateResponse(); + response.setPaperId(UUID.randomUUID().toString()); + response.setPaperTitle("自动生成的试卷"); + response.setGeneratedAt(java.time.LocalDateTime.now()); + response.setPermissionScope("department"); + + List allQuestions = new ArrayList<>(); + BigDecimal totalScore = BigDecimal.ZERO; + Map warnings = new HashMap<>(); + + if (request.getSingleChoiceCount() != null && request.getSingleChoiceCount() > 0) { + QuestionSelectionResult result = selectQuestionsByType( + "single_choice", + request.getSingleChoiceCount(), + deptId, + Boolean.TRUE.equals(request.getIncludePersonal()), + request.getDifficulty(), + request.getFileIds(), + userType + ); + allQuestions.addAll(result.getQuestions()); + if (result.hasWarning) { + warnings.put("single_choice", result.warning); + } + } + + if (request.getMultipleChoiceCount() != null && request.getMultipleChoiceCount() > 0) { + QuestionSelectionResult result = selectQuestionsByType( + "multiple_choice", + request.getMultipleChoiceCount(), + deptId, + Boolean.TRUE.equals(request.getIncludePersonal()), + request.getDifficulty(), + request.getFileIds(), + userType + ); + allQuestions.addAll(result.getQuestions()); + if (result.hasWarning) { + warnings.put("multiple_choice", result.warning); + } + } + + if (request.getTrueFalseCount() != null && request.getTrueFalseCount() > 0) { + QuestionSelectionResult result = selectQuestionsByType( + "true_false", + request.getTrueFalseCount(), + deptId, + Boolean.TRUE.equals(request.getIncludePersonal()), + request.getDifficulty(), + request.getFileIds(), + userType + ); + allQuestions.addAll(result.getQuestions()); + if (result.hasWarning) { + warnings.put("true_false", result.warning); + } + } + + if (request.getFillBlankCount() != null && request.getFillBlankCount() > 0) { + QuestionSelectionResult result = selectQuestionsByType( + "fill_blank", + request.getFillBlankCount(), + deptId, + Boolean.TRUE.equals(request.getIncludePersonal()), + request.getDifficulty(), + request.getFileIds(), + userType + ); + allQuestions.addAll(result.getQuestions()); + if (result.hasWarning) { + warnings.put("fill_blank", result.warning); + } + } + + if (request.getSubjectiveCount() != null && request.getSubjectiveCount() > 0) { + QuestionSelectionResult result = selectQuestionsByType( + "subjective", + request.getSubjectiveCount(), + deptId, + Boolean.TRUE.equals(request.getIncludePersonal()), + request.getDifficulty(), + request.getFileIds(), + userType + ); + allQuestions.addAll(result.getQuestions()); + if (result.hasWarning) { + warnings.put("subjective", result.warning); + } + } + + for (ExamPaperGenerateResponse.ExamPaperQuestion q : allQuestions) { + if (q.getScore() != null) { + totalScore = totalScore.add(q.getScore()); + } + } + + response.setQuestions(allQuestions); + response.setTotalScore(totalScore); + response.setQuestionCount(allQuestions.size()); + response.setWarnings(warnings.isEmpty() ? null : warnings); + + log.info("试卷生成完成: paperId={}, questionCount={}, totalScore={}", + response.getPaperId(), response.getQuestionCount(), response.getTotalScore()); + + return response; + } + + private QuestionSelectionResult selectQuestionsByType(String type, int count, Long deptId, boolean includePersonal, Integer difficulty, List fileIds, Integer userType) { + QuestionSelectionResult result = new QuestionSelectionResult(); + List selectedQuestions = new ArrayList<>(); + try { + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper queryWrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>(); + queryWrapper.eq(Question::getQuestionType, type); + if (difficulty != null && difficulty > 0) { + queryWrapper.eq(Question::getDifficulty, difficulty); + } + if (fileIds != null && !fileIds.isEmpty()) { + List validDocumentIds = fileIds.stream() + .filter(id -> id != null && id > 0) + .map(String::valueOf) + .collect(Collectors.toList()); + if (!validDocumentIds.isEmpty()) { + queryWrapper.in(Question::getDocumentId, validDocumentIds); + } + } + queryWrapper.in(Question::getStatus, "active", "approved"); + long totalCount = questionMapper.selectCount(queryWrapper); + if (totalCount < count) { + log.warn("题库中{}类型的题目数量不足,需要{}道,实际只有{}道", type, count, totalCount); + result.hasWarning = true; + result.warning = String.format("%s类型题目数量不足,仅有%d道(需要%d道)", getQuestionTypeName(type), (int) totalCount, count); + count = (int) Math.min(count, totalCount); + } + if (count > 0) { + queryWrapper.last("ORDER BY RAND() LIMIT " + count); + List questions = questionMapper.selectList(queryWrapper); + for (Question question : questions) { + ExamPaperGenerateResponse.ExamPaperQuestion paperQuestion = new ExamPaperGenerateResponse.ExamPaperQuestion(); + paperQuestion.setQuestionId(question.getQuestionId()); + paperQuestion.setQuestionType(question.getQuestionType()); + paperQuestion.setContent(question.getContent()); + paperQuestion.setScore(question.getScore() != null ? question.getScore() : new BigDecimal(5)); + paperQuestion.setDifficulty(question.getDifficulty()); + paperQuestion.setDocumentName(question.getDocumentName()); + paperQuestion.setKnowledgeBasePath(question.getKnowledgeBasePath()); + selectedQuestions.add(paperQuestion); + } + log.info("成功选择{}道{}类型题目", selectedQuestions.size(), type); + } + } catch (Exception e) { + log.error("选择{}类型题目失败: {}", type, e.getMessage(), e); + result.hasWarning = true; + result.warning = "选择" + getQuestionTypeName(type) + "题目时发生错误"; + } + result.setQuestions(selectedQuestions); + return result; + } + + private String getQuestionTypeName(String type) { + switch (type) { + case "single_choice": + return "单选题"; + case "multiple_choice": + return "多选题"; + case "true_false": + return "判断题"; + case "fill_blank": + return "填空题"; + case "subjective": + return "主观题"; + default: + return type; + } + } + + private String postJsonRequest(String url, String jsonBody, int timeout) throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + entity, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return response.getBody(); + } else { + throw new IOException("HTTP请求失败: " + response.getStatusCode()); + } + } catch (Exception e) { + log.error("POST请求失败: url={}, error={}", url, e.getMessage()); + throw new IOException("POST请求失败: " + e.getMessage(), e); + } + } + + private ExamGenerateResponse parseExamGenerateResponse(String jsonResponse) throws IOException { + try { + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(jsonResponse); + + if (rootNode.has("data")) { + return objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + } + + return objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + } catch (Exception e) { + log.error("解析生成题目响应失败: {}", e.getMessage()); + throw new IOException("解析生成题目响应失败: " + e.getMessage(), e); + } + } + + private ExamGradeResponse parseExamGradeResponseBasic(com.fasterxml.jackson.databind.JsonNode dataNode) throws IOException { + try { + ExamGradeResponse response = new ExamGradeResponse(); + + // 解析顶层字段 + if (dataNode.has("success")) { + response.setSuccess(dataNode.get("success").asBoolean()); + } + if (dataNode.has("request_id")) { + response.setRequestId(dataNode.get("request_id").asText()); + } + if (dataNode.has("paper_id")) { + response.setPaperId(dataNode.get("paper_id").asText()); + } + if (dataNode.has("total_score")) { + response.setTotalScore(dataNode.get("total_score").decimalValue()); + } + if (dataNode.has("total_max_score")) { + response.setTotalMaxScore(dataNode.get("total_max_score").decimalValue()); + } + if (dataNode.has("score_rate")) { + response.setScoreRate(dataNode.get("score_rate").decimalValue()); + } + + // 解析 results 数组 + if (dataNode.has("results") && dataNode.get("results").isArray()) { + com.fasterxml.jackson.databind.JsonNode resultsArray = dataNode.get("results"); + List results = new ArrayList<>(); + + log.info("开始解析 {} 道题", resultsArray.size()); + + for (int i = 0; i < resultsArray.size(); i++) { + com.fasterxml.jackson.databind.JsonNode resultNode = resultsArray.get(i); + ExamGradeResponse.GradeResult result = new ExamGradeResponse.GradeResult(); + + // 打印完整的 resultNode 结构 + log.info("第 {} 题 resultNode 内容: {}", i + 1, resultNode.toString()); + log.info("resultNode 字段: {}", java.util.stream.StreamSupport.stream(resultNode.spliterator(), false) + .map(n -> n.asText()).collect(java.util.stream.Collectors.joining(", "))); + + // 基本字段 + if (resultNode.has("question_id")) { + result.setQuestionId(resultNode.get("question_id").asText()); + log.info("读取到 question_id: {}", result.getQuestionId()); + } + if (resultNode.has("question_type")) { + result.setQuestionType(resultNode.get("question_type").asText()); + log.info("读取到 question_type: {}", result.getQuestionType()); + } + if (resultNode.has("score")) { + result.setScore(resultNode.get("score").decimalValue()); + } + if (resultNode.has("max_score")) { + result.setMaxScore(resultNode.get("max_score").decimalValue()); + } + if (resultNode.has("correct")) { + result.setCorrect(resultNode.get("correct").asBoolean()); + } + if (resultNode.has("is_correct")) { + result.setIsCorrect(resultNode.get("is_correct").asBoolean()); + } + + results.add(result); + } + + response.setResults(results); + } + + return response; + } catch (Exception e) { + log.error("解析批改响应失败: {}", e.getMessage(), e); + throw new IOException("解析批改响应失败: " + e.getMessage(), e); + } + } + + private void processGradeResults(ExamGradeResponse response, com.fasterxml.jackson.databind.JsonNode dataNode) { + if (response.getResults() == null || response.getResults().isEmpty()) { + return; + } + + com.fasterxml.jackson.databind.JsonNode resultsArray = dataNode.get("results"); + + for (int i = 0; i < response.getResults().size(); i++) { + ExamGradeResponse.GradeResult result = response.getResults().get(i); + com.fasterxml.jackson.databind.JsonNode resultNode = resultsArray.get(i); + + StringBuilder feedbackBuilder = new StringBuilder(); + + String questionType = result.getQuestionType(); + log.info("处理第 {} 题, 类型: {}", i + 1, questionType); + + // 主观题处理 + if ("subjective".equals(questionType) && resultNode.has("details")) { + log.info("检测到主观题,手动解析 details"); + com.fasterxml.jackson.databind.JsonNode detailsNode = resultNode.get("details"); + + if (detailsNode.has("highlights")) { + feedbackBuilder.append("【亮点】\n"); + com.fasterxml.jackson.databind.JsonNode highlightsNode = detailsNode.get("highlights"); + if (highlightsNode.isArray()) { + for (int j = 0; j < highlightsNode.size(); j++) { + feedbackBuilder.append(j + 1).append(". ").append(highlightsNode.get(j).asText()).append("\n"); + } + } + } + + if (detailsNode.has("overall_feedback")) { + feedbackBuilder.append("【综合评价】\n").append(detailsNode.get("overall_feedback").asText()).append("\n"); + } + + if (detailsNode.has("scoring_breakdown")) { + feedbackBuilder.append("【评分详情】\n"); + com.fasterxml.jackson.databind.JsonNode breakdownsNode = detailsNode.get("scoring_breakdown"); + if (breakdownsNode.isArray()) { + for (int j = 0; j < breakdownsNode.size(); j++) { + com.fasterxml.jackson.databind.JsonNode breakdownNode = breakdownsNode.get(j); + String point = breakdownNode.has("point") ? breakdownNode.get("point").asText() : ""; + double weight = breakdownNode.has("weight") ? breakdownNode.get("weight").asDouble() : 0; + double achieved = breakdownNode.has("achieved") ? breakdownNode.get("achieved").asDouble() : 0; + String comment = breakdownNode.has("comment") ? breakdownNode.get("comment").asText() : ""; + + feedbackBuilder.append("- ").append(point) + .append(" (权重: ").append((int)(weight * 100)).append("%, 得分率: ").append((int)(achieved * 100)).append("%)") + .append("\n 评价: ").append(comment).append("\n"); + } + } + } + + if (detailsNode.has("shortcomings")) { + feedbackBuilder.append("【可改进之处】\n"); + com.fasterxml.jackson.databind.JsonNode shortcomingsNode = detailsNode.get("shortcomings"); + if (shortcomingsNode.isArray()) { + for (int j = 0; j < shortcomingsNode.size(); j++) { + feedbackBuilder.append(j + 1).append(". ").append(shortcomingsNode.get(j).asText()).append("\n"); + } + } + } + + if (feedbackBuilder.length() > 0) { + result.setFeedback(feedbackBuilder.toString().trim()); + log.info("主观题 feedback 已设置,长度: {}", result.getFeedback().length()); + } + } + // 填空题处理 + else if ("fill_blank".equals(questionType)) { + log.info("检测到填空题"); + + if (resultNode.has("feedback")) { + feedbackBuilder.append(resultNode.get("feedback").asText()).append("\n"); + } + + if (resultNode.has("correct_answers")) { + com.fasterxml.jackson.databind.JsonNode correctAnswersNode = resultNode.get("correct_answers"); + if (correctAnswersNode.isArray()) { + feedbackBuilder.append("【正确答案】\n"); + for (int j = 0; j < correctAnswersNode.size(); j++) { + feedbackBuilder.append("第").append(j + 1).append("空: ").append(correctAnswersNode.get(j).asText()).append("\n"); + } + } + } + + if (feedbackBuilder.length() > 0) { + result.setFeedback(feedbackBuilder.toString().trim()); + log.info("填空题 feedback 已设置,长度: {}", result.getFeedback().length()); + } + } + // 其他题型,直接用原始 feedback + else if (resultNode.has("feedback")) { + result.setFeedback(resultNode.get("feedback").asText()); + } + } + } + + private static class QuestionSelectionResult { + private List questions; + private boolean hasWarning; + private String warning; + + public List getQuestions() { + return questions; + } + + public void setQuestions(List questions) { + this.questions = questions; + } + } + + private int saveQuestionsToDatabase(List questions, Long fileId, String collectionName, Integer sourceChunksUsed) { + int savedCount = 0; + for (ExamGenerateRequest.Question q : questions) { + try { + Question question = new Question(); + question.setQuestionId(UUID.randomUUID().toString()); + question.setQuestionType(q.getQuestionType()); + question.setDifficulty(q.getDifficulty() != null ? q.getDifficulty() : 3); + question.setScore(q.getScore() != null ? q.getScore() : new BigDecimal("5.00")); + question.setStatus("pending"); + question.setDocumentId(fileId != null ? fileId.toString() : null); + question.setDocumentName(q.getContent() != null ? + (q.getContent().getStem() != null ? + (q.getContent().getStem().length() > 255 ? + q.getContent().getStem().substring(0, 255) : q.getContent().getStem()) : null) : null); + question.setKnowledgeBasePath(collectionName); + question.setSourceContext(null); + question.setSourceChunksUsed(sourceChunksUsed != null ? sourceChunksUsed : 0); + question.setExamCount(0); + question.setCorrectCount(0); + + if (q.getContent() != null) { + question.setContent(toJsonNode(q.getContent())); + } + + if (q.getSourceTrace() != null) { + question.setChunkIds(toJsonNode(q.getSourceTrace().getChunkIds())); + } + + question.setCreatedAt(LocalDateTime.now()); + question.setUpdatedAt(LocalDateTime.now()); + + questionMapper.insert(question); + savedCount++; + log.debug("保存题目成功: questionId={}, type={}", question.getQuestionId(), question.getQuestionType()); + } catch (Exception e) { + log.error("保存题目失败: questionType={}, error={}", q.getQuestionType(), e.getMessage(), e); + } + } + return savedCount; + } + + private com.fasterxml.jackson.databind.JsonNode toJsonNode(Object obj) { + try { + if (obj == null) { + return null; + } + return objectMapper.valueToTree(obj); + } catch (Exception e) { + log.warn("转换对象为JsonNode失败: {}", e.getMessage()); + return null; + } + } + + /** + * 创建空白试卷批改响应 + * 当检测到空答案时,直接返回0分,不调用AI服务 + */ + private ExamGradeResponse createBlankGradeResponse(List answers) { + ExamGradeResponse response = new ExamGradeResponse(); + response.setSuccess(true); + response.setTotalScore(BigDecimal.ZERO); + response.setTotalMaxScore(BigDecimal.ZERO); + response.setScoreRate(BigDecimal.ZERO); + + List results = new ArrayList<>(); + + if (answers != null && !answers.isEmpty()) { + BigDecimal totalMaxScore = BigDecimal.ZERO; + + for (ExamSaveRequest.AnswerItem item : answers) { + ExamGradeResponse.GradeResult result = new ExamGradeResponse.GradeResult(); + result.setQuestionId(item.getQuestionId()); + result.setQuestionType(item.getQuestionType()); + result.setScore(BigDecimal.ZERO); + + // 获取题目满分 + BigDecimal maxScore = new BigDecimal(5); + if (item.getQuestionId() != null) { + Question question = questionMapper.selectOne( + new LambdaQueryWrapper().eq(Question::getQuestionId, item.getQuestionId())); + if (question != null && question.getScore() != null) { + maxScore = question.getScore(); + } + } + result.setMaxScore(maxScore); + totalMaxScore = totalMaxScore.add(maxScore); + + result.setCorrect(false); + result.setIsCorrect(false); + result.setFeedback("未作答"); + + results.add(result); + } + + response.setTotalMaxScore(totalMaxScore); + } + + response.setResults(results); + log.info("创建空白试卷响应: totalScore=0, totalMaxScore={}, questionCount={}", + response.getTotalMaxScore(), results.size()); + + return response; + } +} + diff --git a/src/main/java/top/tqx/demo_1/service/impl/FAQServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/FAQServiceImpl.java new file mode 100644 index 0000000..7f99c86 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/FAQServiceImpl.java @@ -0,0 +1,221 @@ +package top.tqx.demo_1.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import top.tqx.demo_1.dto.FAQItem; +import top.tqx.demo_1.dto.FAQListResponse; +import top.tqx.demo_1.dto.FAQSuggestionItem; +import top.tqx.demo_1.dto.FAQSuggestionListResponse; +import top.tqx.demo_1.mapper.FAQMapper; +import top.tqx.demo_1.mapper.FAQSuggestionMapper; +import top.tqx.demo_1.dto.FAQCreateRequest; +import top.tqx.demo_1.dto.FAQUpdateRequest; +import top.tqx.demo_1.entity.FAQ; +import top.tqx.demo_1.entity.FAQSuggestion; +import top.tqx.demo_1.service.FAQService; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class FAQServiceImpl implements FAQService { + + @Autowired + private FAQMapper faqMapper; + + @Autowired + private FAQSuggestionMapper faqSuggestionMapper; + + @Override + public FAQListResponse getFAQList(Integer page, Integer pageSize) { + log.info("获取FAQ列表: page={}, pageSize={}", page, pageSize); + + IPage pageResult = faqMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper().eq(FAQ::getStatus, "approved").orderByDesc(FAQ::getCreatedAt) + ); + + FAQListResponse response = new FAQListResponse(); + response.setSuccess(true); + + List faqs = new ArrayList<>(); + for (FAQ faq : pageResult.getRecords()) { + FAQItem item = new FAQItem(); + item.setId(faq.getId()); + item.setQuestion(faq.getQuestion()); + item.setAnswer(faq.getAnswer()); + item.setAvgRating(faq.getAvgRating()); + item.setFrequency(faq.getFrequency()); + item.setStatus(faq.getStatus()); + faqs.add(item); + } + response.setFaqs(faqs); + response.setTotal((int) pageResult.getTotal()); + + log.info("获取FAQ列表成功: count={}", faqs.size()); + return response; + } + + @Override + public FAQSuggestionListResponse getFAQSuggestions(Integer page, Integer pageSize) { + log.info("获取FAQ建议列表: page={}, pageSize={}", page, pageSize); + + IPage pageResult = faqSuggestionMapper.selectPage( + new Page<>(page, pageSize), + new LambdaQueryWrapper().eq(FAQSuggestion::getStatus, "pending").orderByDesc(FAQSuggestion::getCreatedAt) + ); + + FAQSuggestionListResponse response = new FAQSuggestionListResponse(); + response.setSuccess(true); + + List suggestions = new ArrayList<>(); + for (FAQSuggestion suggestion : pageResult.getRecords()) { + FAQSuggestionItem item = new FAQSuggestionItem(); + item.setId(suggestion.getId()); + item.setQuery(suggestion.getQuery()); + item.setAnswer(suggestion.getAnswer()); + item.setAvgRating(suggestion.getAvgRating()); + item.setFrequency(suggestion.getFrequency()); + suggestions.add(item); + } + response.setSuggestions(suggestions); + response.setTotal((int) pageResult.getTotal()); + + log.info("获取FAQ建议列表成功: count={}", suggestions.size()); + return response; + } + + @Override + public boolean approveSuggestion(Long id, String answer) { + log.info("批准FAQ建议: id={}", id); + + FAQSuggestion suggestion = faqSuggestionMapper.selectById(id); + if (suggestion == null) { + log.warn("FAQ建议不存在: id={}", id); + return false; + } + + FAQ faq = new FAQ(); + faq.setQuestion(suggestion.getQuery()); + faq.setAnswer(answer != null && !answer.isEmpty() ? answer : suggestion.getAnswer()); + faq.setStatus("approved"); + faq.setAvgRating(suggestion.getAvgRating()); + faq.setFrequency(suggestion.getFrequency()); + faq.setCreatedAt(LocalDateTime.now()); + faq.setUpdatedAt(LocalDateTime.now()); + + faqMapper.insert(faq); + + suggestion.setStatus("approved"); + suggestion.setFaqId(faq.getId()); + faqSuggestionMapper.updateById(suggestion); + + log.info("批准FAQ建议成功: id={}, faqId={}", id, faq.getId()); + return true; + } + + @Override + public boolean rejectSuggestion(Long id) { + log.info("拒绝FAQ建议: id={}", id); + + FAQSuggestion suggestion = faqSuggestionMapper.selectById(id); + if (suggestion == null) { + log.warn("FAQ建议不存在: id={}", id); + return false; + } + + suggestion.setStatus("rejected"); + faqSuggestionMapper.updateById(suggestion); + + log.info("拒绝FAQ建议成功: id={}", id); + return true; + } + + @Override + public FAQ createFAQ(FAQCreateRequest request) { + FAQ faq = new FAQ(); + faq.setQuestion(request.getQuestion()); + faq.setAnswer(request.getAnswer()); + faq.setStatus("approved"); + faq.setAvgRating(BigDecimal.ZERO); + faq.setFrequency(0); + + if (request.getSourceDocuments() != null && !request.getSourceDocuments().isEmpty()) { + faq.setSourceDocuments(String.join(",", request.getSourceDocuments())); + } + + faq.setCreatedAt(LocalDateTime.now()); + faq.setUpdatedAt(LocalDateTime.now()); + + faqMapper.insert(faq); + log.info("创建FAQ成功: id={}", faq.getId()); + return faq; + } + + @Override + public FAQ getFAQById(Long id) { + return faqMapper.selectById(id); + } + + @Override + public FAQ updateFAQ(Long id, FAQUpdateRequest request) { + FAQ faq = faqMapper.selectById(id); + if (faq == null) { + throw new IllegalArgumentException("FAQ不存在"); + } + + if (request.getQuestion() != null) { + faq.setQuestion(request.getQuestion()); + } + if (request.getAnswer() != null) { + faq.setAnswer(request.getAnswer()); + } + if (request.getAvgRating() != null) { + faq.setAvgRating(request.getAvgRating()); + } + if (request.getFrequency() != null) { + faq.setFrequency(request.getFrequency()); + } + if (request.getStatus() != null) { + faq.setStatus(request.getStatus()); + } + if (request.getSourceDocuments() != null) { + faq.setSourceDocuments(String.join(",", request.getSourceDocuments())); + } + + faq.setUpdatedAt(LocalDateTime.now()); + faqMapper.updateById(faq); + return faq; + } + + @Override + public boolean deleteFAQ(Long id) { + return faqMapper.deleteById(id) > 0; + } + + @Override + public int batchDeleteFAQs(List ids) { + return faqMapper.deleteBatchIds(ids); + } + + @Override + public List listFAQs(String status, Integer limit) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (status != null && !status.isEmpty()) { + wrapper.eq(FAQ::getStatus, status); + } + + wrapper.orderByDesc(FAQ::getCreatedAt); + wrapper.last("LIMIT " + (limit != null ? limit : 100)); + + return faqMapper.selectList(wrapper); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/FAQSuggestionServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/FAQSuggestionServiceImpl.java new file mode 100644 index 0000000..e9b754c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/FAQSuggestionServiceImpl.java @@ -0,0 +1,85 @@ +package top.tqx.demo_1.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.entity.FAQ; +import top.tqx.demo_1.entity.FAQSuggestion; +import top.tqx.demo_1.mapper.FAQMapper; +import top.tqx.demo_1.mapper.FAQSuggestionMapper; +import top.tqx.demo_1.service.FAQSuggestionService; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FAQSuggestionServiceImpl implements FAQSuggestionService { + + private final FAQSuggestionMapper faqSuggestionMapper; + private final FAQMapper faqMapper; + + @Override + public FAQSuggestion save(FAQSuggestion suggestion) { + faqSuggestionMapper.insert(suggestion); + log.info("保存FAQ建议: id={}", suggestion.getId()); + return suggestion; + } + + @Override + public FAQSuggestion findById(Long id) { + return faqSuggestionMapper.selectById(id); + } + + @Override + public List findByStatus(String status) { + return faqSuggestionMapper.selectByStatus(status); + } + + @Override + public FAQSuggestion approve(Long id, String answer) { + FAQSuggestion suggestion = faqSuggestionMapper.selectById(id); + if (suggestion != null) { + FAQ faq = new FAQ(); + faq.setQuestion(suggestion.getQuery()); + faq.setAnswer(answer); + faq.setStatus("approved"); + faqMapper.insert(faq); + + suggestion.setStatus("approved"); + suggestion.setFaqId(faq.getId()); + suggestion.setAnswer(answer); + faqSuggestionMapper.updateById(suggestion); + log.info("审批通过FAQ建议: suggestionId={}, faqId={}", id, faq.getId()); + } + return suggestion; + } + + @Override + public void reject(Long id) { + faqSuggestionMapper.updateStatus(id, "rejected"); + log.info("拒绝FAQ建议: id={}", id); + } + + @Override + public void updateFrequency(Long id, Integer frequency) { + faqSuggestionMapper.updateFrequency(id, frequency); + } + + @Override + public FAQSuggestion findOrCreate(String query, String answer) { + FAQSuggestion existing = faqSuggestionMapper.selectByQuery(query); + if (existing != null) { + existing.setFrequency(existing.getFrequency() + 1); + faqSuggestionMapper.updateById(existing); + return existing; + } + FAQSuggestion newSuggestion = new FAQSuggestion(); + newSuggestion.setQuery(query); + newSuggestion.setAnswer(answer); + newSuggestion.setFrequency(1); + newSuggestion.setStatus("pending"); + faqSuggestionMapper.insert(newSuggestion); + return newSuggestion; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/FeedbackServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/FeedbackServiceImpl.java new file mode 100644 index 0000000..db76b50 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/FeedbackServiceImpl.java @@ -0,0 +1,155 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import top.tqx.demo_1.dto.FeedbackCreateRequest; +import top.tqx.demo_1.dto.FeedbackItem; +import top.tqx.demo_1.dto.FeedbackListResponse; +import top.tqx.demo_1.dto.FeedbackResponse; +import top.tqx.demo_1.dto.FeedbackStatsResponse; +import top.tqx.demo_1.entity.Feedback; +import top.tqx.demo_1.mapper.FeedbackMapper; +import top.tqx.demo_1.service.FeedbackService; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +public class FeedbackServiceImpl implements FeedbackService { + + @Autowired + private FeedbackMapper feedbackMapper; + + @Autowired + private ObjectMapper objectMapper; + + @Override + public FeedbackResponse submitFeedback(FeedbackCreateRequest request) { + log.info("提交反馈: sessionId={}, rating={}", request.getSessionId(), request.getRating()); + + String feedbackId = UUID.randomUUID().toString(); + + try { + Feedback feedback = new Feedback(); + feedback.setFeedbackId(feedbackId); + feedback.setSessionId(request.getSessionId()); + feedback.setQuery(request.getQuery()); + feedback.setAnswer(request.getAnswer()); + feedback.setRating(request.getRating()); + feedback.setReason(request.getReason()); + if (request.getSources() != null && !request.getSources().isEmpty()) { + feedback.setSources(objectMapper.writeValueAsString(request.getSources())); + } + feedback.setFaqSuggested(0); + + feedbackMapper.insert(feedback); + + FeedbackResponse response = new FeedbackResponse(); + response.setSuccess(true); + response.setFeedbackId(feedbackId); + response.setFaqSuggested(false); + + log.info("反馈提交成功: feedbackId={}", feedbackId); + return response; + + } catch (Exception e) { + log.error("提交反馈失败: {}", e.getMessage(), e); + FeedbackResponse response = new FeedbackResponse(); + response.setSuccess(false); + response.setFeedbackId(feedbackId); + return response; + } + } + + @Override + public FeedbackListResponse getFeedbackList(Integer page, Integer pageSize) { + log.info("获取反馈列表: page={}, pageSize={}", page, pageSize); + + IPage pageResult = feedbackMapper.selectPage( + new Page<>(page, pageSize), + null + ); + + FeedbackListResponse response = new FeedbackListResponse(); + response.setSuccess(true); + response.setTotal((int) pageResult.getTotal()); + + List feedbacks = new ArrayList<>(); + for (Feedback fb : pageResult.getRecords()) { + FeedbackItem item = new FeedbackItem(); + item.setFeedbackId(fb.getFeedbackId()); + item.setSessionId(fb.getSessionId()); + item.setQuery(fb.getQuery()); + item.setAnswer(fb.getAnswer()); + item.setRating(fb.getRating()); + item.setReason(fb.getReason()); + item.setCreatedAt(fb.getCreatedAt() != null ? fb.getCreatedAt().toString() : null); + feedbacks.add(item); + } + response.setFeedbacks(feedbacks); + + log.info("获取反馈列表成功: total={}", response.getTotal()); + return response; + } + + @Override + public FeedbackStatsResponse getFeedbackStats() { + log.info("获取反馈统计"); + + FeedbackStatsResponse response = new FeedbackStatsResponse(); + try { + int total = feedbackMapper.selectCount(null).intValue(); + int positive = feedbackMapper.countByRating(1); + int negative = feedbackMapper.countByRating(-1); + Double avgRating = feedbackMapper.avgRating(); + + response.setTotal(total); + response.setPositive(positive); + response.setNegative(negative); + response.setAvgRating(avgRating != null ? avgRating : 0.0); + response.setSatisfactionRate(total > 0 ? (positive * 100.0 / total) : 0.0); + response.setSuccess(true); + + log.info("获取反馈统计成功: total={}, positive={}, negative={}", total, positive, negative); + } catch (Exception e) { + log.error("获取反馈统计失败: {}", e.getMessage(), e); + response.setSuccess(false); + } + return response; + } + + @Override + public FeedbackListResponse getBadCases() { + log.info("获取差评案例"); + + FeedbackListResponse response = new FeedbackListResponse(); + try { + List badCases = feedbackMapper.selectBadCases(); + List items = new ArrayList<>(); + for (Feedback fb : badCases) { + FeedbackItem item = new FeedbackItem(); + item.setFeedbackId(fb.getFeedbackId()); + item.setQuery(fb.getQuery()); + item.setAnswer(fb.getAnswer()); + item.setRating(fb.getRating()); + item.setReason(fb.getReason()); + items.add(item); + } + response.setFeedbacks(items); + response.setTotal(items.size()); + response.setSuccess(true); + + log.info("获取差评案例成功: count={}", items.size()); + } catch (Exception e) { + log.error("获取差评案例失败: {}", e.getMessage(), e); + response.setSuccess(false); + } + return response; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/FilePermissionServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/FilePermissionServiceImpl.java new file mode 100644 index 0000000..a4b483e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/FilePermissionServiceImpl.java @@ -0,0 +1,412 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.FilePermissionRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.FilePermission; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.exception.PermissionDeniedException; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.FilePermissionMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.mapper.UserRoleMapper; +import top.tqx.demo_1.service.DataPermissionService; +import top.tqx.demo_1.service.FilePermissionService; +import top.tqx.demo_1.service.LogService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class FilePermissionServiceImpl implements FilePermissionService { + + @Autowired + private FilePermissionMapper filePermissionMapper; + + @Autowired + private UserRoleMapper userRoleMapper; + + @Autowired + private FileMapper fileMapper; + + @Autowired + private UserMapper userMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + @Autowired + private DataPermissionService dataPermissionService; + + @Autowired + private LogService logService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 检查用户是否有文件访问权限 + */ + @Override + public boolean checkFilePermission(Long userId, Long fileId, Integer requiredPermission) { + // 1. 超级管理员拥有所有权限 + if (isSuperAdmin(userId)) { + return true; + } + + // 2. 检查文件是否存在 + File file = fileMapper.selectById(fileId); + if (file == null) { + return false; + } + + // 3. 检查是否是文件上传者 + if (file.getUploadUserId() != null && file.getUploadUserId().equals(userId)) { + return true; + } + + User user = userMapper.selectById(userId); + if (user == null) { + return false; + } + + // 4. 检查文件是否属于用户所在部门(支持多部门) + if (isFileOwnedByUserDept(file, user.getDeptId())) { + // 如果是本部门文件,部门管理员有权限 + if (isDeptAdmin(userId)) { + return true; + } + // 公开文件本部门用户可访问 + if (file.getIsPublic() != null && file.getIsPublic() == 1) { + return true; + } + } + + // 5. 检查授予的用户权限 + FilePermission userPermission = filePermissionMapper.selectByUserAndFile(userId, fileId); + if (userPermission != null && userPermission.getPermissionType() >= requiredPermission) { + if (userPermission.getExpireTime() == null || userPermission.getExpireTime().isAfter(LocalDateTime.now())) { + return true; + } + } + + // 6. 检查授予的部门权限 + if (user.getDeptId() != null) { + FilePermission deptPermission = filePermissionMapper.selectByDeptAndFile(user.getDeptId(), fileId); + if (deptPermission != null && deptPermission.getPermissionType() >= requiredPermission) { + if (deptPermission.getExpireTime() == null || deptPermission.getExpireTime().isAfter(LocalDateTime.now())) { + return true; + } + } + } + + // 7. 检查上级部门权限(递归检查) + if (user.getDeptId() != null) { + List ancestorDeptIds = getAncestorDeptIds(user.getDeptId()); + for (Long ancestorDeptId : ancestorDeptIds) { + FilePermission ancestorPermission = filePermissionMapper.selectByDeptAndFile(ancestorDeptId, fileId); + if (ancestorPermission != null && ancestorPermission.getPermissionType() >= requiredPermission) { + if (ancestorPermission.getExpireTime() == null || ancestorPermission.getExpireTime().isAfter(LocalDateTime.now())) { + return true; + } + } + } + } + + return false; + } + + /** + * 授予文件权限(支持用户和部门) + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void grantFilePermission(FilePermissionRequest request) { + Long currentUserId = getCurrentUserId(); + + // 验证当前用户是否为部门管理员 + if (!isDeptAdmin(currentUserId) && !isSuperAdmin(currentUserId)) { + throw new PermissionDeniedException("只有部门管理员可以授予权限"); + } + + // 验证文件是否存在 + File file = fileMapper.selectById(request.getFileId()); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + // 验证文件是否属于本部门(支持多部门检查) + User currentUser = userMapper.selectById(currentUserId); + if (currentUser != null && currentUser.getDeptId() != null) { + if (!isFileOwnedByUserDept(file, currentUser.getDeptId())) { + throw new PermissionDeniedException("只能管理本部门的文件权限"); + } + } + + // 如果是按部门授权 + if (request.getDeptId() != null || (request.getDeptIds() != null && !request.getDeptIds().isEmpty())) { + List targetDeptIds = new ArrayList<>(); + if (request.getDeptId() != null) { + targetDeptIds.add(request.getDeptId()); + } + if (request.getDeptIds() != null) { + targetDeptIds.addAll(request.getDeptIds()); + } + + for (Long deptId : targetDeptIds) { + // 检查是否已存在该部门的权限记录 + FilePermission existing = filePermissionMapper.selectByDeptAndFile(deptId, request.getFileId()); + if (existing != null) { + // 更新已有权限 + existing.setPermissionType(request.getPermissionType()); + existing.setExpireTime(request.getExpireTime()); + filePermissionMapper.updateById(existing); + } else { + // 创建新权限记录 + FilePermission permission = new FilePermission(); + permission.setFileId(request.getFileId()); + permission.setDeptId(deptId); + permission.setPermissionType(request.getPermissionType()); + permission.setGrantUserId(currentUserId); + permission.setExpireTime(request.getExpireTime()); + filePermissionMapper.insert(permission); + } + } + + if (logService != null) { + logService.recordLog(currentUserId, "GRANT_DEPT_PERMISSION", + String.format("授予部门%s文件%d的权限", targetDeptIds, request.getFileId())); + } + } else if (request.getUserId() != null) { + // 按用户授权 + FilePermission existing = filePermissionMapper.selectByUserAndFile(request.getUserId(), request.getFileId()); + if (existing != null) { + existing.setPermissionType(request.getPermissionType()); + existing.setExpireTime(request.getExpireTime()); + filePermissionMapper.updateById(existing); + } else { + FilePermission permission = new FilePermission(); + permission.setFileId(request.getFileId()); + permission.setUserId(request.getUserId()); + permission.setPermissionType(request.getPermissionType()); + permission.setGrantUserId(currentUserId); + permission.setExpireTime(request.getExpireTime()); + filePermissionMapper.insert(permission); + } + + if (logService != null) { + logService.recordLog(currentUserId, "GRANT_PERMISSION", + String.format("授予用户%d文件%d的权限", request.getUserId(), request.getFileId())); + } + } + } + + /** + * 撤销文件权限 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void revokeFilePermission(Long permissionId) { + Long currentUserId = getCurrentUserId(); + + FilePermission permission = filePermissionMapper.selectById(permissionId); + if (permission == null) { + throw new RuntimeException("权限记录不存在"); + } + + // 验证是否是部门管理员或授权人 + if (!isDeptAdmin(currentUserId) && !isSuperAdmin(currentUserId) && !permission.getGrantUserId().equals(currentUserId)) { + throw new PermissionDeniedException("无权撤销此权限"); + } + + filePermissionMapper.deleteById(permissionId); + + if (logService != null) { + String targetType = permission.getUserId() != null ? "用户" : "部门"; + Long targetId = permission.getUserId() != null ? permission.getUserId() : permission.getDeptId(); + logService.recordLog(currentUserId, "REVOKE_PERMISSION", + String.format("撤销%s%d的文件权限", targetType, targetId)); + } + } + + /** + * 更新权限过期时间 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void updatePermissionExpire(Long permissionId, LocalDateTime expireTime) { + Long currentUserId = getCurrentUserId(); + + FilePermission permission = filePermissionMapper.selectById(permissionId); + if (permission == null) { + throw new RuntimeException("权限记录不存在"); + } + + if (!isDeptAdmin(currentUserId) && !isSuperAdmin(currentUserId) && !permission.getGrantUserId().equals(currentUserId)) { + throw new PermissionDeniedException("无权修改此权限"); + } + + permission.setExpireTime(expireTime); + filePermissionMapper.updateById(permission); + + if (logService != null) { + logService.recordLog(currentUserId, "UPDATE_PERMISSION_EXPIRE", + String.format("更新权限过期时间为%s", expireTime)); + } + } + + /** + * 获取用户的文件权限列表 + */ + @Override + public List getUserFilePermissions(Long userId, Long fileId) { + List permissions = new ArrayList<>(); + + // 获取用户个人权限 + List userPerms = filePermissionMapper.selectByUserIdAndFileId(userId, fileId); + if (userPerms != null) { + permissions.addAll(userPerms); + } + + // 获取用户所在部门的权限 + User user = userMapper.selectById(userId); + if (user != null && user.getDeptId() != null) { + FilePermission deptPerm = filePermissionMapper.selectByDeptAndFile(user.getDeptId(), fileId); + if (deptPerm != null) { + permissions.add(deptPerm); + } + } + + return permissions; + } + + /** + * 获取有权限访问文件的所有用户 + */ + @Override + public List getFileAuthorizedUsers(Long fileId) { + Set userIds = new HashSet<>(); + + // 1. 获取直接授权的用户 + List userPermissions = filePermissionMapper.selectByFileId(fileId); + for (FilePermission perm : userPermissions) { + if (perm.getUserId() != null) { + userIds.add(perm.getUserId()); + } + } + + // 2. 获取授权部门的用户 + List deptPermissions = filePermissionMapper.selectByFileId(fileId); + for (FilePermission perm : deptPermissions) { + if (perm.getDeptId() != null) { + List deptUsers = userMapper.selectList( + new LambdaQueryWrapper().eq(User::getDeptId, perm.getDeptId())); + for (User user : deptUsers) { + userIds.add(user.getId()); + } + } + } + + // 3. 添加文件上传者 + File file = fileMapper.selectById(fileId); + if (file != null && file.getUploadUserId() != null) { + userIds.add(file.getUploadUserId()); + } + + if (userIds.isEmpty()) { + return new ArrayList<>(); + } + + return userMapper.selectBatchIds(new ArrayList<>(userIds)); + } + + /** + * 检查文件是否属于用户所在部门(支持多部门) + */ + private boolean isFileOwnedByUserDept(File file, Long userDeptId) { + if (userDeptId == null) { + return false; + } + + // 检查单个部门 + if (file.getDeptId() != null && file.getDeptId().equals(userDeptId)) { + return true; + } + + // 检查多部门 + if (file.getDeptIds() != null && !file.getDeptIds().isNull()) { + if (file.getDeptIds().isArray()) { + for (JsonNode node : file.getDeptIds()) { + if (node.asLong() == userDeptId) { + return true; + } + } + } + } + + return false; + } + + /** + * 获取上级部门ID列表 + */ + private List getAncestorDeptIds(Long deptId) { + List ancestorIds = new ArrayList<>(); + Department current = departmentMapper.selectById(deptId); + while (current != null && current.getParentId() != null && current.getParentId() > 0) { + ancestorIds.add(current.getParentId()); + current = departmentMapper.selectById(current.getParentId()); + } + return ancestorIds; + } + + /** + * 检查是否为超级管理员 + */ + private boolean isSuperAdmin(Long userId) { + List roles = userRoleMapper.selectRolesByUserId(userId); + return roles.stream() + .anyMatch(role -> "SUPER_ADMIN".equals(role.getRoleCode())); + } + + /** + * 检查是否为部门管理员 + */ + private boolean isDeptAdmin(Long userId) { + List roles = userRoleMapper.selectRolesByUserId(userId); + return roles.stream() + .anyMatch(role -> "DEPT_ADMIN".equals(role.getRoleCode())); + } + + /** + * 获取当前登录用户 ID + */ + private Long getCurrentUserId() { + try { + org.springframework.web.context.request.RequestAttributes attributes = + org.springframework.web.context.request.RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + jakarta.servlet.http.HttpServletRequest request = + ((org.springframework.web.context.request.ServletRequestAttributes) attributes).getRequest(); + String token = request.getHeader("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + // 需要通过 AuthService 验证 token + } + } + } catch (Exception e) { + // 忽略异常 + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/FileServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/FileServiceImpl.java new file mode 100644 index 0000000..094071f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/FileServiceImpl.java @@ -0,0 +1,866 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.enums.FileProcessStepStatus; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.AsyncVectorizeService; +import top.tqx.demo_1.service.CollectionFileService; +import top.tqx.demo_1.service.DepartmentService; +import top.tqx.demo_1.service.FileService; +import top.tqx.demo_1.service.KnowledgeBasePathService; +import top.tqx.demo_1.service.UserService; +import top.tqx.demo_1.vo.FileVO; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class FileServiceImpl extends ServiceImpl implements FileService { + + @Autowired + private DepartmentService departmentService; + + @Autowired + private AsyncVectorizeService asyncVectorizeService; + + @Autowired + private KnowledgeBasePathMapper knowledgeBasePathMapper; + + @Autowired + private UserMapper userMapper; + + @Autowired + private CollectionFileService collectionFileService; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private AiApiConfig aiApiConfig; + + @Value("${file.upload.path:./uploads/}") + private String uploadPath; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public PageResult pageFiles(Integer pageNum, Integer pageSize, String fileName, top.tqx.demo_1.entity.User user) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (fileName != null && !fileName.isEmpty()) { + wrapper.like(File::getFileName, fileName); + } + + Integer userType = user.getUserType(); + Long currentUserId = user.getId(); + Long currentDeptId = user.getDeptId(); + + if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) { + wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + } else if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) { + wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.and(w -> w.eq(File::getDeptId, currentDeptId) + .or().eq(File::getUploadUserId, currentUserId)); + } else if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) { + wrapper.eq(File::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.ne(File::getAuditStatus, 0); + wrapper.and(w -> w.eq(File::getDeptId, currentDeptId) + .or().eq(File::getUploadUserId, currentUserId)); + } + + wrapper.orderByDesc(File::getCreateTime); + + Page filePage = this.page(page, wrapper); + + List voList = filePage.getRecords().stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + + return PageResult.of(voList, filePage.getTotal(), filePage.getSize(), filePage.getCurrent()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isEmpty()) { + throw new RuntimeException("文件名不能为空"); + } + + try { + byte[] fileContent = file.getBytes(); + + String fileNameWithoutExt = originalFilename.contains(".") + ? originalFilename.substring(0, originalFilename.lastIndexOf(".")) + : originalFilename; + String extension = originalFilename.contains(".") + ? originalFilename.substring(originalFilename.lastIndexOf(".")) + : ""; + + boolean needAudit = shouldRequireAudit(userType, isPublic, targetDeptId); + + Path basePath = determineUploadPath(userType, userId, deptId, targetDeptId, fileNameWithoutExt, isPublic, needAudit); + + if (!Files.exists(basePath)) { + Files.createDirectories(basePath); + } + + int version = findAvailableVersion(basePath); + Path versionPath = basePath.resolve("v" + version); + Files.createDirectories(versionPath); + + Path filePath = versionPath.resolve(originalFilename); + Files.write(filePath, fileContent); + + String relativePath = buildRelativePath(filePath); + + File fileEntity = new File(); + fileEntity.setFileName(originalFilename); + fileEntity.setFilePath(relativePath); + fileEntity.setFileSize((long) fileContent.length); + String contentType = file.getContentType(); + if (contentType != null && contentType.length() > 50) { + contentType = contentType.substring(0, 50); + } + fileEntity.setFileType(contentType); + fileEntity.setExtension(extension); + fileEntity.setFileMd5(null); + fileEntity.setUploadUserId(userId); + + Long finalDeptId = determineDeptId(userType, deptId, targetDeptId); + fileEntity.setDeptId(finalDeptId); + + Integer isPublicValue = determineIsPublicValue(userType, isPublic, needAudit); + fileEntity.setIsPublic(isPublicValue); + + String fileCategory = determineFileCategory(userType, targetDeptId, isPublic); + fileEntity.setFileCategory(fileCategory); + + fileEntity.setStatus(CommonConstant.STATUS_ENABLE); + + Integer auditStatus = needAudit ? 0 : 1; + fileEntity.setAuditStatus(auditStatus); + + fileEntity.setCreateTime(LocalDateTime.now()); + fileEntity.setProcessStatus("PENDING"); + fileEntity.setExamStatus("UNGENERATED"); + fileEntity.setProcessStepStatus(FileProcessStepStatus.UPLOADED.getCode()); + fileEntity.setProcessStepMessage(FileProcessStepStatus.UPLOADED.getDescription()); + + boolean saved = this.save(fileEntity); + if (!saved) { + throw new RuntimeException("文件记录保存到数据库失败"); + } + + log.info("文件上传成功: fileName={}, userId={}, deptId={}, isPublic={}, category={}, auditStatus={}, path={}", + originalFilename, userId, finalDeptId, isPublicValue, fileCategory, auditStatus, relativePath); + + if (!needAudit) { + String collectionName = determineCollectionName(userType, finalDeptId, isPublic); + if (collectionName != null) { + checkAndDeleteOldVectorization(originalFilename, finalDeptId, collectionName); + + log.info("文件上传完成,准备触发异步向量化: fileId={}, collection={}", fileEntity.getId(), collectionName); + asyncVectorizeService.vectorizeFileAsync(collectionName, fileEntity.getId(), userId); + } else { + log.warn("无法确定collection名称,跳过向量化: fileId={}, userType={}, deptId={}, isPublic={}", + fileEntity.getId(), userType, finalDeptId, isPublic); + } + } else { + log.info("文件需要审核,暂不向量化: fileId={}", fileEntity.getId()); + } + + return convertToVO(fileEntity); + } catch (IOException e) { + log.error("文件IO操作失败", e); + throw new RuntimeException("文件上传失败: " + e.getMessage(), e); + } catch (Exception e) { + log.error("文件上传异常", e); + throw new RuntimeException("文件上传失败: " + e.getMessage(), e); + } + } + + private void checkAndDeleteOldVectorization(String fileName, Long deptId, String collectionName) { + try { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(File::getFileName, fileName) + .eq(File::getDeptId, deptId) + .eq(File::getStatus, CommonConstant.STATUS_ENABLE) + .ne(File::getVectorDbAddress, (Object) null) + .orderByDesc(File::getCreateTime); + + List oldFiles = this.list(wrapper); + + if (oldFiles != null && !oldFiles.isEmpty()) { + for (File oldFile : oldFiles) { + log.info("发现同名旧文件,准备删除其向量化数据: fileId={}, fileName={}, vectorDbAddress={}", + oldFile.getId(), oldFile.getFileName(), oldFile.getVectorDbAddress()); + deleteDocumentFromRAG(oldFile.getVectorDbAddress()); + log.info("已删除旧文件向量化数据: fileId={}", oldFile.getId()); + } + } + } catch (Exception e) { + log.error("检查并删除旧文件向量化数据失败: fileName={}, deptId={}", fileName, deptId, e); + } + } + + private boolean shouldRequireAudit(Integer userType, Boolean isPublic, Long targetDeptId) { + if (userType == 3 && isPublic != null && isPublic && targetDeptId == null) { + return true; + } + return false; + } + + private Path determineUploadPath(Integer userType, Long userId, Long deptId, Long targetDeptId, + String fileNameWithoutExt, Boolean isPublic, boolean needAudit) { + if (userType == 1 || userType == 2) { + Long finalDeptId = targetDeptId != null ? targetDeptId : deptId; + String fullPath = departmentService.getDepartmentFullPath(finalDeptId); + if (fullPath != null && !fullPath.isEmpty()) { + return Paths.get(uploadPath, "resources", "rule", fullPath, fileNameWithoutExt); + } else { + return Paths.get(uploadPath, "resources", "rule", "默认部门", fileNameWithoutExt); + } + } else { + String username = getUserUsername(userId); + if (needAudit) { + return Paths.get(uploadPath, "resources", "user", username, "pending", fileNameWithoutExt); + } + String privacyPath = (isPublic != null && isPublic) ? "public" : "private"; + return Paths.get(uploadPath, "resources", "user", username, privacyPath, fileNameWithoutExt); + } + } + + private Integer determineIsPublicValue(Integer userType, Boolean isPublic, boolean needAudit) { + if (needAudit) { + return 0; + } + return (isPublic != null && isPublic) ? 1 : 0; + } + + private int findAvailableVersion(Path basePath) { + int version = 1; + while (Files.exists(basePath.resolve("v" + version))) { + version++; + } + return version; + } + + private String buildRelativePath(Path filePath) { + String relativePath = filePath.toString().replace(uploadPath, ""); + if (relativePath.startsWith(java.io.File.separator)) { + relativePath = relativePath.substring(1); + } + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + if (relativePath.startsWith("\\")) { + relativePath = relativePath.substring(1); + } + return relativePath.replace("\\", "/"); + } + + private Long determineDeptId(Integer userType, Long deptId, Long targetDeptId) { + if (userType == 1 || userType == 2) { + return targetDeptId != null ? targetDeptId : deptId; + } + return deptId; + } + + private String determineFileCategory(Integer userType, Long targetDeptId, Boolean isPublic) { + if (targetDeptId != null) { + return "rule"; + } + if (userType == 3 && Boolean.TRUE.equals(isPublic)) { + return "rule"; + } + return "personal"; + } + + private Department getDepartmentByIdSafe(Long deptId) { + if (deptId == null) { + return null; + } + try { + return departmentService.getDepartmentById(deptId); + } catch (Exception e) { + log.warn("获取部门信息失败: deptId={}", deptId, e); + return null; + } + } + + private String getUserUsername(Long userId) { + try { + User user = userMapper.selectById(userId); + if (user != null && user.getUsername() != null && !user.getUsername().isEmpty()) { + log.debug("获取用户名成功: userId={}, username={}", userId, user.getUsername()); + return user.getUsername(); + } + } catch (Exception e) { + log.warn("获取用户名失败,使用默认格式: userId={}", userId, e); + } + return "user_" + userId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public FileVO uploadStreamFromBackend(InputStream inputStream, String fileName, String fileType, Long userId, Long deptId) throws Exception { + if (fileName == null || fileName.isEmpty()) { + throw new RuntimeException("文件名不能为空"); + } + + String fullPath; + if (deptId != null) { + fullPath = departmentService.getDepartmentFullPath(deptId); + if (fullPath == null || fullPath.isEmpty()) { + throw new RuntimeException("部门路径获取失败"); + } + } else { + fullPath = "默认部门"; + } + + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[8192]; + int nRead; + long totalSize = 0; + + while ((nRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + totalSize += nRead; + } + buffer.flush(); + + byte[] fileContent = buffer.toByteArray(); + + String fileNameWithoutExt = fileName.contains(".") ? fileName.substring(0, fileName.lastIndexOf(".")) : fileName; + String extension = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf(".")) : ""; + + Path basePath = Paths.get(uploadPath, "resources", "rule", fullPath, fileNameWithoutExt); + + if (!Files.exists(basePath)) { + Files.createDirectories(basePath); + } + + int version = 1; + while (Files.exists(basePath.resolve("v" + version))) { + version++; + } + + Path versionPath = basePath.resolve("v" + version); + Files.createDirectories(versionPath); + Path filePath = versionPath.resolve(fileName); + Files.write(filePath, fileContent); + + String relativePath = buildRelativePath(filePath); + + File fileEntity = new File(); + fileEntity.setFileName(fileName); + fileEntity.setFilePath(relativePath); + fileEntity.setFileSize(totalSize); + fileEntity.setFileType(fileType); + fileEntity.setExtension(extension); + fileEntity.setUploadUserId(userId); + fileEntity.setDeptId(deptId); + fileEntity.setIsPublic(1); + fileEntity.setFileCategory("rule"); + fileEntity.setStatus(CommonConstant.STATUS_ENABLE); + fileEntity.setAuditStatus(1); + fileEntity.setCreateTime(LocalDateTime.now()); + fileEntity.setProcessStatus("INDEXED"); + fileEntity.setExamStatus("UNGENERATED"); + + boolean saved = this.save(fileEntity); + if (!saved) { + throw new RuntimeException("文件记录保存到数据库失败"); + } + + log.info("流式文件上传成功: {}, 大小: {} bytes", fileName, totalSize); + return convertToVO(fileEntity); + } catch (IOException e) { + log.error("流式文件IO操作失败", e); + throw new RuntimeException("流式文件上传失败: " + e.getMessage(), e); + } catch (Exception e) { + log.error("流式文件上传异常", e); + throw e; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFile(Long id) { + top.tqx.demo_1.entity.File file = this.getById(id); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + try { + if (file.getVectorDbAddress() != null && !file.getVectorDbAddress().isEmpty()) { + log.info("文件已向量化,准备删除向量库中的文档: fileId={}, vectorDbAddress={}", id, file.getVectorDbAddress()); + deleteDocumentFromRAG(file.getVectorDbAddress(), id); + } + } catch (Exception e) { + log.error("删除向量库文档失败,但继续删除本地文件: fileId={}, error={}", id, e.getMessage(), e); + } + + try { + Path filePath = Paths.get(uploadPath, file.getFilePath()); + Files.deleteIfExists(filePath); + } catch (IOException e) { + throw new RuntimeException("删除文件失败", e); + } + + file.setStatus(CommonConstant.STATUS_DISABLE); + file.setUpdateTime(LocalDateTime.now()); + this.updateById(file); + + log.info("文件删除成功: fileId={}, fileName={}", id, file.getFileName()); + } + + private void deleteDocumentFromRAG(String vectorDbAddress) { + deleteDocumentFromRAG(vectorDbAddress, null); + } + + private void deleteDocumentFromRAG(String vectorDbAddress, Long fileId) { + try { + String[] parts = vectorDbAddress.split("/"); + if (parts.length < 2) { + log.warn("向量库地址格式错误: {}", vectorDbAddress); + return; + } + + String collectionName = parts[0]; + String filename = parts[1]; + + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getDelete()); + url = url + "/" + collectionName + "/" + filename; + + if (fileId != null) { + url = url + "?fileId=" + fileId; + } + + log.info("调用RAG服务删除文档: collection={}, filename={}, fileId={}, url={}", collectionName, filename, fileId, url); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("向量库文档删除成功: collection={}, filename={}, fileId={}", collectionName, filename, fileId); + } else { + String errorMsg = response.getBody() != null ? response.getBody() : ""; + log.warn("向量库文档删除失败: status={}, body={}", response.getStatusCode(), errorMsg); + } + } catch (Exception e) { + log.error("删除向量库文档异常: vectorDbAddress={}, fileId={}", vectorDbAddress, fileId, e); + } + } + + @Override + public FileVO getFileById(Long id, top.tqx.demo_1.entity.User user) { + top.tqx.demo_1.entity.File file = this.getById(id); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + checkFilePermission(file, user); + + return convertToVO(file); + } + + @Override + public byte[] getFileContent(Long id, top.tqx.demo_1.entity.User user) { + top.tqx.demo_1.entity.File file = this.getById(id); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + checkFilePermission(file, user); + + try { + String dbFilePath = file.getFilePath(); + + if (dbFilePath == null || dbFilePath.isEmpty()) { + log.error("文件路径为空: fileId={}", id); + throw new RuntimeException("文件路径为空"); + } + + String cleanedPath = cleanFilePath(dbFilePath); + + Path fullPath = Paths.get(uploadPath, cleanedPath).normalize(); + + log.info("尝试读取文件: fileId={}, fileName={}, dbPath={}, cleanedPath={}, uploadPath={}, fullPath={}", + id, file.getFileName(), dbFilePath, cleanedPath, uploadPath, fullPath.toAbsolutePath()); + + if (!Files.exists(fullPath)) { + log.error("文件物理路径不存在: fileId={}, fileName={}, dbFilePath={}, cleanedPath={}, fullPath={}", + id, file.getFileName(), dbFilePath, cleanedPath, fullPath.toAbsolutePath()); + + Path parentDir = fullPath.getParent(); + if (parentDir != null && Files.exists(parentDir)) { + log.error("父目录存在,列出目录内容: {}", parentDir.toAbsolutePath()); + try { + Files.list(parentDir).forEach(p -> log.error(" - {}", p.getFileName())); + } catch (Exception e) { + log.error("无法列出目录内容", e); + } + } else { + log.error("父目录也不存在: {}", parentDir != null ? parentDir.toAbsolutePath() : "null"); + } + + throw new RuntimeException("文件不存在: " + file.getFileName()); + } + + long fileSize = Files.size(fullPath); + if (fileSize > 50 * 1024 * 1024) { + throw new RuntimeException("文件过大,无法预览(最大50MB)"); + } + + byte[] content = Files.readAllBytes(fullPath); + log.info("读取文件成功: fileId={}, fileName={}, size={} bytes", + id, file.getFileName(), content.length); + return content; + } catch (RuntimeException e) { + throw e; + } catch (IOException e) { + log.error("读取文件内容失败: fileId={}, filePath={}", id, file.getFilePath(), e); + throw new RuntimeException("读取文件内容失败: " + e.getMessage(), e); + } + } + + private String cleanFilePath(String filePath) { + if (filePath == null || filePath.isEmpty()) { + return filePath; + } + + String cleaned = filePath; + + cleaned = cleaned.replaceFirst("^\\.\\\\", ""); + cleaned = cleaned.replaceFirst("^\\./", ""); + cleaned = cleaned.replaceFirst("^\\\\", ""); + cleaned = cleaned.replaceFirst("^/", ""); + + cleaned = cleaned.replace("\\\\", "\\"); + cleaned = cleaned.replace("//", "/"); + + cleaned = cleaned.replace("\\", "/"); + + if (cleaned.startsWith("uploads/") || cleaned.startsWith("uploads\\")) { + cleaned = cleaned.substring(8); + } + + if (cleaned.contains("..") || cleaned.startsWith("/") || cleaned.matches("^[a-zA-Z]:.*")) { + log.error("检测到非法路径遍历攻击: original={}, cleaned={}", filePath, cleaned); + throw new RuntimeException("文件路径包含非法字符"); + } + + return cleaned; + } + + private void checkFilePermission(top.tqx.demo_1.entity.File file, top.tqx.demo_1.entity.User user) { + Integer userType = user.getUserType(); + Long currentUserId = user.getId(); + Long currentDeptId = user.getDeptId(); + + if (userType.equals(CommonConstant.RoleType.SUPER_ADMIN)) { + return; + } + + if (userType.equals(CommonConstant.RoleType.DEPT_ADMIN)) { + if (!file.getDeptId().equals(currentDeptId)) { + throw new RuntimeException("无权限查看此文件"); + } + return; + } + + if (userType.equals(CommonConstant.RoleType.NORMAL_USER)) { + if (!file.getDeptId().equals(currentDeptId)) { + throw new RuntimeException("无权限查看此文件"); + } + if (!file.getUploadUserId().equals(currentUserId)) { + throw new RuntimeException("无权限查看此文件"); + } + if (file.getAuditStatus() != null && file.getAuditStatus() == 0) { + throw new RuntimeException("文件待审核,暂时无法查看"); + } + } + } + + private FileVO convertToVO(top.tqx.demo_1.entity.File file) { + FileVO vo = new FileVO(); + BeanUtils.copyProperties(file, vo); + + if (file.getUploadUserId() != null) { + try { + User user = userMapper.selectById(file.getUploadUserId()); + if (user != null && user.getUsername() != null) { + vo.setUploadUserName(user.getUsername()); + } else { + vo.setUploadUserName("user_" + file.getUploadUserId()); + } + } catch (Exception e) { + log.warn("获取上传用户名称失败: userId={}", file.getUploadUserId()); + vo.setUploadUserName("user_" + file.getUploadUserId()); + } + } + + if (file.getDeptId() != null) { + try { + Department department = departmentService.getDepartmentById(file.getDeptId()); + if (department != null) { + vo.setDeptName(department.getDeptName()); + } + } catch (Exception e) { + log.warn("获取部门名称失败: deptId={}", file.getDeptId()); + } + } + + return vo; + } + + @Override + public void updateFileDescription(Long fileId, String description) { + top.tqx.demo_1.entity.File file = this.getById(fileId); + if (file != null) { + file.setDescription(description); + file.setUpdateTime(LocalDateTime.now()); + this.updateById(file); + } + } + + @Override + public top.tqx.demo_1.entity.File getFileEntityById(Long id) { + return this.getById(id); + } + + @Override + public void updateFileEntity(top.tqx.demo_1.entity.File file) { + this.updateById(file); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void approveFile(Long fileId, Long auditorId) { + top.tqx.demo_1.entity.File file = this.getById(fileId); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + if (file.getAuditStatus() == null) { + throw new RuntimeException("文件审核状态异常"); + } + + if (file.getAuditStatus() == 1) { + log.warn("文件已审核通过,执行更新操作: fileId={}, auditorId={}", fileId, auditorId); + } + + String oldPath = file.getFilePath(); + String newPath = moveFileToPublic(file); + + file.setFilePath(newPath); + log.info("审核通过,文件已移动到公开目录: {} -> {}", oldPath, newPath); + + file.setIsPublic(1); + file.setAuditStatus(1); + file.setUpdateTime(LocalDateTime.now()); + + if ("personal".equals(file.getFileCategory())) { + file.setFileCategory("rule"); + log.info("审核通过,文件分类已从 personal 更新为 rule: fileId={}", fileId); + } + + this.updateById(file); + + log.info("文件审核通过: fileId={}, auditorId={}", fileId, auditorId); + + String collectionName = determineCollectionName(3, file.getDeptId(), true); + if (collectionName != null) { + log.info("审核通过,准备触发异步向量化: fileId={}, collection={}", fileId, collectionName); + asyncVectorizeService.vectorizeFileAsync(collectionName, fileId, auditorId); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void rejectFile(Long fileId, Long auditorId) { + top.tqx.demo_1.entity.File file = this.getById(fileId); + if (file == null) { + throw new RuntimeException("文件不存在"); + } + + if (file.getAuditStatus() == null) { + throw new RuntimeException("文件审核状态异常"); + } + + if (file.getAuditStatus() == 2) { + log.warn("文件已被拒绝,执行更新操作: fileId={}, auditorId={}", fileId, auditorId); + } + + file.setAuditStatus(2); + file.setStatus(CommonConstant.STATUS_DISABLE); + file.setUpdateTime(LocalDateTime.now()); + this.updateById(file); + + log.info("文件审核拒绝: fileId={}, auditorId={}", fileId, auditorId); + } + + private String moveFileToPublic(top.tqx.demo_1.entity.File file) { + try { + String fileNameWithoutExt = file.getFileName().contains(".") + ? file.getFileName().substring(0, file.getFileName().lastIndexOf(".")) + : file.getFileName(); + + String username = getUserUsername(file.getUploadUserId()); + + String filePath = file.getFilePath(); + if (filePath.startsWith("./uploads/") || filePath.startsWith("/uploads/") || filePath.startsWith("uploads/")) { + filePath = filePath.replaceFirst("^\\.?/?uploads/?", ""); + log.warn("检测到路径包含uploads前缀,已清理: original={}, cleaned={}", file.getFilePath(), filePath); + } + + Path oldPath = Paths.get(uploadPath, filePath); + + log.info("开始移动文件: fileId={}, oldPath={}, fullPath={}", + file.getId(), filePath, oldPath.toString()); + + if (!Files.exists(oldPath)) { + log.error("原文件不存在: fileId={}, fileName={}, filePath={}, fullPath={}", + file.getId(), file.getFileName(), filePath, oldPath.toString()); + + String newPath = "resources/user/" + username + "/public/" + fileNameWithoutExt + "/v1/" + file.getFileName(); + Path newFullPath = Paths.get(uploadPath, newPath); + + if (Files.exists(newFullPath)) { + log.warn("目标文件已存在,跳过移动: newPath={}", newPath); + return newPath; + } + + throw new RuntimeException("原文件不存在: " + oldPath.toString()); + } + + Path newBasePath = Paths.get(uploadPath, "resources", "user", username, "public", fileNameWithoutExt); + + int version = 1; + while (Files.exists(newBasePath.resolve("v" + version))) { + version++; + } + + Path newVersionPath = newBasePath.resolve("v" + version); + Files.createDirectories(newVersionPath); + Path newPath = newVersionPath.resolve(file.getFileName()); + + Files.createDirectories(newPath.getParent()); + Files.move(oldPath, newPath); + + String relativePath = newPath.toString().replace(uploadPath, ""); + if (relativePath.startsWith(java.io.File.separator)) { + relativePath = relativePath.substring(1); + } + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + + log.info("文件移动成功: {} -> {}", oldPath, newPath); + return relativePath; + } catch (IOException e) { + log.error("移动文件失败: fileId={}, filePath={}", file.getId(), file.getFilePath(), e); + throw new RuntimeException("移动文件失败: " + e.getMessage(), e); + } + } + + private String determineCollectionName(Integer userType, Long deptId, Boolean isPublic) { + try { + if (userType == 1 || userType == 2) { + if (deptId != null) { + String deptName = getDepartmentName(deptId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getDepartment, deptName) + .or() + .eq(KnowledgeBasePath::getName, "dept_" + deptId + "_kb") + .last("LIMIT 1"); + KnowledgeBasePath kb = knowledgeBasePathMapper.selectOne(wrapper); + if (kb != null) { + return kb.getName(); + } + return "dept_" + deptId + "_kb"; + } + return "public_kb"; + } else { + if (Boolean.TRUE.equals(isPublic)) { + return "public_kb"; + } else { + return "private_kb"; + } + } + } catch (Exception e) { + log.error("确定collection名称失败,使用默认值", e); + return Boolean.TRUE.equals(isPublic) ? "public_kb" : "private_kb"; + } + } + + private String getDepartmentName(Long deptId) { + if (deptId == null) { + return null; + } + try { + top.tqx.demo_1.entity.Department dept = departmentService.getDepartmentById(deptId); + if (dept != null && dept.getDeptName() != null) { + return dept.getDeptName(); + } + } catch (Exception e) { + log.warn("查询部门名称失败: deptId={}", deptId, e); + } + return null; + } + + @Override + public PageResult getPendingFiles(Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + wrapper.eq(top.tqx.demo_1.entity.File::getAuditStatus, 0); + wrapper.eq(top.tqx.demo_1.entity.File::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.orderByDesc(top.tqx.demo_1.entity.File::getCreateTime); + + Page filePage = this.page(page, wrapper); + + List voList = filePage.getRecords().stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + + return PageResult.of(voList, filePage.getTotal(), filePage.getSize(), filePage.getCurrent()); + } +} + diff --git a/src/main/java/top/tqx/demo_1/service/impl/ImageServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ImageServiceImpl.java new file mode 100644 index 0000000..ce1df2f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ImageServiceImpl.java @@ -0,0 +1,735 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.AiApiConfig; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import top.tqx.demo_1.dto.ImageItem; +import top.tqx.demo_1.dto.ImageListResponse; +import top.tqx.demo_1.dto.ImageStatsResponse; +import top.tqx.demo_1.mapper.ImageMapper; +import top.tqx.demo_1.dto.ImageCreateRequest; +import top.tqx.demo_1.dto.ImageUpdateRequest; +import top.tqx.demo_1.entity.Image; + +import java.time.LocalDateTime; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import top.tqx.demo_1.service.ImageService; +import top.tqx.demo_1.service.RagService; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class ImageServiceImpl implements ImageService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private RagService ragService; + + @Autowired + private ImageMapper imageMapper; + + @Value("${file.upload.path:./uploads/}") + private String uploadPath; + + @Value("${file.upload.url-prefix:/files/}") + private String urlPrefix; + + @Override + public ImageListResponse getImageList(Integer limit, Integer offset) { + String url = aiApiConfig.getFullUrl(aiApiConfig.getImages().getList()); + url += "?limit=" + limit + "&offset=" + offset; + log.info("调用RAG服务获取图片列表: url={}", url); + + try { + String jsonResponse = ragService.getJsonRequest(url, 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + + ImageListResponse response = new ImageListResponse(); + response.setSuccess(rootNode.has("success") ? rootNode.get("success").asBoolean() : false); + + if (rootNode.has("images") && rootNode.get("images").isArray()) { + List images = new ArrayList<>(); + for (JsonNode imageNode : rootNode.get("images")) { + ImageItem item = new ImageItem(); + if (imageNode.has("image_id")) { + item.setImageId(imageNode.get("image_id").asText()); + } + if (imageNode.has("filename")) { + item.setFilename(imageNode.get("filename").asText()); + } + if (imageNode.has("collection")) { + item.setCollection(imageNode.get("collection").asText()); + } + if (imageNode.has("page")) { + item.setPage(imageNode.get("page").asInt()); + } + if (imageNode.has("size")) { + item.setSize(imageNode.get("size").asLong()); + } + if (imageNode.has("created_at")) { + item.setCreatedAt(imageNode.get("created_at").asText()); + } + images.add(item); + } + response.setImages(images); + } + if (rootNode.has("total")) { + response.setTotal(rootNode.get("total").asInt()); + } + if (rootNode.has("limit")) { + response.setLimit(rootNode.get("limit").asInt()); + } + if (rootNode.has("offset")) { + response.setOffset(rootNode.get("offset").asInt()); + } + + log.info("获取图片列表成功: count={}", response.getImages() != null ? response.getImages().size() : 0); + return response; + + } catch (Exception e) { + log.error("调用RAG服务获取图片列表失败: {}", e.getMessage(), e); + ImageListResponse response = new ImageListResponse(); + response.setSuccess(false); + response.setImages(new ArrayList<>()); + return response; + } + } + + /** + * 尝试从本地获取图片 + * @param imageId 图片ID + * @return 图片字节数组,如果未找到返回null + */ + private byte[] tryGetLocalImage(String imageId) { + try { + Image imageRecord = getImageByImageId(imageId); + if (imageRecord != null && imageRecord.getPath() != null && !imageRecord.getPath().isEmpty()) { + String dbPath = imageRecord.getPath(); + log.info("尝试从本地路径读取图片: imageId={}, path={}", imageId, dbPath); + + // 方案1:如果数据库路径是完整路径且文件存在,直接使用 + Path fullPath = Paths.get(dbPath); + if (fullPath.isAbsolute() && Files.exists(fullPath)) { + byte[] imageBytes = Files.readAllBytes(fullPath); + log.info("从本地读取图片成功(完整路径): imageId={}, path={}, size={} bytes", + imageId, dbPath, imageBytes.length); + return imageBytes; + } + + // 方案2:如果数据库路径是相对路径,相对于 uploadPath + Path tryPath = Paths.get(uploadPath, dbPath); + if (Files.exists(tryPath)) { + byte[] imageBytes = Files.readAllBytes(tryPath); + log.info("从本地读取图片成功(相对路径): imageId={}, path={}, size={} bytes", + imageId, tryPath, imageBytes.length); + return imageBytes; + } + + // 方案3:兼容旧版代码,尝试其他可能的路径 + String filename = dbPath.startsWith("/img/") ? dbPath.substring(5) : dbPath; + String[] possibleDirs = { + "./img", + System.getProperty("user.home") + "/img", + System.getProperty("java.io.tmpdir") + "/img", + "/tmp/img" + }; + + for (String dir : possibleDirs) { + Path backupPath = Paths.get(dir, filename); + if (Files.exists(backupPath)) { + byte[] imageBytes = Files.readAllBytes(backupPath); + log.info("从本地读取图片成功(遍历目录): imageId={}, path={}, size={} bytes", + imageId, backupPath, imageBytes.length); + return imageBytes; + } + } + + log.warn("本地路径不存在,尝试过的路径: dbPath={}", dbPath); + } + } catch (Exception e) { + log.warn("从本地读取图片失败: imageId={}, error={}", imageId, e.getMessage(), e); + } + return null; + } + + @Override + public byte[] getImage(String imageId) { + log.info("获取图片: imageId={}", imageId); + + // 1. 首先尝试从数据库查找本地路径(先尝试原始ID) + byte[] localImage = tryGetLocalImage(imageId); + if (localImage != null) { + log.info("从本地获取图片成功: imageId={}", imageId); + return localImage; + } + + // 2. 如果原始ID没找到,尝试添加.jpg后缀(数据库中存储的格式) + if (!imageId.toLowerCase().endsWith(".jpg")) { + localImage = tryGetLocalImage(imageId + ".jpg"); + if (localImage != null) { + log.info("从本地获取图片成功(带.jpg后缀): imageId={}", imageId); + return localImage; + } + } + + // 3. 如果带后缀也没找到,尝试去掉后缀(如果有) + if (imageId.toLowerCase().endsWith(".jpg") || imageId.toLowerCase().endsWith(".jpeg") || + imageId.toLowerCase().endsWith(".png") || imageId.toLowerCase().endsWith(".gif")) { + int lastDotIndex = imageId.lastIndexOf('.'); + if (lastDotIndex > 0) { + String imageIdWithoutExt = imageId.substring(0, lastDotIndex); + localImage = tryGetLocalImage(imageIdWithoutExt); + if (localImage != null) { + log.info("从本地获取图片成功(去掉后缀): imageId={}", imageId); + return localImage; + } + } + } + + log.warn("本地未找到图片,尝试从AI服务获取: imageId={}", imageId); + + // 4. 如果本地没有,从AI服务获取 + String url = aiApiConfig.getFullUrl(aiApiConfig.getImages().getImage()); + + // 去掉可能的文件后缀 + String cleanImageId = imageId; + if (cleanImageId.toLowerCase().endsWith(".jpg") || cleanImageId.toLowerCase().endsWith(".jpeg") || + cleanImageId.toLowerCase().endsWith(".png") || cleanImageId.toLowerCase().endsWith(".gif")) { + int lastDotIndex = cleanImageId.lastIndexOf('.'); + if (lastDotIndex > 0) { + cleanImageId = cleanImageId.substring(0, lastDotIndex); + } + } + + url = url.replace("{image_id}", cleanImageId); + log.info("调用RAG服务获取图片: url={}", url); + + try { + byte[] imageBytes = ragService.getBinaryRequest(url, 30000); + log.info("从RAG服务获取图片成功: imageId={}, size={}", imageId, imageBytes.length); + return imageBytes; + + } catch (Exception e) { + log.error("调用RAG服务获取图片失败: imageId={}, error={}", imageId, e.getMessage()); + // 返回一个占位符或者空数组,而不是抛出异常,避免影响整个页面加载 + log.warn("返回空图片数据作为回退: imageId={}", imageId); + return new byte[0]; + } + } + + @Override + public ImageStatsResponse getImageStats() { + String url = aiApiConfig.getFullUrl(aiApiConfig.getImages().getStats()); + log.info("调用RAG服务获取图片统计: url={}", url); + + try { + String jsonResponse = ragService.getJsonRequest(url, 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + + ImageStatsResponse response = new ImageStatsResponse(); + response.setSuccess(rootNode.has("success") ? rootNode.get("success").asBoolean() : false); + + if (rootNode.has("total")) { + response.setTotal(rootNode.get("total").asInt()); + } + if (rootNode.has("total_size")) { + response.setTotalSize(rootNode.get("total_size").asLong()); + } + + log.info("获取图片统计成功: total={}", response.getTotal()); + return response; + + } catch (Exception e) { + log.error("调用RAG服务获取图片统计失败: {}", e.getMessage(), e); + ImageStatsResponse response = new ImageStatsResponse(); + response.setSuccess(false); + return response; + } + } + + @Override + public Image createImage(ImageCreateRequest request) { + Image image = new Image(); + image.setImageId(request.getImageId()); + image.setFilename(request.getFilename()); + image.setPath(request.getPath()); + image.setSizeBytes(request.getSizeBytes()); + image.setFormat(request.getFormat()); + image.setSource(request.getSource()); + image.setPage(request.getPage()); + image.setPageEnd(request.getPageEnd()); + image.setPageRange(request.getPageRange()); + image.setSection(request.getSection()); + image.setChunkType(request.getChunkType()); + image.setType(request.getType()); + image.setDescription(request.getDescription()); + image.setFullDescription(request.getFullDescription()); + image.setScore(request.getScore()); + image.setCollection(request.getCollection()); + image.setCreateTime(LocalDateTime.now()); + + imageMapper.insert(image); + log.info("创建图片记录成功: id={}, imageId={}", image.getId(), image.getImageId()); + return image; + } + + @Override + public Image getImageById(Long id) { + return imageMapper.selectById(id); + } + + @Override + public Image updateImage(Long id, ImageUpdateRequest request) { + Image image = imageMapper.selectById(id); + if (image == null) { + throw new IllegalArgumentException("图片不存在"); + } + + if (request.getFilename() != null) { + image.setFilename(request.getFilename()); + } + if (request.getPath() != null) { + image.setPath(request.getPath()); + } + if (request.getSizeBytes() != null) { + image.setSizeBytes(request.getSizeBytes()); + } + if (request.getFormat() != null) { + image.setFormat(request.getFormat()); + } + if (request.getSource() != null) { + image.setSource(request.getSource()); + } + if (request.getPage() != null) { + image.setPage(request.getPage()); + } + if (request.getCollection() != null) { + image.setCollection(request.getCollection()); + } + + imageMapper.updateById(image); + return image; + } + + @Override + public boolean deleteImage(Long id) { + return imageMapper.deleteById(id) > 0; + } + + @Override + public int batchDeleteImages(List ids) { + return imageMapper.deleteBatchIds(ids); + } + + @Override + public Image getImageByImageId(String imageId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Image::getImageId, imageId); + return imageMapper.selectOne(queryWrapper); + } + + @Override + public boolean existsByImageId(String imageId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Image::getImageId, imageId); + return imageMapper.exists(queryWrapper); + } + + @Override + public Map getImageInfo(String imageId) { + String url = aiApiConfig.getFullUrl(aiApiConfig.getImages().getInfo()); + url = url.replace("{image_id}", imageId); + log.info("调用RAG服务获取图片信息: url={}", url); + + try { + String jsonResponse = ragService.getJsonRequest(url, 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + + Map info = new HashMap<>(); + if (rootNode.has("image_id")) { + info.put("image_id", rootNode.get("image_id").asText()); + } + if (rootNode.has("format")) { + info.put("format", rootNode.get("format").asText()); + } + if (rootNode.has("width")) { + info.put("width", rootNode.get("width").asInt()); + } + if (rootNode.has("height")) { + info.put("height", rootNode.get("height").asInt()); + } + if (rootNode.has("size_bytes")) { + info.put("size_bytes", rootNode.get("size_bytes").asLong()); + } + if (rootNode.has("mode")) { + info.put("mode", rootNode.get("mode").asText()); + } + if (rootNode.has("url")) { + info.put("url", rootNode.get("url").asText()); + } + + log.info("获取图片信息成功: imageId={}, info={}", imageId, info); + return info; + + } catch (Exception e) { + log.error("调用RAG服务获取图片信息失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public Image downloadAndSaveImage(String imageId) { + log.info("开始下载并保存图片: imageId={}", imageId); + + try { + if (existsByImageId(imageId)) { + Image existing = getImageByImageId(imageId); + log.info("图片已存在于数据库: imageId={}, id={}", imageId, existing.getId()); + return existing; + } + + Map imageInfo = getImageInfo(imageId); + if (imageInfo == null) { + log.warn("无法获取图片信息: imageId={}", imageId); + return null; + } + + byte[] imageData = getImage(imageId); + if (imageData == null || imageData.length == 0) { + log.warn("无法获取图片数据: imageId={}", imageId); + return null; + } + + ImageCreateRequest request = new ImageCreateRequest(); + request.setImageId(imageId); + request.setFormat((String) imageInfo.get("format")); + request.setSizeBytes((Long) imageInfo.get("size_bytes")); + request.setPath((String) imageInfo.get("url")); + + Image image = createImage(request); + log.info("图片下载并保存成功: imageId={}, dbId={}", imageId, image.getId()); + return image; + + } catch (Exception e) { + log.error("下载并保存图片失败: imageId={}, error={}", imageId, e.getMessage(), e); + return null; + } + } + + @Override + public Image downloadAndSaveImageWithMetadata(Map imageMetadata) { + long startTime = System.currentTimeMillis(); + String operationId = java.util.UUID.randomUUID().toString().substring(0, 8); + + log.error("[{}] [{}] ========== 图片保存流程开始 ==========", java.time.LocalDateTime.now(), operationId); + + if (imageMetadata == null || !imageMetadata.containsKey("id")) { + log.error("[{}] [{}] [文件接收阶段] 失败 - 图片元数据为空或不包含id字段, metadata={}", + java.time.LocalDateTime.now(), operationId, imageMetadata); + return null; + } + + String imageId = String.valueOf(imageMetadata.get("id")); + log.error("[{}] [{}] [文件接收阶段] 成功 - 接收到图片元数据, imageId={}, metadataKeys={}", + java.time.LocalDateTime.now(), operationId, imageId, imageMetadata.keySet()); + + // 处理图片 ID:去掉可能的文件后缀(如 .jpg, .png) + String cleanImageId = imageId; + if (cleanImageId.toLowerCase().endsWith(".jpg") || cleanImageId.toLowerCase().endsWith(".jpeg") || + cleanImageId.toLowerCase().endsWith(".png") || cleanImageId.toLowerCase().endsWith(".gif") || + cleanImageId.toLowerCase().endsWith(".webp")) { + int lastDotIndex = cleanImageId.lastIndexOf('.'); + if (lastDotIndex > 0) { + cleanImageId = cleanImageId.substring(0, lastDotIndex); + log.error("[{}] [{}] [文件接收阶段] 处理 - 去掉图片ID后缀, original={}, clean={}", + java.time.LocalDateTime.now(), operationId, imageId, cleanImageId); + } + } + final String finalImageId = cleanImageId; + + try { + log.error("[{}] [{}] [数据验证阶段] 开始 - 检查图片是否已存在, imageId={}", java.time.LocalDateTime.now(), operationId, imageId); + if (existsByImageId(imageId)) { + Image existing = getImageByImageId(imageId); + log.error("[{}] [{}] [数据验证阶段] 成功 - 图片已存在于数据库, imageId={}, dbId={}", + java.time.LocalDateTime.now(), operationId, imageId, existing.getId()); + long duration = System.currentTimeMillis() - startTime; + log.error("[{}] [{}] ========== 图片保存流程结束 - 图片已存在, 耗时={}ms ==========", + java.time.LocalDateTime.now(), operationId, duration); + return existing; + } + log.error("[{}] [{}] [数据验证阶段] 成功 - 图片不存在于数据库, 需要新建, imageId={}", + java.time.LocalDateTime.now(), operationId, imageId); + + log.error("[{}] [{}] [数据转换阶段] 开始 - 构建ImageCreateRequest", java.time.LocalDateTime.now(), operationId); + ImageCreateRequest request = new ImageCreateRequest(); + request.setImageId(imageId); // 数据库保存原始ID + + if (imageMetadata.containsKey("type")) { + request.setType(String.valueOf(imageMetadata.get("type"))); + log.debug("[{}] [{}] [数据转换阶段] 设置type={}", java.time.LocalDateTime.now(), operationId, request.getType()); + } + if (imageMetadata.containsKey("source")) { + request.setSource(String.valueOf(imageMetadata.get("source"))); + log.debug("[{}] [{}] [数据转换阶段] 设置source={}", java.time.LocalDateTime.now(), operationId, request.getSource()); + } + if (imageMetadata.containsKey("page")) { + Object page = imageMetadata.get("page"); + if (page instanceof Number) { + request.setPage(((Number) page).intValue()); + } else if (page != null) { + request.setPage(Integer.parseInt(page.toString())); + } + log.debug("[{}] [{}] [数据转换阶段] 设置page={}", java.time.LocalDateTime.now(), operationId, request.getPage()); + } + if (imageMetadata.containsKey("page_end")) { + request.setPageEnd(String.valueOf(imageMetadata.get("page_end"))); + log.debug("[{}] [{}] [数据转换阶段] 设置page_end={}", java.time.LocalDateTime.now(), operationId, request.getPageEnd()); + } + if (imageMetadata.containsKey("page_range")) { + request.setPageRange(String.valueOf(imageMetadata.get("page_range"))); + log.debug("[{}] [{}] [数据转换阶段] 设置page_range={}", java.time.LocalDateTime.now(), operationId, request.getPageRange()); + } + if (imageMetadata.containsKey("section")) { + request.setSection(String.valueOf(imageMetadata.get("section"))); + log.debug("[{}] [{}] [数据转换阶段] 设置section={}", java.time.LocalDateTime.now(), operationId, request.getSection()); + } + if (imageMetadata.containsKey("description")) { + request.setDescription(String.valueOf(imageMetadata.get("description"))); + log.debug("[{}] [{}] [数据转换阶段] 设置description={}", java.time.LocalDateTime.now(), operationId, + request.getDescription() != null ? request.getDescription().substring(0, Math.min(50, request.getDescription().length())) + "..." : "null"); + } + if (imageMetadata.containsKey("full_description")) { + request.setFullDescription(String.valueOf(imageMetadata.get("full_description"))); + log.debug("[{}] [{}] [数据转换阶段] 设置full_description长度={}", java.time.LocalDateTime.now(), operationId, + request.getFullDescription() != null ? request.getFullDescription().length() : 0); + } + if (imageMetadata.containsKey("score")) { + Object score = imageMetadata.get("score"); + if (score instanceof Number) { + request.setScore(java.math.BigDecimal.valueOf(((Number) score).doubleValue())); + } + log.debug("[{}] [{}] [数据转换阶段] 设置score={}", java.time.LocalDateTime.now(), operationId, request.getScore()); + } + if (imageMetadata.containsKey("chunk_type")) { + request.setChunkType(String.valueOf(imageMetadata.get("chunk_type"))); + log.debug("[{}] [{}] [数据转换阶段] 设置chunk_type={}", java.time.LocalDateTime.now(), operationId, request.getChunkType()); + } + log.info("[{}] [{}] [数据转换阶段] 成功 - ImageCreateRequest构建完成", java.time.LocalDateTime.now(), operationId); + + log.error("[{}] [{}] [图片信息获取阶段] 开始 - 调用AI服务获取图片元信息, finalImageId={}", java.time.LocalDateTime.now(), operationId, finalImageId); + Map aiImageInfo = getImageInfo(finalImageId); + if (aiImageInfo != null) { + log.error("[{}] [{}] [图片信息获取阶段] 成功 - 获取到图片信息, format={}, size_bytes={}, width={}, height={}", + java.time.LocalDateTime.now(), operationId, + aiImageInfo.get("format"), aiImageInfo.get("size_bytes"), aiImageInfo.get("width"), aiImageInfo.get("height")); + + if (aiImageInfo.containsKey("format") && request.getFormat() == null) { + request.setFormat((String) aiImageInfo.get("format")); + log.debug("[{}] [{}] [图片信息获取阶段] 补充format={}", java.time.LocalDateTime.now(), operationId, request.getFormat()); + } + if (aiImageInfo.containsKey("size_bytes") && request.getSizeBytes() == null) { + Object sizeBytes = aiImageInfo.get("size_bytes"); + if (sizeBytes instanceof Number) { + request.setSizeBytes(((Number) sizeBytes).longValue()); + } + log.debug("[{}] [{}] [图片信息获取阶段] 补充size_bytes={}", java.time.LocalDateTime.now(), operationId, request.getSizeBytes()); + } + } else { + log.warn("[{}] [{}] [图片信息获取阶段] 警告 - 未能获取图片元信息, imageId={}", + java.time.LocalDateTime.now(), operationId, imageId); + } + + log.error("[{}] [{}] [图片下载阶段] 开始 - 调用AI服务下载图片数据, finalImageId={}", java.time.LocalDateTime.now(), operationId, finalImageId); + byte[] imageData = getImage(finalImageId); + if (imageData != null && imageData.length > 0) { + log.error("[{}] [{}] [图片下载阶段] 成功 - 图片数据下载完成, finalImageId={}, size={} bytes", + java.time.LocalDateTime.now(), operationId, finalImageId, imageData.length); + + log.error("[{}] [{}] [本地保存阶段] 开始 - 保存图片到本地目录, imageId={}", java.time.LocalDateTime.now(), operationId, imageId); + String localPath = saveImageToLocal(imageId, imageData); + if (localPath != null) { + request.setPath(localPath); + log.error("[{}] [{}] [本地保存阶段] 成功 - 图片保存到本地, path={}", java.time.LocalDateTime.now(), operationId, localPath); + + if (request.getSizeBytes() == null) { + request.setSizeBytes((long) imageData.length); + log.debug("[{}] [{}] [本地保存阶段] 补充size_bytes={}", java.time.LocalDateTime.now(), operationId, request.getSizeBytes()); + } + } else { + log.error("[{}] [{}] [本地保存阶段] 警告 - 未能保存图片到本地, 将继续保存数据库记录", java.time.LocalDateTime.now(), operationId); + } + } else { + log.error("[{}] [{}] [图片下载阶段] 警告 - 图片数据为空或下载失败, imageId={}", + java.time.LocalDateTime.now(), operationId, imageId); + } + + log.error("[{}] [{}] [数据库写入前] 开始 - 准备写入数据库, imageId={}, request={}", + java.time.LocalDateTime.now(), operationId, imageId, + request.getImageId() + "," + request.getPath() + "," + request.getFormat()); + + Image image = createImage(request); + + log.error("[{}] [{}] [数据库写入后] 成功 - 图片记录已保存到数据库, imageId={}, dbId={}, path={}, format={}, sizeBytes={}", + java.time.LocalDateTime.now(), operationId, imageId, image.getId(), image.getPath(), image.getFormat(), image.getSizeBytes()); + + long duration = System.currentTimeMillis() - startTime; + log.error("[{}] [{}] ========== 图片保存流程成功完成, 耗时={}ms ==========", + java.time.LocalDateTime.now(), operationId, duration); + + return image; + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("[{}] [{}] [异常捕获阶段] 失败 - 图片保存流程异常, imageId={}, error={}, stackTrace={}, 耗时={}ms", + java.time.LocalDateTime.now(), operationId, imageId, e.getMessage(), + org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(e), duration); + return null; + } + } + + private String saveImageToLocal(String imageId, byte[] imageData) { + try { + log.debug("[本地保存] 开始 - 准备保存图片到本地, imageId={}, dataSize={} bytes", + imageId, imageData.length); + + Path imgDir = getImageStorageDir(); + + if (!Files.exists(imgDir)) { + Files.createDirectories(imgDir); + } + + String filename = imageId; + if (!filename.contains(".")) { + filename = imageId + ".jpg"; + } + Path filePath = imgDir.resolve(filename); + + log.debug("[本地保存] 开始写入文件..."); + Files.write(filePath, imageData); + + long fileSize = Files.size(filePath); + log.info("[本地保存] 成功 - 图片已保存到本地, path={}, size={} bytes", + filePath.toAbsolutePath(), fileSize); + + // 构建相对路径(相对于 uploadPath),这样数据库里是 img/xxx.jpg + Path basePath = Paths.get(uploadPath); + String relativePath = basePath.relativize(filePath).toString().replace("\\", "/"); + log.debug("[本地保存] 返回相对路径: {}", relativePath); + return relativePath; + + } catch (java.io.IOException e) { + log.error("[本地保存] 失败 - IO异常, imageId={}, error={}", imageId, e.getMessage(), e); + return null; + } catch (Exception e) { + log.error("[本地保存] 失败 - 未知异常, imageId={}, error={}", imageId, e.getMessage(), e); + return null; + } + } + + private Path getImageStorageDir() { + // 使用配置的 uploadPath + /img 作为图片存储目录 + Path imgDir = Paths.get(uploadPath, "img"); + try { + if (!Files.exists(imgDir)) { + Files.createDirectories(imgDir); + log.info("[存储目录] 创建图片目录: {}", imgDir.toAbsolutePath()); + } + return imgDir; + } catch (Exception e) { + log.error("[存储目录] 无法创建图片目录, 使用临时目录, error={}", e.getMessage()); + // 回退到临时目录 + Path fallbackPath = Paths.get(System.getProperty("java.io.tmpdir")).resolve("img"); + try { + if (!Files.exists(fallbackPath)) { + Files.createDirectories(fallbackPath); + } + return fallbackPath; + } catch (Exception ex) { + log.error("[存储目录] 无法创建任何目录, error={}", ex.getMessage()); + return Paths.get("/tmp"); + } + } + } + + /** + * 根据图片ID获取Nginx格式的URL + * @param imageId 图片ID + * @return Nginx格式的URL,如果找不到则返回null + */ + public String getNginxUrlByImageId(String imageId) { + try { + Image image = getImageByImageId(imageId); + if (image == null && !imageId.toLowerCase().endsWith(".jpg")) { + image = getImageByImageId(imageId + ".jpg"); + } + + if (image != null && image.getPath() != null) { + // 从 path 中提取文件名,格式是 img/xxx.jpg + String dbPath = image.getPath(); + String filename = extractFilenameFromPath(dbPath); + if (filename != null) { + return urlPrefix + "img/" + filename; + } + } + } catch (Exception e) { + log.warn("获取图片Nginx URL失败: imageId={}, error={}", imageId, e.getMessage()); + } + return null; + } + + /** + * 从文件路径中提取文件名 + */ + private String extractFilenameFromPath(String path) { + if (path == null || path.isEmpty()) { + return null; + } + try { + Path filePath = Paths.get(path); + return filePath.getFileName().toString(); + } catch (Exception e) { + try { + int lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (lastSlash >= 0 && lastSlash < path.length() - 1) { + return path.substring(lastSlash + 1); + } + } catch (Exception ex) { + log.warn("提取文件名失败: path={}", path); + } + } + return null; + } + + @Override + public List findImagesByReference(String docName, Integer page, String chunkId) { + log.info("查找与引用关联的图片: docName={}, page={}, chunkId={}", docName, page, chunkId); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + + // 尝试通过 docName 和 page 查找 + if (docName != null && !docName.isEmpty()) { + queryWrapper.eq(Image::getSource, docName); + } + if (page != null) { + queryWrapper.eq(Image::getPage, page); + } + + List results = imageMapper.selectList(queryWrapper); + log.info("通过 docName 和 page 找到 {} 张图片", results != null ? results.size() : 0); + + return results != null ? results : new ArrayList<>(); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/KBRouteServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/KBRouteServiceImpl.java new file mode 100644 index 0000000..3a8ac95 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/KBRouteServiceImpl.java @@ -0,0 +1,98 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.KBRouteRequest; +import top.tqx.demo_1.dto.KBRouteResponse; +import top.tqx.demo_1.service.KBRouteService; +import top.tqx.demo_1.service.RagService; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class KBRouteServiceImpl implements KBRouteService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private RagService ragService; + + @Override + public KBRouteResponse route(KBRouteRequest request) throws IOException { + String url = aiApiConfig.getFullUrl(aiApiConfig.getKbRoute().getRoute()); + log.info("调用RAG服务知识库路由: url={}", url); + + try { + ObjectNode requestBody = ragService.createObjectNode(); + if (request.getQuery() != null) { + requestBody.put("query", request.getQuery()); + } + + String jsonResponse = ragService.postJsonRequest(url, requestBody.toString(), 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + log.info("RAG服务知识库路由成功"); + + KBRouteResponse response = new KBRouteResponse(); + + if (rootNode.has("query")) { + response.setQuery(rootNode.get("query").asText()); + } + if (rootNode.has("user_role")) { + response.setUserRole(rootNode.get("user_role").asText()); + } + if (rootNode.has("user_department")) { + response.setUserDepartment(rootNode.get("user_department").asText()); + } + + if (rootNode.has("target_collections") && rootNode.get("target_collections").isArray()) { + List targetCollections = new ArrayList<>(); + for (JsonNode collectionNode : rootNode.get("target_collections")) { + targetCollections.add(collectionNode.asText()); + } + response.setTargetCollections(targetCollections); + } + + if (rootNode.has("intent") && rootNode.get("intent").isObject()) { + JsonNode intentNode = rootNode.get("intent"); + KBRouteResponse.IntentData intent = new KBRouteResponse.IntentData(); + + if (intentNode.has("is_general")) { + intent.setIsGeneral(intentNode.get("is_general").asBoolean()); + } + if (intentNode.has("department")) { + intent.setDepartment(intentNode.get("department").asText()); + } + if (intentNode.has("confidence")) { + intent.setConfidence(intentNode.get("confidence").asDouble()); + } + if (intentNode.has("reason")) { + intent.setReason(intentNode.get("reason").asText()); + } + + if (intentNode.has("keywords") && intentNode.get("keywords").isArray()) { + List keywords = new ArrayList<>(); + for (JsonNode keywordNode : intentNode.get("keywords")) { + keywords.add(keywordNode.asText()); + } + intent.setKeywords(keywords); + } + + response.setIntent(intent); + } + + return response; + + } catch (Exception e) { + log.error("调用RAG服务知识库路由失败: {}", e.getMessage(), e); + return new KBRouteResponse(); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBasePathServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBasePathServiceImpl.java new file mode 100644 index 0000000..64c04ad --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBasePathServiceImpl.java @@ -0,0 +1,54 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.KnowledgeBasePathService; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class KnowledgeBasePathServiceImpl extends ServiceImpl implements KnowledgeBasePathService { + + @Autowired + private UserMapper userMapper; + + @Override + public List getUserAccessibleKnowledgeBases(Long userId, Long deptId, String deptName) { + User user = userMapper.selectById(userId); + if (user != null && CommonConstant.RoleType.SUPER_ADMIN.equals(user.getUserType())) { + List knowledgeBases = this.list(); + log.info("Super admin gets all knowledge bases: userId={}, count={}", userId, knowledgeBases.size()); + return knowledgeBases; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getVisibility, "public"); + wrapper.and(w -> w.eq(KnowledgeBasePath::getOwnerType, "department") + .and(w1 -> w1.eq(KnowledgeBasePath::getDepartment, deptName) + .or().eq(KnowledgeBasePath::getDepartment, "公共部门"))); + wrapper.or(w -> w.eq(KnowledgeBasePath::getOwnerType, "personal") + .eq(KnowledgeBasePath::getUploader, userId.toString())); + + List knowledgeBases = this.list(wrapper); + log.info("Get user accessible knowledge bases: userId={}, deptId={}, deptName={}, count={}", userId, deptId, deptName, knowledgeBases.size()); + return knowledgeBases; + } + + @Override + public List getUserAccessibleKnowledgeBaseNames(Long userId, Long deptId, String deptName) { + List knowledgeBases = getUserAccessibleKnowledgeBases(userId, deptId, deptName); + return knowledgeBases.stream() + .map(KnowledgeBasePath::getName) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBaseServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBaseServiceImpl.java new file mode 100644 index 0000000..989c226 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/KnowledgeBaseServiceImpl.java @@ -0,0 +1,550 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.entity.DeptAdmin; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.KnowledgeBasePath; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.mapper.DeptAdminMapper; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.KnowledgeBasePathMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.service.KnowledgeBaseService; +import top.tqx.demo_1.vo.CollectionVO; +import top.tqx.demo_1.vo.ChunkVO; +import top.tqx.demo_1.vo.DocumentVO; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +public class KnowledgeBaseServiceImpl extends ServiceImpl + implements KnowledgeBaseService { + + @Autowired + private KnowledgeBasePathMapper knowledgeBasePathMapper; + + @Autowired + private UserMapper userMapper; + + @Autowired + private DeptAdminMapper deptAdminMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private AiApiConfig aiApiConfig; + + @Override + public List getCollections(Long userId, Long deptId, String deptCode) { + List allKnowledgeBases = list(); + + List collections = new ArrayList<>(); + for (KnowledgeBasePath kb : allKnowledgeBases) { + if (canAccess(userId, deptCode, kb)) { + CollectionVO vo = new CollectionVO(); + vo.setName(kb.getName()); + vo.setDisplayName(kb.getDisplayName() != null ? kb.getDisplayName() : kb.getName()); + vo.setDepartment(kb.getDepartment()); + vo.setDescription(kb.getDescription()); + vo.setDocumentCount(0); + collections.add(vo); + } + } + + log.info("获取用户可访问向量库列表: userId={}, deptId={}, deptCode={}, 数量={}", + userId, deptId, deptCode, collections.size()); + return collections; + } + + private boolean canAccess(Long userId, String deptCode, KnowledgeBasePath kb) { + if (kb.getOwnerType() == null) { + return false; + } + + if ("personal".equals(kb.getOwnerType())) { + return kb.getUploader() != null && kb.getUploader().equals(userId.toString()); + } + + if ("department".equals(kb.getOwnerType())) { + if ("public".equals(kb.getVisibility())) { + return true; + } + if ("private".equals(kb.getVisibility()) && kb.getDepartment() != null) { + return kb.getDepartment().equals(deptCode); + } + } + + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public CollectionVO createCollection(String name, String displayName, String department, String description, Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getName, name); + KnowledgeBasePath existing = this.getOne(wrapper); + + if (existing != null) { + throw new RuntimeException("向量库已存在: " + name); + } + + String departmentName = getDepartmentNameById(department); + + KnowledgeBasePath knowledgeBase = new KnowledgeBasePath(); + knowledgeBase.setName(name); + knowledgeBase.setDisplayName(displayName); + knowledgeBase.setPath("/knowledge/" + name); + knowledgeBase.setDepartment(departmentName); + knowledgeBase.setDescription(description); + knowledgeBase.setUploader(userId != null ? userId.toString() : null); + knowledgeBase.setOwnerType(departmentName != null ? "department" : "personal"); + knowledgeBase.setVisibility("public"); + knowledgeBase.setCreateTime(LocalDateTime.now()); + knowledgeBase.setUpdateTime(LocalDateTime.now()); + + boolean saved = this.save(knowledgeBase); + if (!saved) { + throw new RuntimeException("创建向量库失败"); + } + + log.info("创建向量库成功: name={}, displayName={}, department={}, userId={}", + name, displayName, departmentName, userId); + + CollectionVO vo = new CollectionVO(); + vo.setName(knowledgeBase.getName()); + vo.setDisplayName(knowledgeBase.getDisplayName()); + vo.setDepartment(knowledgeBase.getDepartment()); + vo.setDescription(knowledgeBase.getDescription()); + vo.setDocumentCount(0); + vo.setCreateTime(knowledgeBase.getCreateTime()); + return vo; + } + + private String getDepartmentNameById(String departmentId) { + if (departmentId == null || departmentId.isEmpty()) { + return null; + } + try { + Long deptId = Long.parseLong(departmentId); + Department dept = departmentMapper.selectById(deptId); + if (dept != null && dept.getDeptName() != null) { + return dept.getDeptName(); + } + } catch (NumberFormatException e) { + log.warn("部门ID格式不正确: {}", departmentId); + } catch (Exception e) { + log.warn("查询部门名称失败: departmentId={}", departmentId, e); + } + return null; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public CollectionVO updateCollection(String name, String displayName, String department, String description) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getName, name); + KnowledgeBasePath knowledgeBase = this.getOne(wrapper); + + if (knowledgeBase == null) { + throw new RuntimeException("向量库不存在: " + name); + } + + if (displayName != null) { + knowledgeBase.setDisplayName(displayName); + } + if (department != null) { + String departmentName = getDepartmentNameById(department); + knowledgeBase.setDepartment(departmentName); + } + if (description != null) { + knowledgeBase.setDescription(description); + } + knowledgeBase.setUpdateTime(LocalDateTime.now()); + + boolean updated = this.updateById(knowledgeBase); + if (!updated) { + throw new RuntimeException("更新向量库失败"); + } + + log.info("更新向量库成功: name={}, displayName={}, department={}", name, displayName, knowledgeBase.getDepartment()); + + CollectionVO vo = new CollectionVO(); + vo.setName(knowledgeBase.getName()); + vo.setDisplayName(knowledgeBase.getDisplayName()); + vo.setDepartment(knowledgeBase.getDepartment()); + vo.setDescription(knowledgeBase.getDescription()); + vo.setUpdateTime(knowledgeBase.getUpdateTime()); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteCollection(String name) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getName, name); + KnowledgeBasePath knowledgeBase = this.getOne(wrapper); + + if (knowledgeBase == null) { + throw new RuntimeException("向量库不存在: " + name); + } + + boolean removed = this.removeById(knowledgeBase.getId()); + if (!removed) { + throw new RuntimeException("删除向量库失败"); + } + + log.info("删除向量库成功: name={}", name); + } + + @Override + public List getCollectionChunks(String kbName, String documentId, Integer limit, Integer offset) { + log.info("获取向量库切片列表: kbName={}, documentId={}, limit={}, offset={}", + kbName, documentId, limit, offset); + + return new ArrayList<>(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map deprecateDocument(String kbName, String filename, String reason, Long userId) { + log.info("废止文档: kbName={}, filename={}, reason={}, userId={}", + kbName, filename, reason, userId); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("deprecated_chunks", 0); + result.put("document_id", filename); + result.put("collection", kbName); + result.put("deprecated_date", LocalDateTime.now().toString()); + + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map restoreDocument(String kbName, String filename) { + log.info("恢复文档: kbName={}, filename={}", kbName, filename); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("restored_chunks", 0); + result.put("document_id", filename); + result.put("collection", kbName); + + return result; + } + + @Override + public List> getDocumentVersions(String kbName, String filename, Integer limit) { + log.info("获取文档版本历史: kbName={}, filename={}, limit={}", kbName, filename, limit); + + List> versions = new ArrayList<>(); + + Map version = new HashMap<>(); + version.put("document_id", filename); + version.put("collection", kbName); + version.put("version", "v1"); + version.put("status", "active"); + version.put("effective_date", LocalDateTime.now().minusMonths(1).toString()); + version.put("deprecated_date", null); + version.put("deprecated_reason", null); + version.put("change_summary", "初始版本"); + version.put("supersedes", null); + version.put("created_at", LocalDateTime.now().minusMonths(1).toString()); + version.put("created_by", "system"); + version.put("chunk_count", 0); + versions.add(version); + + return versions; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DocumentVO uploadDocument(String collection, String filename, byte[] content, Long userId) { + log.info("上传文档到向量库: collection={}, filename={}, size={}, userId={}", + collection, filename, content != null ? content.length : 0, userId); + + User user = userMapper.selectById(userId); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + validateUploadPermission(user, collection); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getName, collection); + KnowledgeBasePath existingKb = this.getOne(wrapper); + + if (existingKb == null) { + if (isAdmin(user)) { + createCollectionInRAG(collection, collection, null, "管理员创建的向量库", userId); + } else { + throw new RuntimeException("向量库不存在且无权限创建: " + collection); + } + } + + DocumentVO doc = uploadToRAG(collection, filename, content); + + if (existingKb != null) { + existingKb.setUpdateTime(LocalDateTime.now()); + if (content != null) { + existingKb.setFileSize((long) content.length); + } + this.updateById(existingKb); + } + + return doc; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List batchUploadDocuments(String collection, List filenames, List contents, Long userId) { + log.info("批量上传文档到向量库: collection={}, count={}, userId={}", + collection, filenames != null ? filenames.size() : 0, userId); + + User user = userMapper.selectById(userId); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + validateUploadPermission(user, collection); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(KnowledgeBasePath::getName, collection); + KnowledgeBasePath existingKb = this.getOne(wrapper); + + if (existingKb == null) { + if (isAdmin(user)) { + createCollectionInRAG(collection, collection, null, "管理员创建的向量库", userId); + } else { + throw new RuntimeException("向量库不存在且无权限创建: " + collection); + } + } + + List documents = new ArrayList<>(); + if (filenames != null && contents != null) { + for (int i = 0; i < filenames.size(); i++) { + byte[] content = i < contents.size() ? contents.get(i) : null; + DocumentVO doc = uploadToRAG(collection, filenames.get(i), content); + documents.add(doc); + } + } + + if (existingKb != null) { + existingKb.setUpdateTime(LocalDateTime.now()); + this.updateById(existingKb); + } + + return documents; + } + + private void validateUploadPermission(User user, String collection) { + if (isAdmin(user)) { + return; + } + + if (isDeptAdmin(user)) { + List allowedCollections = getDeptAdminAllowedCollections(user.getId()); + if (!allowedCollections.contains(collection)) { + throw new RuntimeException("部门管理员只能上传到本部门所属的向量库"); + } + return; + } + + String publicCollection = "user_" + user.getId() + "_public"; + String privateCollection = "user_" + user.getId() + "_private"; + if (!collection.equals(publicCollection) && !collection.equals(privateCollection)) { + throw new RuntimeException("个人用户只能上传到自己的公开或私密向量库"); + } + } + + private boolean isAdmin(User user) { + return CommonConstant.RoleType.SUPER_ADMIN.equals(user.getUserType()); + } + + private boolean isDeptAdmin(User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DeptAdmin::getUserId, user.getId()) + .eq(DeptAdmin::getStatus, 1); + return deptAdminMapper.selectCount(wrapper) > 0; + } + + private List getDeptAdminAllowedCollections(Long userId) { + List collections = new ArrayList<>(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DeptAdmin::getUserId, userId) + .eq(DeptAdmin::getStatus, 1); + List deptAdminList = deptAdminMapper.selectList(wrapper); + + for (DeptAdmin deptAdmin : deptAdminList) { + Department dept = departmentMapper.selectById(deptAdmin.getDeptId()); + if (dept != null) { + LambdaQueryWrapper kbWrapper = new LambdaQueryWrapper<>(); + kbWrapper.eq(KnowledgeBasePath::getDepartment, dept.getDeptName()); + List deptKbs = knowledgeBasePathMapper.selectList(kbWrapper); + for (KnowledgeBasePath kb : deptKbs) { + collections.add(kb.getName()); + } + } + } + + return collections; + } + + private void createCollectionInRAG(String name, String displayName, String department, String description, Long userId) { + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getCollection().getCreate()); + log.info("调用RAG服务创建向量库: {}", url); + + String departmentName = getDepartmentNameById(department); + + Map requestBody = new HashMap<>(); + requestBody.put("name", name); + requestBody.put("display_name", displayName); + if (departmentName != null) { + requestBody.put("department", departmentName); + } + if (description != null) { + requestBody.put("description", description); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("RAG向量库创建成功: {}", name); + + KnowledgeBasePath knowledgeBase = new KnowledgeBasePath(); + knowledgeBase.setName(name); + knowledgeBase.setDisplayName(displayName); + knowledgeBase.setPath("/knowledge/" + name); + knowledgeBase.setDepartment(departmentName); + knowledgeBase.setDescription(description); + knowledgeBase.setUploader(userId != null ? userId.toString() : null); + knowledgeBase.setOwnerType(departmentName != null ? "department" : "personal"); + knowledgeBase.setVisibility("public"); + knowledgeBase.setCreateTime(LocalDateTime.now()); + knowledgeBase.setUpdateTime(LocalDateTime.now()); + this.save(knowledgeBase); + } else { + log.warn("RAG向量库创建失败: status={}, body={}", response.getStatusCode(), response.getBody()); + throw new RuntimeException("创建向量库失败: " + response.getBody()); + } + } catch (Exception e) { + log.error("调用RAG服务创建向量库失败", e); + throw new RuntimeException("创建向量库失败: " + e.getMessage()); + } + } + + private DocumentVO uploadToRAG(String collection, String filename, byte[] content) { + try { + String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getUpload()); + log.info("调用RAG服务上传文档: {}", url); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }); + body.add("collection", collection); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map bodyMap = response.getBody(); + Boolean success = (Boolean) bodyMap.get("success"); + if (success != null && success) { + log.info("RAG文档上传成功: {}", filename); + + DocumentVO doc = new DocumentVO(); + doc.setFilename(filename); + doc.setCollection(collection); + doc.setPath(collection + "/" + filename); + doc.setSize(content != null ? (long) content.length : 0L); + doc.setStatus("active"); + doc.setChunkCount(0); + doc.setCreateTime(LocalDateTime.now()); + + Map fileInfo = (Map) bodyMap.get("file"); + if (fileInfo != null) { + if (fileInfo.containsKey("path")) { + doc.setPath((String) fileInfo.get("path")); + } + if (fileInfo.containsKey("size")) { + doc.setSize(((Number) fileInfo.get("size")).longValue()); + } + } + + Map vectorization = (Map) bodyMap.get("vectorization"); + if (vectorization != null && vectorization.containsKey("chunk_count")) { + doc.setChunkCount(((Number) vectorization.get("chunk_count")).intValue()); + } + + return doc; + } else { + String message = (String) bodyMap.get("message"); + log.warn("RAG文档上传失败: {}", message); + throw new RuntimeException("文档上传失败: " + message); + } + } else { + log.warn("RAG文档上传失败: status={}", response.getStatusCode()); + throw new RuntimeException("文档上传失败"); + } + } catch (Exception e) { + log.error("调用RAG服务上传文档失败", e); + throw new RuntimeException("文档上传失败: " + e.getMessage()); + } + } + + @Override + public List getDocumentList(String collection, Long userId) { + log.info("获取向量库文档列表: collection={}, userId={}", collection, userId); + + return new ArrayList<>(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDocument(String path) { + log.info("删除文档: path={}", path); + + if (path == null || path.isEmpty()) { + throw new RuntimeException("文档路径不能为空"); + } + } + + @Override + public List getDocumentChunks(String path) { + log.info("获取文档切片: path={}", path); + + return new ArrayList<>(); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/LogServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/LogServiceImpl.java new file mode 100644 index 0000000..5a69064 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/LogServiceImpl.java @@ -0,0 +1,96 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.entity.OperationLog; +import top.tqx.demo_1.mapper.OperationLogMapper; +import top.tqx.demo_1.service.LogService; +import top.tqx.demo_1.vo.OperationLogVO; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class LogServiceImpl implements LogService { + + @Autowired + private OperationLogMapper operationLogMapper; + + /** + * 记录操作日志 + */ + @Override + public void recordLog(Long userId, String operation, String content) { + OperationLog log = new OperationLog(); + log.setUserId(userId); + log.setOperationType(operation); + log.setOperationDesc(content); + log.setCreateTime(LocalDateTime.now()); + + operationLogMapper.insert(log); + } + + /** + * 分页查询操作日志 + */ + @Override + public PageResult pageLogs(Integer pageNum, Integer pageSize, String username, String operationType) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (operationType != null && !operationType.isEmpty()) { + wrapper.eq(OperationLog::getOperationType, operationType); + } + + if (username != null && !username.isEmpty()) { + wrapper.like(OperationLog::getUsername, username); + } + + wrapper.orderByDesc(OperationLog::getCreateTime); + + Page logPage = operationLogMapper.selectPage(page, wrapper); + + List voList = logPage.getRecords().stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + + return PageResult.of(voList, logPage.getTotal(), logPage.getSize(), logPage.getCurrent()); + } + + /** + * 获取用户操作日志 + */ + @Override + public List getUserLogs(Long userId) { + List logs = operationLogMapper.selectByUserId(userId); + return logs.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + /** + * 获取文件操作日志 + */ + @Override + public List getFileLogs(Long fileId) { + List logs = operationLogMapper.selectByTargetId(fileId, "FILE"); + return logs.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + private OperationLogVO convertToVO(OperationLog log) { + OperationLogVO vo = new OperationLogVO(); + BeanUtils.copyProperties(log, vo); + vo.setUserName(log.getUsername()); + vo.setOperation(log.getOperationType()); + vo.setContent(log.getOperationDesc()); + vo.setIp(log.getIpAddress()); + return vo; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/PermissionServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/PermissionServiceImpl.java new file mode 100644 index 0000000..3fbfb8c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/PermissionServiceImpl.java @@ -0,0 +1,89 @@ +package top.tqx.demo_1.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.entity.Permission; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.mapper.PermissionMapper; +import top.tqx.demo_1.mapper.RolePermissionMapper; +import top.tqx.demo_1.mapper.UserRoleMapper; +import top.tqx.demo_1.service.PermissionService; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class PermissionServiceImpl implements PermissionService { + + @Autowired + private UserRoleMapper userRoleMapper; + + @Autowired + private RolePermissionMapper rolePermissionMapper; + + @Autowired + private PermissionMapper permissionMapper; + + /** + * 检查用户是否有指定权限 + */ + @Override + public boolean hasPermission(Long userId, String permissionCode) { + // 获取用户的所有角色 + List roles = userRoleMapper.selectRolesByUserId(userId); + + if (roles.isEmpty()) { + return false; + } + + // 检查是否有超级管理员角色 + boolean isSuperAdmin = roles.stream() + .anyMatch(role -> "SUPER_ADMIN".equals(role.getRoleCode())); + + if (isSuperAdmin) { + return true; + } + + // 获取所有角色的权限 + for (Role role : roles) { + List permissions = rolePermissionMapper.selectPermissionsByRoleId(role.getId()); + + // 检查是否有所需权限 + boolean hasPermission = permissions.stream() + .anyMatch(p -> permissionCode.equals(p.getPermissionCode())); + + if (hasPermission) { + return true; + } + } + + return false; + } + + /** + * 获取用户的所有权限编码 + */ + @Override + public List getUserPermissions(Long userId) { + List roles = userRoleMapper.selectRolesByUserId(userId); + + return roles.stream() + .flatMap(role -> rolePermissionMapper.selectPermissionsByRoleId(role.getId()).stream()) + .map(Permission::getPermissionCode) + .distinct() + .collect(Collectors.toList()); + } + + /** + * 获取用户的所有权限详情 + */ + @Override + public List getUserPermissionDetails(Long userId) { + List roles = userRoleMapper.selectRolesByUserId(userId); + + return roles.stream() + .flatMap(role -> rolePermissionMapper.selectPermissionsByRoleId(role.getId()).stream()) + .distinct() + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/service/impl/QuestionSaveServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/QuestionSaveServiceImpl.java new file mode 100644 index 0000000..4337bd6 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/QuestionSaveServiceImpl.java @@ -0,0 +1,268 @@ +package top.tqx.demo_1.service.impl; + +import com.alibaba.fastjson.JSON; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.dto.ExamGenerateRequest; +import top.tqx.demo_1.dto.ExamGenerateResponse; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.service.QuestionSaveService; +import top.tqx.demo_1.service.QuestionService; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class QuestionSaveServiceImpl implements QuestionSaveService { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private QuestionService questionService; + + @Override + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + public void saveQuestionsToDatabase(ExamGenerateResponse response, ExamGenerateRequest request) { + try { + List questions = response.getQuestions(); + List tagList = request.getTags(); + JsonNode tagsNode = null; + if (tagList != null && !tagList.isEmpty()) { + try { + tagsNode = objectMapper.readTree(JSON.toJSONString(tagList)); + } catch (Exception e) { + log.warn("解析tags失败: {}", e.getMessage()); + } + } + + String knowledgeBasePath = null; + if (request.getCollection() != null) { + if (request.getCollection() instanceof String) { + knowledgeBasePath = (String) request.getCollection(); + } + } else if (request.getCollectionName() != null && !request.getCollectionName().isEmpty()) { + knowledgeBasePath = request.getCollectionName(); + } + + Map questionScores = request.getQuestionScores(); + if (questionScores != null && !questionScores.isEmpty()) { + log.info("使用自定义题型分数配置: {}", questionScores); + } else { + log.info("未配置自定义题型分数,将使用默认分数"); + } + + Integer sourceChunksUsed = response.getSourceChunksUsed(); + int successCount = 0; + int failCount = 0; + + Long fileId = request.getFileId(); + log.info("文件ID: {}", fileId); + + for (ExamGenerateRequest.Question question : questions) { + try { + Question questionEntity = convertToQuestionEntity(question, tagsNode, knowledgeBasePath, sourceChunksUsed, questionScores, fileId); + boolean saved = questionService.saveQuestion(questionEntity); + if (saved) { + successCount++; + log.info("题目已保存到数据库: questionId={}, questionType={}, score={}, knowledgeBasePath={}, documentId={}", + questionEntity.getQuestionId(), questionEntity.getQuestionType(), questionEntity.getScore(), knowledgeBasePath, questionEntity.getDocumentId()); + } else { + failCount++; + log.warn("题目保存失败: questionType={}", question.getQuestionType()); + } + } catch (Exception e) { + failCount++; + log.error("保存单个题目失败: questionType={}, error={}", + question.getQuestionType(), e.getMessage()); + } + } + + log.info("题目保存完成: 总数={}, 成功={}, 失败={}", + questions.size(), successCount, failCount); + + if (failCount > 0 && successCount == 0) { + throw new RuntimeException("所有题目保存失败,共" + failCount + "道题"); + } + } catch (Exception e) { + log.error("保存题目到数据库失败", e); + throw e; + } + } + + private Question convertToQuestionEntity(ExamGenerateRequest.Question question, JsonNode tagsNode, String knowledgeBasePath, Integer sourceChunksUsed, Map questionScores, Long fileId) { + Question entity = new Question(); + entity.setQuestionId(java.util.UUID.randomUUID().toString()); + + String questionType = question.getQuestionType(); + if (questionType == null || questionType.isEmpty()) { + log.warn("AI返回的 questionType 为空,设置为默认值 single_choice"); + questionType = "single_choice"; + } + entity.setQuestionType(questionType); + entity.setDifficulty(question.getDifficulty() != null ? question.getDifficulty() : 3); + entity.setTags(tagsNode); + entity.setScore(calculateScore(questionType, question.getScore(), questionScores)); + entity.setVersion("1.0"); + entity.setStatus("pending"); + entity.setKnowledgeBasePath(knowledgeBasePath); + entity.setSourceChunksUsed(sourceChunksUsed); + + if (fileId != null) { + entity.setDocumentId(String.valueOf(fileId)); + log.info("设置 documentId 为 fileId: {}", fileId); + } else if (question.getSourceTrace() != null && question.getSourceTrace().getDocumentName() != null) { + entity.setDocumentId(question.getSourceTrace().getDocumentName()); + log.info("设置 documentId 为 documentName: {}", question.getSourceTrace().getDocumentName()); + } else { + log.warn("documentId 未设置: fileId={}, sourceTrace={}", fileId, question.getSourceTrace()); + } + + // 初始化统计字段,避免数据库约束问题 + entity.setExamCount(0); + entity.setCorrectCount(0); + entity.setLastExamTime(null); + + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + + log.info("=== 题目数据 ==="); + log.info("questionType: {}", questionType); + log.info("difficulty: {}", entity.getDifficulty()); + log.info("knowledgeBasePath: {}", knowledgeBasePath); + + ExamGenerateRequest.QuestionContent content = question.getContent(); + if (content != null && content.getStem() != null && !content.getStem().isEmpty()) { + try { + String contentJson = JSON.toJSONString(content); + log.info("AI返回的题目内容: {}", contentJson); + entity.setContent(objectMapper.readTree(contentJson)); + log.info("解析后的 content: {}", entity.getContent()); + } catch (Exception e) { + log.error("解析题目内容失败: {}", e.getMessage(), e); + entity.setContent(objectMapper.createObjectNode()); + } + } else { + log.warn("AI返回的 content 为空,从顶层字段构建"); + ExamGenerateRequest.QuestionContent newContent = new ExamGenerateRequest.QuestionContent(); + newContent.setStem(question.getStem()); + newContent.setAnswer(question.getAnswer()); + newContent.setExplanation(question.getExplanation()); + if (question.getOptions() != null) { + newContent.setData(java.util.Collections.singletonMap("options", question.getOptions())); + } + try { + String contentJson = JSON.toJSONString(newContent); + log.info("构建的题目内容: {}", contentJson); + entity.setContent(objectMapper.readTree(contentJson)); + } catch (Exception e) { + log.error("解析构建的题目内容失败: {}", e.getMessage(), e); + entity.setContent(objectMapper.createObjectNode()); + } + } + + ExamGenerateRequest.SourceTrace sourceTrace = question.getSourceTrace(); + log.info("=== Source Trace ==="); + log.info("sourceTrace: {}", sourceTrace); + log.info("documentName: {}", sourceTrace != null ? sourceTrace.getDocumentName() : null); + log.info("chunkIds: {}", sourceTrace != null ? sourceTrace.getChunkIds() : null); + log.info("chunksCount: {}", sourceTrace != null ? sourceTrace.getChunksCount() : null); + log.info("pageNumbers: {}", sourceTrace != null ? sourceTrace.getPageNumbers() : null); + log.info("sources: {}", sourceTrace != null ? sourceTrace.getSources() : null); + + if (sourceTrace != null) { + entity.setDocumentName(sourceTrace.getDocumentName()); + + if (sourceTrace.getChunkIds() != null && !sourceTrace.getChunkIds().isEmpty()) { + try { + entity.setChunkIds(objectMapper.readTree(JSON.toJSONString(sourceTrace.getChunkIds()))); + } catch (Exception e) { + log.warn("解析chunkIds失败: {}", e.getMessage()); + } + } else if (sourceTrace.getChunksCount() != null) { + log.warn("AI返回的 source_trace 没有 chunk_ids,只有 chunks_count: {}", sourceTrace.getChunksCount()); + } + + if (sourceTrace.getPageNumbers() != null && !sourceTrace.getPageNumbers().isEmpty()) { + try { + entity.setPageNumbers(objectMapper.readTree(JSON.toJSONString(sourceTrace.getPageNumbers()))); + } catch (Exception e) { + log.warn("解析pageNumbers失败: {}", e.getMessage()); + } + } + + if (sourceTrace.getSources() != null && !sourceTrace.getSources().isEmpty()) { + StringBuilder sourceContext = new StringBuilder(); + for (ExamGenerateRequest.SourceInfo sourceInfo : sourceTrace.getSources()) { + if (sourceContext.length() > 0) { + sourceContext.append("; "); + } + if (sourceInfo.getSection() != null) { + sourceContext.append("Section:").append(sourceInfo.getSection()); + } + if (sourceInfo.getSnippet() != null) { + sourceContext.append(" Snippet:").append(sourceInfo.getSnippet()); + } + if (sourceInfo.getPage() != null) { + sourceContext.append(" Page:").append(sourceInfo.getPage()); + } + } + entity.setSourceContext(sourceContext.toString()); + } else { + log.warn("AI返回的 source_trace 没有 sources,无法生成 source_context"); + if (sourceTrace.getChunksCount() != null) { + entity.setSourceContext("chunks_count: " + sourceTrace.getChunksCount()); + } + } + } + + return entity; + } + + private BigDecimal calculateScore(String questionType, BigDecimal aiScore, Map questionScores) { + // 1. 优先使用AI返回的分数 + if (aiScore != null && aiScore.compareTo(BigDecimal.ZERO) > 0) { + log.debug("使用AI返回的分数: questionType={}, score={}", questionType, aiScore); + return aiScore; + } + + // 2. 其次使用请求中的配置分数 + if (questionScores != null && questionScores.containsKey(questionType)) { + BigDecimal configScore = questionScores.get(questionType); + log.debug("使用配置的题型分数: questionType={}, score={}", questionType, configScore); + return configScore; + } + + // 3. 最后使用默认分数 + return calculateDefaultScore(questionType); + } + + private BigDecimal calculateDefaultScore(String questionType) { + if (questionType == null) { + log.warn("questionType 为 null,使用默认分数 2.00"); + return new BigDecimal("2.00"); + } + switch (questionType) { + case "single_choice": + return new BigDecimal("2.00"); + case "multiple_choice": + return new BigDecimal("4.00"); + case "true_false": + return new BigDecimal("1.00"); + case "fill_blank": + return new BigDecimal("2.00"); + case "subjective": + return new BigDecimal("6.00"); + default: + log.warn("未知的 questionType: {},使用默认分数 5.00", questionType); + return new BigDecimal("5.00"); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/QuestionServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/QuestionServiceImpl.java new file mode 100644 index 0000000..27f23a5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/QuestionServiceImpl.java @@ -0,0 +1,485 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.dto.QuestionCreateRequest; +import top.tqx.demo_1.dto.QuestionStatsRequest; +import top.tqx.demo_1.dto.QuestionStatsResponse; +import top.tqx.demo_1.dto.QuestionUpdateRequest; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.QuestionMapper; +import top.tqx.demo_1.service.QuestionService; +import top.tqx.demo_1.service.LogService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +@Service +public class QuestionServiceImpl extends ServiceImpl implements QuestionService { + + private final LogService logService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${file.upload.path:./uploads/questions/}") + private String uploadBasePath; + + @Value("${file.allowed-extensions:.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.txt,.jpg,.jpeg,.png,.gif,.bmp}") + private String allowedExtensions; + + @Value("${file.max-size:104857600}") + private long maxFileSize; + + @Autowired + private FileMapper fileMapper; + + @Autowired + public QuestionServiceImpl(LogService logService) { + this.logService = logService; + } + + @Override + public String uploadAndProcessFile(MultipartFile file, String user, String customFileName) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("文件不能为空"); + } + + validateFile(file); + + String directory = createDirectoryByFileName(file, customFileName); + String filePath = saveFileToDirectory(file, directory, customFileName); + + String aiResult = callAIInterface(filePath, user); + + logService.recordLog(0L, "AI_FILE_UPLOAD", + String.format("用户:%s 上传文件:%s, 路径:%s", user, file.getOriginalFilename(), filePath)); + + return aiResult; + } + + @Override + public boolean saveQuestion(Question question) { + return save(question); + } + + @Override + public Question getByQuestionId(String questionId) { + return lambdaQuery().eq(Question::getQuestionId, questionId).one(); + } + + @Override + public boolean updateReviewStatus(String questionId, String status, String reviewerComment) { + return lambdaUpdate() + .eq(Question::getQuestionId, questionId) + .set(Question::getStatus, status) + .set(Question::getReviewerComment, reviewerComment) + .update(); + } + + @Override + public PageResult pageQuestions(Integer pageNum, Integer pageSize, + String questionType, Integer difficulty, String status) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (questionType != null && !questionType.isEmpty()) { + wrapper.eq(Question::getQuestionType, questionType); + } + + if (difficulty != null) { + wrapper.eq(Question::getDifficulty, difficulty); + } + + if (status != null && !status.isEmpty()) { + wrapper.eq(Question::getStatus, status); + } + + wrapper.orderByDesc(Question::getCreatedAt); + + Page questionPage = this.page(page, wrapper); + + return PageResult.of( + questionPage.getRecords(), + questionPage.getTotal(), + questionPage.getSize(), + questionPage.getCurrent() + ); + } + + @Override + public List getPendingQuestions() { + return lambdaQuery() + .eq(Question::getStatus, "pending") + .orderByDesc(Question::getCreatedAt) + .list(); + } + + @Override + public Question createQuestion(QuestionCreateRequest request) { + Question question = new Question(); + question.setQuestionId(UUID.randomUUID().toString()); + question.setQuestionType(request.getQuestionType()); + question.setDifficulty(request.getDifficulty() != null ? request.getDifficulty() : 3); + question.setStatus("pending"); + + if (request.getContent() != null) { + question.setContent(objectMapper.valueToTree(request.getContent())); + } + if (request.getTags() != null && !request.getTags().isEmpty()) { + question.setTags(objectMapper.valueToTree(request.getTags())); + } + if (request.getScore() != null) { + question.setScore(request.getScore()); + } else { + question.setScore(calculateDefaultScore(request.getQuestionType())); + } + question.setDocumentId(request.getDocumentId()); + question.setDocumentName(request.getDocumentName()); + question.setKnowledgeBasePath(request.getKnowledgeBasePath()); + question.setSourceContext(request.getSourceContext()); + if (request.getChunkIds() != null) { + question.setChunkIds(objectMapper.valueToTree(request.getChunkIds())); + } + question.setSourceChunksUsed(request.getSourceChunksUsed()); + if (request.getPageNumbers() != null) { + question.setPageNumbers(objectMapper.valueToTree(request.getPageNumbers())); + } + + question.setVersion("1.0"); + question.setCreatedAt(LocalDateTime.now()); + question.setUpdatedAt(LocalDateTime.now()); + + save(question); + return question; + } + + private BigDecimal calculateDefaultScore(String questionType) { + if (questionType == null) return new BigDecimal("2.00"); + switch (questionType) { + case "single_choice": return new BigDecimal("2.00"); + case "multiple_choice": return new BigDecimal("4.00"); + case "true_false": return new BigDecimal("1.00"); + case "fill_blank": return new BigDecimal("2.00"); + case "subjective": return new BigDecimal("6.00"); + default: return new BigDecimal("2.00"); + } + } + + @Override + public Question updateQuestion(Long id, QuestionUpdateRequest request) { + Question question = getById(id); + if (question == null) { + throw new IllegalArgumentException("题目不存在"); + } + + if (request.getQuestionType() != null) { + question.setQuestionType(request.getQuestionType()); + } + if (request.getDifficulty() != null) { + question.setDifficulty(request.getDifficulty()); + } + if (request.getContent() != null) { + question.setContent(objectMapper.valueToTree(request.getContent())); + } + if (request.getTags() != null) { + question.setTags(objectMapper.valueToTree(request.getTags())); + } + if (request.getScore() != null) { + question.setScore(request.getScore()); + } + if (request.getStatus() != null) { + question.setStatus(request.getStatus()); + } + if (request.getReviewerComment() != null) { + question.setReviewerComment(request.getReviewerComment()); + } + if (request.getDocumentId() != null) { + question.setDocumentId(request.getDocumentId()); + } + if (request.getDocumentName() != null) { + question.setDocumentName(request.getDocumentName()); + } + if (request.getKnowledgeBasePath() != null) { + question.setKnowledgeBasePath(request.getKnowledgeBasePath()); + } + if (request.getSourceContext() != null) { + question.setSourceContext(request.getSourceContext()); + } + if (request.getChunkIds() != null) { + question.setChunkIds(objectMapper.valueToTree(request.getChunkIds())); + } + if (request.getSourceChunksUsed() != null) { + question.setSourceChunksUsed(request.getSourceChunksUsed()); + } + if (request.getPageNumbers() != null) { + question.setPageNumbers(objectMapper.valueToTree(request.getPageNumbers())); + } + + question.setUpdatedAt(LocalDateTime.now()); + updateById(question); + return question; + } + + @Override + public boolean deleteById(Long id) { + return removeById(id); + } + + @Override + public int batchDelete(List ids) { + return removeByIds(ids) ? ids.size() : 0; + } + + @Override + public List listQuestions(String questionType, Integer difficulty, String status, String documentName, Integer limit, Long userId, Long deptId, Integer userType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (questionType != null && !questionType.isEmpty()) { + wrapper.eq(Question::getQuestionType, questionType); + } + if (difficulty != null) { + wrapper.eq(Question::getDifficulty, difficulty); + } + if (status != null && !status.isEmpty()) { + wrapper.eq(Question::getStatus, status); + } + if (documentName != null && !documentName.isEmpty()) { + wrapper.like(Question::getDocumentName, documentName); + } + + if (userType == 1) { + } else if (userType == 2) { + wrapper.and(w -> w.isNull(Question::getKnowledgeBasePath) + .or(w2 -> w2.like(Question::getKnowledgeBasePath, "public")) + .or(w3 -> w3.like(Question::getKnowledgeBasePath, String.valueOf(deptId)))); + } else { + wrapper.and(w -> w.isNull(Question::getKnowledgeBasePath) + .or(w2 -> w2.like(Question::getKnowledgeBasePath, "personal")) + .or(w3 -> w3.like(Question::getKnowledgeBasePath, "public")) + .or(w4 -> w4.like(Question::getKnowledgeBasePath, String.valueOf(deptId)))); + } + + wrapper.orderByDesc(Question::getCreatedAt); + wrapper.last("LIMIT " + (limit != null ? limit : 100)); + + return list(wrapper); + } + + @Override + public QuestionStatsResponse getQuestionStats(QuestionStatsRequest request) { + QuestionStatsResponse response = new QuestionStatsResponse(); + response.setSuccess(true); + + LambdaQueryWrapper baseWrapper = buildStatsQueryWrapper(request); + + Long totalCount = baseMapper.selectCount(baseWrapper); + response.setTotalCount(totalCount); + + if (request.getQuestionTypes() == null || request.getQuestionTypes().isEmpty() || + request.getQuestionTypes().contains("single_choice")) { + Long singleCount = countByType("single_choice", request); + response.setSingleChoiceCount(singleCount); + } + + if (request.getQuestionTypes() == null || request.getQuestionTypes().isEmpty() || + request.getQuestionTypes().contains("multiple_choice")) { + Long multipleCount = countByType("multiple_choice", request); + response.setMultipleChoiceCount(multipleCount); + } + + if (request.getQuestionTypes() == null || request.getQuestionTypes().isEmpty() || + request.getQuestionTypes().contains("true_false")) { + Long trueFalseCount = countByType("true_false", request); + response.setTrueFalseCount(trueFalseCount); + } + + if (request.getQuestionTypes() == null || request.getQuestionTypes().isEmpty() || + request.getQuestionTypes().contains("fill_blank")) { + Long fillBlankCount = countByType("fill_blank", request); + response.setFillBlankCount(fillBlankCount); + } + + if (request.getQuestionTypes() == null || request.getQuestionTypes().isEmpty() || + request.getQuestionTypes().contains("subjective")) { + Long subjectiveCount = countByType("subjective", request); + response.setSubjectiveCount(subjectiveCount); + } + + return response; + } + + private LambdaQueryWrapper buildStatsQueryWrapper(QuestionStatsRequest request) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (request.getStatus() != null && !request.getStatus().isEmpty()) { + wrapper.eq(Question::getStatus, request.getStatus()); + } + + if (request.getDeptId() != null) { + wrapper.like(Question::getKnowledgeBasePath, String.valueOf(request.getDeptId())); + } + + if (request.getFileIds() != null && !request.getFileIds().isEmpty()) { + List filePaths = getFilePathsByIds(request.getFileIds()); + if (!filePaths.isEmpty()) { + wrapper.in(Question::getKnowledgeBasePath, filePaths); + } + } + + if (request.getCollection() != null && !request.getCollection().isEmpty()) { + wrapper.like(Question::getKnowledgeBasePath, request.getCollection()); + } + + return wrapper; + } + + private Long countByType(String questionType, QuestionStatsRequest request) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Question::getQuestionType, questionType); + + if (request.getStatus() != null && !request.getStatus().isEmpty()) { + wrapper.eq(Question::getStatus, request.getStatus()); + } + + if (request.getDeptId() != null) { + wrapper.like(Question::getKnowledgeBasePath, String.valueOf(request.getDeptId())); + } + + if (request.getFileIds() != null && !request.getFileIds().isEmpty()) { + List filePaths = getFilePathsByIds(request.getFileIds()); + if (!filePaths.isEmpty()) { + wrapper.in(Question::getKnowledgeBasePath, filePaths); + } + } + + if (request.getCollection() != null && !request.getCollection().isEmpty()) { + wrapper.like(Question::getKnowledgeBasePath, request.getCollection()); + } + + return baseMapper.selectCount(wrapper); + } + + private List getFilePathsByIds(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return List.of(); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(File::getId, fileIds); + List files = fileMapper.selectList(wrapper); + + return files.stream() + .map(File::getFilePath) + .filter(p -> p != null && !p.isEmpty()) + .collect(Collectors.toList()); + } + + private void validateFile(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("文件不能为空: " + file.getOriginalFilename()); + } + + if (file.getSize() > maxFileSize) { + throw new IllegalArgumentException("文件大小超过限制: " + file.getOriginalFilename()); + } + + String originalFileName = file.getOriginalFilename(); + if (originalFileName == null || originalFileName.isEmpty()) { + throw new IllegalArgumentException("文件名不能为空"); + } + + String extension = getFileExtension(originalFileName).toLowerCase(); + List allowedExts = Arrays.stream(allowedExtensions.split(",")) + .map(String::trim) + .map(String::toLowerCase) + .collect(Collectors.toList()); + + if (!allowedExts.contains(extension)) { + throw new IllegalArgumentException("不支持的文件类型: " + extension + ",允许的类型: " + allowedExts); + } + } + + private String getFileExtension(String fileName) { + if (fileName.contains(".")) { + return fileName.substring(fileName.lastIndexOf(".")); + } + return ""; + } + + private String createDirectoryByFileName(MultipartFile file, String customFileName) throws IOException { + String dirName; + + if (customFileName != null && !customFileName.isEmpty()) { + dirName = customFileName.contains(".") + ? customFileName.substring(0, customFileName.lastIndexOf(".")) + : customFileName; + } else { + String originalFileName = file.getOriginalFilename(); + dirName = originalFileName.contains(".") + ? originalFileName.substring(0, originalFileName.lastIndexOf(".")) + : originalFileName; + } + + String baseDir = uploadBasePath + dirName; + Path dirPath = Paths.get(baseDir); + + if (Files.exists(dirPath)) { + String uniqueSuffix = UUID.randomUUID().toString().substring(0, 8); + baseDir = baseDir + "_" + uniqueSuffix; + dirPath = Paths.get(baseDir); + } + + Files.createDirectories(dirPath); + return baseDir; + } + + private String saveFileToDirectory(MultipartFile file, String directory, String customFileName) throws IOException { + Path dirPath = Paths.get(directory); + + String fileName; + if (customFileName != null && !customFileName.isEmpty()) { + fileName = customFileName; + } else { + String originalFileName = file.getOriginalFilename(); + String extension = getFileExtension(originalFileName); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uniqueId = UUID.randomUUID().toString().substring(0, 8); + fileName = timestamp + "_" + uniqueId + extension; + } + + Path filePath = dirPath.resolve(fileName); + Files.write(filePath, file.getBytes()); + return filePath.toString(); + } + + private String callAIInterface(String filePath, String user) { + StringBuilder result = new StringBuilder(); + + result.append("=== AI 处理结果 ===\n"); + result.append("用户: " + user + "\n"); + result.append("文件路径: " + filePath + "\n"); + result.append("\nAI 处理状态: 成功\n"); + result.append("说明: 这是占位返回,请替换为实际的 AI 接口调用\n"); + + return result.toString(); + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/ReportServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/ReportServiceImpl.java new file mode 100644 index 0000000..1a7a3b2 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/ReportServiceImpl.java @@ -0,0 +1,151 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.ReportResponse; +import top.tqx.demo_1.service.ReportService; +import top.tqx.demo_1.service.RagService; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class ReportServiceImpl implements ReportService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private RagService ragService; + + @Override + public ReportResponse getWeeklyReport() throws IOException { + String url = aiApiConfig.getFullUrl(aiApiConfig.getReports().getWeekly()); + log.info("调用RAG服务获取周报: url={}", url); + + try { + String jsonResponse = ragService.getRequest(url, 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + log.info("RAG服务获取周报成功"); + + return parseReportResponse(rootNode); + + } catch (Exception e) { + log.error("调用RAG服务获取周报失败: {}", e.getMessage(), e); + return new ReportResponse(); + } + } + + @Override + public ReportResponse getMonthlyReport() throws IOException { + String url = aiApiConfig.getFullUrl(aiApiConfig.getReports().getMonthly()); + log.info("调用RAG服务获取月报: url={}", url); + + try { + String jsonResponse = ragService.getRequest(url, 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + log.info("RAG服务获取月报成功"); + + return parseReportResponse(rootNode); + + } catch (Exception e) { + log.error("调用RAG服务获取月报失败: {}", e.getMessage(), e); + return new ReportResponse(); + } + } + + private ReportResponse parseReportResponse(JsonNode rootNode) { + ReportResponse response = new ReportResponse(); + response.setSuccess(rootNode.has("success") ? rootNode.get("success").asBoolean() : false); + + if (rootNode.has("report") && rootNode.get("report").isObject()) { + JsonNode reportNode = rootNode.get("report"); + ReportResponse.ReportData report = new ReportResponse.ReportData(); + + if (reportNode.has("report_type")) { + report.setReportType(reportNode.get("report_type").asText()); + } + if (reportNode.has("start_date")) { + report.setStartDate(reportNode.get("start_date").asText()); + } + if (reportNode.has("end_date")) { + report.setEndDate(reportNode.get("end_date").asText()); + } + if (reportNode.has("total_queries")) { + report.setTotalQueries(reportNode.get("total_queries").asInt()); + } + if (reportNode.has("total_feedback")) { + report.setTotalFeedback(reportNode.get("total_feedback").asInt()); + } + if (reportNode.has("positive_count")) { + report.setPositiveCount(reportNode.get("positive_count").asInt()); + } + if (reportNode.has("negative_count")) { + report.setNegativeCount(reportNode.get("negative_count").asInt()); + } + if (reportNode.has("satisfaction_rate")) { + report.setSatisfactionRate(new BigDecimal(reportNode.get("satisfaction_rate").asText())); + } + if (reportNode.has("avg_rating")) { + report.setAvgRating(new BigDecimal(reportNode.get("avg_rating").asText())); + } + if (reportNode.has("created_at")) { + report.setCreatedAt(reportNode.get("created_at").asText()); + } + + if (reportNode.has("high_freq_queries") && reportNode.get("high_freq_queries").isArray()) { + List highFreqQueries = new ArrayList<>(); + for (JsonNode queryNode : reportNode.get("high_freq_queries")) { + ReportResponse.HighFreqQueryItem item = new ReportResponse.HighFreqQueryItem(); + if (queryNode.has("query")) { + item.setQuery(queryNode.get("query").asText()); + } + if (queryNode.has("frequency")) { + item.setFrequency(queryNode.get("frequency").asInt()); + } + if (queryNode.has("sample_answer")) { + item.setSampleAnswer(queryNode.get("sample_answer").asText()); + } + highFreqQueries.add(item); + } + report.setHighFreqQueries(highFreqQueries); + } + + if (reportNode.has("low_rating_queries") && reportNode.get("low_rating_queries").isArray()) { + List lowRatingQueries = new ArrayList<>(); + for (JsonNode queryNode : reportNode.get("low_rating_queries")) { + ReportResponse.LowRatingQueryItem item = new ReportResponse.LowRatingQueryItem(); + if (queryNode.has("query")) { + item.setQuery(queryNode.get("query").asText()); + } + if (queryNode.has("rating")) { + item.setRating(queryNode.get("rating").asInt()); + } + if (queryNode.has("reason")) { + item.setReason(queryNode.get("reason").asText()); + } + lowRatingQueries.add(item); + } + report.setLowRatingQueries(lowRatingQueries); + } + + if (reportNode.has("improvement_suggestions") && reportNode.get("improvement_suggestions").isArray()) { + List suggestions = new ArrayList<>(); + for (JsonNode suggestionNode : reportNode.get("improvement_suggestions")) { + suggestions.add(suggestionNode.asText()); + } + report.setImprovementSuggestions(suggestions); + } + + response.setReport(report); + } + + return response; + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/RoleServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..840fe90 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/RoleServiceImpl.java @@ -0,0 +1,128 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.dto.RoleCreateRequest; +import top.tqx.demo_1.dto.RoleUpdateRequest; +import top.tqx.demo_1.entity.Permission; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.entity.RolePermission; +import top.tqx.demo_1.mapper.PermissionMapper; +import top.tqx.demo_1.mapper.RoleMapper; +import top.tqx.demo_1.mapper.RolePermissionMapper; +import top.tqx.demo_1.service.RoleService; +import top.tqx.demo_1.vo.PermissionVO; +import top.tqx.demo_1.vo.RoleVO; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class RoleServiceImpl extends ServiceImpl implements RoleService { + + @Autowired + private RolePermissionMapper rolePermissionMapper; + + @Autowired + private PermissionMapper permissionMapper; + + @Override + public List listRoles() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Role::getStatus, CommonConstant.STATUS_ENABLE); + wrapper.orderByAsc(Role::getRoleType, Role::getId); + + List roles = this.list(wrapper); + return roles.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createRole(RoleCreateRequest request) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Role::getRoleCode, request.getRoleCode()); + if (this.count(wrapper) > 0) { + throw new RuntimeException("角色编码已存在"); + } + + Role role = new Role(); + BeanUtils.copyProperties(request, role); + role.setStatus(CommonConstant.STATUS_ENABLE); + role.setCreateTime(LocalDateTime.now()); + + this.save(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRole(Long id, RoleUpdateRequest request) { + Role role = this.getById(id); + if (role == null) { + throw new RuntimeException("角色不存在"); + } + + BeanUtils.copyProperties(request, role, "createTime"); + this.updateById(role); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRole(Long id) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RolePermission::getRoleId, id); + rolePermissionMapper.delete(wrapper); + + this.removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignPermissions(Long roleId, List permissionIds) { + Role role = this.getById(roleId); + if (role == null) { + throw new RuntimeException("角色不存在"); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RolePermission::getRoleId, roleId); + rolePermissionMapper.delete(wrapper); + + if (permissionIds != null && !permissionIds.isEmpty()) { + for (Long permissionId : permissionIds) { + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + rolePermission.setCreateTime(LocalDateTime.now()); + rolePermissionMapper.insert(rolePermission); + } + } + } + + @Override + public List getRolePermissions(Long roleId) { + List permissions = rolePermissionMapper.selectPermissionsByRoleId(roleId); + return permissions.stream() + .map(this::convertToPermissionVO) + .collect(Collectors.toList()); + } + + private RoleVO convertToVO(Role role) { + RoleVO vo = new RoleVO(); + BeanUtils.copyProperties(role, vo); + return vo; + } + + private PermissionVO convertToPermissionVO(Permission permission) { + PermissionVO vo = new PermissionVO(); + BeanUtils.copyProperties(permission, vo); + return vo; + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/SearchServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/SearchServiceImpl.java new file mode 100644 index 0000000..361ce51 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/SearchServiceImpl.java @@ -0,0 +1,102 @@ +package top.tqx.demo_1.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import top.tqx.demo_1.config.AiApiConfig; +import top.tqx.demo_1.dto.SearchRequest; +import top.tqx.demo_1.dto.SearchResponse; +import top.tqx.demo_1.service.RagService; +import top.tqx.demo_1.service.SearchService; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class SearchServiceImpl implements SearchService { + + @Autowired + private AiApiConfig aiApiConfig; + + @Autowired + private RagService ragService; + + @Override + public SearchResponse search(SearchRequest request) throws IOException { + String url = aiApiConfig.getFullUrl(aiApiConfig.getSearch().getSearch()); + log.info("调用RAG服务搜索: url={}", url); + + try { + ObjectNode requestBody = ragService.createObjectNode(); + if (request.getQuery() != null) { + requestBody.put("query", request.getQuery()); + } + if (request.getTopK() != null) { + requestBody.put("top_k", request.getTopK()); + } + if (request.getCollections() != null && !request.getCollections().isEmpty()) { + com.fasterxml.jackson.databind.node.ArrayNode arrayNode = requestBody.putArray("collections"); + for (String collection : request.getCollections()) { + arrayNode.add(collection); + } + } + + String jsonResponse = ragService.postJsonRequest(url, requestBody.toString(), 30000); + JsonNode rootNode = ragService.parseJson(jsonResponse); + log.info("RAG服务搜索成功"); + + SearchResponse response = new SearchResponse(); + + if (rootNode.has("contexts") && rootNode.get("contexts").isArray()) { + List contexts = new ArrayList<>(); + for (JsonNode contextNode : rootNode.get("contexts")) { + contexts.add(contextNode.asText()); + } + response.setContexts(contexts); + } + + if (rootNode.has("metadatas") && rootNode.get("metadatas").isArray()) { + List> metadatas = new ArrayList<>(); + for (JsonNode metadataNode : rootNode.get("metadatas")) { + Map metadata = new HashMap<>(); + metadataNode.fieldNames().forEachRemaining(key -> { + JsonNode value = metadataNode.get(key); + if (value.isInt()) { + metadata.put(key, value.asInt()); + } else if (value.isLong()) { + metadata.put(key, value.asLong()); + } else if (value.isDouble()) { + metadata.put(key, value.asDouble()); + } else if (value.isBoolean()) { + metadata.put(key, value.asBoolean()); + } else { + metadata.put(key, value.asText()); + } + }); + metadatas.add(metadata); + } + response.setMetadatas(metadatas); + } + + if (rootNode.has("scores") && rootNode.get("scores").isArray()) { + List scores = new ArrayList<>(); + for (JsonNode scoreNode : rootNode.get("scores")) { + scores.add(scoreNode.asDouble()); + } + response.setScores(scores); + } + + return response; + + } catch (Exception e) { + log.error("调用RAG服务搜索失败: {}", e.getMessage(), e); + return new SearchResponse(); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/UserAnswerServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/UserAnswerServiceImpl.java new file mode 100644 index 0000000..1786c6c --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/UserAnswerServiceImpl.java @@ -0,0 +1,687 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.dto.*; +import top.tqx.demo_1.entity.File; +import top.tqx.demo_1.entity.Question; +import top.tqx.demo_1.entity.UserAnswer; +import top.tqx.demo_1.entity.WrongQuestionCollection; +import top.tqx.demo_1.mapper.FileMapper; +import top.tqx.demo_1.mapper.QuestionMapper; +import top.tqx.demo_1.mapper.UserAnswerMapper; +import top.tqx.demo_1.mapper.WrongQuestionCollectionMapper; +import top.tqx.demo_1.service.UserAnswerService; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class UserAnswerServiceImpl implements UserAnswerService { + + @Autowired + private UserAnswerMapper userAnswerMapper; + + @Autowired + private QuestionMapper questionMapper; + + @Autowired + private FileMapper fileMapper; + + @Autowired + private WrongQuestionCollectionMapper wrongQuestionCollectionMapper; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public boolean saveAnswer(UserAnswer userAnswer) { + try { + int result = userAnswerMapper.insert(userAnswer); + if (result > 0) { + log.info("用户答题记录保存成功: userId={}, questionId={}", + userAnswer.getUserId(), userAnswer.getQuestionId()); + return true; + } + log.warn("用户答题记录保存失败: userId={}, questionId={}", + userAnswer.getUserId(), userAnswer.getQuestionId()); + return false; + } catch (Exception e) { + log.error("用户答题记录保存异常: userId={}, questionId={}, error={}", + userAnswer.getUserId(), userAnswer.getQuestionId(), e.getMessage(), e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void batchSaveAnswers(List userAnswers) { + if (CollectionUtils.isEmpty(userAnswers)) { + return; + } + + log.info("开始批量保存答题记录: count={}", userAnswers.size()); + + for (UserAnswer ua : userAnswers) { + // 先检查是否已存在该答题记录 + UserAnswer existing = userAnswerMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserAnswer::getUserId, ua.getUserId()) + .eq(UserAnswer::getQuestionId, ua.getQuestionId()) + .eq(UserAnswer::getPaperId, ua.getPaperId()) + .eq(UserAnswer::getAnswerType, ua.getAnswerType())); + + if (existing != null) { + // 如果是正式考试,不能重复答题 + if ("formal_exam".equals(ua.getAnswerType())) { + throw new RuntimeException("正式考试题目已答题,不能重复答题"); + } + // 如果是模拟考或日常练习,更新答题记录 + ua.setId(existing.getId()); + ua.setCreatedAt(existing.getCreatedAt()); // 保持创建时间 + userAnswerMapper.updateById(ua); + } else { + // 不存在则插入新记录 + userAnswerMapper.insert(ua); + } + } + + log.info("批量保存答题记录完成: count={}", userAnswers.size()); + } + + @Override + public UserAnswerQueryResponse queryUserAnswers(UserAnswerQueryRequest request) { + UserAnswerQueryResponse response = new UserAnswerQueryResponse(); + response.setSuccess(true); + response.setGroups(new ArrayList<>()); + response.setTotalCount(0); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + + if (request.getUserId() != null) { + queryWrapper.eq(UserAnswer::getUserId, request.getUserId()); + } + + if (request.getPaperId() != null && !request.getPaperId().isEmpty()) { + queryWrapper.eq(UserAnswer::getPaperId, request.getPaperId()); + } else if (request.getSessionId() != null && !request.getSessionId().isEmpty()) { + queryWrapper.eq(UserAnswer::getSessionId, request.getSessionId()); + } + + List userAnswers = userAnswerMapper.selectList(queryWrapper); + if (CollectionUtils.isEmpty(userAnswers)) { + log.info("未找到答题记录: paperId={}, sessionId={}, userId={}", + request.getPaperId(), request.getSessionId(), request.getUserId()); + return response; + } + + Map> groupedAnswers; + String groupType; + + if (request.getPaperId() != null && !request.getPaperId().isEmpty()) { + groupedAnswers = userAnswers.stream() + .collect(Collectors.groupingBy(a -> a.getPaperId() != null ? a.getPaperId() : "unknown")); + groupType = "exam"; + } else { + groupedAnswers = userAnswers.stream() + .collect(Collectors.groupingBy(a -> a.getSessionId() != null ? a.getSessionId() : "unknown")); + groupType = "practice"; + } + + Map questionMap = loadQuestions(userAnswers); + Map fileMap = loadFiles(questionMap.values()); + + for (Map.Entry> entry : groupedAnswers.entrySet()) { + String groupId = entry.getKey(); + List answers = entry.getValue(); + + UserAnswerQueryResponse.AnswerGroup group = new UserAnswerQueryResponse.AnswerGroup(); + group.setGroupType(groupType); + group.setGroupId(groupId); + group.setGroupName(buildGroupName(groupType, groupId)); + group.setCreatedAt(findEarliestCreatedAt(answers)); + + List answerDetails = new ArrayList<>(); + BigDecimal totalScore = BigDecimal.ZERO; + BigDecimal totalMaxScore = BigDecimal.ZERO; + int correctCount = 0; + + for (UserAnswer answer : answers) { + UserAnswerQueryResponse.AnswerDetail detail = buildAnswerDetail(answer, questionMap, fileMap); + answerDetails.add(detail); + + if (answer.getScore() != null) { + totalScore = totalScore.add(answer.getScore()); + } + if (answer.getMaxScore() != null) { + totalMaxScore = totalMaxScore.add(answer.getMaxScore()); + } + if (answer.getIsCorrect() != null && answer.getIsCorrect() == 1) { + correctCount++; + } + } + + group.setAnswers(answerDetails); + group.setAnswerCount(answerDetails.size()); + group.setCorrectCount(correctCount); + group.setTotalScore(totalScore); + group.setTotalMaxScore(totalMaxScore); + + if (totalMaxScore.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal scoreRate = totalScore.multiply(BigDecimal.valueOf(100)) + .divide(totalMaxScore, 2, RoundingMode.HALF_UP); + group.setScoreRate(scoreRate); + } else { + group.setScoreRate(BigDecimal.ZERO); + } + + response.getGroups().add(group); + } + + response.setTotalCount(userAnswers.size()); + log.info("查询答题记录成功: totalCount={}, groups={}", response.getTotalCount(), response.getGroups().size()); + + return response; + } + + private Map loadQuestions(List answers) { + Set questionIds = answers.stream() + .map(UserAnswer::getQuestionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (CollectionUtils.isEmpty(questionIds)) { + return Collections.emptyMap(); + } + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(Question::getQuestionId, questionIds); + List questions = questionMapper.selectList(queryWrapper); + + return questions.stream() + .collect(Collectors.toMap(Question::getQuestionId, q -> q)); + } + + private Map loadFiles(Collection questions) { + Set filePaths = questions.stream() + .map(Question::getKnowledgeBasePath) + .filter(p -> p != null && !p.isEmpty()) + .collect(Collectors.toSet()); + + if (CollectionUtils.isEmpty(filePaths)) { + return Collections.emptyMap(); + } + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(File::getFilePath, filePaths); + List files = fileMapper.selectList(queryWrapper); + + return files.stream() + .collect(Collectors.toMap(File::getFilePath, f -> f)); + } + + private UserAnswerQueryResponse.AnswerDetail buildAnswerDetail( + UserAnswer answer, Map questionMap, Map fileMap) { + + UserAnswerQueryResponse.AnswerDetail detail = new UserAnswerQueryResponse.AnswerDetail(); + detail.setAnswerId(answer.getId()); + detail.setQuestionId(answer.getQuestionId()); + detail.setQuestionType(answer.getQuestionType()); + detail.setQuestionTypeName(mapQuestionTypeName(answer.getQuestionType())); + detail.setScore(answer.getScore()); + detail.setMaxScore(answer.getMaxScore()); + detail.setIsCorrect(answer.getIsCorrect()); + detail.setIsCorrectText(mapIsCorrectText(answer.getIsCorrect())); + detail.setFeedback(answer.getFeedback()); + detail.setGradingType(answer.getGradingType()); + detail.setTimeSpent(answer.getTimeSpent()); + detail.setAnsweredAt(answer.getCreatedAt()); + + if (answer.getUserAnswer() != null) { + try { + detail.setUserAnswer(objectMapper.treeToValue(answer.getUserAnswer(), Object.class)); + } catch (Exception e) { + log.warn("解析用户答案失败: {}", e.getMessage()); + } + } + + Question question = questionMap.get(answer.getQuestionId()); + if (question != null) { + detail.setKnowledgeBasePath(question.getKnowledgeBasePath()); + + if (question.getChunkIds() != null && !question.getChunkIds().isNull()) { + try { + List chunkIds = objectMapper.convertValue( + question.getChunkIds(), new TypeReference>() {}); + detail.setChunkIds(chunkIds); + } catch (Exception e) { + log.warn("解析chunk_ids失败: {}", e.getMessage()); + } + } + + if (question.getPageNumbers() != null && !question.getPageNumbers().isNull()) { + try { + List pageNumbers = objectMapper.convertValue( + question.getPageNumbers(), new TypeReference>() {}); + detail.setPageNumbers(pageNumbers); + } catch (Exception e) { + log.warn("解析page_numbers失败: {}", e.getMessage()); + } + } + + File file = fileMap.get(question.getKnowledgeBasePath()); + if (file != null) { + detail.setDocumentName(file.getFileName()); + } + } + + return detail; + } + + private String mapQuestionTypeName(String questionType) { + if (questionType == null) { + return "未知"; + } + return switch (questionType) { + case "single_choice" -> "单选题"; + case "multiple_choice" -> "多选题"; + case "true_false" -> "判断题"; + case "fill_blank" -> "填空题"; + case "subjective" -> "简答题"; + default -> questionType; + }; + } + + private String mapIsCorrectText(Integer isCorrect) { + if (isCorrect == null) { + return "未批改"; + } + return switch (isCorrect) { + case 0 -> "错误"; + case 1 -> "正确"; + case 2 -> "部分正确"; + default -> "未知"; + }; + } + + private String buildGroupName(String groupType, String groupId) { + if ("exam".equals(groupType)) { + return "试卷 - " + groupId; + } else { + return "练习 - " + groupId; + } + } + + private LocalDateTime findEarliestCreatedAt(List answers) { + return answers.stream() + .map(UserAnswer::getCreatedAt) + .filter(Objects::nonNull) + .min(LocalDateTime::compareTo) + .orElse(null); + } + + @Override + public WrongQuestionListResponse getWrongQuestions(Long userId, WrongQuestionListRequest request) { + WrongQuestionListResponse response = new WrongQuestionListResponse(); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + queryWrapper.and(wrapper -> wrapper.eq(UserAnswer::getIsCorrect, 0).or().eq(UserAnswer::getIsCorrect, 2)); + + if (request.getQuestionType() != null && !request.getQuestionType().isEmpty()) { + queryWrapper.eq(UserAnswer::getQuestionType, request.getQuestionType()); + } + + if (request.getStartTime() != null) { + queryWrapper.ge(UserAnswer::getCreatedAt, request.getStartTime()); + } + + if (request.getEndTime() != null) { + queryWrapper.le(UserAnswer::getCreatedAt, request.getEndTime()); + } + + if (request.getKnowledgeBasePath() != null && !request.getKnowledgeBasePath().isEmpty()) { + Set questionIds = getQuestionIdsByKnowledgeBasePath(request.getKnowledgeBasePath()); + if (questionIds.isEmpty()) { + response.setPage(new PageResult(new ArrayList<>(), 0L, request.getPage().longValue(), request.getPageSize().longValue())); + response.setStats(new WrongQuestionListResponse.WrongQuestionStats()); + return response; + } + queryWrapper.in(UserAnswer::getQuestionId, questionIds); + } + + queryWrapper.orderByDesc(UserAnswer::getCreatedAt); + + Page page = new Page<>(request.getPage(), request.getPageSize()); + IPage iPage = userAnswerMapper.selectPage(page, queryWrapper); + + List wrongAnswers = iPage.getRecords(); + List items = new ArrayList<>(); + + Map questionMap = loadQuestions(wrongAnswers); + Map fileMap = loadFiles(questionMap.values()); + Set collectedAnswerIds = getCollectedAnswerIds(userId); + Map wrongCountMap = getWrongCountMap(userId); + + for (UserAnswer answer : wrongAnswers) { + WrongQuestionListResponse.WrongQuestionItem item = buildWrongQuestionItem( + answer, questionMap, fileMap, collectedAnswerIds, wrongCountMap); + items.add(item); + } + + if (request.getIsCollected() != null) { + if (request.getIsCollected()) { + items = items.stream().filter(WrongQuestionListResponse.WrongQuestionItem::getIsCollected).collect(Collectors.toList()); + } else { + items = items.stream().filter(item -> !item.getIsCollected()).collect(Collectors.toList()); + } + } + + PageResult pageResult = new PageResult<>( + items, iPage.getTotal(), request.getPage().longValue(), request.getPageSize().longValue()); + response.setPage(pageResult); + response.setStats(calculateStats(userId)); + + log.info("查询错题列表成功: userId={}, total={}", userId, iPage.getTotal()); + return response; + } + + @Override + @Transactional + public boolean toggleWrongQuestionCollection(Long userId, WrongQuestionCollectRequest request) { + // 修复查询逻辑:分别处理answerId和questionId + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(WrongQuestionCollection::getUserId, userId); + + if (request.getAnswerId() != null) { + queryWrapper.eq(WrongQuestionCollection::getAnswerId, request.getAnswerId()); + // 如果同时有questionId,也添加条件 + if (request.getQuestionId() != null) { + queryWrapper.eq(WrongQuestionCollection::getQuestionId, request.getQuestionId()); + } + } else if (request.getQuestionId() != null) { + // 仅通过questionId查询 + queryWrapper.eq(WrongQuestionCollection::getQuestionId, request.getQuestionId()); + } else { + return false; + } + + WrongQuestionCollection existing = wrongQuestionCollectionMapper.selectOne(queryWrapper); + + if (Boolean.TRUE.equals(request.getIsCollected())) { + if (existing != null) { + // 已存在,更新备注 + existing.setNote(request.getNote()); + existing.setUpdatedAt(LocalDateTime.now()); + return wrongQuestionCollectionMapper.updateById(existing) > 0; + } else { + // 不存在,新增收藏 + WrongQuestionCollection collection = new WrongQuestionCollection(); + collection.setUserId(userId); + collection.setAnswerId(request.getAnswerId()); + collection.setQuestionId(request.getQuestionId()); + collection.setNote(request.getNote()); + collection.setCreatedAt(LocalDateTime.now()); + collection.setUpdatedAt(LocalDateTime.now()); + return wrongQuestionCollectionMapper.insert(collection) > 0; + } + } else { + // 取消收藏 + if (existing != null) { + return wrongQuestionCollectionMapper.deleteById(existing.getId()) > 0; + } + return true; // 不存在也算成功 + } + } + + @Override + public ExamPaperGenerateResponse redoWrongQuestions(Long userId, WrongQuestionRedoRequest request) { + ExamPaperGenerateResponse response = new ExamPaperGenerateResponse(); + + List wrongAnswers; + if (request.getAnswerIds() != null && !request.getAnswerIds().isEmpty()) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + queryWrapper.in(UserAnswer::getId, request.getAnswerIds()); + wrongAnswers = userAnswerMapper.selectList(queryWrapper); + } else { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + queryWrapper.and(wrapper -> wrapper.eq(UserAnswer::getIsCorrect, 0).or().eq(UserAnswer::getIsCorrect, 2)); + + if (request.getQuestionType() != null && !request.getQuestionType().isEmpty()) { + queryWrapper.eq(UserAnswer::getQuestionType, request.getQuestionType()); + } + + if (Boolean.TRUE.equals(request.getIsCollected())) { + Set collectedAnswerIds = getCollectedAnswerIds(userId); + if (!collectedAnswerIds.isEmpty()) { + queryWrapper.in(UserAnswer::getId, collectedAnswerIds); + } + } + + queryWrapper.orderByDesc(UserAnswer::getCreatedAt); + wrongAnswers = userAnswerMapper.selectList(queryWrapper); + } + + Set usedQuestionIds = new HashSet<>(); + List questions = new ArrayList<>(); + + int count = 0; + for (UserAnswer answer : wrongAnswers) { + if (count >= request.getQuestionCount()) break; + if (usedQuestionIds.contains(answer.getQuestionId())) continue; + + Question question = questionMapper.selectOne( + new LambdaQueryWrapper() + .eq(Question::getQuestionId, answer.getQuestionId()) + ); + + if (question != null) { + ExamPaperGenerateResponse.ExamPaperQuestion examQuestion = new ExamPaperGenerateResponse.ExamPaperQuestion(); + examQuestion.setQuestionId(question.getQuestionId()); + examQuestion.setQuestionType(question.getQuestionType()); + examQuestion.setQuestionTypeName(mapQuestionTypeName(question.getQuestionType())); + examQuestion.setDifficulty(question.getDifficulty()); + examQuestion.setScore(question.getScore()); + examQuestion.setContent(question.getContent()); + examQuestion.setDocumentName(question.getDocumentName()); + examQuestion.setKnowledgeBasePath(question.getKnowledgeBasePath()); + questions.add(examQuestion); + usedQuestionIds.add(answer.getQuestionId()); + count++; + } + } + + response.setPaperId("WRONG_REDO_" + System.currentTimeMillis()); + response.setPaperTitle("错题重练 - " + LocalDateTime.now().toString()); + response.setQuestions(questions); + response.setQuestionCount(questions.size()); + response.setTotalScore(questions.stream() + .map(ExamPaperGenerateResponse.ExamPaperQuestion::getScore) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + response.setGeneratedAt(LocalDateTime.now()); + + log.info("生成错题重练试卷成功: userId={}, questionCount={}", userId, questions.size()); + return response; + } + + @Override + public List exportWrongQuestions(Long userId, WrongQuestionListRequest request) { + request.setPage(1); + request.setPageSize(10000); + WrongQuestionListResponse response = getWrongQuestions(userId, request); + return response.getPage().getRecords(); + } + + @Override + public boolean checkQuestionCollected(Long userId, String questionId) { + if (userId == null || questionId == null || questionId.isEmpty()) { + return false; + } + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(WrongQuestionCollection::getUserId, userId); + queryWrapper.eq(WrongQuestionCollection::getQuestionId, questionId); + + Long count = wrongQuestionCollectionMapper.selectCount(queryWrapper); + return count > 0; + } + + private WrongQuestionListResponse.WrongQuestionItem buildWrongQuestionItem( + UserAnswer answer, Map questionMap, + Map fileMap, Set collectedAnswerIds, + Map wrongCountMap) { + + WrongQuestionListResponse.WrongQuestionItem item = new WrongQuestionListResponse.WrongQuestionItem(); + item.setAnswerId(answer.getId()); + item.setQuestionId(answer.getQuestionId()); + item.setQuestionType(answer.getQuestionType()); + item.setQuestionTypeName(mapQuestionTypeName(answer.getQuestionType())); + item.setScore(answer.getScore()); + item.setMaxScore(answer.getMaxScore()); + item.setFeedback(answer.getFeedback()); + item.setAnsweredAt(answer.getCreatedAt()); + // 通过answerId判断是否收藏 + item.setIsCollected(collectedAnswerIds.contains(answer.getId())); + item.setWrongCount(wrongCountMap.getOrDefault(answer.getQuestionId(), 1L).intValue()); + + if (answer.getUserAnswer() != null) { + try { + item.setUserAnswer(objectMapper.treeToValue(answer.getUserAnswer(), Object.class)); + } catch (Exception e) { + log.warn("解析用户答案失败: {}", e.getMessage()); + } + } + + Question question = questionMap.get(answer.getQuestionId()); + if (question != null) { + item.setQuestionContent(question.getContent()); + item.setDifficulty(question.getDifficulty()); + item.setKnowledgeBasePath(question.getKnowledgeBasePath()); + + if (question.getContent() != null) { + try { + JsonNode contentNode = question.getContent(); + if (contentNode.has("answer")) { + item.setCorrectAnswer(contentNode.get("answer")); + } else if (contentNode.has("correctAnswer")) { + item.setCorrectAnswer(contentNode.get("correctAnswer")); + } + } catch (Exception e) { + log.warn("解析正确答案失败: {}", e.getMessage()); + } + } + + if (question.getTags() != null && !question.getTags().isNull()) { + try { + List tags = objectMapper.convertValue( + question.getTags(), new TypeReference>() {}); + item.setTags(tags); + } catch (Exception e) { + log.warn("解析tags失败: {}", e.getMessage()); + } + } + + File file = fileMap.get(question.getKnowledgeBasePath()); + if (file != null) { + item.setDocumentName(file.getFileName()); + } + } + + return item; + } + + private WrongQuestionListResponse.WrongQuestionStats calculateStats(Long userId) { + WrongQuestionListResponse.WrongQuestionStats stats = new WrongQuestionListResponse.WrongQuestionStats(); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + queryWrapper.and(wrapper -> wrapper.eq(UserAnswer::getIsCorrect, 0).or().eq(UserAnswer::getIsCorrect, 2)); + List allWrongAnswers = userAnswerMapper.selectList(queryWrapper); + + int singleChoiceCount = 0, multipleChoiceCount = 0, trueFalseCount = 0; + int fillBlankCount = 0, subjectiveCount = 0; + + for (UserAnswer answer : allWrongAnswers) { + switch (answer.getQuestionType()) { + case "single_choice" -> singleChoiceCount++; + case "multiple_choice" -> multipleChoiceCount++; + case "true_false" -> trueFalseCount++; + case "fill_blank" -> fillBlankCount++; + case "subjective" -> subjectiveCount++; + } + } + + stats.setTotalWrongCount(allWrongAnswers.size()); + stats.setSingleChoiceCount(singleChoiceCount); + stats.setMultipleChoiceCount(multipleChoiceCount); + stats.setTrueFalseCount(trueFalseCount); + stats.setFillBlankCount(fillBlankCount); + stats.setSubjectiveCount(subjectiveCount); + + LambdaQueryWrapper collectionWrapper = new LambdaQueryWrapper<>(); + collectionWrapper.eq(WrongQuestionCollection::getUserId, userId); + stats.setCollectedCount(Math.toIntExact(wrongQuestionCollectionMapper.selectCount(collectionWrapper))); + + return stats; + } + + private Set getCollectedAnswerIds(Long userId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(WrongQuestionCollection::getUserId, userId); + queryWrapper.isNotNull(WrongQuestionCollection::getAnswerId); + List collections = wrongQuestionCollectionMapper.selectList(queryWrapper); + return collections.stream() + .map(WrongQuestionCollection::getAnswerId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + // 新增:获取收藏的questionId集合 + private Set getCollectedQuestionIds(Long userId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(WrongQuestionCollection::getUserId, userId); + queryWrapper.isNotNull(WrongQuestionCollection::getQuestionId); + List collections = wrongQuestionCollectionMapper.selectList(queryWrapper); + return collections.stream() + .map(WrongQuestionCollection::getQuestionId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private Map getWrongCountMap(Long userId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + queryWrapper.and(wrapper -> wrapper.eq(UserAnswer::getIsCorrect, 0).or().eq(UserAnswer::getIsCorrect, 2)); + List wrongAnswers = userAnswerMapper.selectList(queryWrapper); + + return wrongAnswers.stream() + .collect(Collectors.groupingBy( + UserAnswer::getQuestionId, + Collectors.counting() + )); + } + + private Set getQuestionIdsByKnowledgeBasePath(String knowledgeBasePath) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Question::getKnowledgeBasePath, knowledgeBasePath); + List questions = questionMapper.selectList(queryWrapper); + return questions.stream() + .map(Question::getQuestionId) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/UserServiceImpl.java b/src/main/java/top/tqx/demo_1/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..b410e78 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/UserServiceImpl.java @@ -0,0 +1,183 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.tqx.demo_1.common.PageResult; +import top.tqx.demo_1.constant.CommonConstant; +import top.tqx.demo_1.dto.UserCreateRequest; +import top.tqx.demo_1.dto.UserUpdateRequest; +import top.tqx.demo_1.entity.Department; +import top.tqx.demo_1.entity.Role; +import top.tqx.demo_1.entity.User; +import top.tqx.demo_1.entity.UserRole; +import top.tqx.demo_1.mapper.DepartmentMapper; +import top.tqx.demo_1.mapper.UserMapper; +import top.tqx.demo_1.mapper.UserRoleMapper; +import top.tqx.demo_1.service.UserService; +import top.tqx.demo_1.vo.UserVO; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class UserServiceImpl extends ServiceImpl implements UserService { + + @Autowired + private UserRoleMapper userRoleMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Override + public PageResult pageUsers(Integer pageNum, Integer pageSize, String username, Long deptId) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (username != null && !username.isEmpty()) { + wrapper.like(User::getUsername, username); + } + if (deptId != null) { + wrapper.eq(User::getDeptId, deptId); + } + wrapper.orderByDesc(User::getCreateTime); + + Page userPage = this.page(page, wrapper); + + List voList = userPage.getRecords().stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + + return PageResult.of(voList, userPage.getTotal(), userPage.getSize(), userPage.getCurrent()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createUser(UserCreateRequest request) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getUsername, request.getUsername()); + if (this.count(wrapper) > 0) { + throw new RuntimeException("用户名已存在"); + } + + User user = new User(); + BeanUtils.copyProperties(request, user); + + String encryptedPassword = passwordEncoder.encode(CommonConstant.DEFAULT_PASSWORD); + user.setPassword(encryptedPassword); + + user.setStatus(CommonConstant.STATUS_ENABLE); + user.setCreateTime(LocalDateTime.now()); + + this.save(user); + + if (request.getRoleIds() != null && !request.getRoleIds().isEmpty()) { + assignRoles(user.getId(), request.getRoleIds()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateUser(UserUpdateRequest request) { + User user = this.getById(request.getId()); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + BeanUtils.copyProperties(request, user, "password", "createTime"); + user.setUpdateTime(LocalDateTime.now()); + + this.updateById(user); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteUser(Long id) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserRole::getUserId, id); + userRoleMapper.delete(wrapper); + + this.removeById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetPassword(Long id) { + User user = this.getById(id); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + user.setPassword(passwordEncoder.encode(CommonConstant.DEFAULT_PASSWORD)); + user.setUpdateTime(LocalDateTime.now()); + this.updateById(user); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + User user = this.getById(id); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + user.setStatus(status); + user.setUpdateTime(LocalDateTime.now()); + this.updateById(user); + } + + @Override + public UserVO getUserById(Long id) { + User user = this.getById(id); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + return convertToVO(user); + } + + private UserVO convertToVO(User user) { + UserVO vo = new UserVO(); + BeanUtils.copyProperties(user, vo); + + if (user.getDeptId() != null) { + Department dept = departmentMapper.selectById(user.getDeptId()); + if (dept != null) { + vo.setDeptName(dept.getDeptName()); + } + } + + List roles = userRoleMapper.selectRolesByUserId(user.getId()); + List roleNames = roles.stream() + .map(Role::getRoleName) + .collect(Collectors.toList()); + vo.setRoles(roleNames); + + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignRoles(Long userId, List roleIds) { + LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); + deleteWrapper.eq(UserRole::getUserId, userId); + userRoleMapper.delete(deleteWrapper); + + if (roleIds != null && !roleIds.isEmpty()) { + for (Long roleId : roleIds) { + UserRole userRole = new UserRole(); + userRole.setUserId(userId); + userRole.setRoleId(roleId); + userRole.setCreateTime(LocalDateTime.now()); + userRoleMapper.insert(userRole); + } + } + } +} diff --git a/src/main/java/top/tqx/demo_1/service/impl/WrongQuestionSyncValidator.java b/src/main/java/top/tqx/demo_1/service/impl/WrongQuestionSyncValidator.java new file mode 100644 index 0000000..01c92ce --- /dev/null +++ b/src/main/java/top/tqx/demo_1/service/impl/WrongQuestionSyncValidator.java @@ -0,0 +1,352 @@ +package top.tqx.demo_1.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import top.tqx.demo_1.entity.UserAnswer; +import top.tqx.demo_1.entity.WrongQuestionCollection; +import top.tqx.demo_1.mapper.UserAnswerMapper; +import top.tqx.demo_1.mapper.WrongQuestionCollectionMapper; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class WrongQuestionSyncValidator { + + @Autowired + private UserAnswerMapper userAnswerMapper; + + @Autowired + private WrongQuestionCollectionMapper wrongQuestionCollectionMapper; + + public ValidationResult validateSyncIntegrity(Long userId, String paperId, String sessionId) { + ValidationResult result = new ValidationResult(); + result.setUserId(userId); + result.setPaperId(paperId); + result.setSessionId(sessionId); + result.setValidationTime(LocalDateTime.now()); + + List issues = new ArrayList<>(); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(UserAnswer::getUserId, userId); + + if (paperId != null && !paperId.isEmpty()) { + queryWrapper.eq(UserAnswer::getPaperId, paperId); + } + if (sessionId != null && !sessionId.isEmpty()) { + queryWrapper.eq(UserAnswer::getSessionId, sessionId); + } + + List userAnswers = userAnswerMapper.selectList(queryWrapper); + + if (userAnswers.isEmpty()) { + issues.add(new ValidationIssue("NO_ANSWERS", "未找到用户答题记录", "warn")); + result.setIssues(issues); + return result; + } + + int totalCount = userAnswers.size(); + int correctCount = 0; + int wrongCount = 0; + int partialCount = 0; + int unknownCount = 0; + + Set paperIds = new HashSet<>(); + Set sessionIds = new HashSet<>(); + Set questionIds = new HashSet<>(); + + List missingQuestionIds = new ArrayList<>(); + List missingPaperIds = new ArrayList<>(); + List missingSessionIds = new ArrayList<>(); + + for (UserAnswer answer : userAnswers) { + paperIds.add(answer.getPaperId()); + sessionIds.add(answer.getSessionId()); + questionIds.add(answer.getQuestionId()); + + if (answer.getQuestionId() == null || answer.getQuestionId().isEmpty()) { + missingQuestionIds.add(String.valueOf(answer.getId())); + } + + if (answer.getPaperId() == null || answer.getPaperId().isEmpty()) { + missingPaperIds.add(String.valueOf(answer.getId())); + } + + if (answer.getSessionId() == null || answer.getSessionId().isEmpty()) { + missingSessionIds.add(String.valueOf(answer.getId())); + } + + Integer isCorrect = answer.getIsCorrect(); + if (isCorrect == 1) { + correctCount++; + } else if (isCorrect == 0) { + wrongCount++; + } else if (isCorrect == 2) { + partialCount++; + } else { + unknownCount++; + issues.add(new ValidationIssue("UNKNOWN_IS_CORRECT", + "答题记录ID " + answer.getId() + " 的isCorrect值未知: " + isCorrect, "warn")); + } + } + + if (!missingQuestionIds.isEmpty()) { + issues.add(new ValidationIssue("MISSING_QUESTION_ID", + "存在缺少question_id的答题记录: " + missingQuestionIds.size() + "条", "error")); + } + + if (!missingPaperIds.isEmpty()) { + issues.add(new ValidationIssue("MISSING_PAPER_ID", + "存在缺少paper_id的答题记录: " + missingPaperIds.size() + "条", "warn")); + } + + if (!missingSessionIds.isEmpty()) { + issues.add(new ValidationIssue("MISSING_SESSION_ID", + "存在缺少session_id的答题记录: " + missingSessionIds.size() + "条", "warn")); + } + + if (unknownCount > 0) { + issues.add(new ValidationIssue("INVALID_IS_CORRECT", + "存在isCorrect值无效的答题记录: " + unknownCount + "条", "error")); + } + + LambdaQueryWrapper wrongQuery = new LambdaQueryWrapper<>(); + wrongQuery.eq(UserAnswer::getUserId, userId); + wrongQuery.and(w -> w.eq(UserAnswer::getIsCorrect, 0).or().eq(UserAnswer::getIsCorrect, 2)); + if (paperId != null && !paperId.isEmpty()) { + wrongQuery.eq(UserAnswer::getPaperId, paperId); + } + int expectedWrongCount = userAnswerMapper.selectCount(wrongQuery).intValue(); + + if (wrongCount + partialCount != expectedWrongCount) { + issues.add(new ValidationIssue("WRONG_COUNT_MISMATCH", + "错题统计不一致: 计算值=" + (wrongCount + partialCount) + ", 查询值=" + expectedWrongCount, "error")); + } + + LambdaQueryWrapper collectionQuery = new LambdaQueryWrapper<>(); + collectionQuery.eq(WrongQuestionCollection::getUserId, userId); + List collections = wrongQuestionCollectionMapper.selectList(collectionQuery); + + Set collectedAnswerIds = collections.stream() + .map(WrongQuestionCollection::getAnswerId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Set answerIds = userAnswers.stream() + .map(UserAnswer::getId) + .collect(Collectors.toSet()); + + for (Long collectedId : collectedAnswerIds) { + if (!answerIds.contains(collectedId)) { + issues.add(new ValidationIssue("STALE_COLLECTION", + "收藏记录引用了不存在的答题记录: answerId=" + collectedId, "error")); + } + } + + result.setTotalAnswers(totalCount); + result.setCorrectCount(correctCount); + result.setWrongCount(wrongCount); + result.setPartialCount(partialCount); + result.setUnknownCount(unknownCount); + result.setCollectedCount(collections.size()); + result.setUniquePaperCount(paperIds.size()); + result.setUniqueSessionCount(sessionIds.size()); + result.setUniqueQuestionCount(questionIds.size()); + result.setIssues(issues); + result.setSuccess(issues.stream().noneMatch(i -> "error".equals(i.getSeverity()))); + + log.info("数据同步验证完成: userId={}, total={}, correct={}, wrong={}, partial={}, success={}", + userId, totalCount, correctCount, wrongCount, partialCount, result.isSuccess()); + + return result; + } + + public ValidationResult validateAllUsers(int pageSize) { + ValidationResult result = new ValidationResult(); + result.setValidationTime(LocalDateTime.now()); + result.setIssues(new ArrayList<>()); + + LambdaQueryWrapper userQuery = new LambdaQueryWrapper<>(); + userQuery.select(UserAnswer::getUserId); + userQuery.groupBy(UserAnswer::getUserId); + + List userIds = userAnswerMapper.selectObjs(userQuery); + + int totalUsers = userIds.size(); + int failedUsers = 0; + int totalAnswers = 0; + int totalWrong = 0; + + for (Object userIdObj : userIds) { + Long userId = ((Number) userIdObj).longValue(); + ValidationResult userResult = validateSyncIntegrity(userId, null, null); + + totalAnswers += userResult.getTotalAnswers(); + totalWrong += userResult.getWrongCount() + userResult.getPartialCount(); + + if (!userResult.isSuccess()) { + failedUsers++; + for (ValidationIssue issue : userResult.getIssues()) { + if ("error".equals(issue.getSeverity())) { + result.getIssues().add(new ValidationIssue( + issue.getCode(), + "用户 " + userId + ": " + issue.getMessage(), + issue.getSeverity() + )); + } + } + } + } + + result.setTotalAnswers(totalAnswers); + result.setWrongCount(totalWrong); + result.setSuccess(failedUsers == 0); + result.setMessage(String.format("共验证 %d 个用户, %d 条答题记录, %d 条错题, 失败 %d 个用户", + totalUsers, totalAnswers, totalWrong, failedUsers)); + + log.info("全量数据同步验证完成: users={}, totalAnswers={}, totalWrong={}, failedUsers={}", + totalUsers, totalAnswers, totalWrong, failedUsers); + + return result; + } + + public ValidationResult validateRecentAnswers(LocalDateTime startTime) { + ValidationResult result = new ValidationResult(); + result.setValidationTime(LocalDateTime.now()); + result.setIssues(new ArrayList<>()); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.ge(UserAnswer::getCreatedAt, startTime); + + Page page = new Page<>(1, 1000); + IPage iPage = userAnswerMapper.selectPage(page, queryWrapper); + + List recentAnswers = iPage.getRecords(); + + int missingPaperId = 0; + int missingSessionId = 0; + int invalidIsCorrect = 0; + + for (UserAnswer answer : recentAnswers) { + if (answer.getPaperId() == null || answer.getPaperId().isEmpty()) { + missingPaperId++; + } + if (answer.getSessionId() == null || answer.getSessionId().isEmpty()) { + missingSessionId++; + } + Integer isCorrect = answer.getIsCorrect(); + if (isCorrect == null || isCorrect < 0 || isCorrect > 2) { + invalidIsCorrect++; + } + } + + if (missingPaperId > 0) { + result.getIssues().add(new ValidationIssue("RECENT_MISSING_PAPER_ID", + "最近答题记录中缺少paper_id的数量: " + missingPaperId, "warn")); + } + + if (missingSessionId > 0) { + result.getIssues().add(new ValidationIssue("RECENT_MISSING_SESSION_ID", + "最近答题记录中缺少session_id的数量: " + missingSessionId, "warn")); + } + + if (invalidIsCorrect > 0) { + result.getIssues().add(new ValidationIssue("RECENT_INVALID_IS_CORRECT", + "最近答题记录中isCorrect值无效的数量: " + invalidIsCorrect, "error")); + } + + result.setTotalAnswers((int) iPage.getTotal()); + result.setSuccess(result.getIssues().stream().noneMatch(i -> "error".equals(i.getSeverity()))); + result.setMessage(String.format("验证 %s 之后的 %d 条答题记录", startTime, iPage.getTotal())); + + return result; + } + + public static class ValidationResult { + private Long userId; + private String paperId; + private String sessionId; + private LocalDateTime validationTime; + private int totalAnswers; + private int correctCount; + private int wrongCount; + private int partialCount; + private int unknownCount; + private int collectedCount; + private int uniquePaperCount; + private int uniqueSessionCount; + private int uniqueQuestionCount; + private boolean success; + private String message; + private List issues; + + public ValidationResult() { + this.issues = new ArrayList<>(); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getPaperId() { return paperId; } + public void setPaperId(String paperId) { this.paperId = paperId; } + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + public LocalDateTime getValidationTime() { return validationTime; } + public void setValidationTime(LocalDateTime validationTime) { this.validationTime = validationTime; } + public int getTotalAnswers() { return totalAnswers; } + public void setTotalAnswers(int totalAnswers) { this.totalAnswers = totalAnswers; } + public int getCorrectCount() { return correctCount; } + public void setCorrectCount(int correctCount) { this.correctCount = correctCount; } + public int getWrongCount() { return wrongCount; } + public void setWrongCount(int wrongCount) { this.wrongCount = wrongCount; } + public int getPartialCount() { return partialCount; } + public void setPartialCount(int partialCount) { this.partialCount = partialCount; } + public int getUnknownCount() { return unknownCount; } + public void setUnknownCount(int unknownCount) { this.unknownCount = unknownCount; } + public int getCollectedCount() { return collectedCount; } + public void setCollectedCount(int collectedCount) { this.collectedCount = collectedCount; } + public int getUniquePaperCount() { return uniquePaperCount; } + public void setUniquePaperCount(int uniquePaperCount) { this.uniquePaperCount = uniquePaperCount; } + public int getUniqueSessionCount() { return uniqueSessionCount; } + public void setUniqueSessionCount(int uniqueSessionCount) { this.uniqueSessionCount = uniqueSessionCount; } + public int getUniqueQuestionCount() { return uniqueQuestionCount; } + public void setUniqueQuestionCount(int uniqueQuestionCount) { this.uniqueQuestionCount = uniqueQuestionCount; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public List getIssues() { return issues; } + public void setIssues(List issues) { this.issues = issues; } + } + + public static class ValidationIssue { + private String code; + private String message; + private String severity; + + public ValidationIssue(String code, String message, String severity) { + this.code = code; + this.message = message; + this.severity = severity; + } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public String getSeverity() { return severity; } + public void setSeverity(String severity) { this.severity = severity; } + } +} diff --git a/src/main/java/top/tqx/demo_1/util/FileUtil.java b/src/main/java/top/tqx/demo_1/util/FileUtil.java new file mode 100644 index 0000000..6ec0f0f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/FileUtil.java @@ -0,0 +1,84 @@ +package top.tqx.demo_1.util; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; + +public class FileUtil { + + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + ".doc", ".docx", ".pdf", ".txt", ".jpg", ".jpeg", ".png", ".gif", ".webp" + ); + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + + public static void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new RuntimeException("文件不能为空"); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new RuntimeException("文件大小不能超过10MB"); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isEmpty()) { + throw new RuntimeException("文件名不能为空"); + } + + if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")) { + throw new RuntimeException("非法的文件名"); + } + + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new RuntimeException("不支持的文件类型: " + extension); + } + } + + public static String calculateMD5(MultipartFile file) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] bytes = file.getBytes(); + byte[] digest = md.digest(bytes); + + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + throw new RuntimeException("计算文件MD5失败", e); + } + } + + public static String getFileExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return ""; + } + int lastDotIndex = filename.lastIndexOf("."); + if (lastDotIndex == -1) { + return ""; + } + return filename.substring(lastDotIndex).toLowerCase(); + } + + public static String formatFileSize(long size) { + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format("%.2f KB", size / 1024.0); + } else if (size < 1024 * 1024 * 1024) { + return String.format("%.2f MB", size / (1024.0 * 1024)); + } else { + return String.format("%.2f GB", size / (1024.0 * 1024 * 1024)); + } + } +} diff --git a/src/main/java/top/tqx/demo_1/util/IpUtil.java b/src/main/java/top/tqx/demo_1/util/IpUtil.java new file mode 100644 index 0000000..8abc800 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/IpUtil.java @@ -0,0 +1,28 @@ +package top.tqx.demo_1.util; + +import jakarta.servlet.http.HttpServletRequest; + +public class IpUtil { + + private static final String[] IP_HEADERS = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + + public static String getIpAddress(HttpServletRequest request) { + for (String header : IP_HEADERS) { + String ip = request.getHeader(header); + if (ip != null && ip.length() > 0 && !"unknown".equalsIgnoreCase(ip)) { + if ("X-Forwarded-For".equals(header)) { + ip = ip.split(",")[0]; + } + return ip.trim(); + } + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/top/tqx/demo_1/util/JwtUtil.java b/src/main/java/top/tqx/demo_1/util/JwtUtil.java new file mode 100644 index 0000000..de94aa5 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/JwtUtil.java @@ -0,0 +1,69 @@ +package top.tqx.demo_1.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; +import top.tqx.demo_1.config.JwtProperties; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Long expiration; + + public JwtUtil(JwtProperties jwtProperties) { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + this.expiration = jwtProperties.getExpiration(); + } + + public String generateToken(Long userId, String username, Map claims) { + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .claims(claims) + .subject(String.valueOf(userId)) + .claim("username", username) + .issuedAt(now) + .expiration(expirationDate) + .signWith(secretKey) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public Long getUserIdFromToken(String token) { + Claims claims = parseToken(token); + return Long.parseLong(claims.getSubject()); + } + + public String getUsernameFromToken(String token) { + Claims claims = parseToken(token); + return claims.get("username", String.class); + } + + public boolean isTokenExpired(String token) { + Claims claims = parseToken(token); + return claims.getExpiration().before(new Date()); + } + + public boolean validateToken(String token) { + try { + return !isTokenExpired(token); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/top/tqx/demo_1/util/PasswordGenerator.java b/src/main/java/top/tqx/demo_1/util/PasswordGenerator.java new file mode 100644 index 0000000..5250d07 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/PasswordGenerator.java @@ -0,0 +1,17 @@ +package top.tqx.demo_1.util; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +public class PasswordGenerator { + public static void main(String[] args) { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + String adminPassword = encoder.encode("admin123456"); + String testPassword = encoder.encode("123456"); + + System.out.println("=== BCrypt 密码哈希值 ==="); + System.out.println("admin123456: " + adminPassword); + System.out.println("123456: " + testPassword); + System.out.println("========================"); + } +} diff --git a/src/main/java/top/tqx/demo_1/util/RedisUtil.java b/src/main/java/top/tqx/demo_1/util/RedisUtil.java new file mode 100644 index 0000000..909a19e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/RedisUtil.java @@ -0,0 +1,55 @@ +package top.tqx.demo_1.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class RedisUtil { + + @Autowired + private RedisTemplate redisTemplate; + + public void set(String key, Object value) { + redisTemplate.opsForValue().set(key, value); + } + + public void set(String key, Object value, long timeout, TimeUnit unit) { + redisTemplate.opsForValue().set(key, value, timeout, unit); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @SuppressWarnings("unchecked") + public T get(String key, Class clazz) { + return (T) redisTemplate.opsForValue().get(key); + } + + public Boolean delete(String key) { + return redisTemplate.delete(key); + } + + public Boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } + + public void expire(String key, long timeout, TimeUnit unit) { + redisTemplate.expire(key, timeout, unit); + } + + public Long increment(String key) { + return redisTemplate.opsForValue().increment(key); + } + + public Long decrement(String key) { + return redisTemplate.opsForValue().decrement(key); + } + + public Boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) { + return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); + } +} diff --git a/src/main/java/top/tqx/demo_1/util/Sha256Util.java b/src/main/java/top/tqx/demo_1/util/Sha256Util.java new file mode 100644 index 0000000..e516a9e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/util/Sha256Util.java @@ -0,0 +1,135 @@ +package top.tqx.demo_1.util; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * SHA256 哈希工具类 + * 支持文件和字符串的 SHA256 哈希计算 + */ +public class Sha256Util { + + private static final String ALGORITHM = "SHA-256"; + private static final int BUFFER_SIZE = 8192; + + /** + * 计算字符串的 SHA256 哈希值 + * + * @param content 要哈希的字符串 + * @return SHA256 哈希值(64位十六进制字符串) + */ + public static String hash(String content) { + if (content == null) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * 计算 MultipartFile 的 SHA256 哈希值 + * + * @param file MultipartFile 文件 + * @return SHA256 哈希值(64位十六进制字符串) + */ + public static String hash(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + try (InputStream is = file.getInputStream()) { + return hash(is); + } catch (IOException e) { + throw new RuntimeException("Failed to calculate SHA-256 hash for file", e); + } + } + + /** + * 计算 File 的 SHA256 哈希值 + * + * @param file File 对象 + * @return SHA256 哈希值(64位十六进制字符串) + */ + public static String hash(File file) { + if (file == null || !file.exists()) { + return null; + } + try (FileInputStream fis = new FileInputStream(file)) { + return hash(fis); + } catch (IOException e) { + throw new RuntimeException("Failed to calculate SHA-256 hash for file", e); + } + } + + /** + * 计算 InputStream 的 SHA256 哈希值 + * + * @param inputStream 输入流 + * @return SHA256 哈希值(64位十六进制字符串) + */ + public static String hash(InputStream inputStream) { + if (inputStream == null) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + return bytesToHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } catch (IOException e) { + throw new RuntimeException("Failed to read input stream", e); + } + } + + /** + * 计算字节数组的 SHA256 哈希值 + * + * @param bytes 字节数组 + * @return SHA256 哈希值(64位十六进制字符串) + */ + public static String hash(byte[] bytes) { + if (bytes == null) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + return bytesToHex(digest.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * 将字节数组转换为十六进制字符串 + * + * @param bytes 字节数组 + * @return 十六进制字符串 + */ + private static String bytesToHex(byte[] bytes) { + StringBuilder hexString = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/src/main/java/top/tqx/demo_1/vo/ChunkVO.java b/src/main/java/top/tqx/demo_1/vo/ChunkVO.java new file mode 100644 index 0000000..fa0f12d --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/ChunkVO.java @@ -0,0 +1,16 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.Map; + +@Data +public class ChunkVO { + private String id; + private String document; + private String content; + private Map metadata; + private Double score; + private String status; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/vo/CollectionVO.java b/src/main/java/top/tqx/demo_1/vo/CollectionVO.java new file mode 100644 index 0000000..a47ec04 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/CollectionVO.java @@ -0,0 +1,19 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class CollectionVO { + private String name; + private String displayName; + private Integer documentCount; + private String department; + private String description; + private String ownerType; + private String visibility; + private String uploader; + private Long fileSize; + private LocalDateTime createTime; + private LocalDateTime updateTime; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/vo/DepartmentVO.java b/src/main/java/top/tqx/demo_1/vo/DepartmentVO.java new file mode 100644 index 0000000..98af0c8 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/DepartmentVO.java @@ -0,0 +1,36 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class DepartmentVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String deptName; + + private Long parentId; + + private String parentName; + + private String deptType; + + private String deptTypeName; + + private Integer deptLevel; + + private Integer sortOrder; + + private String remark; + + private String status; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/vo/DocumentVO.java b/src/main/java/top/tqx/demo_1/vo/DocumentVO.java new file mode 100644 index 0000000..2a23e2f --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/DocumentVO.java @@ -0,0 +1,18 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class DocumentVO { + private String filename; + private String collection; + private String path; + private Long size; + private String status; + private Integer chunkCount; + private LocalDateTime createTime; + private LocalDateTime deprecatedDate; + private String deprecatedReason; + private String version; +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/vo/FilePermissionVO.java b/src/main/java/top/tqx/demo_1/vo/FilePermissionVO.java new file mode 100644 index 0000000..849dee4 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/FilePermissionVO.java @@ -0,0 +1,30 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class FilePermissionVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private Long fileId; + + private Long userId; + + private String userName; + + private Integer permissionType; + + private String permissionTypeName; + + private LocalDateTime expireTime; + + private Long grantUserId; + + private String grantUserName; +} diff --git a/src/main/java/top/tqx/demo_1/vo/FileVO.java b/src/main/java/top/tqx/demo_1/vo/FileVO.java new file mode 100644 index 0000000..65f4b71 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/FileVO.java @@ -0,0 +1,60 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class FileVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String fileName; + + private String filePath; + + private Long fileSize; + + private String fileType; + + private String extension; + + private String fileMd5; + + private Long uploadUserId; + + private String uploadUserName; + + private Long deptId; + + private String deptName; + + private Integer isPublic; + + private String fileCategory; + + private String description; + + private Integer status; + + private Integer auditStatus; + + private String vectorDbAddress; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + private String processStatus; + + private String examStatus; + + private String processMessage; + + private String processStepStatus; + + private String processStepMessage; +} diff --git a/src/main/java/top/tqx/demo_1/vo/LoginResponse.java b/src/main/java/top/tqx/demo_1/vo/LoginResponse.java new file mode 100644 index 0000000..eec786e --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/LoginResponse.java @@ -0,0 +1,48 @@ +package top.tqx.demo_1.vo; + +import java.io.Serializable; + +public class LoginResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private String token; + + private String refreshToken; + + private Long expiresIn; + + private UserInfoVO userInfo; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + public UserInfoVO getUserInfo() { + return userInfo; + } + + public void setUserInfo(UserInfoVO userInfo) { + this.userInfo = userInfo; + } +} \ No newline at end of file diff --git a/src/main/java/top/tqx/demo_1/vo/OperationLogVO.java b/src/main/java/top/tqx/demo_1/vo/OperationLogVO.java new file mode 100644 index 0000000..a243a08 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/OperationLogVO.java @@ -0,0 +1,26 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class OperationLogVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private Long userId; + + private String userName; + + private String operation; + + private String content; + + private String ip; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/vo/PermissionVO.java b/src/main/java/top/tqx/demo_1/vo/PermissionVO.java new file mode 100644 index 0000000..35336cb --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/PermissionVO.java @@ -0,0 +1,30 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class PermissionVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String permissionName; + + private String permissionCode; + + private Long parentId; + + private Integer type; + + private String path; + + private String icon; + + private Integer sortOrder; + + private LocalDateTime createTime; +} diff --git a/src/main/java/top/tqx/demo_1/vo/RoleVO.java b/src/main/java/top/tqx/demo_1/vo/RoleVO.java new file mode 100644 index 0000000..a716e45 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/RoleVO.java @@ -0,0 +1,30 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class RoleVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String roleName; + + private String roleCode; + + private String uuid; + + private Integer roleType; + + private Integer sortOrder; + + private String description; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/top/tqx/demo_1/vo/UserInfoVO.java b/src/main/java/top/tqx/demo_1/vo/UserInfoVO.java new file mode 100644 index 0000000..0ea4f0a --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/UserInfoVO.java @@ -0,0 +1,28 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public class UserInfoVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String username; + + private String realName; + + private String email; + + private String phone; + + private String avatar; + + private Integer userType; + + private LocalDateTime lastLoginTime; +} diff --git a/src/main/java/top/tqx/demo_1/vo/UserVO.java b/src/main/java/top/tqx/demo_1/vo/UserVO.java new file mode 100644 index 0000000..68145f0 --- /dev/null +++ b/src/main/java/top/tqx/demo_1/vo/UserVO.java @@ -0,0 +1,35 @@ +package top.tqx.demo_1.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class UserVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String username; + + private String realName; + + private String email; + + private String phone; + + private Long deptId; + + private String deptName; + + private Integer userType; + + private String status; + + private List roles; + + private LocalDateTime createTime; +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..84e6693 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,107 @@ +server: + port: 8080 + servlet: + context-path: /api + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/knowledge_management_system?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:123456} + + servlet: + multipart: + enabled: true + max-file-size: 100MB + max-request-size: 100MB + file-size-threshold: 2KB + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + data: + redis: + host: ${REDIS_HOST:redis} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + +springdoc: + api-docs: + enabled: ${SWAGGER_ENABLED:true} + path: /v3/api-docs + swagger-ui: + enabled: ${SWAGGER_ENABLED:true} + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + group-configs: + - group: 'default' + paths-to-match: '/**' + packages-to-scan: top.tqx.demo_1.controller + +mybatis-plus: + mapper-locations: classpath*:mapper/**/*.xml + type-aliases-package: top.tqx.demo_1.entity + global-config: + db-config: + id-type: auto + logic-delete-value: 1 + logic-not-delete-value: 0 + configuration: + map-underscore-to-camel-case: true + cache-enabled: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +jwt: + secret: ${JWT_SECRET:your-secret-key-for-jwt-token-generation-2024-change-in-production} + expiration: 7200000 + header: Authorization + prefix: Bearer + +ai: + api: + host: ${AI_HOST:localhost} + port: ${AI_PORT:5001} + key: ${AI_KEY:app-0iWrwSYdTczCmRmtVNhfjWnq} + +file: + upload: + path: ./uploads/ + url-prefix: /files/ + allowed-extensions: + - .doc + - .docx + - .pdf + - .txt + - .jpg + - .jpeg + - .png + - .gif + - .webp + max-size: 10485760 + +x-key: ${X_KEY:password123} + +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:*} + +logging: + level: + top.tqx.demo_1: DEBUG + top.tqx.demo_1.mapper: DEBUG + org.springframework.security: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: /app/logs/file-management-system.log + max-size: 10MB + max-history: 30 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..de60c3f --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,106 @@ +server: + port: 8080 + servlet: + context-path: /api + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST:mysql}:3306/knowledge_management_system?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:123456} + + servlet: + multipart: + enabled: true + max-file-size: 500MB + max-request-size: 500MB + file-size-threshold: 2KB + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + data: + redis: + host: ${REDIS_HOST:redis} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + max-wait: -1ms + +springdoc: + api-docs: + enabled: ${SWAGGER_ENABLED:true} + path: /v3/api-docs + swagger-ui: + enabled: ${SWAGGER_ENABLED:true} + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + group-configs: + - group: 'default' + paths-to-match: '/**' + packages-to-scan: top.tqx.demo_1.controller + +mybatis-plus: + mapper-locations: classpath*:mapper/**/*.xml + type-aliases-package: top.tqx.demo_1.entity + global-config: + db-config: + id-type: auto + logic-delete-value: 1 + logic-not-delete-value: 0 + configuration: + map-underscore-to-camel-case: true + cache-enabled: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +jwt: + secret: ${JWT_SECRET:your-secret-key-for-jwt-token-generation-2024-change-in-production} + expiration: 7200000 + header: Authorization + prefix: Bearer + +ai: + api: + host: ${AI_HOST:localhost} + port: ${AI_PORT:5001} + +file: + upload: + path: /app/uploads/ + url-prefix: /files/ + allowed-extensions: + - .doc + - .docx + - .pdf + - .txt + - .jpg + - .jpeg + - .png + - .gif + - .webp + max-size: 524288000 + +x-key: ${X_KEY:password123} + +cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:*} + +logging: + level: + top.tqx.demo_1: INFO + top.tqx.demo_1.mapper: DEBUG + org.springframework.security: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: /app/logs/file-management-system.log + max-size: 10MB + max-history: 30 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9b0e6a6 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + main: + allow-circular-references: true + profiles: + active: prod \ No newline at end of file diff --git a/src/test/java/top/tqx/demo_1/Demo1ApplicationTests.java b/src/test/java/top/tqx/demo_1/Demo1ApplicationTests.java new file mode 100644 index 0000000..90a34cb --- /dev/null +++ b/src/test/java/top/tqx/demo_1/Demo1ApplicationTests.java @@ -0,0 +1,13 @@ +package top.tqx.demo_1; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class Demo1ApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/top/tqx/demo_1/ExamServiceTest.java.backup b/src/test/java/top/tqx/demo_1/ExamServiceTest.java.backup new file mode 100644 index 0000000..cd792d6 --- /dev/null +++ b/src/test/java/top/tqx/demo_1/ExamServiceTest.java.backup @@ -0,0 +1,430 @@ +package top.tqx.demo_1; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import top.tqx.demo_1.dto.ExamGenerateRequest; +import top.tqx.demo_1.dto.ExamGenerateResponse; +import top.tqx.demo_1.dto.ExamGradeRequest; +import top.tqx.demo_1.dto.ExamGradeResponse; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ExamServiceTest { + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("测试 ExamGenerateRequest 序列化") + void testExamGenerateRequestSerialization() throws Exception { + ExamGenerateRequest request = new ExamGenerateRequest(); + request.setRequestId("test-request-001"); + request.setQuestionCount(10); + request.setQuestionTypes(Arrays.asList("single_choice", "multiple_choice")); + request.setDifficulty(3); + request.setCollectionName("test-collection"); + request.setFilePath("/documents/test.pdf"); + request.setTags(Arrays.asList("tag1", "tag2")); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertTrue(json.contains("\"request_id\":\"test-request-001\"")); + assertTrue(json.contains("\"question_count\":10")); + assertTrue(json.contains("\"difficulty\":3")); + assertTrue(json.contains("\"collection_name\":\"test-collection\"")); + } + + @Test + @DisplayName("测试 ExamGenerateResponse 解析") + void testExamGenerateResponseDeserialization() throws Exception { + String jsonResponse = """ + { + "success": true, + "request_id": "xxx", + "total": 10, + "source_chunks_used": 15, + "questions": [ + { + "question_type": "single_choice", + "difficulty": 3, + "content": { + "stem": "题干内容", + "data": { + "options": [ + {"key": "A", "content": "选项A"}, + {"key": "B", "content": "选项B"}, + {"key": "C", "content": "选项C"}, + {"key": "D", "content": "选项D"} + ] + }, + "answer": "B", + "explanation": "答案解析" + }, + "source_trace": { + "document_name": "考勤制度.docx", + "chunk_ids": ["chunk_001"], + "page_numbers": [5], + "sources": [ + { + "chunk_id": "chunk_001", + "page": 5, + "section": "请假制度", + "snippet": "原文片段..." + } + ] + } + } + ] + } + """; + + ExamGenerateResponse response = objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + + assertTrue(response.getSuccess()); + assertEquals("xxx", response.getRequestId()); + assertEquals(10, response.getTotal()); + assertEquals(15, response.getSourceChunksUsed()); + assertNotNull(response.getQuestions()); + assertEquals(1, response.getQuestions().size()); + + ExamGenerateRequest.Question question = response.getQuestions().get(0); + assertEquals("single_choice", question.getQuestionType()); + assertEquals(3, question.getDifficulty()); + assertEquals("题干内容", question.getContent().getStem()); + assertEquals("B", question.getContent().getAnswer()); + assertEquals("答案解析", question.getContent().getExplanation()); + assertEquals("考勤制度.docx", question.getSourceTrace().getDocumentName()); + } + + @Test + @DisplayName("测试 ExamGradeRequest 序列化") + void testExamGradeRequestSerialization() throws Exception { + ExamGradeRequest request = new ExamGradeRequest(); + request.setRequestId("grade-request-001"); + + ExamGradeRequest.AnswerItem answer1 = new ExamGradeRequest.AnswerItem(); + answer1.setQuestionId("uuid-001"); + answer1.setQuestionType("single_choice"); + answer1.setMaxScore(BigDecimal.valueOf(2.0)); + + ExamGradeRequest.QuestionContent content1 = new ExamGradeRequest.QuestionContent(); + content1.setStem("单选题题干"); + Map data1 = new HashMap<>(); + data1.put("options", Arrays.asList( + Map.of("key", "A", "content", "选项A"), + Map.of("key", "B", "content", "选项B") + )); + content1.setData(data1); + content1.setAnswer("B"); + answer1.setQuestionContent(content1); + answer1.setStudentAnswer("A"); + + request.setAnswers(Arrays.asList(answer1)); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertTrue(json.contains("\"question_id\":\"uuid-001\"")); + assertTrue(json.contains("\"question_type\":\"single_choice\"")); + assertTrue(json.contains("\"student_answer\":\"A\"")); + } + + @Test + @DisplayName("测试 ExamGradeResponse 解析") + void testExamGradeResponseDeserialization() throws Exception { + String jsonResponse = """ + { + "success": true, + "request_id": "grade-request-001", + "total_score": 12.5, + "total_max_score": 22.0, + "score_rate": 56.8, + "results": [ + { + "question_id": "uuid-001", + "score": 0, + "max_score": 2.0, + "correct": false, + "feedback": "正确答案: B" + }, + { + "question_id": "uuid-004", + "score": 2.0, + "max_score": 4.0, + "details": { + "blank_scores": [2.0, 0], + "correct_answers": [["答案1", "同义词1"], ["答案2"]] + } + }, + { + "question_id": "uuid-005", + "score": 7.5, + "max_score": 10.0, + "details": { + "scoring_breakdown": [ + {"point": "要点1", "weight": 0.4, "achieved": 0.35, "comment": "部分掌握"}, + {"point": "要点2", "weight": 0.6, "achieved": 0.55, "comment": "基本掌握"} + ], + "highlights": ["思路清晰"], + "shortcomings": ["细节不够完整"], + "overall_feedback": "整体回答良好,建议补充细节。" + } + } + ] + } + """; + + ExamGradeResponse response = objectMapper.readValue(jsonResponse, ExamGradeResponse.class); + + assertTrue(response.getSuccess()); + assertEquals("grade-request-001", response.getRequestId()); + assertEquals(0, response.getTotalScore().compareTo(BigDecimal.valueOf(12.5))); + assertEquals(0, response.getTotalMaxScore().compareTo(BigDecimal.valueOf(22.0))); + assertEquals(0, response.getScoreRate().compareTo(BigDecimal.valueOf(56.8))); + assertNotNull(response.getResults()); + assertEquals(3, response.getResults().size()); + + ExamGradeResponse.GradeResult result1 = response.getResults().get(0); + assertEquals("uuid-001", result1.getQuestionId()); + assertFalse(result1.getCorrect()); + assertEquals("正确答案: B", result1.getFeedback()); + + ExamGradeResponse.GradeResult result2 = response.getResults().get(1); + assertEquals("uuid-004", result2.getQuestionId()); + assertNotNull(result2.getDetails()); + assertEquals(2, result2.getDetails().getBlankScores().size()); + + ExamGradeResponse.GradeResult result3 = response.getResults().get(2); + assertNotNull(result3.getDetails()); + assertNotNull(result3.getDetails().getScoringBreakdown()); + assertEquals(2, result3.getDetails().getScoringBreakdown().size()); + assertEquals("思路清晰", result3.getDetails().getHighlights().get(0)); + } + + @Test + @DisplayName("测试错误响应格式解析") + void testErrorResponseDeserialization() throws Exception { + String jsonResponse = """ + { + "success": false, + "error": "文件内容为空,无法出题", + "error_code": "NO_CONTENT" + } + """; + + ExamGenerateResponse response = objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + + assertFalse(response.getSuccess()); + assertEquals("文件内容为空,无法出题", response.getError()); + assertEquals("NO_CONTENT", response.getErrorCode()); + } + + @Test + @DisplayName("测试各题型answer格式") + void testQuestionTypeAnswerFormats() throws Exception { + String jsonResponse = """ + { + "success": true, + "questions": [ + {"question_type": "single_choice", "content": {"answer": "B"}}, + {"question_type": "multiple_choice", "content": {"answer": ["A", "C"]}}, + {"question_type": "true_false", "content": {"answer": true}}, + {"question_type": "fill_blank", "content": {"answer": [["答案1"], ["答案2", "同义词"]]}}, + {"question_type": "subjective", "content": {"answer": "参考范文..."}} + ] + } + """; + + ExamGenerateResponse response = objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + + assertEquals(5, response.getQuestions().size()); + assertEquals("B", response.getQuestions().get(0).getContent().getAnswer()); + assertTrue(response.getQuestions().get(1).getContent().getAnswer() instanceof List); + assertTrue(response.getQuestions().get(2).getContent().getAnswer() instanceof Boolean); + assertTrue(response.getQuestions().get(3).getContent().getAnswer() instanceof List); + assertEquals("参考范文...", response.getQuestions().get(4).getContent().getAnswer()); + } + + @Test + @DisplayName("测试多选题学生答案格式") + void testMultipleChoiceStudentAnswer() throws Exception { + ExamGradeRequest request = new ExamGradeRequest(); + + ExamGradeRequest.AnswerItem answer = new ExamGradeRequest.AnswerItem(); + answer.setQuestionId("uuid-002"); + answer.setQuestionType("multiple_choice"); + answer.setMaxScore(BigDecimal.valueOf(4.0)); + answer.setStudentAnswer(Arrays.asList("A", "B")); + + request.setAnswers(Arrays.asList(answer)); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertTrue(json.contains("\"student_answer\":[\"A\",\"B\"]")); + } + + @Test + @DisplayName("测试填空题学生答案格式") + void testFillBlankStudentAnswer() throws Exception { + ExamGradeRequest request = new ExamGradeRequest(); + + ExamGradeRequest.AnswerItem answer = new ExamGradeRequest.AnswerItem(); + answer.setQuestionId("uuid-004"); + answer.setQuestionType("fill_blank"); + answer.setMaxScore(BigDecimal.valueOf(4.0)); + answer.setStudentAnswer(Arrays.asList("学生答案1", "学生答案2")); + + request.setAnswers(Arrays.asList(answer)); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertTrue(json.contains("\"student_answer\":[\"学生答案1\",\"学生答案2\"]")); + } + + @Test + @DisplayName("测试主观题学生答案格式") + void testSubjectiveStudentAnswer() throws Exception { + ExamGradeRequest request = new ExamGradeRequest(); + + ExamGradeRequest.AnswerItem answer = new ExamGradeRequest.AnswerItem(); + answer.setQuestionId("uuid-005"); + answer.setQuestionType("subjective"); + answer.setMaxScore(BigDecimal.valueOf(10.0)); + answer.setStudentAnswer("学生作答的长文本内容..."); + + request.setAnswers(Arrays.asList(answer)); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertTrue(json.contains("\"student_answer\":\"学生作答的长文本内容...\"")); + } + + @Test + @DisplayName("测试题目内容结构完整性") + void testQuestionContentStructure() throws Exception { + String jsonResponse = """ + { + "success": true, + "questions": [ + { + "question_type": "single_choice", + "difficulty": 3, + "content": { + "stem": "这是题干", + "data": { + "options": [ + {"key": "A", "content": "选项A"}, + {"key": "B", "content": "选项B"}, + {"key": "C", "content": "选项C"}, + {"key": "D", "content": "选项D"} + ] + }, + "answer": "B", + "explanation": "这是解析" + }, + "source_trace": { + "document_name": "测试文档.docx", + "chunk_ids": ["chunk_001", "chunk_002"], + "page_numbers": [1, 2], + "sources": [ + { + "chunk_id": "chunk_001", + "page": 1, + "section": "第一章", + "snippet": "原文片段1..." + }, + { + "chunk_id": "chunk_002", + "page": 2, + "section": "第二章", + "snippet": "原文片段2..." + } + ] + } + } + ] + } + """; + + ExamGenerateResponse response = objectMapper.readValue(jsonResponse, ExamGenerateResponse.class); + + ExamGenerateRequest.Question question = response.getQuestions().get(0); + assertEquals("single_choice", question.getQuestionType()); + assertEquals(3, question.getDifficulty()); + + ExamGenerateRequest.QuestionContent content = question.getContent(); + assertEquals("这是题干", content.getStem()); + assertNotNull(content.getData()); + assertEquals("B", content.getAnswer()); + assertEquals("这是解析", content.getExplanation()); + + ExamGenerateRequest.SourceTrace sourceTrace = question.getSourceTrace(); + assertEquals("测试文档.docx", sourceTrace.getDocumentName()); + assertEquals(2, sourceTrace.getChunkIds().size()); + assertEquals(2, sourceTrace.getPageNumbers().size()); + assertEquals(2, sourceTrace.getSources().size()); + assertEquals("chunk_001", sourceTrace.getSources().get(0).getChunkId()); + assertEquals(1, sourceTrace.getSources().get(0).getPage()); + assertEquals("第一章", sourceTrace.getSources().get(0).getSection()); + } + + @Test + @DisplayName("测试批量答案批改请求") + void testBatchGradeRequest() throws Exception { + ExamGradeRequest request = new ExamGradeRequest(); + request.setRequestId("batch-grade-001"); + + List answers = new ArrayList<>(); + + ExamGradeRequest.AnswerItem singleChoice = new ExamGradeRequest.AnswerItem(); + singleChoice.setQuestionId("q-001"); + singleChoice.setQuestionType("single_choice"); + singleChoice.setMaxScore(BigDecimal.valueOf(2.0)); + ExamGradeRequest.QuestionContent c1 = new ExamGradeRequest.QuestionContent(); + c1.setStem("题干"); + c1.setAnswer("B"); + singleChoice.setQuestionContent(c1); + singleChoice.setStudentAnswer("A"); + answers.add(singleChoice); + + ExamGradeRequest.AnswerItem multipleChoice = new ExamGradeRequest.AnswerItem(); + multipleChoice.setQuestionId("q-002"); + multipleChoice.setQuestionType("multiple_choice"); + multipleChoice.setMaxScore(BigDecimal.valueOf(4.0)); + ExamGradeRequest.QuestionContent c2 = new ExamGradeRequest.QuestionContent(); + c2.setStem("多选题题干"); + c2.setAnswer(Arrays.asList("A", "C")); + multipleChoice.setQuestionContent(c2); + multipleChoice.setStudentAnswer(Arrays.asList("A", "B")); + answers.add(multipleChoice); + + ExamGradeRequest.AnswerItem trueFalse = new ExamGradeRequest.AnswerItem(); + trueFalse.setQuestionId("q-003"); + trueFalse.setQuestionType("true_false"); + trueFalse.setMaxScore(BigDecimal.valueOf(2.0)); + ExamGradeRequest.QuestionContent c3 = new ExamGradeRequest.QuestionContent(); + c3.setStem("判断题题干"); + c3.setAnswer(true); + trueFalse.setQuestionContent(c3); + trueFalse.setStudentAnswer(false); + answers.add(trueFalse); + + request.setAnswers(answers); + + String json = objectMapper.writeValueAsString(request); + assertNotNull(json); + assertEquals(3, request.getAnswers().size()); + } +} \ No newline at end of file diff --git a/src/test/java/top/tqx/demo_1/FileUploadTest.java b/src/test/java/top/tqx/demo_1/FileUploadTest.java new file mode 100644 index 0000000..d434052 --- /dev/null +++ b/src/test/java/top/tqx/demo_1/FileUploadTest.java @@ -0,0 +1,62 @@ +package top.tqx.demo_1; + +import top.tqx.demo_1.util.Sha256Util; + +import java.util.UUID; + +/** + * 文件上传测试类 + * 展示 tempstring 拼接过程和第三方接口描述 + */ +public class FileUploadTest { + + public static void main(String[] args) { + // 模拟数据 + String xFile = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; // 空字符串的 SHA256 + Long userId = 12345L; + String xTime = "1712937600000"; // 2024-04-12 00:00:00 + String xNonce = "550e8400e29b41d4a716446655440000"; + String xKey = "password123"; + + // 拼接 tempString + String tempString = "file=" + xFile + "&userid=" + userId + "&time=" + xTime + "&nonce=" + xNonce + "&x-key=" + xKey; + System.out.println("=== tempString 拼接结果 ==="); + System.out.println(tempString); + System.out.println(); + + // 计算签名 + String xSign = Sha256Util.hash(tempString); + System.out.println("=== xSign 计算结果 ==="); + System.out.println(xSign); + System.out.println(); + + // 第三方接口描述 + System.out.println("=== 第三方接口详细描述 ==="); + System.out.println("接口地址: http://192.168.137.73:8080/totop"); + System.out.println("请求方式: POST"); + System.out.println("Content-Type: multipart/form-data"); + System.out.println("请求参数:"); + System.out.println(" 1. file: 文件 (MultipartFile)"); + System.out.println(" 2. userId: 上传用户ID (String)"); + System.out.println(" 3. x-file: 文件哈希值 (String)"); + System.out.println(" 4. x-time: 时间戳 (String)"); + System.out.println(" 5. x-nonce: 随机串 (String)"); + System.out.println(" 6. x-sign: 签名 (String)"); + System.out.println(); + System.out.println("签名计算规则:"); + System.out.println(" tempString = \"file={x-file}&userid={userId}&time={x-time}&nonce={x-nonce}&x-key={x-key}\""); + System.out.println(" x-sign = SHA256(tempString)"); + System.out.println(); + System.out.println("返回格式: JSON"); + System.out.println("返回示例:"); + System.out.println("{"); + System.out.println(" \"code\": 200,"); + System.out.println(" \"message\": \"success\","); + System.out.println(" \"data\": {"); + System.out.println(" \"fileId\": \"12345\","); + System.out.println(" \"fileName\": \"example.pdf\","); + System.out.println(" \"status\": \"uploaded\""); + System.out.println(" }"); + System.out.println("}"); + } +} diff --git a/uploads/resources/rule/总公司/a/v1/a.pdf b/uploads/resources/rule/总公司/a/v1/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v1/a.pdf differ diff --git a/uploads/resources/rule/总公司/a/v2/a.pdf b/uploads/resources/rule/总公司/a/v2/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v2/a.pdf differ diff --git a/uploads/resources/rule/总公司/a/v3/a.pdf b/uploads/resources/rule/总公司/a/v3/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v3/a.pdf differ diff --git a/uploads/resources/rule/总公司/a/v4/a.pdf b/uploads/resources/rule/总公司/a/v4/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v4/a.pdf differ diff --git a/uploads/resources/rule/总公司/a/v5/a.pdf b/uploads/resources/rule/总公司/a/v5/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v5/a.pdf differ diff --git a/uploads/resources/rule/总公司/a/v6/a.pdf b/uploads/resources/rule/总公司/a/v6/a.pdf new file mode 100644 index 0000000..93bc30b Binary files /dev/null and b/uploads/resources/rule/总公司/a/v6/a.pdf differ diff --git a/文件/AIChatController_流程图.md b/文件/AIChatController_流程图.md new file mode 100644 index 0000000..18ad2c2 --- /dev/null +++ b/文件/AIChatController_流程图.md @@ -0,0 +1,399 @@ +# AIChatController 交互流程图 + +## 1. AI流式问答流程 (/ai/chat/stream) + +### 1.1 流程图 + +```mermaid +flowchart TD + A[开始: 用户发起AI问答] --> B{用户已登录?} + B -->|否| C[返回错误: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回错误: 用户信息获取失败] + D -->|是| F[获取部门信息] + F --> G[获取用户可访问知识库] + G --> H{知识库列表为空?} + H -->|是| I[返回错误: 没有可用的知识库] + H -->|否| J[获取或生成session_id] + J --> K[从数据库获取历史记录] + K --> L[调用AI服务进行流式问答] + L --> M[返回SseEmitter流式响应] + C --> N[结束] + E --> N + I --> N + M --> N +``` + +### 1.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant Dept as DepartmentService + participant KB as KnowledgeBasePathService + participant Chat as ChatService + participant AIChat as AiChatService + + Client->>+AI: POST /ai/chat/stream + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + AI->>+Dept: getDepartmentById(deptId) + Dept-->>-AI: Department + + AI->>+KB: getUserAccessibleKnowledgeBaseNames(userId, deptId, deptCode) + KB-->>-AI: List collections + + alt 知识库为空 + AI-->>Client: 错误: 没有可用的知识库 + end + + AI->>+Chat: getSessionMessagesAsHistory(sessionId) + Chat-->>-AI: List + + AI->>+AIChat: streamFromRagService(request, userId, deptId) + loop 流式响应 + AIChat-->>Client: SSE事件流 + end + AIChat-->>-AI: SseEmitter + + AI-->>-Client: SseEmitter流式响应 +``` + +### 1.3 流程说明 + +| 步骤 | 说明 | 代码位置 | +|------|------|----------| +| 1. 用户发起请求 | POST /ai/chat/stream | AIChatController:56 | +| 2. 验证登录状态 | 检查Authorization header | AIChatController:61-73 | +| 3. 验证用户信息 | validateToken获取用户 | AIChatController:75-87 | +| 4. 获取部门信息 | 根据deptId获取Department | AIChatController:94-104 | +| 5. 获取知识库列表 | getUserAccessibleKnowledgeBaseNames | AIChatController:106 | +| 6. 生成session_id | 无session_id时生成新的 | AIChatController:123-127 | +| 7. 获取历史记录 | getSessionMessagesAsHistory | AIChatController:134-140 | +| 8. 调用AI服务 | streamFromRagService | AIChatController:145 | + +--- + +## 2. 获取用户会话列表 (/ai/sessions) + +### 2.1 流程图 + +```mermaid +flowchart TD + A[开始: 获取会话列表] --> B{用户已登录?} + B -->|否| C[返回: success=false, 用户未登录] + B -->|是| D[获取用户所有会话] + D --> E[实现简单分页] + E --> F[返回会话列表及分页信息] + C --> G[结束] + F --> G +``` + +### 2.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant Chat as ChatService + + Client->>+AI: GET /ai/sessions + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + AI->>+Chat: getUserSessions(userId) + Chat-->>-AI: List + + Note over AI: 实现简单分页
计算start/end + + AI-->>-Client: 返回会话列表及分页信息 +``` + +### 2.3 流程说明 + +| 步骤 | 说明 | 代码位置 | +|------|------|----------| +| 1. 用户发起请求 | GET /ai/sessions | AIChatController:163 | +| 2. 验证登录状态 | getCurrentUserId | AIChatController:170-175 | +| 3. 获取会话列表 | chatService.getUserSessions | AIChatController:177 | +| 4. 分页处理 | 计算start/end | AIChatController:179-185 | +| 5. 返回结果 | 返回data/total/page等信息 | AIChatController:187-192 | + +--- + +## 3. 获取会话详情 (/ai/session/{sessionId}) + +### 3.1 流程图 + +```mermaid +flowchart TD + A[开始: 获取会话详情] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D[获取用户所有会话] + D --> E{会话属于当前用户?} + E -->|否| F[返回: 无权访问该会话] + E -->|是| G[获取会话消息列表] + G --> H[实现简单分页] + H --> I[返回会话详情及分页信息] + C --> J[结束] + F --> J + I --> J +``` + +### 3.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant Chat as ChatService + + Client->>+AI: GET /ai/session/{sessionId} + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + AI->>+Chat: getUserSessions(userId) + Chat-->>-AI: List + + Note over AI: 验证会话所属权 + + alt 会话不属于用户 + AI-->>Client: 错误: 无权访问该会话 + else 会话属于用户 + AI->>+Chat: getSessionMessages(sessionId) + Chat-->>-AI: List + Note over AI: 实现简单分页 + AI-->>-Client: 返回会话详情及分页信息 + end +``` + +--- + +## 4. 删除单个会话 (/ai/session/{sessionId}) + +### 4.1 流程图 + +```mermaid +flowchart TD + A[开始: 删除单个会话] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D[获取用户所有会话] + D --> E{会话属于当前用户?} + E -->|否| F[返回: 无权删除该会话] + E -->|是| G[执行删除操作] + G --> H{删除成功?} + H -->|是| I[返回: 删除成功] + H -->|否| J[返回: 删除失败] + C --> K[结束] + F --> K + I --> K + J --> K +``` + +### 4.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant Chat as ChatService + + Client->>+AI: DELETE /ai/session/{sessionId} + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + AI->>+Chat: getUserSessions(userId) + Chat-->>-AI: List + + Note over AI: 验证会话所属权 + + alt 会话不属于用户 + AI-->>Client: 错误: 无权删除该会话 + else 会话属于用户 + AI->>+Chat: deleteSession(sessionId, userId) + Chat-->>-AI: boolean success + alt 删除成功 + AI-->>Client: {success: true, message: "删除成功"} + else 删除失败 + AI-->>Client: {success: false, message: "删除失败"} + end + end +``` + +--- + +## 5. 批量删除会话 (/ai/sessions) + +### 5.1 流程图 + +```mermaid +flowchart TD + A[开始: 批量删除会话] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{sessionIds列表有效?} + D -->|否| E[返回: 会话ID列表不能为空] + D -->|是| F[获取用户所有会话] + F --> G[提取用户会话ID集合] + G --> H{所有会话属于用户?} + H -->|否| I[返回: 无权删除部分会话] + H -->|是| J[执行批量删除] + J --> K{删除成功?} + K -->|是| L[返回: 批量删除成功] + K -->|否| M[返回: 批量删除失败] + C --> N[结束] + E --> N + I --> N + L --> N + M --> N +``` + +### 5.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant Chat as ChatService + + Client->>+AI: DELETE /ai/sessions + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + alt sessionIds为空 + AI-->>Client: 错误: 会话ID列表不能为空 + end + + AI->>+Chat: getUserSessions(userId) + Chat-->>-AI: List + + Note over AI: 提取用户会话ID集合
验证所有会话属于用户 + + alt 部分会话不属于用户 + AI-->>Client: 错误: 无权删除部分会话 + else 所有会话属于用户 + AI->>+Chat: deleteSessions(sessionIds, userId) + Chat-->>-AI: boolean success + alt 删除成功 + AI-->>Client: {success: true, deletedCount: N} + else 删除失败 + AI-->>Client: {success: false, message: "批量删除失败"} + end + end +``` + +--- + +## 6. 中断AI流式问答 (/ai/chat/stop) + +### 6.1 流程图 + +```mermaid +flowchart TD + A[开始: 中断AI问答] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D[调用sseEmitterManager.stop] + D --> E{中断成功?} + E -->|是| F[返回: 已中断AI回答] + E -->|否| G[返回: 未找到活跃AI进程] + C --> H[结束] + F --> H + G --> H +``` + +### 6.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant AI as AIChatController + participant Auth as AuthService + participant SSE as SseEmitterManager + + Client->>+AI: POST /ai/chat/stop + AI->>+Auth: validateToken(token) + Auth-->>-AI: User对象 + + AI->>+SSE: stop(sessionId) + SSE-->>-AI: boolean success + + alt 中断成功 + AI-->>Client: {success: true, message: "已中断AI回答"} + else 中断失败 + AI-->>Client: {success: false, message: "未找到活跃的AI进程或已结束"} + end +``` + +--- + +## 核心服务调用关系 + +```mermaid +flowchart LR + subgraph Controller + AI[AIChatController] + end + + subgraph Service + AS[AuthService] + CS[ChatService] + ACS[AiChatService] + SS[SseEmitterManager] + KBS[KnowledgeBasePathService] + DS[DepartmentService] + end + + subgraph Entity + U[User] + D[Department] + CS1[ChatSession] + CM[ChatMessage] + CR[ChatReference] + end + + subgraph Database + DB[(MySQL)] + end + + AI --> AS + AI --> CS + AI --> ACS + AI --> SS + AI --> KBS + AI --> DS + + AS --> U + CS --> CS1 + CS --> CM + CS --> CR + KBS --> DB + DS --> U + DS --> D +``` + +--- + +## 接口汇总 + +| 接口 | 方法 | 路径 | 功能 | +|------|------|------|------| +| chatStream | POST | /ai/chat/stream | AI流式问答 | +| getUserSessions | GET | /ai/sessions | 获取用户会话列表 | +| getSessionDetail | GET | /ai/session/{sessionId} | 获取会话详情 | +| getMessageReferences | GET | /ai/message/{messageId}/references | 获取消息引用 | +| getSessionReferences | GET | /ai/session/{sessionId}/references | 获取会话引用 | +| deleteSession | DELETE | /ai/session/{sessionId} | 删除单个会话 | +| deleteSessions | DELETE | /ai/sessions | 批量删除会话 | +| stopChatStream | POST | /ai/chat/stop | 中断AI流式问答 | diff --git a/文件/FileController_流程图.md b/文件/FileController_流程图.md new file mode 100644 index 0000000..23dbcee --- /dev/null +++ b/文件/FileController_流程图.md @@ -0,0 +1,522 @@ +# FileController 交互流程图 + +## 接口总览 + +| 接口 | 方法 | 路径 | 功能 | +|------|------|------|------| +| pageFiles | GET | /file/page | 分页查询文件 | +| getFileById | GET | /file/{id} | 查询文件详情 | +| uploadFile | POST | /file/upload | 上传文件 | +| ragStream | POST | /file/rag/stream | RAG流式问答 | +| downloadFile | GET | /file/download/{id} | 下载文件(已禁用) | +| getFileContent | GET | /file/content/{id} | 获取文件内容(已禁用) | +| getFileInfo | GET | /file/info/{id} | 获取文件详细信息 | +| deleteFile | DELETE | /file/{id} | 删除文件 | +| approveFile | POST | /file/audit/approve/{id} | 审核通过 | +| rejectFile | POST | /file/audit/reject/{id} | 审核拒绝 | +| getPendingFiles | GET | /file/audit/pending | 查询待审核文件列表 | +| updateFileInfo | PUT | /file/{id} | 更新文件信息 | + +--- + +## 1. 分页查询文件 (/file/page) + +### 1.1 流程图 + +```mermaid +flowchart TD + A[开始: 分页查询文件] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F[调用fileService.pageFiles] + F --> G[返回分页结果] + C --> H[结束] + E --> H + G --> H +``` + +### 1.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: GET /file/page + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + FC->>+FS: pageFiles(pageNum, pageSize, fileName, user) + FS-->>-FC: PageResult + + FC-->>-Client: 返回分页结果 +``` + +--- + +## 2. 查询文件详情 (/file/{id}) + +### 2.1 流程图 + +```mermaid +flowchart TD + A[开始: 查询文件详情] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F[调用fileService.getFileById] + F --> G[返回文件详情] + C --> H[结束] + E --> H + G --> H +``` + +### 2.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: GET /file/{id} + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + FC->>+FS: getFileById(id, user) + FS-->>-FC: FileVO + + FC-->>-Client: 返回文件详情 +``` + +--- + +## 3. 上传文件 (/file/upload) + +### 3.1 流程图 + +```mermaid +flowchart TD + A[开始: 上传文件] --> B[校验文件] + B --> C{文件校验通过?} + C -->|否| D[返回错误信息] + C -->|是| E{用户已登录?} + E -->|否| F[返回: 用户未登录] + E -->|是| G{用户信息获取成功?} + G -->|否| H[返回: 用户信息获取失败] + G -->|是| I[调用fileService.uploadFile] + I --> J{有文件描述?} + J -->|是| K[更新文件描述] + K --> L[返回FileVO] + J -->|否| L[返回FileVO] + D --> M[结束] + F --> M + H --> M + L --> M +``` + +### 3.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant FU as FileUtil + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: POST /file/upload + FC->>+FU: validateFile(file) + alt 文件校验失败 + FU-->>-FC: 抛出异常 + FC-->>Client: 返回错误信息 + end + + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + FC->>+FS: uploadFile(file, userId, deptId, userType, targetDeptId, isPublic) + FS-->>-FC: FileVO + + alt 有描述信息 + FC->>+FS: updateFileDescription(fileId, description) + FS-->>-FC: void + end + + FC-->>-Client: 返回FileVO +``` + +--- + +## 4. RAG流式问答 (/file/rag/stream) + +### 4.1 流程图 + +```mermaid +flowchart TD + A[开始: RAG流式问答] --> B{用户已登录?} + B -->|否| C[返回错误: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回错误: 用户信息获取失败] + D -->|是| F[设置collections为public_kb] + F --> G[生成session_id] + G --> H[调用aiChatService.streamFromRagService] + H --> I[返回SseEmitter流式响应] + C --> J[结束] + E --> J + I --> J +``` + +### 4.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant Auth as AuthService + participant AI as AiChatService + + Client->>+FC: POST /file/rag/stream + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + Note over FC: 设置collections
collections.add("public_kb")
生成session_id + + FC->>+AI: streamFromRagService(request, userId, deptId) + loop 流式响应 + AI-->>Client: SSE事件流 + end + AI-->>-FC: SseEmitter + + FC-->>-Client: SseEmitter流式响应 +``` + +--- + +## 5. 删除文件 (/file/{id}) + +### 5.1 流程图 + +```mermaid +flowchart TD + A[开始: 删除文件] --> B[调用fileService.deleteFile] + B --> C[返回成功] +``` + +### 5.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant FS as FileService + + Client->>+FC: DELETE /file/{id} + FC->>+FS: deleteFile(id) + FS-->>-FC: void + + FC-->>-Client: 返回成功 +``` + +--- + +## 6. 审核通过文件 (/file/audit/approve/{id}) + +### 6.1 流程图 + +```mermaid +flowchart TD + A[开始: 审核通过] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F{有管理员权限?} + F -->|否| G[返回: 无权限] + F -->|是| H[调用fileService.approveFile] + H --> I[返回成功] + C --> J[结束] + E --> J + G --> J + I --> J +``` + +### 6.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 管理员 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: POST /file/audit/approve/{id} + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + Note over FC: 检查userType==1或userType==2 + + alt 无管理员权限 + FC-->>Client: 返回: 无权限执行此操作 + else 有管理员权限 + FC->>+FS: approveFile(id, userId) + FS-->>-FC: void + FC-->>Client: 返回成功 + end +``` + +--- + +## 7. 审核拒绝文件 (/file/audit/reject/{id}) + +### 7.1 流程图 + +```mermaid +flowchart TD + A[开始: 审核拒绝] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F{有管理员权限?} + F -->|否| G[返回: 无权限] + F -->|是| H[调用fileService.rejectFile] + H --> I[返回成功] + C --> J[结束] + E --> J + G --> J + I --> J +``` + +### 7.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 管理员 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: POST /file/audit/reject/{id} + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + Note over FC: 检查userType==1或userType==2 + + alt 无管理员权限 + FC-->>Client: 返回: 无权限执行此操作 + else 有管理员权限 + FC->>+FS: rejectFile(id, userId) + FS-->>-FC: void + FC-->>Client: 返回成功 + end +``` + +--- + +## 8. 查询待审核文件列表 (/file/audit/pending) + +### 8.1 流程图 + +```mermaid +flowchart TD + A[开始: 查询待审核列表] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F{有管理员权限?} + F -->|否| G[返回: 无权限] + F -->|是| H[调用fileService.getPendingFiles] + H --> I[返回分页结果] + C --> J[结束] + E --> J + G --> J + I --> J +``` + +### 8.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 管理员 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + + Client->>+FC: GET /file/audit/pending + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + Note over FC: 检查userType==1或userType==2 + + alt 无管理员权限 + FC-->>Client: 返回: 无权限执行此操作 + else 有管理员权限 + FC->>+FS: getPendingFiles(pageNum, pageSize) + FS-->>-FC: PageResult + FC-->>Client: 返回分页结果 + end +``` + +--- + +## 9. 更新文件信息 (/file/{id}) + +### 9.1 流程图 + +```mermaid +flowchart TD + A[开始: 更新文件信息] --> B{用户已登录?} + B -->|否| C[返回: 用户未登录] + B -->|是| D{用户信息获取成功?} + D -->|否| E[返回: 用户信息获取失败] + D -->|是| F[获取原文件信息] + F --> G{文件存在?} + G -->|否| H[返回: 文件不存在] + G -->|是| I{isPublic变更?} + I -->|是| J[重建文件路径] + J --> K[移动文件到新路径] + K --> L[更新文件信息] + I -->|否| L[直接更新文件信息] + L --> M[返回更新后的文件] + C --> N[结束] + E --> N + H --> N + M --> N +``` + +### 9.2 时序图 + +```mermaid +sequenceDiagram + autonumber + participant Client as 用户 + participant FC as FileController + participant Auth as AuthService + participant FS as FileService + participant Dept as DepartmentService + participant UM as UserMapper + + Client->>+FC: PUT /file/{id} + FC->>+Auth: validateToken(token) + Auth-->>-FC: User对象 + + FC->>+FS: getFileEntityById(id) + FS-->>-FC: File对象 + + alt 文件不存在 + FC-->>Client: 返回: 文件不存在 + end + + alt isPublic变更 + FC->>+Dept: getDepartmentFullPath(deptId) + Dept-->>-FC: fullPath + + Note over FC: rebuildFilePath
创建新路径 + + FC->>FC: Files.move移动文件 + + FC->>+FS: updateFileEntity(file) + FS-->>-FC: void + else 仅更新描述 + FC->>+FS: updateFileEntity(file) + FS-->>-FC: void + end + + FC->>UM: selectById(uploadUserId) + UM-->>FC: User + + FC->>FC: convertToVO(file) + + FC-->>-Client: 返回FileVO +``` + +--- + +## 10. 已禁用的接口 + +### 10.1 下载文件 (/file/download/{id}) + +```mermaid +flowchart TD + A[开始: 下载文件] --> B[直接返回403 Forbidden] +``` + +### 10.2 获取文件内容 (/file/content/{id}) + +```mermaid +flowchart TD + A[开始: 获取文件内容] --> B[直接返回错误: 文件内容获取功能已禁用] +``` + +--- + +## 核心服务调用关系 + +```mermaid +flowchart LR + subgraph Controller + FC[FileController] + end + + subgraph Service + FS[FileService] + AI[AiChatService] + DS[DepartmentService] + end + + subgraph Util + FU[FileUtil] + end + + subgraph Mapper + UM[UserMapper] + end + + subgraph Entity + F[File] + U[User] + D[Department] + FV[FileVO] + end + + FC --> FS + FC --> AI + FC --> DS + FC --> FU + FC --> UM + + FS --> F + FS --> FV + DS --> D + UM --> U +``` + +--- + +## 权限说明 + +| 接口 | 普通用户 | 部门管理员 | 系统管理员 | +|------|---------|-----------|-----------| +| 分页查询文件 | ✅ | ✅ | ✅ | +| 查询文件详情 | ✅ | ✅ | ✅ | +| 上传文件 | ✅ | ✅ | ✅ | +| RAG流式问答 | ✅ | ✅ | ✅ | +| 下载文件 | ❌ 已禁用 | ❌ 已禁用 | ❌ 已禁用 | +| 获取文件内容 | ❌ 已禁用 | ❌ 已禁用 | ❌ 已禁用 | +| 获取文件信息 | ✅ | ✅ | ✅ | +| 删除文件 | - | - | - | +| 审核通过 | ❌ | ✅ | ✅ | +| 审核拒绝 | ❌ | ✅ | ✅ | +| 查询待审核列表 | ❌ | ✅ | ✅ | +| 更新文件信息 | ✅ | ✅ | ✅ | diff --git a/文件/出题做题生成流程示意图.md b/文件/出题做题生成流程示意图.md new file mode 100644 index 0000000..91ea472 --- /dev/null +++ b/文件/出题做题生成流程示意图.md @@ -0,0 +1,542 @@ +# 知识库管理系统 - 出题做题生成流程 + +## 文档信息 + +| 项目 | 内容 | +| -------- | ----------- | +| **文档名称** | 出题做题生成流程示意图 | +| **版本号** | V1.0.0 | +| **发布日期** | 2026-05-10 | + +*** + +## 一、整体流程概述 + +本系统的出题做题流程主要包含两大核心环节: + +1. **题目生成流程**:从文档中自动/手动生成题目,经过审核后进入题库 +2. **答题考试流程**:用户获取试卷、答题、提交、获得成绩 + +*** + +## 二、题目生成流程 + +### 2.1 流程图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 题目生成流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 上传文档 │───▶│ 文件向量化 │───▶│ 题目生成 │ │ +│ │ (FileUpload) │ │ (Vectorize) │ │ (Generate) │ │ +│ └──────────────┘ └──────────────┘ └───────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ 题目审核 │ │ +│ │ (Review) │ │ +│ └───────┬──────┘ │ +│ ┌─┴─┐ │ +│ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ 审核通过 │ │ 审核拒绝 │ │ +│ │ Approved │ │ Rejected │ │ +│ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 进入题库 │ │ 返回修改 │ │ +│ │ (Question │ │ (Modify) │ │ +│ │ Bank) │ └──────────────┘ │ +│ └──────┬───────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ 试卷生成 │ │ +│ │ (Paper │ │ +│ │ Generate) │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 详细流程说明 + +| 步骤 | 阶段 | 接口 | 说明 | +| -- | ----- | ---------------------------------------- | ------------- | +| 1 | 文档上传 | `POST /api/file/upload` | 用户上传文档,保存到服务器 | +| 2 | 文件向量化 | `POST /api/collections/{name}/vectorize` | 将文档内容转换为向量存储 | +| 3 | 题目生成 | `POST /api/exam/generate` | 根据文档内容自动生成题目 | +| 4 | 题目审核 | `PUT /api/question/review/{questionId}` | 人工审核生成的题目 | +| 5 | 进入题库 | 自动 | 审核通过的题目进入题库 | +| 6 | 试卷生成 | `POST /api/exam/paper/generate` | 根据配置从题库生成试卷 | + +### 2.3 题目类型 + +| 类型 | 英文标识 | 说明 | +| --- | ---------------- | ------------ | +| 单选题 | single\_choice | 从选项中选择一个正确答案 | +| 多选题 | multiple\_choice | 从选项中选择多个正确答案 | +| 判断题 | true\_false | 判断陈述是否正确 | +| 填空题 | fill\_blank | 填写正确答案 | +| 简答题 | subjective | 主观题,需要文字回答 | + +*** + +## 三、答题考试流程 + +### 3.1 流程图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 答题考试流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 获取试卷列表 │───▶│ 选择试卷 │───▶│ 开始答题 │ │ +│ │ (My Papers) │ │ (Select) │ │ (Start) │ │ +│ └──────────────┘ └──────────────┘ └───────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ 答题界面 │ │ +│ │ (Answer) │ │ +│ └──────────┬─────────┘ │ +│ │ │ +│ ┌──────────┴─────────┐ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 保存答案 │ │ 提交试卷 │ │ +│ │ (Save) │ │ (Submit) │ │ +│ └──────────────┘ └───────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ AI批改答案 │ │ +│ │ (Grade) │ │ +│ └───────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ 显示成绩 │ │ +│ │ (Result) │ │ +│ └───────┬──────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ 错题收集 │ │ +│ │ (Wrong Q) │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 详细流程说明 + +| 步骤 | 阶段 | 接口 | 说明 | +| -- | ------ | ----------------------------------------- | ---------- | +| 1 | 获取试卷列表 | `GET /api/exam/my/papers` | 获取用户有权限的试卷 | +| 2 | 获取试卷详情 | `GET /api/exam/paper/{paperId}` | 获取试卷内容 | +| 3 | 开始答题 | `POST /api/exam/record/{recordId}/start` | 标记开始考试 | +| 4 | 保存答案 | `PUT /api/exam/record/{recordId}/save` | 保存单题答案 | +| 5 | 提交试卷 | `POST /api/exam/record/{recordId}/submit` | 提交全部答案 | +| 6 | AI批改 | 后台自动 | AI自动批改答案 | +| 7 | 获取结果 | `GET /api/exam/record/{recordId}/result` | 获取考试成绩 | +| 8 | 错题收集 | 自动 | 自动将错题加入错题本 | + +*** + +## 四、数据流向图 + +### 4.1 题目生成数据流向 + +``` +用户上传 ──▶ File表 ──▶ 向量库 ──▶ 题目生成 ──▶ Question表(待审核) + │ + ▼ + ┌─────────────────┐ + │ 审核流程 │ + └────────┬────────┘ + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + Question表(已通过) Question表(已拒绝) + │ + ▼ + ExamPaper表(试卷) +``` + +### 4.2 答题数据流向 + +``` +ExamPaper表 ──▶ 用户答题 ──▶ UserAnswer表 ──▶ AI批改 ──▶ ExamRecord表 + │ + ▼ + WrongQuestion表 +``` + +*** + +## 五、状态流转 + +### 5.1 题目状态 + +``` +pending(待审核) + │ + ├─▶ approved(已通过) ──▶ 进入题库 + │ + └─▶ rejected(已拒绝) ──▶ 可重新编辑 +``` + +### 5.2 考试记录状态 + +``` +created(已创建) + │ + ▼ +started(进行中) + │ + ├─▶ submitted(已提交) + │ │ + │ ▼ + │ graded(已批改) + │ + └─▶ expired(已过期) +``` + +*** + +## 六、核心数据库表关系 + +``` +┌──────────┐ ┌───────────┐ ┌────────────┐ +│ File │ │ Question │ │ ExamPaper │ +├──────────┤ ├───────────┤ ├────────────┤ +│ id │ │ id │ │ id │ +│ fileName │◀──────│ fileId │◀───────│ questions │ +│ filePath │ │ content │ │ title │ +│ status │ │ status │ │ status │ +│ examStatus│ │ difficulty│ └────────────┘ +└──────────┘ └─────┬─────┘ + │ + ▼ + ┌──────────────┐ ┌────────────────┐ + │ UserAnswer │ │ ExamRecord │ + ├──────────────┤ ├────────────────┤ + │ questionId │◀───────│ id │ + │ answer │ │ paperId │ + │ isCorrect │ │ userId │ + │ score │ │ totalScore │ + └──────────────┘ └────────────────┘ +``` + +*** + +## 七、流程时序图 + +### 7.1 题目生成时序 + +``` +用户 FileController FileServiceImpl CollectionController + │ │ │ │ + │ 上传文件 │ │ │ + ├─────────────▶│ │ │ + │ │ 保存文件 │ │ + │ ├─────────────────▶│ │ + │ │ │ │ + │ 向量化 │ │ │ + ├─────────────▶│ │ 调用向量化接口 │ + │ │ ├───────────────────▶│ + │ │ │ │ + │ 生成题目 │ │ │ + ├─────────────▶│ │ 调用AI生成题目 │ + │ │ ├───────────────────▶│ + │ │ │ │ + │ 审核题目 │ │ │ + ├─────────────▶│ │ │ + │ │ 更新题目状态 │ │ + │ ├─────────────────▶│ │ + │ │ │ │ + │ ▼ ▼ ▼ +``` + +### 7.2 答题考试时序 + +``` +用户 UserExamController ExamServiceImpl QuestionMapper + │ │ │ │ + │ 获取试卷列表 │ │ │ + ├─────────────────▶│ │ │ + │ │ 查询数据库 │ │ + │ │ ├─────────────────▶│ + │ │ │ │ + │ │ 返回试卷列表 │ │ + │◀─────────────────┤ │ │ + │ │ │ │ + │ 开始答题 │ │ │ + ├─────────────────▶│ │ 创建考试记录 │ + │ │ ├─────────────────▶│ + │ │ │ │ + │ 保存答案 │ │ │ + ├─────────────────▶│ │ 保存答题记录 │ + │ │ ├─────────────────▶│ + │ │ │ │ + │ 提交试卷 │ │ │ + ├─────────────────▶│ │ AI批改答案 │ + │ │ ├─────────────────▶│ + │ │ │ │ + │ │ 返回考试结果 │ │ + │◀─────────────────┤ │ │ + │ │ │ │ + │ ▼ ▼ ▼ +``` + +*** + +## 八、关键业务规则 + +### 8.1 题目生成规则 + +| 规则 | 说明 | +| ------ | ------------------------------ | +| 文件状态检查 | 只有审核通过(auditStatus=1)的文件才能生成题目 | +| 难度等级 | 支持1-5级难度,1最简单,5最难 | +| 题目数量限制 | 每次生成最多100道题目 | +| 去重机制 | 相同内容的题目会被自动去重 | + +### 8.2 试卷生成规则 + +| 规则 | 说明 | +| ---- | ------------------- | +| 权限范围 | 只能选择用户有权限访问的知识库中的题目 | +| 题型配比 | 根据配置的题型数量分配题目 | +| 难度均衡 | 尽量均衡分布不同难度的题目 | +| 随机抽取 | 从题库中随机抽取题目组成试卷 | + +### 8.3 答题规则 + +| 规则 | 说明 | +| ---- | ------------ | +| 答题时间 | 每道题默认答题时间不限制 | +| 答案保存 | 支持自动保存和手动保存 | +| 提交限制 | 提交后不可修改答案 | +| 错题收集 | 自动收集错题到错题本 | + +*** + +**文档结束** + +
+ +让我先查看考试相关的实体类和数据库表结构: + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\entity\ExamPaper.java + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\entity\ExamRecord.java + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\entity\ExamAssignment.java + +让我查看考试相关的Controller来了解完整流程: + +toolName: search\_by\_regex + +status: success + +query: AdminExamController + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\controller\AdminExamController.java + +让我查看ExamPaperService的实现来了解完整流程: + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\service\impl\ExamPaperServiceImpl.java + +让我查看用户考试相关的Controller和Service: + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\controller\UserExamController.java + +toolName: view\_files + +status: success + +filePath: d:\Users\19211\Desktop\demo\demo\src\main\java\top\tqx\demo\_1\service\impl\ExamRecordServiceImpl.java + +## 三张考试表的完整流程说明 + +### 一、表结构关系 + +| 表名 | 中文名称 | 核心作用 | +| :---------------- | :---- | :--------------------- | +| `exam_paper` | 试卷表 | 存储试卷的基本信息(标题、描述、题目列表等) | +| `exam_assignment` | 试卷分配表 | 记录试卷发布的范围(部门、用户) | +| `exam_record` | 考试记录表 | 记录每个用户的考试状态和答题情况 | + +### 二、完整业务流程 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 管理员端流程 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 1. 预览试卷 → 2. 保存试卷 → 3. 发布试卷 │ +│ ↓ ↓ ↓ │ +│ 生成试卷内容 创建ExamPaper 更新状态为published │ +│ (不保存) status=draft 创建ExamAssignment │ +│ 创建ExamRecord(每个用户一条) │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 用户端流程 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 4. 查看试卷列表 → 5. 查看试卷内容 → 6. 开始考试 → 7. 保存答案 │ +│ ↓ ↓ ↓ ↓ │ +│ 查询ExamRecord 查询ExamPaper 更新status= 更新answers字段 │ +│ (属于自己的) 获取题目内容 in_progress 记录答题进度 │ +│ ↓ │ +│ 8. 提交试卷 → 9. 查看成绩 │ +│ ↓ ↓ │ +│ 调用AI批阅 查询ExamRecord │ +│ 保存到UserAnswer 和UserAnswer │ +│ 更新status= │ +│ submitted │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 三、各步骤详解 + +#### 阶段一:管理员预览试卷 + +- **接口**:`POST /admin/exam/paper/preview` +- **操作**:根据条件动态生成试卷内容,**不保存到数据库** +- **目的**:让管理员在保存前预览试卷效果 + +#### 阶段二:管理员保存试卷 + +- **接口**:`POST /admin/exam/paper/save` +- **数据流向**: + ``` + ExamPaper (status=draft) + ↓ + exam_paper表 + ``` +- **关键字段**: + - `paper_id`:UUID唯一标识 + - `question_ids`:题目ID列表(JSON格式) + - `status`:`draft` + +#### 阶段三:管理员发布试卷 + +- **接口**:`POST /admin/exam/paper/{paperId}/publish` +- **数据流向**: + ``` + 1. 更新ExamPaper + status → published + publish_time → 当前时间 + deadline → 截止时间 + + 2. 创建ExamAssignment + paper_id → 试卷ID + dept_id / user_ids → 发布范围 + status → active + + 3. 创建ExamRecord(每个目标用户一条) + paper_id → 试卷ID + user_id → 用户ID + status → not_started + ``` + +#### 阶段四:用户查看试卷列表 + +- **接口**:`GET /exam/my/papers` +- **查询条件**:`user_id = 当前用户 AND status != revoked` + +#### 阶段五:用户查看试卷内容 + +- **接口**:`GET /exam/paper/{paperId}` +- **验证**:确保用户有该试卷的答题权限(检查ExamRecord存在) + +#### 阶段六:用户开始考试 + +- **接口**:`POST /exam/record/{recordId}/start` +- **状态变更**:`not_started` → `in_progress` +- **记录**:`start_time`、`last_active_time` + +#### 阶段七:用户保存答案 + +- **接口**:`PUT /exam/record/{recordId}/save` +- **操作**:将答案JSON序列化后存入`answers`字段 + +#### 阶段八:用户提交试卷 + +- **接口**:`POST /exam/record/{recordId}/submit` +- **数据流向**: + ``` + 1. 解析用户答案 → 调用AI批阅接口 + + 2. 保存到UserAnswer表(每题一条记录) + user_id, paper_id, question_id, score, is_correct, feedback + + 3. 更新ExamRecord + status → submitted + total_score → AI返回的总分 + submit_time / graded_time → 当前时间 + time_spent → 答题耗时(秒) + ``` + +#### 阶段九:用户查看成绩 + +- **接口**:`GET /exam/record/{recordId}/result` +- **数据来源**:ExamRecord(总分、状态)+ UserAnswer(每题详情) + +### 四、表关系图 + +``` +exam_paper (试卷表) + │ + ├── 1 : 1 → exam_assignment (分配表) + │ │ + │ └── 1 : N → exam_record (记录表) + │ │ + │ └── 1 : N → user_answer (答题记录表) + │ + └── 1 : N → question (题目表) via question_ids +``` + +### 五、状态流转 + +| 表 | 状态字段 | 状态值 | 说明 | +| :--------------- | :----- | :----------- | :--- | +| exam\_paper | status | draft | 草稿状态 | +|
|
| published | 已发布 | +| exam\_assignment | status | active | 有效分配 | +|
|
| revoked | 已撤销 | +| exam\_record | status | not\_started | 未开始 | +|
|
| in\_progress | 进行中 | +|
|
| submitted | 已提交 | +|
|
| revoked | 已撤销 | + diff --git a/文件/系统功能模块划分图.md b/文件/系统功能模块划分图.md new file mode 100644 index 0000000..bf59311 --- /dev/null +++ b/文件/系统功能模块划分图.md @@ -0,0 +1,328 @@ +# 知识管理系统功能模块划分图 + +## 系统级功能结构图 + +```mermaid +flowchart TB + subgraph TOP["知识管理系统"] + subgraph Front["前台用户端"] + F1["用户认证"] + F2["文件管理"] + F3["题库管理"] + F4["AI对话"] + F5["个人中心"] + end + + subgraph Back["后台管理端"] + B1["用户管理"] + B2["部门管理"] + B3["权限管理"] + B4["文件审核"] + B5["日志管理"] + B6["系统配置"] + end + end +``` + +--- + +## 详细功能模块划分 + +### 第一层:系统层 + +| 模块名称 | 说明 | +|---------|------| +| 知识管理系统 | 整体系统名称 | + +--- + +### 第二层:前端/后端层 + +| 分类 | 说明 | +|------|------| +| 前台用户端 | 普通用户操作界面 | +| 后台管理端 | 管理员操作界面 | + +--- + +### 第三层:功能模块层 + +#### 前台用户端 + +| 模块 | 功能列表 | 控制器 | +|------|---------|--------| +| 用户认证 | 登录、注册、Token验证 | AuthController | +| 文件管理 | 文件上传、文件查询、文件下载、RAG问答 | FileController | +| 题库管理 | 题目查询、答题练习 | QuestionController | +| AI对话 | 流式问答、会话管理、引用查看 | AIChatController | +| 个人中心 | 个人信息管理 | UserController | + +#### 后台管理端 + +| 模块 | 功能列表 | 控制器 | +|------|---------|--------| +| 用户管理 | 用户CRUD、用户列表、用户详情 | UserController | +| 部门管理 | 部门CRUD、部门列表、部门详情 | DepartmentController | +| 权限管理 | 角色CRUD、权限分配、权限列表 | RoleController | +| 文件审核 | 待审核列表、审核通过、审核拒绝 | FileController | +| 日志管理 | 操作日志查询、日志详情 | LogController | +| 系统配置 | 知识库配置、系统参数 | - | + +--- + +## 模块调用关系图 + +```mermaid +flowchart LR + subgraph User["用户层"] + U1["普通用户"] + U2["部门管理员"] + U3["系统管理员"] + end + + subgraph Front["前台功能"] + F_AUTH["用户认证"] + F_FILE["文件管理"] + F_QUESTION["题库管理"] + F_AI["AI对话"] + end + + subgraph Back["后台功能"] + B_USER["用户管理"] + B_DEPT["部门管理"] + B_ROLE["权限管理"] + B_FILE["文件审核"] + B_LOG["日志管理"] + end + + subgraph Service["服务层"] + S_AUTH["AuthService"] + S_FILE["FileService"] + S_USER["UserService"] + S_DEPT["DepartmentService"] + S_ROLE["RoleService"] + S_CHAT["ChatService"] + S_AI["AiChatService"] + end + + subgraph Data["数据层"] + DB[("MySQL")] + KB[("知识库")] + end + + U1 --> F_AUTH + U1 --> F_FILE + U1 --> F_QUESTION + U1 --> F_AI + + U2 --> F_AUTH + U2 --> F_FILE + U2 --> B_DEPT + U2 --> B_FILE + + U3 --> F_AUTH + U3 --> B_USER + U3 --> B_DEPT + U3 --> B_ROLE + U3 --> B_FILE + U3 --> B_LOG + + F_AUTH --> S_AUTH + F_FILE --> S_FILE + F_AI --> S_AI + F_AI --> S_CHAT + + B_USER --> S_USER + B_DEPT --> S_DEPT + B_ROLE --> S_ROLE + B_FILE --> S_FILE + B_LOG --> S_FILE + + S_AUTH --> DB + S_FILE --> DB + S_USER --> DB + S_DEPT --> DB + S_ROLE --> DB + S_AI --> KB + S_CHAT --> DB +``` + +--- + +## 功能菜单树 + +``` +知识管理系统 +│ +├── 前台用户端 +│ ├── 用户认证 +│ │ ├── 用户登录 +│ │ ├── Token验证 +│ │ └── 退出登录 +│ │ +│ ├── 文件管理 +│ │ ├── 文件上传 +│ │ ├── 文件查询 +│ │ ├── 文件详情 +│ │ ├── 文件更新 +│ │ ├── 文件删除 +│ │ └── RAG问答 +│ │ +│ ├── 题库管理 +│ │ ├── 题目查询 +│ │ ├── 题目详情 +│ │ └── 答题练习 +│ │ +│ ├── AI对话 +│ │ ├── 流式问答 +│ │ ├── 会话列表 +│ │ ├── 会话详情 +│ │ ├── 引用查看 +│ │ ├── 会话删除 +│ │ └── 问答中断 +│ │ +│ └── 个人中心 +│ ├── 个人信息 +│ └── 修改密码 +│ +└── 后台管理端 + ├── 用户管理 + │ ├── 用户列表 + │ ├── 用户详情 + │ ├── 添加用户 + │ ├── 编辑用户 + │ └── 删除用户 + │ + ├── 部门管理 + │ ├── 部门列表 + │ ├── 部门详情 + │ ├── 添加部门 + │ ├── 编辑部门 + │ └── 删除部门 + │ + ├── 权限管理 + │ ├── 角色列表 + │ ├── 角色详情 + │ ├── 添加角色 + │ ├── 编辑角色 + │ ├── 删除角色 + │ ├── 权限列表 + │ └── 权限分配 + │ + ├── 文件审核 + │ ├── 待审核列表 + │ ├── 审核通过 + │ └── 审核拒绝 + │ + ├── 日志管理 + │ ├── 日志列表 + │ └── 日志详情 + │ + └── 系统配置 + ├── 知识库配置 + └── 参数设置 +``` + +--- + +## 接口权限矩阵 + +| 功能模块 | 普通用户 | 部门管理员 | 系统管理员 | +|---------|:-------:|:----------:|:----------:| +| **用户认证** | | | | +| 登录 | ✅ | ✅ | ✅ | +| 注册 | ✅ | ✅ | ✅ | +| **文件管理** | | | | +| 上传文件 | ✅ | ✅ | ✅ | +| 查询文件 | ✅ | ✅ | ✅ | +| 删除文件 | ✅ | ✅ | ✅ | +| RAG问答 | ✅ | ✅ | ✅ | +| **题库管理** | | | | +| 查询题目 | ✅ | ✅ | ✅ | +| 答题练习 | ✅ | ✅ | ✅ | +| **AI对话** | | | | +| 流式问答 | ✅ | ✅ | ✅ | +| 会话管理 | ✅ | ✅ | ✅ | +| **用户管理** | | | | +| 用户列表 | ❌ | ❌ | ✅ | +| 用户详情 | ❌ | ❌ | ✅ | +| 添加用户 | ❌ | ❌ | ✅ | +| 编辑用户 | ❌ | ❌ | ✅ | +| 删除用户 | ❌ | ❌ | ✅ | +| **部门管理** | | | | +| 部门列表 | ❌ | ✅ | ✅ | +| 部门详情 | ❌ | ✅ | ✅ | +| 添加部门 | ❌ | ❌ | ✅ | +| 编辑部门 | ❌ | ❌ | ✅ | +| 删除部门 | ❌ | ❌ | ✅ | +| **权限管理** | | | | +| 角色列表 | ❌ | ❌ | ✅ | +| 权限分配 | ❌ | ❌ | ✅ | +| **文件审核** | | | | +| 待审核列表 | ❌ | ✅ | ✅ | +| 审核通过 | ❌ | ✅ | ✅ | +| 审核拒绝 | ❌ | ✅ | ✅ | +| **日志管理** | | | | +| 日志列表 | ❌ | ❌ | ✅ | +| 日志详情 | ❌ | ❌ | ✅ | + +--- + +## 技术架构图 + +```mermaid +flowchart TB + subgraph Client["客户端层"] + WEB["Web浏览器"] + APP["移动App"] + API["第三方系统"] + end + + subgraph Gateway["网关层"] + GATEWAY["Spring Cloud Gateway"] + AUTH["认证中心"] + end + + subgraph Service["业务服务层"] + USER_SVC["用户服务"] + FILE_SVC["文件服务"] + AI_SVC["AI服务"] + CHAT_SVC["对话服务"] + QUESTION_SVC["题库服务"] + end + + subgraph Data["数据层"] + MYSQL["MySQL数据库"] + REDIS["Redis缓存"] + MINIO["MinIO对象存储"] + ES["Elasticsearch"] + end + + subgraph External["外部服务"] + LLM["大模型服务"] + RAG["RAG服务"] + end + + WEB --> GATEWAY + APP --> GATEWAY + API --> GATEWAY + + GATEWAY --> AUTH + AUTH --> USER_SVC + + GATEWAY --> USER_SVC + GATEWAY --> FILE_SVC + GATEWAY --> AI_SVC + GATEWAY --> CHAT_SVC + GATEWAY --> QUESTION_SVC + + USER_SVC --> MYSQL + USER_SVC --> REDIS + FILE_SVC --> MYSQL + FILE_SVC --> MINIO + AI_SVC --> LLM + AI_SVC --> RAG + CHAT_SVC --> MYSQL + CHAT_SVC --> ES + QUESTION_SVC --> MYSQL +``` diff --git a/测试11.txt b/测试11.txt new file mode 100644 index 0000000..e69de29